From 2ca8ee45f175386bab6463e50780d3a6a732af93 Mon Sep 17 00:00:00 2001 From: David Clausen Date: Tue, 2 Jun 2026 09:50:31 -0400 Subject: [PATCH 1/4] feat: add sandboxed Lua backend via :lua VM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `Jido.Shell.Backend.Lua`, a pure-Elixir Lua 5.3 backend backed by the `:lua` hex package (tv-labs/lua / luerl). Mirrors the Bash backend in structure and callback contract. Architecture: - `Lua.Session` holder GenServer owns committed `%Lua{}` state; the holder never evals — each execute spawns a killable worker so timeout/cancel cannot pin the holder. - Commit happens only on clean success; error/timeout/cancel leaves prior state intact. - Per-command context (emit, cwd, env, limits) injected via `Lua.put_private` and stripped before commit to prevent stale emitter capture across commands. - `persistent: false` config flag skips the holder and builds a fresh `Lua.new()` per eval (stateless mode). `JidoApi` bridge exposes the command registry under `jido.*` via `use Lua.API, scope: "jido"` + `deflua`; custom `print` global streams `{:output, …}` to the session. Arg escaping ports `JidoInterop.escape_arg/1` so spaces, `;`, `&&`, quotes, and backslashes stay single Jido arguments. Security: `Lua.new/0` sandbox disables `io`, `os.execute`, `os.exit`, `os.getenv`, `require`, `load`, `loadstring`, `loadfile`, `dofile`, `package.loadlib` by default. No VfsAdapter needed; file access only through bridged `jido.*` commands → `CommandRunner` → VFS. Also fixes `ShellSessionServer.apply_state_updates` to sync env changes from `{:state_update, %{env: …}}` back into `backend_state.env` so Lua (and other backends) see the updated env on subsequent execute calls. 14 tests covering: output streaming, global persistence, jido bridge, cwd sync, arg escaping, sandbox escape proof, output/runtime limits, timeout holder survival, cancel reusability, terminate cleanup, and stateless mode non-persistence. --- README.md | 42 ++- lib/jido_shell/backend/lua.ex | 325 ++++++++++++++++++ lib/jido_shell/backend/lua/jido_api.ex | 169 +++++++++ lib/jido_shell/backend/lua/session.ex | 109 ++++++ lib/jido_shell/shell_session_server.ex | 9 + mix.exs | 2 + mix.lock | 2 + test/jido/shell/backend/lua/jido_api_test.exs | 86 +++++ test/jido/shell/backend/lua_test.exs | 233 +++++++++++++ usage-rules.md | 22 ++ 10 files changed, 998 insertions(+), 1 deletion(-) create mode 100644 lib/jido_shell/backend/lua.ex create mode 100644 lib/jido_shell/backend/lua/jido_api.ex create mode 100644 lib/jido_shell/backend/lua/session.ex create mode 100644 test/jido/shell/backend/lua/jido_api_test.exs create mode 100644 test/jido/shell/backend/lua_test.exs diff --git a/README.md b/README.md index a103fef..0a2fc49 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,46 @@ All registered Jido commands (`echo`, `ls`, `cat`, `cd`, `write`, etc.) are brid - Glob support covers simple `*`/`?` patterns only. - Cancellation uses `Bash.Session.signal/3` with `:sigint`; scripts can run `INT`/`EXIT` traps before stopping. +### Lua Backend + +The Lua backend runs scripts in the pure-Elixir `:lua` VM. Lua globals and functions persist across calls within the same session, and registered Jido commands are available under the explicit `jido.*` namespace. + +**Dependency** — add the optional `:lua` package to your `mix.exs`: + +```elixir +{:lua, "~> 0.4", optional: true} +``` + +**Starting a session:** + +```elixir +{:ok, session_id} = + Jido.Shell.ShellSession.start_with_vfs("my_workspace", + backend: {Jido.Shell.Backend.Lua, %{}} + ) +``` + +**Agent API:** + +```elixir +{:ok, session} = Jido.Shell.Agent.new("my_workspace", + backend: {Jido.Shell.Backend.Lua, %{}}) + +{:ok, output} = Jido.Shell.Agent.run(session, """ + jido.echo("hello", "lua") + x = 5 + print(x) +""") +``` + +**Isolation:** `Lua.new/0` sandboxes host access by default. `io`, file loading, `require`, package loading, `os.execute`, `os.exit`, and `os.getenv` are disabled. File access is only available through bridged Jido commands such as `jido.cat`, `jido.write`, and `jido.ls`, which route through `Jido.Shell.VFS`. + +**Known limitations:** + +- Use `jido.echo`, `jido.ls`, etc.; bare command aliases are not installed. +- `configure_network/2` is a no-op because the Lua VM exposes no network primitives. +- Runtime and output limits are enforced by killing the eval worker; the persistent Lua holder remains reusable after timeout or cancellation. + ### Sprite Backend To execute commands on Fly.io Sprites, pass a backend tuple when starting a session: @@ -240,7 +280,7 @@ Event payloads: - `{:command_started, line}` - `{:output, chunk}` -- `{:output_stderr, chunk}` (Bash backend only) +- `{:output_stderr, chunk}` - `{:error, %Jido.Shell.Error{}}` - `{:cwd_changed, path}` - `:command_done` diff --git a/lib/jido_shell/backend/lua.ex b/lib/jido_shell/backend/lua.ex new file mode 100644 index 0000000..09b66c5 --- /dev/null +++ b/lib/jido_shell/backend/lua.ex @@ -0,0 +1,325 @@ +defmodule Jido.Shell.Backend.Lua do + @moduledoc """ + Backend that executes Lua scripts in the pure-Elixir `:lua` VM. + + The VM is sandboxed by `Lua.new/0`: host file I/O, OS execution, `require`, + and dynamic file loading are disabled. Registered Jido shell commands are + exposed under the `jido.*` namespace and route through + `Jido.Shell.CommandRunner`. + """ + + @behaviour Jido.Shell.Backend + + alias Jido.Shell.Backend.Lua.JidoApi + alias Jido.Shell.Backend.Lua.Session + alias Jido.Shell.Backend.OutputLimiter + alias Jido.Shell.Error + + @default_task_supervisor Jido.Shell.CommandTaskSupervisor + @eval_start_timeout 5_000 + + @impl true + def init(config) when is_map(config) do + persistent = Map.get(config, :persistent, true) + + with :ok <- ensure_dep_available(), + {:ok, session_pid} <- fetch_session_pid(config), + workspace_id <- Map.get(config, :workspace_id, ""), + {:ok, lua_session} <- maybe_start_lua_session(persistent, workspace_id) do + {:ok, + %{ + lua_session: lua_session, + session_id: Map.get(config, :session_id, "lua-session"), + session_pid: session_pid, + task_supervisor: Map.get(config, :task_supervisor, @default_task_supervisor), + workspace_id: workspace_id, + cwd: Map.get(config, :cwd, "/"), + env: Map.get(config, :env, %{}), + max_heap_size: Map.get(config, :max_heap_size), + persistent: persistent + }} + end + end + + @impl true + def execute(state, command, args, exec_opts) when is_binary(command) and is_list(args) and is_list(exec_opts) do + line = command_line(command, args) + timeout = positive_limit(Keyword.get(exec_opts, :timeout)) + output_limit = positive_limit(Keyword.get(exec_opts, :output_limit)) + ref = make_ref() + limit_ref = make_ref() + + case Task.Supervisor.start_child(state.task_supervisor, fn -> + await_eval(ref, limit_ref, state.session_pid, line, timeout) + end) do + {:ok, watcher_pid} -> + emit = limited_emit(state.session_pid, watcher_pid, limit_ref, output_limit) + context = eval_context(state, exec_opts, emit) + + eval_pid = + if state.persistent == false do + spawn_eval_worker_stateless( + watcher_pid, + ref, + state.workspace_id, + line, + context, + Map.get(state, :max_heap_size) + ) + else + spawn_eval_worker(watcher_pid, ref, state.lua_session, line, context, Map.get(state, :max_heap_size)) + end + + send(watcher_pid, {ref, :eval_pid, eval_pid}) + + {:ok, %{monitor_pid: watcher_pid, watcher_pid: watcher_pid, eval_pid: eval_pid}, state} + + {:error, reason} -> + {:error, Error.command(:start_failed, %{reason: reason, line: line})} + end + end + + @impl true + def cancel(_state, %{eval_pid: eval_pid, watcher_pid: watcher_pid}) do + kill_process(eval_pid, :kill) + kill_process(watcher_pid, :shutdown) + :ok + end + + def cancel(_state, _command_ref), do: {:error, :invalid_command_ref} + + @impl true + def terminate(state) do + case Map.get(state, :lua_session) do + pid when is_pid(pid) -> + if Process.alive?(pid), do: Session.stop(pid) + :ok + + _ -> + :ok + end + end + + @impl true + def cwd(state), do: {:ok, state.cwd, state} + + @impl true + def cd(state, path) when is_binary(path), do: {:ok, %{state | cwd: path}} + + @impl true + def configure_network(state, _policy), do: {:ok, state} + + defp ensure_dep_available do + if Code.ensure_loaded?(Lua) and Code.ensure_loaded?(Lua.API) do + :ok + else + {:error, Error.command(:start_failed, %{reason: :lua_dep_unavailable})} + end + end + + defp fetch_session_pid(config) do + case Map.get(config, :session_pid) do + pid when is_pid(pid) -> {:ok, pid} + _ -> {:error, Error.session(:invalid_state_transition, %{reason: :missing_session_pid})} + end + end + + defp maybe_start_lua_session(false, _workspace_id), do: {:ok, nil} + defp maybe_start_lua_session(_persistent, workspace_id), do: start_lua_session(workspace_id) + + defp start_lua_session(workspace_id) do + lua = + Lua.new() + |> Lua.load_api(JidoApi) + |> JidoApi.install_globals() + |> Lua.set!([:JIDO_WORKSPACE_ID], workspace_id) + + Session.new(lua) + rescue + error -> {:error, Error.command(:start_failed, %{reason: Exception.message(error)})} + end + + defp command_line(command, []), do: command + defp command_line(command, args), do: Enum.join([command | args], " ") + + defp eval_context(state, exec_opts, emit) do + %{ + session_id: Map.get(state, :session_id, "lua-session"), + workspace_id: state.workspace_id, + cwd: Map.get(state, :cwd, "/"), + env: Map.get(state, :env, %{}), + execution_context: Keyword.get(exec_opts, :execution_context, %{}), + emit: emit + } + end + + defp spawn_eval_worker(watcher_pid, ref, lua_session, line, context, max_heap_size) do + :erlang.spawn_opt( + fn -> + result = Session.eval(lua_session, line, context) + send(watcher_pid, {ref, {:done, result}}) + end, + spawn_opts(max_heap_size) + ) + end + + defp spawn_eval_worker_stateless(watcher_pid, ref, workspace_id, line, context, max_heap_size) do + :erlang.spawn_opt( + fn -> + result = eval_stateless(workspace_id, line, context) + send(watcher_pid, {ref, {:done, result}}) + end, + spawn_opts(max_heap_size) + ) + end + + defp eval_stateless(workspace_id, script, context) do + lua = + Lua.new() + |> Lua.load_api(JidoApi) + |> JidoApi.install_globals() + |> Lua.set!([:JIDO_WORKSPACE_ID], workspace_id) + |> Lua.put_private(JidoApi.context_key(), context) + + {_return, new_lua} = Lua.eval!(lua, script) + + final_context = + case Lua.get_private(new_lua, JidoApi.context_key()) do + {:ok, ctx} when is_map(ctx) -> ctx + _ -> context + end + + {:ok, extract_state_update(context, final_context)} + rescue + error in [Lua.CompilerException] -> + {:error, Error.command(:syntax_error, %{line: script, reason: Exception.message(error)})} + + error in [Lua.RuntimeException] -> + {:error, Error.command(:runtime_error, %{line: script, reason: Exception.message(error)})} + + error -> + {:error, Error.command(:crashed, %{line: script, reason: Exception.message(error)})} + catch + :throw, {:lua_output_limit_exceeded, %Error{} = error} -> + {:error, error} + + kind, reason -> + {:error, Error.command(:crashed, %{line: script, reason: {kind, reason}})} + end + + defp extract_state_update(initial, final) do + changes = + %{} + |> maybe_change(:cwd, Map.get(initial, :cwd), Map.get(final, :cwd)) + |> maybe_change(:env, Map.get(initial, :env), Map.get(final, :env)) + + if map_size(changes) == 0, do: nil, else: {:state_update, changes} + end + + defp maybe_change(changes, _key, same, same), do: changes + defp maybe_change(changes, key, _old, new_val), do: Map.put(changes, key, new_val) + + defp spawn_opts(max_heap_size) when is_integer(max_heap_size) and max_heap_size > 0 do + [{:max_heap_size, max_heap_size}] + end + + defp spawn_opts(_max_heap_size), do: [] + + defp await_eval(ref, limit_ref, session_pid, line, timeout) do + receive do + {^ref, :eval_pid, eval_pid} when is_pid(eval_pid) -> + monitor_ref = Process.monitor(eval_pid) + await_eval_result(ref, limit_ref, monitor_ref, eval_pid, session_pid, line, timeout) + after + @eval_start_timeout -> + finish(session_pid, {:error, Error.command(:start_failed, %{reason: :eval_worker_not_started, line: line})}) + end + end + + defp await_eval_result(ref, limit_ref, monitor_ref, eval_pid, session_pid, line, timeout) do + receive do + {^limit_ref, {:error, %Error{} = error}} -> + kill_process(eval_pid, :kill) + finish(session_pid, {:error, error}) + + {^ref, {:done, result}} -> + Process.demonitor(monitor_ref, [:flush]) + finish(session_pid, result) + + {:DOWN, ^monitor_ref, :process, ^eval_pid, :normal} -> + receive do + {^ref, {:done, result}} -> finish(session_pid, result) + after + 0 -> finish(session_pid, {:ok, nil}) + end + + {:DOWN, ^monitor_ref, :process, ^eval_pid, reason} -> + finish(session_pid, {:error, Error.command(:crashed, %{line: line, reason: reason})}) + after + receive_timeout(timeout) -> + kill_process(eval_pid, :kill) + finish(session_pid, {:error, Error.command(:runtime_limit_exceeded, %{line: line, max_runtime_ms: timeout})}) + end + end + + defp finish(session_pid, result) do + send(session_pid, {:command_finished, result}) + result + end + + defp limited_emit(session_pid, watcher_pid, limit_ref, output_limit) do + counter = :counters.new(1, []) + + fn event -> + case check_output_limit(event, counter, output_limit) do + :ok -> + send(session_pid, {:command_event, event}) + + {:error, %Error{} = error} -> + send(watcher_pid, {limit_ref, {:error, error}}) + throw({:lua_output_limit_exceeded, error}) + end + + :ok + end + end + + defp check_output_limit(_event, _counter, nil), do: :ok + + defp check_output_limit(event, counter, output_limit) do + case output_size(event) do + nil -> + :ok + + chunk_bytes -> + emitted_bytes = :counters.get(counter, 1) + + case OutputLimiter.check(chunk_bytes, emitted_bytes, output_limit) do + {:ok, updated_total} -> + :counters.put(counter, 1, updated_total) + :ok + + {:limit_exceeded, %Error{} = error} -> + {:error, error} + end + end + end + + defp output_size({:output, chunk}), do: chunk |> IO.iodata_to_binary() |> byte_size() + defp output_size({:output_stderr, chunk}), do: chunk |> IO.iodata_to_binary() |> byte_size() + defp output_size(_event), do: nil + + defp receive_timeout(nil), do: :infinity + defp receive_timeout(timeout) when is_integer(timeout) and timeout > 0, do: timeout + defp receive_timeout(_timeout), do: :infinity + + defp positive_limit(value) when is_integer(value) and value > 0, do: value + defp positive_limit(_value), do: nil + + defp kill_process(pid, reason) when is_pid(pid) do + if Process.alive?(pid), do: Process.exit(pid, reason) + :ok + end + + defp kill_process(_pid, _reason), do: :ok +end diff --git a/lib/jido_shell/backend/lua/jido_api.ex b/lib/jido_shell/backend/lua/jido_api.ex new file mode 100644 index 0000000..ddf6058 --- /dev/null +++ b/lib/jido_shell/backend/lua/jido_api.ex @@ -0,0 +1,169 @@ +defmodule Jido.Shell.Backend.Lua.JidoApi do + @moduledoc """ + Lua API bridge exposing registered Jido shell commands under `jido.*`. + + Functions in this module run as normal Elixir code from inside the Lua VM. + They must keep the sandbox boundary intact by routing all shell behavior + through `Jido.Shell.CommandRunner`. + """ + + use Lua.API, scope: "jido" + + alias Jido.Shell.CommandRunner + alias Jido.Shell.Error + alias Jido.Shell.ShellSession.State + + @context_key :jido_shell_context + + @commands ~w(echo pwd ls cat cd mkdir write sleep seq env rm cp) + + def context_key, do: @context_key + + def install_globals(%Lua{} = lua) do + Lua.set!(lua, [:print], fn args, state -> print(args, state) end) + end + + @variadic true + deflua echo(args), state do + dispatch("echo", args, state) + end + + @variadic true + deflua pwd(args), state do + dispatch("pwd", args, state) + end + + @variadic true + deflua ls(args), state do + dispatch("ls", args, state) + end + + @variadic true + deflua cat(args), state do + dispatch("cat", args, state) + end + + @variadic true + deflua cd(args), state do + dispatch("cd", args, state) + end + + @variadic true + deflua mkdir(args), state do + dispatch("mkdir", args, state) + end + + @variadic true + deflua write(args), state do + dispatch("write", args, state) + end + + @variadic true + deflua sleep(args), state do + dispatch("sleep", args, state) + end + + @variadic true + deflua seq(args), state do + dispatch("seq", args, state) + end + + @variadic true + deflua env(args), state do + dispatch("env", args, state) + end + + @variadic true + deflua rm(args), state do + dispatch("rm", args, state) + end + + @variadic true + deflua cp(args), state do + dispatch("cp", args, state) + end + + @doc false + def print(args, %Lua{} = lua) when is_list(args) do + context = fetch_context!(lua) + + output = + args + |> Enum.map(&lua_to_string/1) + |> Enum.join("\t") + + context.emit.({:output, output <> "\n"}) + {[], lua} + end + + @doc false + def dispatch(command, args, %Lua{} = lua) when command in @commands and is_list(args) do + context = fetch_context!(lua) + state = build_state!(context) + line = build_line(command, Enum.map(args, &lua_to_string/1)) + + case CommandRunner.execute(state, line, context.emit) do + {:ok, {:state_update, changes}} -> + updated_context = apply_state_update(context, changes) + {[], Lua.put_private(lua, @context_key, updated_context)} + + {:ok, _} -> + {[], lua} + + {:error, %Error{} = error} -> + context.emit.({:output_stderr, error.message <> "\n"}) + raise Lua.RuntimeException, "jido.#{command}: #{error.message}" + end + end + + defp fetch_context!(%Lua{} = lua) do + case Lua.get_private(lua, @context_key) do + {:ok, context} when is_map(context) -> + context + + _ -> + raise Lua.RuntimeException, "missing Jido eval context" + end + end + + defp build_state!(context) do + State.new!(%{ + id: Map.get(context, :session_id, "lua-interop"), + workspace_id: Map.fetch!(context, :workspace_id), + cwd: Map.get(context, :cwd, "/"), + env: Map.get(context, :env, %{}), + meta: %{execution_context: Map.get(context, :execution_context, %{})} + }) + end + + defp apply_state_update(context, changes) when is_map(changes) do + Enum.reduce(changes, context, fn + {:cwd, cwd}, acc when is_binary(cwd) -> Map.put(acc, :cwd, cwd) + {:env, env}, acc when is_map(env) -> Map.put(acc, :env, env) + _, acc -> acc + end) + end + + defp build_line(command, []), do: command + + defp build_line(command, args) do + Enum.join([command | Enum.map(args, &escape_arg/1)], " ") + end + + defp escape_arg(arg) when is_binary(arg) do + escaped = + arg + |> String.replace("\\", "\\\\") + |> String.replace("\"", "\\\"") + + "\"" <> escaped <> "\"" + end + + defp lua_to_string(nil), do: "" + defp lua_to_string(value) when is_binary(value), do: value + defp lua_to_string(value) when is_integer(value), do: Integer.to_string(value) + defp lua_to_string(value) when is_float(value), do: Float.to_string(value) + defp lua_to_string(true), do: "true" + defp lua_to_string(false), do: "false" + defp lua_to_string(value), do: inspect(value) +end diff --git a/lib/jido_shell/backend/lua/session.ex b/lib/jido_shell/backend/lua/session.ex new file mode 100644 index 0000000..0678bc7 --- /dev/null +++ b/lib/jido_shell/backend/lua/session.ex @@ -0,0 +1,109 @@ +defmodule Jido.Shell.Backend.Lua.Session do + @moduledoc """ + Holder process for a persistent immutable `%Lua{}` state. + + The GenServer only stores and commits Lua state. Evaluation happens in the + caller process so timeout and cancellation can kill runaway Lua code without + pinning the holder. + """ + + use GenServer + + alias Jido.Shell.Backend.Lua.JidoApi + alias Jido.Shell.Error + + @type eval_context :: map() + + def new(%Lua{} = lua) do + GenServer.start_link(__MODULE__, lua) + end + + def eval(pid, script, context) when is_pid(pid) and is_binary(script) and is_map(context) do + with {:ok, lua} <- checkout(pid) do + do_eval(pid, lua, script, context) + end + end + + def stop(pid) when is_pid(pid) do + GenServer.stop(pid) + catch + :exit, _ -> :ok + end + + @impl true + def init(%Lua{} = lua), do: {:ok, lua} + + @impl true + def handle_call(:checkout, _from, lua) do + {:reply, {:ok, lua}, lua} + end + + def handle_call({:commit, %Lua{} = lua}, _from, _old_lua) do + {:reply, :ok, lua} + end + + defp checkout(pid) do + GenServer.call(pid, :checkout) + catch + :exit, reason -> {:error, Error.command(:crashed, %{reason: reason})} + end + + defp commit(pid, %Lua{} = lua) do + GenServer.call(pid, {:commit, lua}) + catch + :exit, reason -> {:error, Error.command(:crashed, %{reason: reason})} + end + + defp do_eval(pid, %Lua{} = lua, script, context) do + lua = Lua.put_private(lua, JidoApi.context_key(), context) + + {_return, new_lua} = Lua.eval!(lua, script) + final_context = final_context(new_lua, context) + clean_lua = Lua.delete_private(new_lua, JidoApi.context_key()) + + with :ok <- commit(pid, clean_lua) do + {:ok, state_update(context, final_context)} + end + rescue + error in [Lua.CompilerException] -> + {:error, Error.command(:syntax_error, %{line: script, reason: Exception.message(error)})} + + error in [Lua.RuntimeException] -> + {:error, Error.command(:runtime_error, %{line: script, reason: Exception.message(error)})} + + error -> + {:error, Error.command(:crashed, %{line: script, reason: Exception.message(error)})} + catch + :throw, {:lua_output_limit_exceeded, %Error{} = error} -> + {:error, error} + + kind, reason -> + {:error, Error.command(:crashed, %{line: script, reason: {kind, reason}})} + end + + defp final_context(%Lua{} = lua, fallback) do + case Lua.get_private(lua, JidoApi.context_key()) do + {:ok, context} when is_map(context) -> context + _ -> fallback + end + end + + defp state_update(initial, final) do + changes = + %{} + |> maybe_put_change(:cwd, Map.get(initial, :cwd), Map.get(final, :cwd)) + |> maybe_put_change(:env, Map.get(initial, :env), Map.get(final, :env)) + + if map_size(changes) == 0 do + nil + else + {:state_update, changes} + end + end + + defp maybe_put_change(changes, _key, value, value), do: changes + + defp maybe_put_change(changes, key, _old_value, new_value) do + Map.put(changes, key, new_value) + end +end diff --git a/lib/jido_shell/shell_session_server.ex b/lib/jido_shell/shell_session_server.ex index 0b8af9a..acaac01 100644 --- a/lib/jido_shell/shell_session_server.ex +++ b/lib/jido_shell/shell_session_server.ex @@ -268,6 +268,7 @@ defmodule Jido.Shell.ShellSessionServer do end) updated_state = maybe_sync_backend_cwd(updated_state, Map.get(changes, :cwd)) + updated_state = maybe_sync_backend_env(updated_state, Map.get(changes, :env)) {updated_state, Map.has_key?(changes, :cwd)} end @@ -282,6 +283,14 @@ defmodule Jido.Shell.ShellSessionServer do defp maybe_sync_backend_cwd(state, _cwd), do: state + defp maybe_sync_backend_env(state, nil), do: state + + defp maybe_sync_backend_env(state, env) when is_map(env) do + %{state | backend_state: Map.put(state.backend_state, :env, env)} + end + + defp maybe_sync_backend_env(state, _env), do: state + defp broadcast(state, event) do for pid <- state.transports do send(pid, {:jido_shell_session, state.id, event}) diff --git a/mix.exs b/mix.exs index f25cc1f..6c65329 100644 --- a/mix.exs +++ b/mix.exs @@ -75,6 +75,7 @@ defmodule Jido.Shell.MixProject do {:jido_vfs, "~> 1.0"}, {:bash, git: "https://github.com/tv-labs/bash.git", ref: "c1038ff83e825c29ea131bf8b728bd1672734c01", optional: true}, + {:lua, "~> 0.4", optional: true}, # Dev/Test dependencies {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, @@ -146,6 +147,7 @@ defmodule Jido.Shell.MixProject do ], Backends: [ Jido.Shell.Backend.Bash, + Jido.Shell.Backend.Lua, Jido.Shell.Backend.Local, Jido.Shell.Backend.Sprite, Jido.Shell.Backend.SSH diff --git a/mix.lock b/mix.lock index dca1e76..fd937f1 100644 --- a/mix.lock +++ b/mix.lock @@ -29,6 +29,8 @@ "igniter": {:hex, :igniter, "0.8.0", "c7cab589440e5f20ff68e00f60eb094378114dab3105c0784ce8140f8dfdd2c0", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "fcd99096fde4797f7b48bebddcfc58785569acd696346a3eb385bf813f47a7cc"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "jido_vfs": {:hex, :jido_vfs, "1.0.0", "c92ca76e3a11413130b7c1c8f7868a011c1115735ec1047a2e92d4c908b7681d", [:mix], [{:eternal, "~> 1.2.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:ex_aws_s3, "~> 2.2", [hex: :ex_aws_s3, repo: "hexpm", optional: false]}, {:git_cli, "~> 0.3.0", [hex: :git_cli, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:splode, "~> 0.3.0", [hex: :splode, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:tentacat, "~> 2.0", [hex: :tentacat, repo: "hexpm", optional: false]}], "hexpm", "cb760e0d0ae46c407c439e3bf23fdfd5aec8406f4021e04f8d40a8cec9e7e3cd"}, + "lua": {:hex, :lua, "0.4.0", "de0f04871fdd133cd13a0662690b4fd3ba7a73ca5857493c4665a0a4251908fe", [:mix], [{:luerl, "~> 1.5.1", [hex: :luerl, repo: "hexpm", optional: false]}], "hexpm", "648e17ab9faa1ab1a788fa58ed608366a7026d0eeaec2f311510c065817c4067"}, + "luerl": {:hex, :luerl, "1.5.1", "f6700420950fc6889137e7a0c11c4a8467dea04a8c23f707a40d83566d14e786", [:rebar3], [], "hexpm", "abf88d849baa0d5dca93b245a8688d4de2ee3d588159bb2faf51e15946509390"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, diff --git a/test/jido/shell/backend/lua/jido_api_test.exs b/test/jido/shell/backend/lua/jido_api_test.exs new file mode 100644 index 0000000..808d152 --- /dev/null +++ b/test/jido/shell/backend/lua/jido_api_test.exs @@ -0,0 +1,86 @@ +defmodule Jido.Shell.Backend.Lua.JidoApiTest do + use Jido.Shell.Case, async: false + + alias Jido.Shell.Backend.Lua.JidoApi + alias Jido.Shell.VFS + + setup do + VFS.init() + workspace_id = "lua_api_ws_#{System.unique_integer([:positive])}" + fs_name = "lua_api_fs_#{System.unique_integer([:positive])}" + + start_supervised!( + {Jido.VFS.Adapter.InMemory, {Jido.VFS.Adapter.InMemory, %Jido.VFS.Adapter.InMemory.Config{name: fs_name}}} + ) + + :ok = VFS.mount(workspace_id, "/", Jido.VFS.Adapter.InMemory, name: fs_name) + + on_exit(fn -> VFS.unmount(workspace_id, "/") end) + + {:ok, workspace_id: workspace_id} + end + + defp lua_with_context(workspace_id, opts \\ []) do + parent = self() + ref = make_ref() + + emit = fn event -> + send(parent, {ref, event}) + :ok + end + + context = %{ + session_id: "lua-api-test", + workspace_id: workspace_id, + cwd: Keyword.get(opts, :cwd, "/"), + env: Keyword.get(opts, :env, %{}), + execution_context: %{}, + emit: emit + } + + lua = + Lua.new() + |> Lua.load_api(JidoApi) + |> JidoApi.install_globals() + |> Lua.put_private(JidoApi.context_key(), context) + + {lua, ref} + end + + defp collect_events(ref, acc \\ []) do + receive do + {^ref, event} -> collect_events(ref, [event | acc]) + after + 0 -> Enum.reverse(acc) + end + end + + test "jido command arguments preserve parser separators and escapes", %{workspace_id: wid} do + {lua, ref} = lua_with_context(wid) + + Lua.eval!(lua, ~S|jido.echo("foo;bar", "one&&two", "quote\"here", "path\\name")|) + + assert [{:output, ~s(foo;bar one&&two quote"here path\\name\n)}] = collect_events(ref) + end + + test "state updates propagate to later jido calls in the same eval", %{workspace_id: wid} do + :ok = VFS.mkdir(wid, "/work") + {lua, ref} = lua_with_context(wid) + + {_result, lua} = Lua.eval!(lua, ~S|jido.cd("/work"); jido.pwd()|) + + assert [{:output, "/work\n"}] = collect_events(ref) + assert {:ok, %{cwd: "/work"}} = Lua.get_private(lua, JidoApi.context_key()) + end + + test "command errors emit stderr and raise Lua runtime errors", %{workspace_id: wid} do + {lua, ref} = lua_with_context(wid) + + assert_raise Lua.RuntimeException, fn -> + Lua.eval!(lua, ~S|jido.cat("/missing.txt")|) + end + + assert [{:output_stderr, stderr}] = collect_events(ref) + assert stderr =~ "not_found" + end +end diff --git a/test/jido/shell/backend/lua_test.exs b/test/jido/shell/backend/lua_test.exs new file mode 100644 index 0000000..cda3299 --- /dev/null +++ b/test/jido/shell/backend/lua_test.exs @@ -0,0 +1,233 @@ +defmodule Jido.Shell.Backend.LuaTest do + use Jido.Shell.Case, async: false + + alias Jido.Shell.ShellSession + alias Jido.Shell.ShellSessionServer + alias Jido.Shell.VFS + + @event_timeout 2_000 + + setup do + VFS.init() + workspace_id = "lua_backend_ws_#{System.unique_integer([:positive])}" + fs_name = "lua_backend_fs_#{System.unique_integer([:positive])}" + + start_supervised!( + {Jido.VFS.Adapter.InMemory, {Jido.VFS.Adapter.InMemory, %Jido.VFS.Adapter.InMemory.Config{name: fs_name}}} + ) + + :ok = VFS.mount(workspace_id, "/", Jido.VFS.Adapter.InMemory, name: fs_name) + + on_exit(fn -> VFS.unmount(workspace_id, "/") end) + + {:ok, workspace_id: workspace_id} + end + + defp start_session(workspace_id, opts \\ []) do + {:ok, session_id} = + ShellSession.start( + workspace_id, + Keyword.merge([backend: {Jido.Shell.Backend.Lua, %{}}], opts) + ) + + {:ok, :subscribed} = ShellSessionServer.subscribe(session_id, self()) + session_id + end + + defp receive_output(session_id, acc \\ "", stderr_acc \\ "") do + receive do + {:jido_shell_session, ^session_id, {:output, chunk}} -> + receive_output(session_id, acc <> IO.iodata_to_binary(chunk), stderr_acc) + + {:jido_shell_session, ^session_id, {:output_stderr, chunk}} -> + receive_output(session_id, acc, stderr_acc <> IO.iodata_to_binary(chunk)) + + {:jido_shell_session, ^session_id, :command_done} -> + {:ok, acc, stderr_acc} + + {:jido_shell_session, ^session_id, {:error, err}} -> + {:error, err, acc} + after + @event_timeout -> {:timeout, acc} + end + end + + test "print streams output and completes", %{workspace_id: wid} do + session_id = start_session(wid) + + {:ok, :accepted} = ShellSessionServer.run_command(session_id, ~S/print("hello")/) + + assert_receive {:jido_shell_session, ^session_id, {:command_started, ~S/print("hello")/}}, @event_timeout + assert {:ok, "hello\n", ""} = receive_output(session_id) + end + + test "globals persist across run_command calls", %{workspace_id: wid} do + session_id = start_session(wid) + + {:ok, :accepted} = ShellSessionServer.run_command(session_id, "x = 5") + assert_receive {:jido_shell_session, ^session_id, :command_done}, @event_timeout + + {:ok, :accepted} = ShellSessionServer.run_command(session_id, "print(x)") + assert_receive {:jido_shell_session, ^session_id, {:command_started, "print(x)"}}, @event_timeout + assert {:ok, "5\n", ""} = receive_output(session_id) + end + + test "jido commands stream through the VFS-backed command runner", %{workspace_id: wid} do + :ok = VFS.write_file(wid, "/note.txt", "from vfs") + session_id = start_session(wid) + + {:ok, :accepted} = ShellSessionServer.run_command(session_id, ~S/jido.echo("hello", "lua")/) + assert_receive {:jido_shell_session, ^session_id, {:command_started, _}}, @event_timeout + assert {:ok, "hello lua\n", ""} = receive_output(session_id) + + {:ok, :accepted} = ShellSessionServer.run_command(session_id, ~S|jido.cat("/note.txt")|) + assert_receive {:jido_shell_session, ^session_id, {:command_started, _}}, @event_timeout + assert {:ok, "from vfs", ""} = receive_output(session_id) + end + + test "jido cd updates the outer session cwd", %{workspace_id: wid} do + :ok = VFS.mkdir(wid, "/docs") + session_id = start_session(wid) + + {:ok, :accepted} = ShellSessionServer.run_command(session_id, ~S|jido.cd("/docs")|) + + assert_receive {:jido_shell_session, ^session_id, {:command_started, _}}, @event_timeout + assert_receive {:jido_shell_session, ^session_id, {:cwd_changed, "/docs"}}, @event_timeout + assert_receive {:jido_shell_session, ^session_id, :command_done}, @event_timeout + + {:ok, state} = ShellSessionServer.get_state(session_id) + assert state.cwd == "/docs" + + {:ok, :accepted} = ShellSessionServer.run_command(session_id, "jido.pwd()") + assert_receive {:jido_shell_session, ^session_id, {:command_started, _}}, @event_timeout + assert {:ok, "/docs\n", ""} = receive_output(session_id) + end + + test "jido arguments preserve parser separators and quotes", %{workspace_id: wid} do + session_id = start_session(wid) + + {:ok, :accepted} = + ShellSessionServer.run_command(session_id, ~S|jido.echo("foo;bar", "one&&two", "quote\"here", "path\\name")|) + + assert_receive {:jido_shell_session, ^session_id, {:command_started, _}}, @event_timeout + assert {:ok, ~s(foo;bar one&&two quote"here path\\name\n), ""} = receive_output(session_id) + end + + test "sandboxed host access fails without side effects", %{workspace_id: wid} do + session_id = start_session(wid) + host_path = "/tmp/jido_shell_lua_escape_#{System.unique_integer([:positive])}" + + refute File.exists?(host_path) + + {:ok, :accepted} = + ShellSessionServer.run_command(session_id, ~s|os.execute("touch #{host_path}")|) + + assert_receive {:jido_shell_session, ^session_id, {:command_started, _}}, @event_timeout + + assert_receive {:jido_shell_session, ^session_id, {:error, %Jido.Shell.Error{code: {:command, :runtime_error}}}}, + @event_timeout + + refute File.exists?(host_path) + + {:ok, :accepted} = ShellSessionServer.run_command(session_id, ~S|print("alive")|) + assert_receive {:jido_shell_session, ^session_id, {:command_started, _}}, @event_timeout + assert {:ok, "alive\n", ""} = receive_output(session_id) + end + + test "output limit aborts before streaming oversized output", %{workspace_id: wid} do + session_id = start_session(wid) + + {:ok, :accepted} = ShellSessionServer.run_command(session_id, "x = 1") + assert_receive {:jido_shell_session, ^session_id, :command_done}, @event_timeout + + {:ok, :accepted} = + ShellSessionServer.run_command(session_id, ~S|x = 2; print("abcdef")|, + execution_context: %{limits: %{max_output_bytes: 3}} + ) + + assert_receive {:jido_shell_session, ^session_id, {:command_started, _}}, @event_timeout + + assert_receive {:jido_shell_session, ^session_id, + {:error, %Jido.Shell.Error{code: {:command, :output_limit_exceeded}}}}, + @event_timeout + + refute_receive {:jido_shell_session, ^session_id, {:output, _}}, 100 + + {:ok, :accepted} = ShellSessionServer.run_command(session_id, "print(x)") + assert_receive {:jido_shell_session, ^session_id, {:command_started, _}}, @event_timeout + assert {:ok, "1\n", ""} = receive_output(session_id) + end + + test "runtime limit kills an infinite loop and keeps the session reusable", %{workspace_id: wid} do + session_id = start_session(wid) + + {:ok, :accepted} = + ShellSessionServer.run_command(session_id, "while true do end", + execution_context: %{limits: %{max_runtime_ms: 50}} + ) + + assert_receive {:jido_shell_session, ^session_id, {:command_started, _}}, @event_timeout + + assert_receive {:jido_shell_session, ^session_id, + {:error, %Jido.Shell.Error{code: {:command, :runtime_limit_exceeded}}}}, + @event_timeout + + {:ok, state} = ShellSessionServer.get_state(session_id) + assert Process.alive?(state.backend_state.lua_session) + + {:ok, :accepted} = ShellSessionServer.run_command(session_id, ~S|print("alive")|) + assert_receive {:jido_shell_session, ^session_id, {:command_started, _}}, @event_timeout + assert {:ok, "alive\n", ""} = receive_output(session_id) + end + + test "cancel kills a running Lua eval and keeps the session reusable", %{workspace_id: wid} do + session_id = start_session(wid) + + {:ok, :accepted} = ShellSessionServer.run_command(session_id, "while true do end") + assert_receive {:jido_shell_session, ^session_id, {:command_started, _}}, @event_timeout + + {:ok, :cancelled} = ShellSessionServer.cancel(session_id) + assert_receive {:jido_shell_session, ^session_id, :command_cancelled}, @event_timeout + + {:ok, :accepted} = ShellSessionServer.run_command(session_id, ~S|print("alive")|) + assert_receive {:jido_shell_session, ^session_id, {:command_started, _}}, @event_timeout + assert {:ok, "alive\n", ""} = receive_output(session_id) + end + + test "stopping the shell session stops the Lua holder", %{workspace_id: wid} do + session_id = start_session(wid) + {:ok, state} = ShellSessionServer.get_state(session_id) + lua_pid = state.backend_state.lua_session + assert Process.alive?(lua_pid) + + :ok = ShellSession.stop(session_id) + + wait_until(fn -> not Process.alive?(lua_pid) end, 2_000) + refute Process.alive?(lua_pid) + end + + test "persistent: false — globals do not carry over between run_command calls", %{workspace_id: wid} do + session_id = start_session(wid, backend: {Jido.Shell.Backend.Lua, %{persistent: false}}) + + {:ok, :accepted} = ShellSessionServer.run_command(session_id, "x = 99") + assert_receive {:jido_shell_session, ^session_id, :command_done}, @event_timeout + + {:ok, :accepted} = ShellSessionServer.run_command(session_id, "print(x)") + assert_receive {:jido_shell_session, ^session_id, {:command_started, _}}, @event_timeout + # fresh VM each call — x is nil, our print renders nil as "" + assert {:ok, "\n", ""} = receive_output(session_id) + end + + defp wait_until(fun, timeout, interval \\ 20, elapsed \\ 0) + + defp wait_until(_fun, timeout, _interval, elapsed) when elapsed >= timeout, do: :timeout + + defp wait_until(fun, timeout, interval, elapsed) do + if fun.() do + :ok + else + Process.sleep(interval) + wait_until(fun, timeout, interval, elapsed + interval) + end + end +end diff --git a/usage-rules.md b/usage-rules.md index 5362bb0..fce1e16 100644 --- a/usage-rules.md +++ b/usage-rules.md @@ -23,6 +23,27 @@ This document provides guidance for LLMs using Jido.Shell for file and shell ope results = Jido.Shell.Agent.run_all(session, ["mkdir /dir", "cd /dir", "pwd"]) ``` +### Optional Backends + +```elixir +# Persistent sandboxed Bash +{:ok, bash_session} = + Jido.Shell.Agent.new("my_workspace", + backend: {Jido.Shell.Backend.Bash, %{}}) + +# Persistent sandboxed Lua VM +{:ok, lua_session} = + Jido.Shell.Agent.new("my_workspace", + backend: {Jido.Shell.Backend.Lua, %{}}) + +{:ok, output} = + Jido.Shell.Agent.run(lua_session, ~S|jido.echo("hello", "lua")|) +``` + +Lua scripts must call registered commands through the `jido.*` namespace. Host +I/O, `require`, file loading, and `os.execute` are sandboxed by `Lua.new/0`; +filesystem access goes through bridged Jido commands and the VFS. + ### File Operations ```elixir @@ -67,6 +88,7 @@ cwd = Jido.Shell.Agent.cwd(session) | `rm` | `rm file` | Remove file | | `cp` | `cp src dest` | Copy file | | `env` | `env VAR=value` | Set environment variable | +| `bash` | `bash -c "script"` | Execute sandboxed Bash script | | `help` | `help [cmd]` | Show help | ## Best Practices From 6efb0e86b27892b34d41e9333876e327bd04f439 Mon Sep 17 00:00:00 2001 From: David Clausen Date: Tue, 2 Jun 2026 09:57:58 -0400 Subject: [PATCH 2/4] feat: add apis: config option to Lua backend for injecting custom Lua.API modules Allows callers to pass extra Lua.API modules via the backend config: backend: {Jido.Shell.Backend.Lua, %{apis: [MyApp.LuaApi]}} The extra modules are loaded after JidoApi in both the persistent session path (start_lua_session/2) and the stateless eval path (eval_stateless/4). Co-Authored-By: Claude Sonnet 4.6 --- lib/jido_shell/backend/lua.ex | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/lib/jido_shell/backend/lua.ex b/lib/jido_shell/backend/lua.ex index 09b66c5..a455c95 100644 --- a/lib/jido_shell/backend/lua.ex +++ b/lib/jido_shell/backend/lua.ex @@ -22,10 +22,12 @@ defmodule Jido.Shell.Backend.Lua do def init(config) when is_map(config) do persistent = Map.get(config, :persistent, true) + apis = Map.get(config, :apis, []) + with :ok <- ensure_dep_available(), {:ok, session_pid} <- fetch_session_pid(config), workspace_id <- Map.get(config, :workspace_id, ""), - {:ok, lua_session} <- maybe_start_lua_session(persistent, workspace_id) do + {:ok, lua_session} <- maybe_start_lua_session(persistent, workspace_id, apis) do {:ok, %{ lua_session: lua_session, @@ -36,7 +38,8 @@ defmodule Jido.Shell.Backend.Lua do cwd: Map.get(config, :cwd, "/"), env: Map.get(config, :env, %{}), max_heap_size: Map.get(config, :max_heap_size), - persistent: persistent + persistent: persistent, + apis: apis }} end end @@ -64,7 +67,8 @@ defmodule Jido.Shell.Backend.Lua do state.workspace_id, line, context, - Map.get(state, :max_heap_size) + Map.get(state, :max_heap_size), + Map.get(state, :apis, []) ) else spawn_eval_worker(watcher_pid, ref, state.lua_session, line, context, Map.get(state, :max_heap_size)) @@ -124,16 +128,18 @@ defmodule Jido.Shell.Backend.Lua do end end - defp maybe_start_lua_session(false, _workspace_id), do: {:ok, nil} - defp maybe_start_lua_session(_persistent, workspace_id), do: start_lua_session(workspace_id) + defp maybe_start_lua_session(false, _workspace_id, _apis), do: {:ok, nil} + defp maybe_start_lua_session(_persistent, workspace_id, apis), do: start_lua_session(workspace_id, apis) - defp start_lua_session(workspace_id) do + defp start_lua_session(workspace_id, apis \\ []) do lua = Lua.new() |> Lua.load_api(JidoApi) |> JidoApi.install_globals() |> Lua.set!([:JIDO_WORKSPACE_ID], workspace_id) + lua = Enum.reduce(apis, lua, &Lua.load_api(&2, &1)) + Session.new(lua) rescue error -> {:error, Error.command(:start_failed, %{reason: Exception.message(error)})} @@ -163,22 +169,26 @@ defmodule Jido.Shell.Backend.Lua do ) end - defp spawn_eval_worker_stateless(watcher_pid, ref, workspace_id, line, context, max_heap_size) do + defp spawn_eval_worker_stateless(watcher_pid, ref, workspace_id, line, context, max_heap_size, apis \\ []) do :erlang.spawn_opt( fn -> - result = eval_stateless(workspace_id, line, context) + result = eval_stateless(workspace_id, line, context, apis) send(watcher_pid, {ref, {:done, result}}) end, spawn_opts(max_heap_size) ) end - defp eval_stateless(workspace_id, script, context) do + defp eval_stateless(workspace_id, script, context, apis \\ []) do lua = Lua.new() |> Lua.load_api(JidoApi) |> JidoApi.install_globals() |> Lua.set!([:JIDO_WORKSPACE_ID], workspace_id) + + lua = + apis + |> Enum.reduce(lua, &Lua.load_api(&2, &1)) |> Lua.put_private(JidoApi.context_key(), context) {_return, new_lua} = Lua.eval!(lua, script) From e9dda045efaf02100a54d39cbf602e3f3e0eb447 Mon Sep 17 00:00:00 2001 From: David Clausen Date: Tue, 2 Jun 2026 09:58:34 -0400 Subject: [PATCH 3/4] fix: remove unused default values from private functions --- lib/jido_shell/backend/lua.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/jido_shell/backend/lua.ex b/lib/jido_shell/backend/lua.ex index a455c95..ff84663 100644 --- a/lib/jido_shell/backend/lua.ex +++ b/lib/jido_shell/backend/lua.ex @@ -131,7 +131,7 @@ defmodule Jido.Shell.Backend.Lua do defp maybe_start_lua_session(false, _workspace_id, _apis), do: {:ok, nil} defp maybe_start_lua_session(_persistent, workspace_id, apis), do: start_lua_session(workspace_id, apis) - defp start_lua_session(workspace_id, apis \\ []) do + defp start_lua_session(workspace_id, apis) do lua = Lua.new() |> Lua.load_api(JidoApi) @@ -169,7 +169,7 @@ defmodule Jido.Shell.Backend.Lua do ) end - defp spawn_eval_worker_stateless(watcher_pid, ref, workspace_id, line, context, max_heap_size, apis \\ []) do + defp spawn_eval_worker_stateless(watcher_pid, ref, workspace_id, line, context, max_heap_size, apis) do :erlang.spawn_opt( fn -> result = eval_stateless(workspace_id, line, context, apis) @@ -179,7 +179,7 @@ defmodule Jido.Shell.Backend.Lua do ) end - defp eval_stateless(workspace_id, script, context, apis \\ []) do + defp eval_stateless(workspace_id, script, context, apis) do lua = Lua.new() |> Lua.load_api(JidoApi) From d038de703b94aa031cb65c5e23b837066540c2b1 Mon Sep 17 00:00:00 2001 From: Mike Hostetler <84222+mikehostetler@users.noreply.github.com> Date: Wed, 10 Jun 2026 08:34:34 -0500 Subject: [PATCH 4/4] deps: update lua to 1.0 rc --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 27e5e7a..c2314dc 100644 --- a/mix.exs +++ b/mix.exs @@ -78,7 +78,7 @@ defmodule Jido.Shell.MixProject do ref: "c1038ff83e825c29ea131bf8b728bd1672734c01", only: [:dev, :test], optional: true}, - {:lua, "~> 0.4"}, + {:lua, "~> 1.0.0-rc.1"}, # Dev/Test dependencies {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index ec11d0c..038ae8c 100644 --- a/mix.lock +++ b/mix.lock @@ -27,7 +27,7 @@ "igniter": {:hex, :igniter, "0.8.1", "3c6ea47f3a6031015e29da8b4ba5c685f0a2e409facf63041fd83e982ca3aa89", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "d99472e6daf3bfc3675d699c6c7ace9196f377207aab83e09d7b95e9d90e8ae8"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "jido_vfs": {:hex, :jido_vfs, "1.0.0", "c92ca76e3a11413130b7c1c8f7868a011c1115735ec1047a2e92d4c908b7681d", [:mix], [{:eternal, "~> 1.2.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:ex_aws_s3, "~> 2.2", [hex: :ex_aws_s3, repo: "hexpm", optional: false]}, {:git_cli, "~> 0.3.0", [hex: :git_cli, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:splode, "~> 0.3.0", [hex: :splode, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:tentacat, "~> 2.0", [hex: :tentacat, repo: "hexpm", optional: false]}], "hexpm", "cb760e0d0ae46c407c439e3bf23fdfd5aec8406f4021e04f8d40a8cec9e7e3cd"}, - "lua": {:hex, :lua, "0.4.0", "de0f04871fdd133cd13a0662690b4fd3ba7a73ca5857493c4665a0a4251908fe", [:mix], [{:luerl, "~> 1.5.1", [hex: :luerl, repo: "hexpm", optional: false]}], "hexpm", "648e17ab9faa1ab1a788fa58ed608366a7026d0eeaec2f311510c065817c4067"}, + "lua": {:hex, :lua, "1.0.0-rc.1", "78e9e889c1c1d85ffbb1e73cc2e4e3b1a22ebf88ec5b7caff77ef7fa57105368", [:mix], [], "hexpm", "85290b8cb051f0dceaafe42601295d91f16f62c5691ca995118b3dc8c994505e"}, "luerl": {:hex, :luerl, "1.5.1", "f6700420950fc6889137e7a0c11c4a8467dea04a8c23f707a40d83566d14e786", [:rebar3], [], "hexpm", "abf88d849baa0d5dca93b245a8688d4de2ee3d588159bb2faf51e15946509390"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},