From 79deb90ae8987cdf9befff43352964456c1390fa Mon Sep 17 00:00:00 2001 From: Mike Hostetler <84222+mikehostetler@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:44:07 -0500 Subject: [PATCH 1/4] fix: require bash dependency for backend compile --- lib/jido_shell/environment/sprite.ex | 4 ---- mix.exs | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/jido_shell/environment/sprite.ex b/lib/jido_shell/environment/sprite.ex index 24d139a..a5a24c7 100644 --- a/lib/jido_shell/environment/sprite.ex +++ b/lib/jido_shell/environment/sprite.ex @@ -162,10 +162,6 @@ defmodule Jido.Shell.Environment.Sprite do end end - defp destroy_sprite(nil, _client, _sprites_mod), do: {:error, :missing_sprite_name} - defp destroy_sprite("", _client, _sprites_mod), do: {:error, :missing_sprite_name} - defp destroy_sprite(_sprite_name, nil, _sprites_mod), do: {:error, :missing_sprites_client} - defp destroy_sprite(sprite_name, client, sprites_mod) do with true <- supports?(sprites_mod, :sprite, 2), true <- supports?(sprites_mod, :destroy, 1) do diff --git a/mix.exs b/mix.exs index f25cc1f..4803cf9 100644 --- a/mix.exs +++ b/mix.exs @@ -74,7 +74,7 @@ defmodule Jido.Shell.MixProject do {:zoi, "~> 0.17"}, {:jido_vfs, "~> 1.0"}, {:bash, - git: "https://github.com/tv-labs/bash.git", ref: "c1038ff83e825c29ea131bf8b728bd1672734c01", optional: true}, + git: "https://github.com/tv-labs/bash.git", ref: "c1038ff83e825c29ea131bf8b728bd1672734c01"}, # Dev/Test dependencies {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, From 37b2f44011796b149d9ba465e2ea7ea70e886e5f Mon Sep 17 00:00:00 2001 From: Mike Hostetler <84222+mikehostetler@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:45:39 -0500 Subject: [PATCH 2/4] docs: align bash dependency guidance --- README.md | 6 +++--- lib/jido_shell/backend/bash.ex | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a103fef..28c32fd 100644 --- a/README.md +++ b/README.md @@ -96,13 +96,13 @@ Sessions run with `Jido.Shell.Backend.Local` by default. The Bash backend hands entire command lines to a persistent `Bash.Session` process, so loops, conditionals, variables, pipes, and arithmetic expansion all work as in real Bash. State persists across calls within the same session. -**Dependency** — add the optional `:bash` package to your `mix.exs`: +The `:bash` package is a required `jido_shell` dependency, so consumers do not +need to declare it separately. ```elixir {:bash, git: "https://github.com/tv-labs/bash.git", - ref: "c1038ff83e825c29ea131bf8b728bd1672734c01", - optional: true} + ref: "c1038ff83e825c29ea131bf8b728bd1672734c01"} ``` **Starting a session:** diff --git a/lib/jido_shell/backend/bash.ex b/lib/jido_shell/backend/bash.ex index 4dc2c39..def8a60 100644 --- a/lib/jido_shell/backend/bash.ex +++ b/lib/jido_shell/backend/bash.ex @@ -62,7 +62,7 @@ defmodule Jido.Shell.Backend.Bash do Jido.Shell.ShellSession.run_command(sid, "for i in 1 2 3; do echo $i; done") - Requires the optional `:bash` dependency to be compiled into the release. + Requires the package-level `:bash` dependency to be compiled into the release. """ @behaviour Jido.Shell.Backend From 3f7f52faadc4ed9d3b59d1146da78f901cc4c45d Mon Sep 17 00:00:00 2001 From: Mike Hostetler <84222+mikehostetler@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:53:56 -0500 Subject: [PATCH 3/4] fix: guard optional bash backend dependency --- README.md | 5 +- lib/jido_shell/backend/bash.ex | 798 +++++++++++--------- lib/jido_shell/backend/bash/jido_interop.ex | 362 ++++----- lib/jido_shell/backend/bash/vfs_adapter.ex | 410 +++++----- mix.exs | 15 +- 5 files changed, 833 insertions(+), 757 deletions(-) diff --git a/README.md b/README.md index 28c32fd..8953565 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,9 @@ Sessions run with `Jido.Shell.Backend.Local` by default. The Bash backend hands entire command lines to a persistent `Bash.Session` process, so loops, conditionals, variables, pipes, and arithmetic expansion all work as in real Bash. State persists across calls within the same session. -The `:bash` package is a required `jido_shell` dependency, so consumers do not -need to declare it separately. +The Bash backend is optional. Projects that use `Jido.Shell.Backend.Bash` must +add the `:bash` package; projects that do not use this backend compile without +it. ```elixir {:bash, diff --git a/lib/jido_shell/backend/bash.ex b/lib/jido_shell/backend/bash.ex index def8a60..a9c95ee 100644 --- a/lib/jido_shell/backend/bash.ex +++ b/lib/jido_shell/backend/bash.ex @@ -1,458 +1,510 @@ -defmodule Jido.Shell.Backend.Bash do - @moduledoc """ - Backend that executes real Bash scripts via the `:bash` library - ([tv-labs/bash](https://github.com/tv-labs/bash)). - - Unlike `Jido.Shell.Backend.Local`, which parses commands with the Jido shell - parser and routes each statement to a registered command module, this backend - hands the entire command line to a persistent `Bash.Session` GenServer. That - means loops, conditionals, variable assignments, arithmetic expansion, and - pipes all work as in normal Bash — state (variables, functions, cwd) - persists across calls within the same jido session. - - Registered Jido commands (`echo`, `ls`, `cat`, …) are bridged into bash via - `Jido.Shell.Backend.Bash.JidoInterop`, with bash function shims installed at - init time so scripts can call them by their familiar names. Filesystem I/O - routes through `Jido.Shell.Backend.Bash.VfsAdapter`, which delegates to - `Jido.Shell.VFS`. The backend pins `command_policy: :no_external`, so bash - scripts cannot spawn any host process — every effective command is either a - bash builtin or a Jido interop call. - - ## Isolation model - - Four layers enforce sandbox boundaries: - - 1. **Command policy** — `command_policy: [commands: :no_external]` prevents - any OS process from being spawned. Only bash builtins, user-defined shell - functions, and Jido interop calls may execute. - - 2. **Virtual filesystem** — all file I/O (redirections, `source`, PATH - resolution, glob expansion, test operators) routes through - `Jido.Shell.Backend.Bash.VfsAdapter`, which delegates to - `Jido.Shell.VFS`. No `File.*` or `:file.*` calls reach the host. - - 3. **Sanitised environment** — `HOME`, `PATH`, and `MACHTYPE` are overridden - with sandbox-safe values so the `:bash` library's init does not leak - host-system information into session variables. User-supplied env values - from `config.env` take precedence via merge ordering. - - 4. **Interop trust boundary** — `defbash` handlers in - `Jido.Shell.Backend.Bash.JidoInterop` execute as **unrestricted Elixir - code** inside the same BEAM process as the session. The `:bash` library - provides no sandbox around interop function bodies. Any module loaded via - the `apis:` option has full access to `System.*`, `File.*`, `Port.*`, - `spawn`, and the rest of the BEAM. **Only load interop modules you have - audited.** The built-in `JidoInterop` is safe — it delegates every call - to `Jido.Shell.CommandRunner`, which routes through VFS and the command - registry. - - ## Known limitations - - * External binaries (`grep`, `sed`, `awk`, `find`, `curl`, …) are blocked by - the command policy — use the bridged Jido commands instead. - * Glob support (`VfsAdapter.wildcard/3`) covers simple `*`/`?` patterns - only. - * `configure_network/2` is a no-op — network policy is fixed at - `:no_external`. - - ## Usage - - {:ok, sid} = - Jido.Shell.ShellSession.start("ws1", backend: {Jido.Shell.Backend.Bash, %{}}) - - Jido.Shell.ShellSession.run_command(sid, "for i in 1 2 3; do echo $i; done") - - Requires the package-level `:bash` dependency to be compiled into the release. - """ - - @behaviour Jido.Shell.Backend - - alias Jido.Shell.Backend.Bash.JidoInterop - alias Jido.Shell.Backend.Bash.VfsAdapter - alias Jido.Shell.Backend.OutputLimiter - alias Jido.Shell.Error - - @default_task_supervisor Jido.Shell.CommandTaskSupervisor - @cancel_grace_ms 1_000 - @cancel_wait_ms 2_000 - - @impl true - def init(config) when is_map(config) do - with :ok <- ensure_dep_available(), - {:ok, session_pid} <- fetch_session_pid(config), - workspace_id <- Map.get(config, :workspace_id, ""), - {:ok, bash_session} <- start_bash_session(config, workspace_id), - :ok <- install_prelude(bash_session) do - {:ok, - %{ - bash_session: bash_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, %{}) - }} +if Code.ensure_loaded?(Bash.Session) do + defmodule Jido.Shell.Backend.Bash do + @moduledoc """ + Backend that executes real Bash scripts via the `:bash` library + ([tv-labs/bash](https://github.com/tv-labs/bash)). + + Unlike `Jido.Shell.Backend.Local`, which parses commands with the Jido shell + parser and routes each statement to a registered command module, this backend + hands the entire command line to a persistent `Bash.Session` GenServer. That + means loops, conditionals, variable assignments, arithmetic expansion, and + pipes all work as in normal Bash — state (variables, functions, cwd) + persists across calls within the same jido session. + + Registered Jido commands (`echo`, `ls`, `cat`, …) are bridged into bash via + `Jido.Shell.Backend.Bash.JidoInterop`, with bash function shims installed at + init time so scripts can call them by their familiar names. Filesystem I/O + routes through `Jido.Shell.Backend.Bash.VfsAdapter`, which delegates to + `Jido.Shell.VFS`. The backend pins `command_policy: :no_external`, so bash + scripts cannot spawn any host process — every effective command is either a + bash builtin or a Jido interop call. + + ## Isolation model + + Four layers enforce sandbox boundaries: + + 1. **Command policy** — `command_policy: [commands: :no_external]` prevents + any OS process from being spawned. Only bash builtins, user-defined shell + functions, and Jido interop calls may execute. + + 2. **Virtual filesystem** — all file I/O (redirections, `source`, PATH + resolution, glob expansion, test operators) routes through + `Jido.Shell.Backend.Bash.VfsAdapter`, which delegates to + `Jido.Shell.VFS`. No `File.*` or `:file.*` calls reach the host. + + 3. **Sanitised environment** — `HOME`, `PATH`, and `MACHTYPE` are overridden + with sandbox-safe values so the `:bash` library's init does not leak + host-system information into session variables. User-supplied env values + from `config.env` take precedence via merge ordering. + + 4. **Interop trust boundary** — `defbash` handlers in + `Jido.Shell.Backend.Bash.JidoInterop` execute as **unrestricted Elixir + code** inside the same BEAM process as the session. The `:bash` library + provides no sandbox around interop function bodies. Any module loaded via + the `apis:` option has full access to `System.*`, `File.*`, `Port.*`, + `spawn`, and the rest of the BEAM. **Only load interop modules you have + audited.** The built-in `JidoInterop` is safe — it delegates every call + to `Jido.Shell.CommandRunner`, which routes through VFS and the command + registry. + + ## Known limitations + + * External binaries (`grep`, `sed`, `awk`, `find`, `curl`, …) are blocked by + the command policy — use the bridged Jido commands instead. + * Glob support (`VfsAdapter.wildcard/3`) covers simple `*`/`?` patterns + only. + * `configure_network/2` is a no-op — network policy is fixed at + `:no_external`. + + ## Usage + + {:ok, sid} = + Jido.Shell.ShellSession.start("ws1", backend: {Jido.Shell.Backend.Bash, %{}}) + + Jido.Shell.ShellSession.run_command(sid, "for i in 1 2 3; do echo $i; done") + + Requires the optional `:bash` dependency to be compiled into the release. + """ + + @behaviour Jido.Shell.Backend + + alias Jido.Shell.Backend.Bash.JidoInterop + alias Jido.Shell.Backend.Bash.VfsAdapter + alias Jido.Shell.Backend.OutputLimiter + alias Jido.Shell.Error + + @default_task_supervisor Jido.Shell.CommandTaskSupervisor + @cancel_grace_ms 1_000 + @cancel_wait_ms 2_000 + + @impl true + def init(config) when is_map(config) do + with :ok <- ensure_dep_available(), + {:ok, session_pid} <- fetch_session_pid(config), + workspace_id <- Map.get(config, :workspace_id, ""), + {:ok, bash_session} <- start_bash_session(config, workspace_id), + :ok <- install_prelude(bash_session) do + {:ok, + %{ + bash_session: bash_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, %{}) + }} + end 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) - session_pid = state.session_pid - bash_session = state.bash_session - task_supervisor = state.task_supervisor - previous_cwd = state.cwd - timeout = positive_limit(Keyword.get(exec_opts, :timeout)) - output_limit = positive_limit(Keyword.get(exec_opts, :output_limit)) + @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) + session_pid = state.session_pid + bash_session = state.bash_session + task_supervisor = state.task_supervisor + previous_cwd = state.cwd + timeout = positive_limit(Keyword.get(exec_opts, :timeout)) + output_limit = positive_limit(Keyword.get(exec_opts, :output_limit)) - task_fun = fn -> - {emit, limit_ref} = limited_emit(session_pid, bash_session, output_limit) + task_fun = fn -> + {emit, limit_ref} = limited_emit(session_pid, bash_session, output_limit) - result = - case safe_parse(line) do - {:error, parse_error} -> - {:error, Error.command(:syntax_error, %{line: line, reason: inspect(parse_error)})} + result = + case safe_parse(line) do + {:error, parse_error} -> + {:error, Error.command(:syntax_error, %{line: line, reason: inspect(parse_error)})} - {:ok, ast} -> - raw = execute_bash(task_supervisor, bash_session, ast, line, emit, limit_ref, timeout) + {:ok, ast} -> + raw = execute_bash(task_supervisor, bash_session, ast, line, emit, limit_ref, timeout) - maybe_augment_with_cwd(raw, bash_session, previous_cwd) - end + maybe_augment_with_cwd(raw, bash_session, previous_cwd) + end - send(session_pid, {:command_finished, result}) - result - end + send(session_pid, {:command_finished, result}) + result + end - case Task.Supervisor.start_child(state.task_supervisor, task_fun) do - {:ok, task_pid} -> {:ok, task_pid, state} - {:error, reason} -> {:error, Error.command(:start_failed, %{reason: reason, line: line})} + case Task.Supervisor.start_child(state.task_supervisor, task_fun) do + {:ok, task_pid} -> {:ok, task_pid, state} + {:error, reason} -> {:error, Error.command(:start_failed, %{reason: reason, line: line})} + end end - end - @impl true - def cancel(state, command_ref) when is_pid(command_ref) do - # Interrupt the foreground bash execution cooperatively so traps can run. - _ = safe_signal_execution(state.bash_session) - _ = await_process_exit(command_ref, @cancel_wait_ms) + @impl true + def cancel(state, command_ref) when is_pid(command_ref) do + # Interrupt the foreground bash execution cooperatively so traps can run. + _ = safe_signal_execution(state.bash_session) + _ = await_process_exit(command_ref, @cancel_wait_ms) + + # Kill the Task wrapper (may already be finishing after the signal). + if Process.alive?(command_ref) do + Process.exit(command_ref, :shutdown) + end - # Kill the Task wrapper (may already be finishing after the signal). - if Process.alive?(command_ref) do - Process.exit(command_ref, :shutdown) + :ok end - :ok - end + def cancel(_state, _command_ref), do: {:error, :invalid_command_ref} - def cancel(_state, _command_ref), do: {:error, :invalid_command_ref} + @impl true + def terminate(state) do + case Map.get(state, :bash_session) do + pid when is_pid(pid) -> + if Process.alive?(pid), do: safe_stop(pid) + :ok - @impl true - def terminate(state) do - case Map.get(state, :bash_session) do - pid when is_pid(pid) -> - if Process.alive?(pid), do: safe_stop(pid) - :ok + _ -> + :ok + end + end - _ -> - :ok + @impl true + def cwd(state) do + case safe_call(state.bash_session, &Bash.Session.get_cwd/1) do + {:ok, cwd} -> {:ok, cwd, %{state | cwd: cwd}} + _ -> {:ok, state.cwd, state} + end end - end - @impl true - def cwd(state) do - case safe_call(state.bash_session, &Bash.Session.get_cwd/1) do - {:ok, cwd} -> {:ok, cwd, %{state | cwd: cwd}} - _ -> {:ok, state.cwd, state} + @impl true + def cd(state, path) when is_binary(path) do + _ = safe_call(state.bash_session, fn pid -> Bash.Session.chdir(pid, path) end) + {:ok, %{state | cwd: path}} end - end - @impl true - def cd(state, path) when is_binary(path) do - _ = safe_call(state.bash_session, fn pid -> Bash.Session.chdir(pid, path) end) - {:ok, %{state | cwd: path}} - end + @impl true + def configure_network(state, _policy), do: {:ok, state} + + # === private === - @impl true - def configure_network(state, _policy), do: {:ok, state} + defp ensure_dep_available do + if Code.ensure_loaded?(Bash.Session) do + :ok + else + {:error, Error.command(:start_failed, %{reason: :bash_dep_unavailable})} + end + end - # === private === + 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 ensure_dep_available do - if Code.ensure_loaded?(Bash.Session) do - :ok - else - {:error, Error.command(:start_failed, %{reason: :bash_dep_unavailable})} + # Sandbox-safe defaults that prevent the `:bash` library from seeding + # session variables with values read from the host OS at init time + # (`System.get_env("HOME")`, `System.get_env("PATH")`, etc.). + @sandbox_env_defaults %{ + "HOME" => "/", + "PATH" => "", + "MACHTYPE" => "beam-unknown-elixir" + } + + defp start_bash_session(config, workspace_id) do + user_env = Map.get(config, :env, %{}) + cwd = Map.get(config, :cwd, "/") + + env = + @sandbox_env_defaults + |> Map.merge(user_env) + |> Map.put("JIDO_WORKSPACE_ID", workspace_id) + + opts = [ + filesystem: {VfsAdapter, %{workspace_id: workspace_id}}, + working_dir: cwd, + env: env, + command_policy: [commands: :no_external], + apis: [JidoInterop] + ] + + case Bash.Session.new(opts) do + {:ok, pid} -> {:ok, pid} + {:error, reason} -> {:error, Error.command(:start_failed, %{reason: reason})} + end 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})} + # Bash aliases are only active for interactive shells, so we install function + # shims instead. Each shim routes the familiar command name (echo, ls, …) to + # the corresponding `jido.*` interop handler. + defp install_prelude(bash_session) do + prelude = build_prelude() + + case safe_parse(prelude) do + {:ok, ast} -> + case Bash.Session.execute(bash_session, ast, []) do + {:ok, _} -> :ok + {:error, reason} -> {:error, Error.command(:start_failed, %{reason: {:prelude_failed, reason}})} + _ -> :ok + end + + {:error, parse_error} -> + {:error, Error.command(:start_failed, %{reason: {:prelude_parse_failed, parse_error}})} + end end - end - # Sandbox-safe defaults that prevent the `:bash` library from seeding - # session variables with values read from the host OS at init time - # (`System.get_env("HOME")`, `System.get_env("PATH")`, etc.). - @sandbox_env_defaults %{ - "HOME" => "/", - "PATH" => "", - "MACHTYPE" => "beam-unknown-elixir" - } - - defp start_bash_session(config, workspace_id) do - user_env = Map.get(config, :env, %{}) - cwd = Map.get(config, :cwd, "/") - - env = - @sandbox_env_defaults - |> Map.merge(user_env) - |> Map.put("JIDO_WORKSPACE_ID", workspace_id) - - opts = [ - filesystem: {VfsAdapter, %{workspace_id: workspace_id}}, - working_dir: cwd, - env: env, - command_policy: [commands: :no_external], - apis: [JidoInterop] - ] - - case Bash.Session.new(opts) do - {:ok, pid} -> {:ok, pid} - {:error, reason} -> {:error, Error.command(:start_failed, %{reason: reason})} + # `bash` and `help` are internal shell builtins that don't map to Jido + # commands and are handled natively by the bash library. + defp build_prelude do + skip = ~w(bash help) + + Jido.Shell.Command.Registry.commands() + |> Map.keys() + |> Enum.sort() + |> Enum.reject(&(&1 in skip)) + |> Enum.map(fn name -> "#{name}() { jido.#{name} \"$@\"; }" end) + |> Enum.join("\n") end - end - # Bash aliases are only active for interactive shells, so we install function - # shims instead. Each shim routes the familiar command name (echo, ls, …) to - # the corresponding `jido.*` interop handler. - defp install_prelude(bash_session) do - prelude = build_prelude() - - case safe_parse(prelude) do - {:ok, ast} -> - case Bash.Session.execute(bash_session, ast, []) do - {:ok, _} -> :ok - {:error, reason} -> {:error, Error.command(:start_failed, %{reason: {:prelude_failed, reason}})} - _ -> :ok - end + defp command_line(command, []), do: command + defp command_line(command, args), do: Enum.join([command | args], " ") - {:error, parse_error} -> - {:error, Error.command(:start_failed, %{reason: {:prelude_parse_failed, parse_error}})} + defp safe_parse(line) do + case Bash.parse(line) do + {:ok, ast} -> {:ok, ast} + {:error, err} -> {:error, err} + end end - end - # `bash` and `help` are internal shell builtins that don't map to Jido - # commands and are handled natively by the bash library. - defp build_prelude do - skip = ~w(bash help) - - Jido.Shell.Command.Registry.commands() - |> Map.keys() - |> Enum.sort() - |> Enum.reject(&(&1 in skip)) - |> Enum.map(fn name -> "#{name}() { jido.#{name} \"$@\"; }" end) - |> Enum.join("\n") - end + defp execute_bash(task_supervisor, bash_session, ast, line, emit, limit_ref, timeout) do + task = + Task.Supervisor.async_nolink(task_supervisor, fn -> + Bash.Session.execute(bash_session, ast, on_output: &stream_output(emit, &1)) + end) - defp command_line(command, []), do: command - defp command_line(command, args), do: Enum.join([command | args], " ") + await_bash(task, bash_session, line, limit_ref, timeout) + end - defp safe_parse(line) do - case Bash.parse(line) do - {:ok, ast} -> {:ok, ast} - {:error, err} -> {:error, err} + defp await_bash(task, bash_session, line, limit_ref, timeout) do + task_ref = task.ref + + receive do + {^limit_ref, {:error, %Error{} = error}} -> + _ = signal_and_shutdown_task(task, bash_session) + {:error, error} + + {^task_ref, result} -> + case pending_limit_error(limit_ref) do + {:error, %Error{} = error} -> {:error, error} + :none -> bash_result(result, line) + end + + {:DOWN, ^task_ref, :process, _pid, reason} -> + {:error, Error.command(:crashed, %{line: line, reason: reason})} + after + receive_timeout(timeout) -> + _ = signal_and_shutdown_task(task, bash_session) + {:error, Error.command(:runtime_limit_exceeded, %{line: line, max_runtime_ms: timeout})} + end end - end - defp execute_bash(task_supervisor, bash_session, ast, line, emit, limit_ref, timeout) do - task = - Task.Supervisor.async_nolink(task_supervisor, fn -> - Bash.Session.execute(bash_session, ast, on_output: &stream_output(emit, &1)) - end) + defp bash_result({status, execution}, line) when status in [:ok, :error, :exit, :exec] do + case exit_code(execution) do + 0 -> + {:ok, nil} - await_bash(task, bash_session, line, limit_ref, timeout) - end + nil when status == :error -> + {:error, Error.command(:exit_code, %{exit_code: 1, line: line})} + + nil -> + {:ok, nil} + + code -> + {:error, Error.command(:exit_code, %{exit_code: code, line: line})} + end + end + + defp bash_result(other, line), do: {:error, Error.command(:exit_code, %{exit_code: 1, line: line, result: other})} - defp await_bash(task, bash_session, line, limit_ref, timeout) do - task_ref = task.ref + defp limited_emit(session_pid, bash_session, output_limit) do + owner = self() + limit_ref = make_ref() + counter = :counters.new(1, []) - receive do - {^limit_ref, {:error, %Error{} = error}} -> - _ = signal_and_shutdown_task(task, bash_session) - {:error, error} + emit = fn event -> + case check_output_limit(event, counter, output_limit) do + :ok -> + send(session_pid, {:command_event, event}) - {^task_ref, result} -> - case pending_limit_error(limit_ref) do - {:error, %Error{} = error} -> {:error, error} - :none -> bash_result(result, line) + {:error, %Error{} = error} -> + send(owner, {limit_ref, {:error, error}}) + _ = safe_signal_execution(bash_session) + :ok end + end - {:DOWN, ^task_ref, :process, _pid, reason} -> - {:error, Error.command(:crashed, %{line: line, reason: reason})} - after - receive_timeout(timeout) -> - _ = signal_and_shutdown_task(task, bash_session) - {:error, Error.command(:runtime_limit_exceeded, %{line: line, max_runtime_ms: timeout})} + {emit, limit_ref} end - end - defp bash_result({status, execution}, line) when status in [:ok, :error, :exit, :exec] do - case exit_code(execution) do - 0 -> - {:ok, nil} + defp check_output_limit(_event, _counter, nil), do: :ok - nil when status == :error -> - {:error, Error.command(:exit_code, %{exit_code: 1, line: line})} + defp check_output_limit(event, counter, output_limit) do + case output_size(event) do + nil -> + :ok - nil -> - {:ok, nil} + chunk_bytes -> + emitted_bytes = :counters.get(counter, 1) - code -> - {:error, Error.command(:exit_code, %{exit_code: code, line: line})} + 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 - end - defp bash_result(other, line), do: {:error, Error.command(:exit_code, %{exit_code: 1, line: line, result: other})} + 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 pending_limit_error(limit_ref) do + receive do + {^limit_ref, {:error, %Error{} = error}} -> {:error, error} + after + 0 -> :none + end + end - defp limited_emit(session_pid, bash_session, output_limit) do - owner = self() - limit_ref = make_ref() - counter = :counters.new(1, []) + defp signal_and_shutdown_task(task, bash_session) do + _ = safe_signal_execution(bash_session) + Task.shutdown(task, @cancel_wait_ms) || Task.shutdown(task, :brutal_kill) + end - emit = fn event -> - case check_output_limit(event, counter, output_limit) do - :ok -> - send(session_pid, {:command_event, event}) + defp await_process_exit(pid, timeout) when is_pid(pid) do + ref = Process.monitor(pid) - {:error, %Error{} = error} -> - send(owner, {limit_ref, {:error, error}}) - _ = safe_signal_execution(bash_session) - :ok + receive do + {:DOWN, ^ref, :process, ^pid, _reason} -> :ok + after + timeout -> + Process.demonitor(ref, [:flush]) + :timeout end end - {emit, limit_ref} - end + 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 check_output_limit(_event, _counter, nil), do: :ok + defp positive_limit(value) when is_integer(value) and value > 0, do: value + defp positive_limit(_value), do: nil - defp check_output_limit(event, counter, output_limit) do - case output_size(event) do - nil -> - :ok + defp stream_output(emit, {:stdout, data}), do: emit.({:output, data}) + defp stream_output(emit, {:stderr, data}), do: emit.({:output_stderr, data}) + defp stream_output(_emit, _), do: :ok - chunk_bytes -> - emitted_bytes = :counters.get(counter, 1) + defp exit_code(%{exit_code: code}) when is_integer(code), do: code - case OutputLimiter.check(chunk_bytes, emitted_bytes, output_limit) do - {:ok, updated_total} -> - :counters.put(counter, 1, updated_total) - :ok + defp exit_code(execution) do + try do + Bash.ExecutionResult.exit_code(execution) + rescue + _ -> nil + end + end - {:limit_exceeded, %Error{} = error} -> - {:error, error} - end + # After each command, pull the session's current working directory and, if it + # changed, wrap the result in `{:state_update, %{cwd: new_cwd}}` so + # `ShellSessionServer` applies the update and broadcasts `:cwd_changed`. + defp maybe_augment_with_cwd({:ok, nil}, bash_session, previous_cwd) do + case current_cwd(bash_session) do + {:ok, cwd} when cwd != previous_cwd -> {:ok, {:state_update, %{cwd: cwd}}} + _ -> {:ok, nil} + 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 maybe_augment_with_cwd(other, _bash_session, _previous_cwd), do: other - defp pending_limit_error(limit_ref) do - receive do - {^limit_ref, {:error, %Error{} = error}} -> {:error, error} - after - 0 -> :none + defp current_cwd(bash_session) do + case safe_call(bash_session, &Bash.Session.get_cwd/1) do + {:ok, cwd} when is_binary(cwd) -> {:ok, cwd} + _ -> :error + end end - end - - defp signal_and_shutdown_task(task, bash_session) do - _ = safe_signal_execution(bash_session) - Task.shutdown(task, @cancel_wait_ms) || Task.shutdown(task, :brutal_kill) - end - defp await_process_exit(pid, timeout) when is_pid(pid) do - ref = Process.monitor(pid) + defp safe_call(pid, fun) when is_pid(pid) do + if Process.alive?(pid) do + {:ok, fun.(pid)} + else + {:error, :dead} + end + rescue + _ -> {:error, :call_failed} + catch + _, _ -> {:error, :call_failed} + end - receive do - {:DOWN, ^ref, :process, ^pid, _reason} -> :ok - after - timeout -> - Process.demonitor(ref, [:flush]) - :timeout + defp safe_signal_execution(pid) when is_pid(pid) do + if Process.alive?(pid), do: signal_session(pid) + :ok + rescue + _ -> :ok + catch + _, _ -> :ok end - end - 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 safe_signal_execution(_pid), do: :ok - defp positive_limit(value) when is_integer(value) and value > 0, do: value - defp positive_limit(_value), do: nil + defp signal_session(pid) do + cond do + function_exported?(Bash.Session, :signal, 3) -> + apply(Bash.Session, :signal, [pid, :sigint, [grace: @cancel_grace_ms]]) - defp stream_output(emit, {:stdout, data}), do: emit.({:output, data}) - defp stream_output(emit, {:stderr, data}), do: emit.({:output_stderr, data}) - defp stream_output(_emit, _), do: :ok + function_exported?(Bash.Session, :signal_job, 3) -> + apply(Bash.Session, :signal_job, [pid, 1, :sigint]) - defp exit_code(%{exit_code: code}) when is_integer(code), do: code + true -> + :ok + end + end - defp exit_code(execution) do - try do - Bash.ExecutionResult.exit_code(execution) + defp safe_stop(pid) do + Bash.Session.stop(pid) rescue - _ -> nil + _ -> :ok + catch + _, _ -> :ok end end +else + defmodule Jido.Shell.Backend.Bash do + @moduledoc """ + Placeholder Bash backend compiled when the optional `:bash` dependency is not + available. - # After each command, pull the session's current working directory and, if it - # changed, wrap the result in `{:state_update, %{cwd: new_cwd}}` so - # `ShellSessionServer` applies the update and broadcasts `:cwd_changed`. - defp maybe_augment_with_cwd({:ok, nil}, bash_session, previous_cwd) do - case current_cwd(bash_session) do - {:ok, cwd} when cwd != previous_cwd -> {:ok, {:state_update, %{cwd: cwd}}} - _ -> {:ok, nil} - end - end + Add the `:bash` package to the consuming application to enable this backend. + """ - defp maybe_augment_with_cwd(other, _bash_session, _previous_cwd), do: other + @behaviour Jido.Shell.Backend - defp current_cwd(bash_session) do - case safe_call(bash_session, &Bash.Session.get_cwd/1) do - {:ok, cwd} when is_binary(cwd) -> {:ok, cwd} - _ -> :error - end - end + alias Jido.Shell.Error - defp safe_call(pid, fun) when is_pid(pid) do - if Process.alive?(pid) do - {:ok, fun.(pid)} - else - {:error, :dead} + @impl true + def init(_config), do: {:error, Error.command(:start_failed, %{reason: :bash_dep_unavailable})} + + @impl true + def execute(_state, command, args, _exec_opts) do + line = Enum.join([command | args], " ") + {:error, Error.command(:start_failed, %{reason: :bash_dep_unavailable, line: line})} end - rescue - _ -> {:error, :call_failed} - catch - _, _ -> {:error, :call_failed} - end - defp safe_signal_execution(pid) when is_pid(pid) do - if Process.alive?(pid), do: Bash.Session.signal(pid, :sigint, grace: @cancel_grace_ms) - :ok - rescue - _ -> :ok - catch - _, _ -> :ok - end + @impl true + def cancel(_state, _command_ref), do: {:error, :bash_dep_unavailable} + + @impl true + def terminate(_state), do: :ok + + @impl true + def cwd(state), do: {:ok, Map.get(state, :cwd, "/"), state} - defp safe_signal_execution(_pid), do: :ok + @impl true + def cd(state, path) when is_binary(path), do: {:ok, Map.put(state, :cwd, path)} - defp safe_stop(pid) do - Bash.Session.stop(pid) - rescue - _ -> :ok - catch - _, _ -> :ok + @impl true + def configure_network(state, _policy), do: {:ok, state} end end diff --git a/lib/jido_shell/backend/bash/jido_interop.ex b/lib/jido_shell/backend/bash/jido_interop.ex index a302ae4..16e6649 100644 --- a/lib/jido_shell/backend/bash/jido_interop.ex +++ b/lib/jido_shell/backend/bash/jido_interop.ex @@ -1,209 +1,215 @@ -defmodule Jido.Shell.Backend.Bash.JidoInterop do - @moduledoc """ - `Bash.Interop` bridge that exposes registered Jido shell commands to scripts - running inside a `Bash.Session`. - - For each entry in `Jido.Shell.Command.Registry.commands/0` (minus `bash` and - `help`), this module defines a `defbash` handler named after the command. When - bash calls `jido.echo hello`, the handler reconstructs a transient - `Jido.Shell.ShellSession.State` from the current bash session state plus the - captured workspace id, invokes `Jido.Shell.CommandRunner.execute/3`, and - funnels the buffered output back through `Bash.puts/2`. - - The bash session's working directory and environment are kept in sync with - Jido state transitions: a `cd` command emits `{:state_update, %{cwd: …}}`, - and the bridge forwards that via `Bash.update_state/1` so subsequent bash - builtins (and the outer backend) see the new cwd. - - Workspace id is taken from the bash session state under the - `:jido_workspace_id` variable, which `Jido.Shell.Backend.Bash` sets during - initialisation. - - ## Security — interop trust boundary - - `defbash` handlers execute as **unrestricted Elixir code** in the same BEAM - process as the `Bash.Session` GenServer. The `:bash` library does not sandbox - interop function bodies — a handler may call `File.*`, `System.cmd`, - `System.get_env`, `spawn`, or any other BEAM API. - - This module is safe because every handler delegates to - `Jido.Shell.CommandRunner.execute/3`, which routes through the VFS and the - command registry. If you add a new interop module or modify a handler, ensure - it does **not** perform direct host I/O or spawn OS processes — doing so would - bypass the filesystem virtualisation and command policy that the rest of the - backend enforces. - """ - - use Bash.Interop, namespace: "jido" - - alias Jido.Shell.CommandRunner - alias Jido.Shell.ShellSession.State - - defbash(echo(args, session_state), do: __MODULE__.dispatch("echo", args, session_state)) - defbash(pwd(args, session_state), do: __MODULE__.dispatch("pwd", args, session_state)) - defbash(ls(args, session_state), do: __MODULE__.dispatch("ls", args, session_state)) - defbash(cat(args, session_state), do: __MODULE__.dispatch("cat", args, session_state)) - defbash(cd(args, session_state), do: __MODULE__.dispatch("cd", args, session_state)) - defbash(mkdir(args, session_state), do: __MODULE__.dispatch("mkdir", args, session_state)) - defbash(write(args, session_state), do: __MODULE__.dispatch("write", args, session_state)) - defbash(sleep(args, session_state), do: __MODULE__.dispatch("sleep", args, session_state)) - defbash(seq(args, session_state), do: __MODULE__.dispatch("seq", args, session_state)) - defbash(env(args, session_state), do: __MODULE__.dispatch("env", args, session_state)) - defbash(rm(args, session_state), do: __MODULE__.dispatch("rm", args, session_state)) - defbash(cp(args, session_state), do: __MODULE__.dispatch("cp", args, session_state)) - - @doc false - @spec dispatch(String.t(), [String.t()], map()) :: - :ok | {:ok, binary()} | {:error, binary()} - def dispatch(command, args, session_state) do - with {:ok, state} <- build_state(session_state), - line <- build_line(command, args), - {stdout, stderr, result} <- run_command(state, line) do - finalize(result, stdout, stderr) - else - {:error, :missing_workspace} -> - {:error, "jido.#{command}: workspace not configured\n"} - - {:error, reason} -> - {:error, "jido.#{command}: invalid session state: #{inspect(reason)}\n"} +if Code.ensure_loaded?(Bash.Interop) do + defmodule Jido.Shell.Backend.Bash.JidoInterop do + @moduledoc """ + `Bash.Interop` bridge that exposes registered Jido shell commands to scripts + running inside a `Bash.Session`. + + For each entry in `Jido.Shell.Command.Registry.commands/0` (minus `bash` and + `help`), this module defines a `defbash` handler named after the command. When + bash calls `jido.echo hello`, the handler reconstructs a transient + `Jido.Shell.ShellSession.State` from the current bash session state plus the + captured workspace id, invokes `Jido.Shell.CommandRunner.execute/3`, and + funnels the buffered output back through `Bash.puts/2`. + + The bash session's working directory and environment are kept in sync with + Jido state transitions: a `cd` command emits `{:state_update, %{cwd: …}}`, + and the bridge forwards that via `Bash.update_state/1` so subsequent bash + builtins (and the outer backend) see the new cwd. + + Workspace id is taken from the bash session state under the + `:jido_workspace_id` variable, which `Jido.Shell.Backend.Bash` sets during + initialisation. + + ## Security — interop trust boundary + + `defbash` handlers execute as **unrestricted Elixir code** in the same BEAM + process as the `Bash.Session` GenServer. The `:bash` library does not sandbox + interop function bodies — a handler may call `File.*`, `System.cmd`, + `System.get_env`, `spawn`, or any other BEAM API. + + This module is safe because every handler delegates to + `Jido.Shell.CommandRunner.execute/3`, which routes through the VFS and the + command registry. If you add a new interop module or modify a handler, ensure + it does **not** perform direct host I/O or spawn OS processes — doing so would + bypass the filesystem virtualisation and command policy that the rest of the + backend enforces. + """ + + use Bash.Interop, namespace: "jido" + + alias Jido.Shell.CommandRunner + alias Jido.Shell.ShellSession.State + + defbash(echo(args, session_state), do: __MODULE__.dispatch("echo", args, session_state)) + defbash(pwd(args, session_state), do: __MODULE__.dispatch("pwd", args, session_state)) + defbash(ls(args, session_state), do: __MODULE__.dispatch("ls", args, session_state)) + defbash(cat(args, session_state), do: __MODULE__.dispatch("cat", args, session_state)) + defbash(cd(args, session_state), do: __MODULE__.dispatch("cd", args, session_state)) + defbash(mkdir(args, session_state), do: __MODULE__.dispatch("mkdir", args, session_state)) + defbash(write(args, session_state), do: __MODULE__.dispatch("write", args, session_state)) + defbash(sleep(args, session_state), do: __MODULE__.dispatch("sleep", args, session_state)) + defbash(seq(args, session_state), do: __MODULE__.dispatch("seq", args, session_state)) + defbash(env(args, session_state), do: __MODULE__.dispatch("env", args, session_state)) + defbash(rm(args, session_state), do: __MODULE__.dispatch("rm", args, session_state)) + defbash(cp(args, session_state), do: __MODULE__.dispatch("cp", args, session_state)) + + @doc false + @spec dispatch(String.t(), [String.t()], map()) :: + :ok | {:ok, binary()} | {:error, binary()} + def dispatch(command, args, session_state) do + with {:ok, state} <- build_state(session_state), + line <- build_line(command, args), + {stdout, stderr, result} <- run_command(state, line) do + finalize(result, stdout, stderr) + else + {:error, :missing_workspace} -> + {:error, "jido.#{command}: workspace not configured\n"} + + {:error, reason} -> + {:error, "jido.#{command}: invalid session state: #{inspect(reason)}\n"} + end end - end - defp build_state(%{variables: variables} = session_state) do - case Map.get(variables, "JIDO_WORKSPACE_ID") do - nil -> - {:error, :missing_workspace} - - var -> - workspace_id = variable_value(var) - cwd = Map.get(session_state, :working_dir, "/") - env = variables_to_env(variables) - - State.new(%{ - id: "bash-interop", - workspace_id: workspace_id, - cwd: cwd, - env: env - }) + defp build_state(%{variables: variables} = session_state) do + case Map.get(variables, "JIDO_WORKSPACE_ID") do + nil -> + {:error, :missing_workspace} + + var -> + workspace_id = variable_value(var) + cwd = Map.get(session_state, :working_dir, "/") + env = variables_to_env(variables) + + State.new(%{ + id: "bash-interop", + workspace_id: workspace_id, + cwd: cwd, + env: env + }) + end end - end - defp build_state(_), do: {:error, :missing_workspace} - - defp variable_value(%{value: value}) when is_binary(value), do: value - defp variable_value(value) when is_binary(value), do: value - defp variable_value(_), do: "" - - defp variables_to_env(variables) when is_map(variables) do - variables - |> Enum.flat_map(fn - {"JIDO_WORKSPACE_ID", _} -> [] - {key, %{value: value}} when is_binary(value) -> [{key, value}] - {key, value} when is_binary(value) -> [{key, value}] - _ -> [] - end) - |> Map.new() - end + defp build_state(_), do: {:error, :missing_workspace} - defp build_line(command, []), do: command + defp variable_value(%{value: value}) when is_binary(value), do: value + defp variable_value(value) when is_binary(value), do: value + defp variable_value(_), do: "" - defp build_line(command, args) do - Enum.join([command | Enum.map(args, &escape_arg/1)], " ") - end + defp variables_to_env(variables) when is_map(variables) do + variables + |> Enum.flat_map(fn + {"JIDO_WORKSPACE_ID", _} -> [] + {key, %{value: value}} when is_binary(value) -> [{key, value}] + {key, value} when is_binary(value) -> [{key, value}] + _ -> [] + end) + |> Map.new() + end - defp escape_arg(arg) when is_binary(arg) do - escaped = - arg - |> String.replace("\\", "\\\\") - |> String.replace("\"", "\\\"") + defp build_line(command, []), do: command - "\"" <> escaped <> "\"" - end + defp build_line(command, args) do + Enum.join([command | Enum.map(args, &escape_arg/1)], " ") + end - defp escape_arg(other), do: other |> to_string() |> escape_arg() + defp escape_arg(arg) when is_binary(arg) do + escaped = + arg + |> String.replace("\\", "\\\\") + |> String.replace("\"", "\\\"") - defp run_command(state, line) do - parent = self() - ref = make_ref() + "\"" <> escaped <> "\"" + end + + defp escape_arg(other), do: other |> to_string() |> escape_arg() + + defp run_command(state, line) do + parent = self() + ref = make_ref() - emit = fn - {:output, chunk} -> - send(parent, {ref, :stdout, IO.iodata_to_binary(chunk)}) - :ok + emit = fn + {:output, chunk} -> + send(parent, {ref, :stdout, IO.iodata_to_binary(chunk)}) + :ok - {:output_stderr, chunk} -> - send(parent, {ref, :stderr, IO.iodata_to_binary(chunk)}) - :ok + {:output_stderr, chunk} -> + send(parent, {ref, :stderr, IO.iodata_to_binary(chunk)}) + :ok + + _ -> + :ok + end - _ -> - :ok + result = CommandRunner.execute(state, line, emit) + {stdout, stderr} = drain(ref, [], []) + {stdout, stderr, result} end - result = CommandRunner.execute(state, line, emit) - {stdout, stderr} = drain(ref, [], []) - {stdout, stderr, result} - end + defp drain(ref, stdout, stderr) do + receive do + {^ref, :stdout, chunk} -> drain(ref, [chunk | stdout], stderr) + {^ref, :stderr, chunk} -> drain(ref, stdout, [chunk | stderr]) + after + 0 -> {stdout |> Enum.reverse() |> IO.iodata_to_binary(), stderr |> Enum.reverse() |> IO.iodata_to_binary()} + end + end - defp drain(ref, stdout, stderr) do - receive do - {^ref, :stdout, chunk} -> drain(ref, [chunk | stdout], stderr) - {^ref, :stderr, chunk} -> drain(ref, stdout, [chunk | stderr]) - after - 0 -> {stdout |> Enum.reverse() |> IO.iodata_to_binary(), stderr |> Enum.reverse() |> IO.iodata_to_binary()} + defp finalize({:ok, {:state_update, changes}}, stdout, _stderr) do + apply_state_update(changes) + emit_ok(stdout) end - end - defp finalize({:ok, {:state_update, changes}}, stdout, _stderr) do - apply_state_update(changes) - emit_ok(stdout) - end + defp finalize({:ok, _result}, stdout, _stderr) do + emit_ok(stdout) + end - defp finalize({:ok, _result}, stdout, _stderr) do - emit_ok(stdout) - end + defp finalize({:error, %Jido.Shell.Error{} = error}, _stdout, stderr) do + message = + case stderr do + "" -> error.message <> "\n" + s -> s + end - defp finalize({:error, %Jido.Shell.Error{} = error}, _stdout, stderr) do - message = - case stderr do - "" -> error.message <> "\n" - s -> s - end + {:error, message} + end - {:error, message} - end + defp finalize({:error, other}, _stdout, stderr) when is_binary(stderr) and stderr != "" do + _ = other + {:error, stderr} + end - defp finalize({:error, other}, _stdout, stderr) when is_binary(stderr) and stderr != "" do - _ = other - {:error, stderr} - end + defp finalize({:error, other}, _stdout, _stderr) do + {:error, inspect(other) <> "\n"} + end - defp finalize({:error, other}, _stdout, _stderr) do - {:error, inspect(other) <> "\n"} - end + defp emit_ok(""), do: :ok + defp emit_ok(stdout) when is_binary(stdout), do: {:ok, stdout} - defp emit_ok(""), do: :ok - defp emit_ok(stdout) when is_binary(stdout), do: {:ok, stdout} + defp apply_state_update(%{cwd: cwd} = changes) when is_binary(cwd) do + updates = %{working_dir: cwd} + updates = maybe_add_env(updates, changes) + Bash.update_state(updates) + end - defp apply_state_update(%{cwd: cwd} = changes) when is_binary(cwd) do - updates = %{working_dir: cwd} - updates = maybe_add_env(updates, changes) - Bash.update_state(updates) - end + defp apply_state_update(%{env: _} = changes) do + Bash.update_state(maybe_add_env(%{}, changes)) + end - defp apply_state_update(%{env: _} = changes) do - Bash.update_state(maybe_add_env(%{}, changes)) - end + defp apply_state_update(_), do: :ok - defp apply_state_update(_), do: :ok + defp maybe_add_env(updates, %{env: env}) when is_map(env) do + variables = + Map.new(env, fn {k, v} -> + {to_string(k), Bash.Variable.new(to_string(v))} + end) - defp maybe_add_env(updates, %{env: env}) when is_map(env) do - variables = - Map.new(env, fn {k, v} -> - {to_string(k), Bash.Variable.new(to_string(v))} - end) + Map.put(updates, :variables, variables) + end - Map.put(updates, :variables, variables) + defp maybe_add_env(updates, _), do: updates + end +else + defmodule Jido.Shell.Backend.Bash.JidoInterop do + @moduledoc false end - - defp maybe_add_env(updates, _), do: updates end diff --git a/lib/jido_shell/backend/bash/vfs_adapter.ex b/lib/jido_shell/backend/bash/vfs_adapter.ex index c9dc63f..dad0a41 100644 --- a/lib/jido_shell/backend/bash/vfs_adapter.ex +++ b/lib/jido_shell/backend/bash/vfs_adapter.ex @@ -1,261 +1,267 @@ -defmodule Jido.Shell.Backend.Bash.VfsAdapter do - @moduledoc """ - `Bash.Filesystem` implementation that routes calls to `Jido.Shell.VFS`. +if Code.ensure_loaded?(Bash.Filesystem) do + defmodule Jido.Shell.Backend.Bash.VfsAdapter do + @moduledoc """ + `Bash.Filesystem` implementation that routes calls to `Jido.Shell.VFS`. - Used exclusively by `Jido.Shell.Backend.Bash` so that scripts executed by the - `:bash` library see the same virtual filesystem as Jido shell commands. The - adapter is configured with the workspace id at session init time: + Used exclusively by `Jido.Shell.Backend.Bash` so that scripts executed by the + `:bash` library see the same virtual filesystem as Jido shell commands. The + adapter is configured with the workspace id at session init time: - {Jido.Shell.Backend.Bash.VfsAdapter, %{workspace_id: "ws1"}} + {Jido.Shell.Backend.Bash.VfsAdapter, %{workspace_id: "ws1"}} - The adapter is stateless on its own — it looks up mounts from - `Jido.Shell.VFS.MountTable` for every call — and buffers `open/write/close` - streams in the calling process dictionary before flushing on close. + The adapter is stateless on its own — it looks up mounts from + `Jido.Shell.VFS.MountTable` for every call — and buffers `open/write/close` + streams in the calling process dictionary before flushing on close. - Only simple `*`/`?` globbing is supported; more elaborate patterns emit a - single warning per pattern and return an empty list. - """ + Only simple `*`/`?` globbing is supported; more elaborate patterns emit a + single warning per pattern and return an empty list. + """ - @behaviour Bash.Filesystem + @behaviour Bash.Filesystem - alias Jido.Shell.VFS + alias Jido.Shell.VFS - @impl true - def exists?(config, path), do: VFS.exists?(ws(config), normalize(path)) + @impl true + def exists?(config, path), do: VFS.exists?(ws(config), normalize(path)) - @impl true - def dir?(config, path) do - case VFS.stat(ws(config), normalize(path)) do - {:ok, %Jido.VFS.Stat.Dir{}} -> true - _ -> false + @impl true + def dir?(config, path) do + case VFS.stat(ws(config), normalize(path)) do + {:ok, %Jido.VFS.Stat.Dir{}} -> true + _ -> false + end end - end - @impl true - def regular?(config, path) do - case VFS.stat(ws(config), normalize(path)) do - {:ok, %Jido.VFS.Stat.File{}} -> true - _ -> false + @impl true + def regular?(config, path) do + case VFS.stat(ws(config), normalize(path)) do + {:ok, %Jido.VFS.Stat.File{}} -> true + _ -> false + end end - end - @impl true - def stat(config, path) do - case VFS.stat(ws(config), normalize(path)) do - {:ok, entry} -> {:ok, to_file_stat(entry)} - {:error, _} -> {:error, :enoent} + @impl true + def stat(config, path) do + case VFS.stat(ws(config), normalize(path)) do + {:ok, entry} -> {:ok, to_file_stat(entry)} + {:error, _} -> {:error, :enoent} + end end - end - @impl true - def lstat(config, path), do: stat(config, path) + @impl true + def lstat(config, path), do: stat(config, path) - @impl true - def read(config, path) do - case VFS.read_file(ws(config), normalize(path)) do - {:ok, _} = ok -> ok - {:error, _} -> {:error, :enoent} + @impl true + def read(config, path) do + case VFS.read_file(ws(config), normalize(path)) do + {:ok, _} = ok -> ok + {:error, _} -> {:error, :enoent} + end end - end - @impl true - def write(config, path, content, opts) do - path = normalize(path) - binary = IO.iodata_to_binary(content) - workspace_id = ws(config) - - final_content = - if Keyword.get(opts, :append, false) do - case VFS.read_file(workspace_id, path) do - {:ok, existing} -> existing <> binary - {:error, _} -> binary + @impl true + def write(config, path, content, opts) do + path = normalize(path) + binary = IO.iodata_to_binary(content) + workspace_id = ws(config) + + final_content = + if Keyword.get(opts, :append, false) do + case VFS.read_file(workspace_id, path) do + {:ok, existing} -> existing <> binary + {:error, _} -> binary + end + else + binary end - else - binary - end - case VFS.write_file(workspace_id, path, final_content) do - :ok -> :ok - {:error, _} -> {:error, :eacces} + case VFS.write_file(workspace_id, path, final_content) do + :ok -> :ok + {:error, _} -> {:error, :eacces} + end end - end - @impl true - def mkdir_p(config, path) do - case VFS.mkdir(ws(config), normalize(path)) do - :ok -> - :ok + @impl true + def mkdir_p(config, path) do + case VFS.mkdir(ws(config), normalize(path)) do + :ok -> + :ok - {:error, %{code: {:vfs, :already_exists}}} -> - :ok + {:error, %{code: {:vfs, :already_exists}}} -> + :ok - {:error, _} -> - # Collapse any other VFS failure — `mkdir_p` should be idempotent. - if VFS.exists?(ws(config), normalize(path)), do: :ok, else: {:error, :eacces} + {:error, _} -> + # Collapse any other VFS failure — `mkdir_p` should be idempotent. + if VFS.exists?(ws(config), normalize(path)), do: :ok, else: {:error, :eacces} + end end - end - @impl true - def rm(config, path) do - case VFS.delete(ws(config), normalize(path)) do - :ok -> :ok - {:error, _} -> {:error, :enoent} + @impl true + def rm(config, path) do + case VFS.delete(ws(config), normalize(path)) do + :ok -> :ok + {:error, _} -> {:error, :enoent} + end end - end - @impl true - def ls(config, path) do - path = normalize(path) + @impl true + def ls(config, path) do + path = normalize(path) - case VFS.list_dir(ws(config), path) do - {:ok, entries} -> {:ok, entries |> Enum.map(& &1.name) |> Enum.sort()} - {:error, _} -> {:error, :enoent} + case VFS.list_dir(ws(config), path) do + {:ok, entries} -> {:ok, entries |> Enum.map(& &1.name) |> Enum.sort()} + {:error, _} -> {:error, :enoent} + end end - end - @impl true - def wildcard(config, pattern, _opts) do - dir = Path.dirname(pattern) - base = Path.basename(pattern) - - cond do - # Only handle simple `*`/`?` patterns in the last path segment. - not simple_pattern?(base) -> - require Logger - Logger.warning("Jido.Shell.Backend.Bash.VfsAdapter: unsupported glob #{inspect(pattern)}") - [] - - true -> - case VFS.list_dir(ws(config), normalize(dir)) do - {:ok, entries} -> - regex = compile_glob(base) - - entries - |> Enum.filter(fn entry -> Regex.match?(regex, entry.name) end) - |> Enum.map(fn entry -> Path.join(normalize(dir), entry.name) end) - |> Enum.sort() - - {:error, _} -> - [] - end + @impl true + def wildcard(config, pattern, _opts) do + dir = Path.dirname(pattern) + base = Path.basename(pattern) + + cond do + # Only handle simple `*`/`?` patterns in the last path segment. + not simple_pattern?(base) -> + require Logger + Logger.warning("Jido.Shell.Backend.Bash.VfsAdapter: unsupported glob #{inspect(pattern)}") + [] + + true -> + case VFS.list_dir(ws(config), normalize(dir)) do + {:ok, entries} -> + regex = compile_glob(base) + + entries + |> Enum.filter(fn entry -> Regex.match?(regex, entry.name) end) + |> Enum.map(fn entry -> Path.join(normalize(dir), entry.name) end) + |> Enum.sort() + + {:error, _} -> + [] + end + end end - end - @impl true - def open(config, path, modes) when is_list(modes) do - cond do - :write in modes or :append in modes -> - {:ok, make_device(config, path, :append in modes)} + @impl true + def open(config, path, modes) when is_list(modes) do + cond do + :write in modes or :append in modes -> + {:ok, make_device(config, path, :append in modes)} - :read in modes -> - case VFS.read_file(ws(config), normalize(path)) do - {:ok, content} -> - {:ok, device} = StringIO.open(content) - {:ok, device} + :read in modes -> + case VFS.read_file(ws(config), normalize(path)) do + {:ok, content} -> + {:ok, device} = StringIO.open(content) + {:ok, device} - {:error, _} -> - {:error, :enoent} - end + {:error, _} -> + {:error, :enoent} + end - true -> - {:error, :einval} + true -> + {:error, :einval} + end end - end - def open(_config, _path, _modes), do: {:error, :einval} + def open(_config, _path, _modes), do: {:error, :einval} - @impl true - def handle_write(_config, device, data) do - case Process.get({__MODULE__, device}) do - %{kind: :write, path: _path, buffer: buffer} = state -> - Process.put({__MODULE__, device}, %{state | buffer: buffer <> IO.iodata_to_binary(data)}) - :ok + @impl true + def handle_write(_config, device, data) do + case Process.get({__MODULE__, device}) do + %{kind: :write, path: _path, buffer: buffer} = state -> + Process.put({__MODULE__, device}, %{state | buffer: buffer <> IO.iodata_to_binary(data)}) + :ok - _ -> - IO.binwrite(device, data) + _ -> + IO.binwrite(device, data) + end end - end - @impl true - def handle_close(config, device) do - case Process.get({__MODULE__, device}) do - %{kind: :write, path: path, append?: append?, buffer: buffer} -> - Process.delete({__MODULE__, device}) - _ = StringIO.close(device) - workspace_id = ws(config) - - final = - if append? do - case VFS.read_file(workspace_id, path) do - {:ok, existing} -> existing <> buffer - {:error, _} -> buffer + @impl true + def handle_close(config, device) do + case Process.get({__MODULE__, device}) do + %{kind: :write, path: path, append?: append?, buffer: buffer} -> + Process.delete({__MODULE__, device}) + _ = StringIO.close(device) + workspace_id = ws(config) + + final = + if append? do + case VFS.read_file(workspace_id, path) do + {:ok, existing} -> existing <> buffer + {:error, _} -> buffer + end + else + buffer end - else - buffer - end - case VFS.write_file(workspace_id, path, final) do - :ok -> :ok - {:error, _} -> {:error, :eacces} - end + case VFS.write_file(workspace_id, path, final) do + :ok -> :ok + {:error, _} -> {:error, :eacces} + end - _ -> - _ = StringIO.close(device) - :ok + _ -> + _ = StringIO.close(device) + :ok + end end - end - @impl true - def read_link(_config, _path), do: {:error, :enotsup} + @impl true + def read_link(_config, _path), do: {:error, :enotsup} - @impl true - def read_link_all(_config, _path), do: {:error, :enotsup} + @impl true + def read_link_all(_config, _path), do: {:error, :enotsup} - # === helpers === + # === helpers === - defp make_device(_config, path, append?) do - {:ok, device} = StringIO.open("") - Process.put({__MODULE__, device}, %{kind: :write, path: normalize(path), append?: append?, buffer: ""}) - device - end + defp make_device(_config, path, append?) do + {:ok, device} = StringIO.open("") + Process.put({__MODULE__, device}, %{kind: :write, path: normalize(path), append?: append?, buffer: ""}) + device + end - defp ws(%{workspace_id: wid}) when is_binary(wid), do: wid + defp ws(%{workspace_id: wid}) when is_binary(wid), do: wid - defp normalize(path) when is_binary(path) do - case Path.type(path) do - :absolute -> Path.expand(path) - _ -> Path.expand(path, "/") + defp normalize(path) when is_binary(path) do + case Path.type(path) do + :absolute -> Path.expand(path) + _ -> Path.expand(path, "/") + end end - end - defp to_file_stat(%Jido.VFS.Stat.Dir{}) do - now = :calendar.universal_time() - %File.Stat{type: :directory, size: 0, access: :read_write, mode: 0o755, mtime: now, atime: now, ctime: now} - end + defp to_file_stat(%Jido.VFS.Stat.Dir{}) do + now = :calendar.universal_time() + %File.Stat{type: :directory, size: 0, access: :read_write, mode: 0o755, mtime: now, atime: now, ctime: now} + end - defp to_file_stat(%Jido.VFS.Stat.File{size: size}) do - now = :calendar.universal_time() - - %File.Stat{ - type: :regular, - size: size || 0, - access: :read_write, - mode: 0o644, - mtime: now, - atime: now, - ctime: now - } - end + defp to_file_stat(%Jido.VFS.Stat.File{size: size}) do + now = :calendar.universal_time() + + %File.Stat{ + type: :regular, + size: size || 0, + access: :read_write, + mode: 0o644, + mtime: now, + atime: now, + ctime: now + } + end - defp simple_pattern?(base), do: String.match?(base, ~r/^[A-Za-z0-9_.*?\-]*$/) + defp simple_pattern?(base), do: String.match?(base, ~r/^[A-Za-z0-9_.*?\-]*$/) - defp compile_glob(base) do - source = - base - |> Regex.escape() - |> String.replace("\\*", ".*") - |> String.replace("\\?", ".") + defp compile_glob(base) do + source = + base + |> Regex.escape() + |> String.replace("\\*", ".*") + |> String.replace("\\?", ".") - Regex.compile!("^" <> source <> "$") + Regex.compile!("^" <> source <> "$") + end + end +else + defmodule Jido.Shell.Backend.Bash.VfsAdapter do + @moduledoc false end end diff --git a/mix.exs b/mix.exs index 4803cf9..203e9a1 100644 --- a/mix.exs +++ b/mix.exs @@ -73,8 +73,7 @@ defmodule Jido.Shell.MixProject do {:uniq, "~> 0.6"}, {:zoi, "~> 0.17"}, {:jido_vfs, "~> 1.0"}, - {:bash, - git: "https://github.com/tv-labs/bash.git", ref: "c1038ff83e825c29ea131bf8b728bd1672734c01"}, + bash_dep(), # Dev/Test dependencies {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, @@ -106,6 +105,18 @@ defmodule Jido.Shell.MixProject do ] end + defp bash_dep do + if Mix.env() in [:dev, :test] do + {:bash, + git: "https://github.com/tv-labs/bash.git", + ref: "c1038ff83e825c29ea131bf8b728bd1672734c01", + only: [:dev, :test], + optional: true} + else + {:bash, "~> 0.5.1", optional: true} + end + end + defp package do [ files: From 783c790c074281401eac584e69d82f80bea5b577 Mon Sep 17 00:00:00 2001 From: Mike Hostetler <84222+mikehostetler@users.noreply.github.com> Date: Wed, 10 Jun 2026 08:16:08 -0500 Subject: [PATCH 4/4] fix: exclude bash backend from hex package --- README.md | 7 +- lib/jido_shell/backend/bash.ex | 799 +++++++++----------- lib/jido_shell/backend/bash/jido_interop.ex | 362 +++++---- lib/jido_shell/backend/bash/vfs_adapter.ex | 410 +++++----- mix.exs | 36 +- 5 files changed, 779 insertions(+), 835 deletions(-) diff --git a/README.md b/README.md index 8953565..82fa1ad 100644 --- a/README.md +++ b/README.md @@ -96,9 +96,10 @@ Sessions run with `Jido.Shell.Backend.Local` by default. The Bash backend hands entire command lines to a persistent `Bash.Session` process, so loops, conditionals, variables, pipes, and arithmetic expansion all work as in real Bash. State persists across calls within the same session. -The Bash backend is optional. Projects that use `Jido.Shell.Backend.Bash` must -add the `:bash` package; projects that do not use this backend compile without -it. +The Bash backend currently depends on upstream `tv-labs/bash` changes that are +newer than the latest Hex release. Until those changes are published, the Bash +backend is available from source/Git builds and is excluded from the Hex +package. ```elixir {:bash, diff --git a/lib/jido_shell/backend/bash.ex b/lib/jido_shell/backend/bash.ex index a9c95ee..af9b884 100644 --- a/lib/jido_shell/backend/bash.ex +++ b/lib/jido_shell/backend/bash.ex @@ -1,510 +1,459 @@ -if Code.ensure_loaded?(Bash.Session) do - defmodule Jido.Shell.Backend.Bash do - @moduledoc """ - Backend that executes real Bash scripts via the `:bash` library - ([tv-labs/bash](https://github.com/tv-labs/bash)). - - Unlike `Jido.Shell.Backend.Local`, which parses commands with the Jido shell - parser and routes each statement to a registered command module, this backend - hands the entire command line to a persistent `Bash.Session` GenServer. That - means loops, conditionals, variable assignments, arithmetic expansion, and - pipes all work as in normal Bash — state (variables, functions, cwd) - persists across calls within the same jido session. - - Registered Jido commands (`echo`, `ls`, `cat`, …) are bridged into bash via - `Jido.Shell.Backend.Bash.JidoInterop`, with bash function shims installed at - init time so scripts can call them by their familiar names. Filesystem I/O - routes through `Jido.Shell.Backend.Bash.VfsAdapter`, which delegates to - `Jido.Shell.VFS`. The backend pins `command_policy: :no_external`, so bash - scripts cannot spawn any host process — every effective command is either a - bash builtin or a Jido interop call. - - ## Isolation model - - Four layers enforce sandbox boundaries: - - 1. **Command policy** — `command_policy: [commands: :no_external]` prevents - any OS process from being spawned. Only bash builtins, user-defined shell - functions, and Jido interop calls may execute. - - 2. **Virtual filesystem** — all file I/O (redirections, `source`, PATH - resolution, glob expansion, test operators) routes through - `Jido.Shell.Backend.Bash.VfsAdapter`, which delegates to - `Jido.Shell.VFS`. No `File.*` or `:file.*` calls reach the host. - - 3. **Sanitised environment** — `HOME`, `PATH`, and `MACHTYPE` are overridden - with sandbox-safe values so the `:bash` library's init does not leak - host-system information into session variables. User-supplied env values - from `config.env` take precedence via merge ordering. - - 4. **Interop trust boundary** — `defbash` handlers in - `Jido.Shell.Backend.Bash.JidoInterop` execute as **unrestricted Elixir - code** inside the same BEAM process as the session. The `:bash` library - provides no sandbox around interop function bodies. Any module loaded via - the `apis:` option has full access to `System.*`, `File.*`, `Port.*`, - `spawn`, and the rest of the BEAM. **Only load interop modules you have - audited.** The built-in `JidoInterop` is safe — it delegates every call - to `Jido.Shell.CommandRunner`, which routes through VFS and the command - registry. - - ## Known limitations - - * External binaries (`grep`, `sed`, `awk`, `find`, `curl`, …) are blocked by - the command policy — use the bridged Jido commands instead. - * Glob support (`VfsAdapter.wildcard/3`) covers simple `*`/`?` patterns - only. - * `configure_network/2` is a no-op — network policy is fixed at - `:no_external`. - - ## Usage - - {:ok, sid} = - Jido.Shell.ShellSession.start("ws1", backend: {Jido.Shell.Backend.Bash, %{}}) - - Jido.Shell.ShellSession.run_command(sid, "for i in 1 2 3; do echo $i; done") - - Requires the optional `:bash` dependency to be compiled into the release. - """ - - @behaviour Jido.Shell.Backend - - alias Jido.Shell.Backend.Bash.JidoInterop - alias Jido.Shell.Backend.Bash.VfsAdapter - alias Jido.Shell.Backend.OutputLimiter - alias Jido.Shell.Error - - @default_task_supervisor Jido.Shell.CommandTaskSupervisor - @cancel_grace_ms 1_000 - @cancel_wait_ms 2_000 - - @impl true - def init(config) when is_map(config) do - with :ok <- ensure_dep_available(), - {:ok, session_pid} <- fetch_session_pid(config), - workspace_id <- Map.get(config, :workspace_id, ""), - {:ok, bash_session} <- start_bash_session(config, workspace_id), - :ok <- install_prelude(bash_session) do - {:ok, - %{ - bash_session: bash_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, %{}) - }} - end +defmodule Jido.Shell.Backend.Bash do + @moduledoc """ + Backend that executes real Bash scripts via the `:bash` library + ([tv-labs/bash](https://github.com/tv-labs/bash)). + + Unlike `Jido.Shell.Backend.Local`, which parses commands with the Jido shell + parser and routes each statement to a registered command module, this backend + hands the entire command line to a persistent `Bash.Session` GenServer. That + means loops, conditionals, variable assignments, arithmetic expansion, and + pipes all work as in normal Bash — state (variables, functions, cwd) + persists across calls within the same jido session. + + Registered Jido commands (`echo`, `ls`, `cat`, …) are bridged into bash via + `Jido.Shell.Backend.Bash.JidoInterop`, with bash function shims installed at + init time so scripts can call them by their familiar names. Filesystem I/O + routes through `Jido.Shell.Backend.Bash.VfsAdapter`, which delegates to + `Jido.Shell.VFS`. The backend pins `command_policy: :no_external`, so bash + scripts cannot spawn any host process — every effective command is either a + bash builtin or a Jido interop call. + + ## Isolation model + + Four layers enforce sandbox boundaries: + + 1. **Command policy** — `command_policy: [commands: :no_external]` prevents + any OS process from being spawned. Only bash builtins, user-defined shell + functions, and Jido interop calls may execute. + + 2. **Virtual filesystem** — all file I/O (redirections, `source`, PATH + resolution, glob expansion, test operators) routes through + `Jido.Shell.Backend.Bash.VfsAdapter`, which delegates to + `Jido.Shell.VFS`. No `File.*` or `:file.*` calls reach the host. + + 3. **Sanitised environment** — `HOME`, `PATH`, and `MACHTYPE` are overridden + with sandbox-safe values so the `:bash` library's init does not leak + host-system information into session variables. User-supplied env values + from `config.env` take precedence via merge ordering. + + 4. **Interop trust boundary** — `defbash` handlers in + `Jido.Shell.Backend.Bash.JidoInterop` execute as **unrestricted Elixir + code** inside the same BEAM process as the session. The `:bash` library + provides no sandbox around interop function bodies. Any module loaded via + the `apis:` option has full access to `System.*`, `File.*`, `Port.*`, + `spawn`, and the rest of the BEAM. **Only load interop modules you have + audited.** The built-in `JidoInterop` is safe — it delegates every call + to `Jido.Shell.CommandRunner`, which routes through VFS and the command + registry. + + ## Known limitations + + * External binaries (`grep`, `sed`, `awk`, `find`, `curl`, …) are blocked by + the command policy — use the bridged Jido commands instead. + * Glob support (`VfsAdapter.wildcard/3`) covers simple `*`/`?` patterns + only. + * `configure_network/2` is a no-op — network policy is fixed at + `:no_external`. + + ## Usage + + {:ok, sid} = + Jido.Shell.ShellSession.start("ws1", backend: {Jido.Shell.Backend.Bash, %{}}) + + Jido.Shell.ShellSession.run_command(sid, "for i in 1 2 3; do echo $i; done") + + Requires the compatible upstream `:bash` Git dependency until the next + `tv-labs/bash` Hex release includes `Bash.Session.signal/3`. + """ + + @behaviour Jido.Shell.Backend + + alias Jido.Shell.Backend.Bash.JidoInterop + alias Jido.Shell.Backend.Bash.VfsAdapter + alias Jido.Shell.Backend.OutputLimiter + alias Jido.Shell.Error + + @default_task_supervisor Jido.Shell.CommandTaskSupervisor + @cancel_grace_ms 1_000 + @cancel_wait_ms 2_000 + + @impl true + def init(config) when is_map(config) do + with :ok <- ensure_dep_available(), + {:ok, session_pid} <- fetch_session_pid(config), + workspace_id <- Map.get(config, :workspace_id, ""), + {:ok, bash_session} <- start_bash_session(config, workspace_id), + :ok <- install_prelude(bash_session) do + {:ok, + %{ + bash_session: bash_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, %{}) + }} 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) - session_pid = state.session_pid - bash_session = state.bash_session - task_supervisor = state.task_supervisor - previous_cwd = state.cwd - timeout = positive_limit(Keyword.get(exec_opts, :timeout)) - output_limit = positive_limit(Keyword.get(exec_opts, :output_limit)) - - task_fun = fn -> - {emit, limit_ref} = limited_emit(session_pid, bash_session, output_limit) + @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) + session_pid = state.session_pid + bash_session = state.bash_session + task_supervisor = state.task_supervisor + previous_cwd = state.cwd + timeout = positive_limit(Keyword.get(exec_opts, :timeout)) + output_limit = positive_limit(Keyword.get(exec_opts, :output_limit)) - result = - case safe_parse(line) do - {:error, parse_error} -> - {:error, Error.command(:syntax_error, %{line: line, reason: inspect(parse_error)})} + task_fun = fn -> + {emit, limit_ref} = limited_emit(session_pid, bash_session, output_limit) - {:ok, ast} -> - raw = execute_bash(task_supervisor, bash_session, ast, line, emit, limit_ref, timeout) + result = + case safe_parse(line) do + {:error, parse_error} -> + {:error, Error.command(:syntax_error, %{line: line, reason: inspect(parse_error)})} - maybe_augment_with_cwd(raw, bash_session, previous_cwd) - end + {:ok, ast} -> + raw = execute_bash(task_supervisor, bash_session, ast, line, emit, limit_ref, timeout) - send(session_pid, {:command_finished, result}) - result - end + maybe_augment_with_cwd(raw, bash_session, previous_cwd) + end - case Task.Supervisor.start_child(state.task_supervisor, task_fun) do - {:ok, task_pid} -> {:ok, task_pid, state} - {:error, reason} -> {:error, Error.command(:start_failed, %{reason: reason, line: line})} - end + send(session_pid, {:command_finished, result}) + result end - @impl true - def cancel(state, command_ref) when is_pid(command_ref) do - # Interrupt the foreground bash execution cooperatively so traps can run. - _ = safe_signal_execution(state.bash_session) - _ = await_process_exit(command_ref, @cancel_wait_ms) + case Task.Supervisor.start_child(state.task_supervisor, task_fun) do + {:ok, task_pid} -> {:ok, task_pid, state} + {:error, reason} -> {:error, Error.command(:start_failed, %{reason: reason, line: line})} + end + end - # Kill the Task wrapper (may already be finishing after the signal). - if Process.alive?(command_ref) do - Process.exit(command_ref, :shutdown) - end + @impl true + def cancel(state, command_ref) when is_pid(command_ref) do + # Interrupt the foreground bash execution cooperatively so traps can run. + _ = safe_signal_execution(state.bash_session) + _ = await_process_exit(command_ref, @cancel_wait_ms) - :ok + # Kill the Task wrapper (may already be finishing after the signal). + if Process.alive?(command_ref) do + Process.exit(command_ref, :shutdown) end - def cancel(_state, _command_ref), do: {:error, :invalid_command_ref} + :ok + end - @impl true - def terminate(state) do - case Map.get(state, :bash_session) do - pid when is_pid(pid) -> - if Process.alive?(pid), do: safe_stop(pid) - :ok + def cancel(_state, _command_ref), do: {:error, :invalid_command_ref} - _ -> - :ok - end - end + @impl true + def terminate(state) do + case Map.get(state, :bash_session) do + pid when is_pid(pid) -> + if Process.alive?(pid), do: safe_stop(pid) + :ok - @impl true - def cwd(state) do - case safe_call(state.bash_session, &Bash.Session.get_cwd/1) do - {:ok, cwd} -> {:ok, cwd, %{state | cwd: cwd}} - _ -> {:ok, state.cwd, state} - end + _ -> + :ok end + end - @impl true - def cd(state, path) when is_binary(path) do - _ = safe_call(state.bash_session, fn pid -> Bash.Session.chdir(pid, path) end) - {:ok, %{state | cwd: path}} + @impl true + def cwd(state) do + case safe_call(state.bash_session, &Bash.Session.get_cwd/1) do + {:ok, cwd} -> {:ok, cwd, %{state | cwd: cwd}} + _ -> {:ok, state.cwd, state} end + end - @impl true - def configure_network(state, _policy), do: {:ok, state} - - # === private === + @impl true + def cd(state, path) when is_binary(path) do + _ = safe_call(state.bash_session, fn pid -> Bash.Session.chdir(pid, path) end) + {:ok, %{state | cwd: path}} + end - defp ensure_dep_available do - if Code.ensure_loaded?(Bash.Session) do - :ok - else - {:error, Error.command(:start_failed, %{reason: :bash_dep_unavailable})} - end - end + @impl true + def configure_network(state, _policy), do: {:ok, state} - 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 + # === private === - # Sandbox-safe defaults that prevent the `:bash` library from seeding - # session variables with values read from the host OS at init time - # (`System.get_env("HOME")`, `System.get_env("PATH")`, etc.). - @sandbox_env_defaults %{ - "HOME" => "/", - "PATH" => "", - "MACHTYPE" => "beam-unknown-elixir" - } - - defp start_bash_session(config, workspace_id) do - user_env = Map.get(config, :env, %{}) - cwd = Map.get(config, :cwd, "/") - - env = - @sandbox_env_defaults - |> Map.merge(user_env) - |> Map.put("JIDO_WORKSPACE_ID", workspace_id) - - opts = [ - filesystem: {VfsAdapter, %{workspace_id: workspace_id}}, - working_dir: cwd, - env: env, - command_policy: [commands: :no_external], - apis: [JidoInterop] - ] - - case Bash.Session.new(opts) do - {:ok, pid} -> {:ok, pid} - {:error, reason} -> {:error, Error.command(:start_failed, %{reason: reason})} - end + defp ensure_dep_available do + if Code.ensure_loaded?(Bash.Session) do + :ok + else + {:error, Error.command(:start_failed, %{reason: :bash_dep_unavailable})} end + end - # Bash aliases are only active for interactive shells, so we install function - # shims instead. Each shim routes the familiar command name (echo, ls, …) to - # the corresponding `jido.*` interop handler. - defp install_prelude(bash_session) do - prelude = build_prelude() - - case safe_parse(prelude) do - {:ok, ast} -> - case Bash.Session.execute(bash_session, ast, []) do - {:ok, _} -> :ok - {:error, reason} -> {:error, Error.command(:start_failed, %{reason: {:prelude_failed, reason}})} - _ -> :ok - end - - {:error, parse_error} -> - {:error, Error.command(:start_failed, %{reason: {:prelude_parse_failed, parse_error}})} - 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 - # `bash` and `help` are internal shell builtins that don't map to Jido - # commands and are handled natively by the bash library. - defp build_prelude do - skip = ~w(bash help) - - Jido.Shell.Command.Registry.commands() - |> Map.keys() - |> Enum.sort() - |> Enum.reject(&(&1 in skip)) - |> Enum.map(fn name -> "#{name}() { jido.#{name} \"$@\"; }" end) - |> Enum.join("\n") + # Sandbox-safe defaults that prevent the `:bash` library from seeding + # session variables with values read from the host OS at init time + # (`System.get_env("HOME")`, `System.get_env("PATH")`, etc.). + @sandbox_env_defaults %{ + "HOME" => "/", + "PATH" => "", + "MACHTYPE" => "beam-unknown-elixir" + } + + defp start_bash_session(config, workspace_id) do + user_env = Map.get(config, :env, %{}) + cwd = Map.get(config, :cwd, "/") + + env = + @sandbox_env_defaults + |> Map.merge(user_env) + |> Map.put("JIDO_WORKSPACE_ID", workspace_id) + + opts = [ + filesystem: {VfsAdapter, %{workspace_id: workspace_id}}, + working_dir: cwd, + env: env, + command_policy: [commands: :no_external], + apis: [JidoInterop] + ] + + case Bash.Session.new(opts) do + {:ok, pid} -> {:ok, pid} + {:error, reason} -> {:error, Error.command(:start_failed, %{reason: reason})} end + end - defp command_line(command, []), do: command - defp command_line(command, args), do: Enum.join([command | args], " ") + # Bash aliases are only active for interactive shells, so we install function + # shims instead. Each shim routes the familiar command name (echo, ls, …) to + # the corresponding `jido.*` interop handler. + defp install_prelude(bash_session) do + prelude = build_prelude() + + case safe_parse(prelude) do + {:ok, ast} -> + case Bash.Session.execute(bash_session, ast, []) do + {:ok, _} -> :ok + {:error, reason} -> {:error, Error.command(:start_failed, %{reason: {:prelude_failed, reason}})} + _ -> :ok + end - defp safe_parse(line) do - case Bash.parse(line) do - {:ok, ast} -> {:ok, ast} - {:error, err} -> {:error, err} - end + {:error, parse_error} -> + {:error, Error.command(:start_failed, %{reason: {:prelude_parse_failed, parse_error}})} end + end - defp execute_bash(task_supervisor, bash_session, ast, line, emit, limit_ref, timeout) do - task = - Task.Supervisor.async_nolink(task_supervisor, fn -> - Bash.Session.execute(bash_session, ast, on_output: &stream_output(emit, &1)) - end) + # `bash` and `help` are internal shell builtins that don't map to Jido + # commands and are handled natively by the bash library. + defp build_prelude do + skip = ~w(bash help) + + Jido.Shell.Command.Registry.commands() + |> Map.keys() + |> Enum.sort() + |> Enum.reject(&(&1 in skip)) + |> Enum.map(fn name -> "#{name}() { jido.#{name} \"$@\"; }" end) + |> Enum.join("\n") + end - await_bash(task, bash_session, line, limit_ref, timeout) - end + defp command_line(command, []), do: command + defp command_line(command, args), do: Enum.join([command | args], " ") - defp await_bash(task, bash_session, line, limit_ref, timeout) do - task_ref = task.ref - - receive do - {^limit_ref, {:error, %Error{} = error}} -> - _ = signal_and_shutdown_task(task, bash_session) - {:error, error} - - {^task_ref, result} -> - case pending_limit_error(limit_ref) do - {:error, %Error{} = error} -> {:error, error} - :none -> bash_result(result, line) - end - - {:DOWN, ^task_ref, :process, _pid, reason} -> - {:error, Error.command(:crashed, %{line: line, reason: reason})} - after - receive_timeout(timeout) -> - _ = signal_and_shutdown_task(task, bash_session) - {:error, Error.command(:runtime_limit_exceeded, %{line: line, max_runtime_ms: timeout})} - end + defp safe_parse(line) do + case Bash.parse(line) do + {:ok, ast} -> {:ok, ast} + {:error, err} -> {:error, err} end + end - defp bash_result({status, execution}, line) when status in [:ok, :error, :exit, :exec] do - case exit_code(execution) do - 0 -> - {:ok, nil} - - nil when status == :error -> - {:error, Error.command(:exit_code, %{exit_code: 1, line: line})} - - nil -> - {:ok, nil} - - code -> - {:error, Error.command(:exit_code, %{exit_code: code, line: line})} - end - end + defp execute_bash(task_supervisor, bash_session, ast, line, emit, limit_ref, timeout) do + task = + Task.Supervisor.async_nolink(task_supervisor, fn -> + Bash.Session.execute(bash_session, ast, on_output: &stream_output(emit, &1)) + end) - defp bash_result(other, line), do: {:error, Error.command(:exit_code, %{exit_code: 1, line: line, result: other})} + await_bash(task, bash_session, line, limit_ref, timeout) + end - defp limited_emit(session_pid, bash_session, output_limit) do - owner = self() - limit_ref = make_ref() - counter = :counters.new(1, []) + defp await_bash(task, bash_session, line, limit_ref, timeout) do + task_ref = task.ref - emit = fn event -> - case check_output_limit(event, counter, output_limit) do - :ok -> - send(session_pid, {:command_event, event}) + receive do + {^limit_ref, {:error, %Error{} = error}} -> + _ = signal_and_shutdown_task(task, bash_session) + {:error, error} - {:error, %Error{} = error} -> - send(owner, {limit_ref, {:error, error}}) - _ = safe_signal_execution(bash_session) - :ok + {^task_ref, result} -> + case pending_limit_error(limit_ref) do + {:error, %Error{} = error} -> {:error, error} + :none -> bash_result(result, line) end - end - {emit, limit_ref} + {:DOWN, ^task_ref, :process, _pid, reason} -> + {:error, Error.command(:crashed, %{line: line, reason: reason})} + after + receive_timeout(timeout) -> + _ = signal_and_shutdown_task(task, bash_session) + {:error, Error.command(:runtime_limit_exceeded, %{line: line, max_runtime_ms: timeout})} end + end - defp check_output_limit(_event, _counter, nil), do: :ok + defp bash_result({status, execution}, line) when status in [:ok, :error, :exit, :exec] do + case exit_code(execution) do + 0 -> + {:ok, nil} - defp check_output_limit(event, counter, output_limit) do - case output_size(event) do - nil -> - :ok + nil when status == :error -> + {:error, Error.command(:exit_code, %{exit_code: 1, line: line})} - chunk_bytes -> - emitted_bytes = :counters.get(counter, 1) + nil -> + {:ok, nil} - 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 + code -> + {:error, Error.command(:exit_code, %{exit_code: code, line: line})} 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 pending_limit_error(limit_ref) do - receive do - {^limit_ref, {:error, %Error{} = error}} -> {:error, error} - after - 0 -> :none - end - end + defp bash_result(other, line), do: {:error, Error.command(:exit_code, %{exit_code: 1, line: line, result: other})} - defp signal_and_shutdown_task(task, bash_session) do - _ = safe_signal_execution(bash_session) - Task.shutdown(task, @cancel_wait_ms) || Task.shutdown(task, :brutal_kill) - end + defp limited_emit(session_pid, bash_session, output_limit) do + owner = self() + limit_ref = make_ref() + counter = :counters.new(1, []) - defp await_process_exit(pid, timeout) when is_pid(pid) do - ref = Process.monitor(pid) + emit = fn event -> + case check_output_limit(event, counter, output_limit) do + :ok -> + send(session_pid, {:command_event, event}) - receive do - {:DOWN, ^ref, :process, ^pid, _reason} -> :ok - after - timeout -> - Process.demonitor(ref, [:flush]) - :timeout + {:error, %Error{} = error} -> + send(owner, {limit_ref, {:error, error}}) + _ = safe_signal_execution(bash_session) + :ok end end - 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 + {emit, limit_ref} + end - defp positive_limit(value) when is_integer(value) and value > 0, do: value - defp positive_limit(_value), do: nil + defp check_output_limit(_event, _counter, nil), do: :ok - defp stream_output(emit, {:stdout, data}), do: emit.({:output, data}) - defp stream_output(emit, {:stderr, data}), do: emit.({:output_stderr, data}) - defp stream_output(_emit, _), do: :ok + defp check_output_limit(event, counter, output_limit) do + case output_size(event) do + nil -> + :ok - defp exit_code(%{exit_code: code}) when is_integer(code), do: code + chunk_bytes -> + emitted_bytes = :counters.get(counter, 1) - defp exit_code(execution) do - try do - Bash.ExecutionResult.exit_code(execution) - rescue - _ -> nil - end - end + case OutputLimiter.check(chunk_bytes, emitted_bytes, output_limit) do + {:ok, updated_total} -> + :counters.put(counter, 1, updated_total) + :ok - # After each command, pull the session's current working directory and, if it - # changed, wrap the result in `{:state_update, %{cwd: new_cwd}}` so - # `ShellSessionServer` applies the update and broadcasts `:cwd_changed`. - defp maybe_augment_with_cwd({:ok, nil}, bash_session, previous_cwd) do - case current_cwd(bash_session) do - {:ok, cwd} when cwd != previous_cwd -> {:ok, {:state_update, %{cwd: cwd}}} - _ -> {:ok, nil} - end + {:limit_exceeded, %Error{} = error} -> + {:error, error} + end end + end - defp maybe_augment_with_cwd(other, _bash_session, _previous_cwd), do: other + 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 current_cwd(bash_session) do - case safe_call(bash_session, &Bash.Session.get_cwd/1) do - {:ok, cwd} when is_binary(cwd) -> {:ok, cwd} - _ -> :error - end + defp pending_limit_error(limit_ref) do + receive do + {^limit_ref, {:error, %Error{} = error}} -> {:error, error} + after + 0 -> :none end + end - defp safe_call(pid, fun) when is_pid(pid) do - if Process.alive?(pid) do - {:ok, fun.(pid)} - else - {:error, :dead} - end - rescue - _ -> {:error, :call_failed} - catch - _, _ -> {:error, :call_failed} - end + defp signal_and_shutdown_task(task, bash_session) do + _ = safe_signal_execution(bash_session) + Task.shutdown(task, @cancel_wait_ms) || Task.shutdown(task, :brutal_kill) + end - defp safe_signal_execution(pid) when is_pid(pid) do - if Process.alive?(pid), do: signal_session(pid) - :ok - rescue - _ -> :ok - catch - _, _ -> :ok + defp await_process_exit(pid, timeout) when is_pid(pid) do + ref = Process.monitor(pid) + + receive do + {:DOWN, ^ref, :process, ^pid, _reason} -> :ok + after + timeout -> + Process.demonitor(ref, [:flush]) + :timeout end + end - defp safe_signal_execution(_pid), do: :ok + 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 signal_session(pid) do - cond do - function_exported?(Bash.Session, :signal, 3) -> - apply(Bash.Session, :signal, [pid, :sigint, [grace: @cancel_grace_ms]]) + defp positive_limit(value) when is_integer(value) and value > 0, do: value + defp positive_limit(_value), do: nil - function_exported?(Bash.Session, :signal_job, 3) -> - apply(Bash.Session, :signal_job, [pid, 1, :sigint]) + defp stream_output(emit, {:stdout, data}), do: emit.({:output, data}) + defp stream_output(emit, {:stderr, data}), do: emit.({:output_stderr, data}) + defp stream_output(_emit, _), do: :ok - true -> - :ok - end - end + defp exit_code(%{exit_code: code}) when is_integer(code), do: code - defp safe_stop(pid) do - Bash.Session.stop(pid) + defp exit_code(execution) do + try do + Bash.ExecutionResult.exit_code(execution) rescue - _ -> :ok - catch - _, _ -> :ok + _ -> nil end end -else - defmodule Jido.Shell.Backend.Bash do - @moduledoc """ - Placeholder Bash backend compiled when the optional `:bash` dependency is not - available. - - Add the `:bash` package to the consuming application to enable this backend. - """ - @behaviour Jido.Shell.Backend - - alias Jido.Shell.Error + # After each command, pull the session's current working directory and, if it + # changed, wrap the result in `{:state_update, %{cwd: new_cwd}}` so + # `ShellSessionServer` applies the update and broadcasts `:cwd_changed`. + defp maybe_augment_with_cwd({:ok, nil}, bash_session, previous_cwd) do + case current_cwd(bash_session) do + {:ok, cwd} when cwd != previous_cwd -> {:ok, {:state_update, %{cwd: cwd}}} + _ -> {:ok, nil} + end + end - @impl true - def init(_config), do: {:error, Error.command(:start_failed, %{reason: :bash_dep_unavailable})} + defp maybe_augment_with_cwd(other, _bash_session, _previous_cwd), do: other - @impl true - def execute(_state, command, args, _exec_opts) do - line = Enum.join([command | args], " ") - {:error, Error.command(:start_failed, %{reason: :bash_dep_unavailable, line: line})} + defp current_cwd(bash_session) do + case safe_call(bash_session, &Bash.Session.get_cwd/1) do + {:ok, cwd} when is_binary(cwd) -> {:ok, cwd} + _ -> :error end + end - @impl true - def cancel(_state, _command_ref), do: {:error, :bash_dep_unavailable} - - @impl true - def terminate(_state), do: :ok + defp safe_call(pid, fun) when is_pid(pid) do + if Process.alive?(pid) do + {:ok, fun.(pid)} + else + {:error, :dead} + end + rescue + _ -> {:error, :call_failed} + catch + _, _ -> {:error, :call_failed} + end - @impl true - def cwd(state), do: {:ok, Map.get(state, :cwd, "/"), state} + defp safe_signal_execution(pid) when is_pid(pid) do + if Process.alive?(pid), do: Bash.Session.signal(pid, :sigint, grace: @cancel_grace_ms) + :ok + rescue + _ -> :ok + catch + _, _ -> :ok + end - @impl true - def cd(state, path) when is_binary(path), do: {:ok, Map.put(state, :cwd, path)} + defp safe_signal_execution(_pid), do: :ok - @impl true - def configure_network(state, _policy), do: {:ok, state} + defp safe_stop(pid) do + Bash.Session.stop(pid) + rescue + _ -> :ok + catch + _, _ -> :ok end end diff --git a/lib/jido_shell/backend/bash/jido_interop.ex b/lib/jido_shell/backend/bash/jido_interop.ex index 16e6649..a302ae4 100644 --- a/lib/jido_shell/backend/bash/jido_interop.ex +++ b/lib/jido_shell/backend/bash/jido_interop.ex @@ -1,215 +1,209 @@ -if Code.ensure_loaded?(Bash.Interop) do - defmodule Jido.Shell.Backend.Bash.JidoInterop do - @moduledoc """ - `Bash.Interop` bridge that exposes registered Jido shell commands to scripts - running inside a `Bash.Session`. - - For each entry in `Jido.Shell.Command.Registry.commands/0` (minus `bash` and - `help`), this module defines a `defbash` handler named after the command. When - bash calls `jido.echo hello`, the handler reconstructs a transient - `Jido.Shell.ShellSession.State` from the current bash session state plus the - captured workspace id, invokes `Jido.Shell.CommandRunner.execute/3`, and - funnels the buffered output back through `Bash.puts/2`. - - The bash session's working directory and environment are kept in sync with - Jido state transitions: a `cd` command emits `{:state_update, %{cwd: …}}`, - and the bridge forwards that via `Bash.update_state/1` so subsequent bash - builtins (and the outer backend) see the new cwd. - - Workspace id is taken from the bash session state under the - `:jido_workspace_id` variable, which `Jido.Shell.Backend.Bash` sets during - initialisation. - - ## Security — interop trust boundary - - `defbash` handlers execute as **unrestricted Elixir code** in the same BEAM - process as the `Bash.Session` GenServer. The `:bash` library does not sandbox - interop function bodies — a handler may call `File.*`, `System.cmd`, - `System.get_env`, `spawn`, or any other BEAM API. - - This module is safe because every handler delegates to - `Jido.Shell.CommandRunner.execute/3`, which routes through the VFS and the - command registry. If you add a new interop module or modify a handler, ensure - it does **not** perform direct host I/O or spawn OS processes — doing so would - bypass the filesystem virtualisation and command policy that the rest of the - backend enforces. - """ - - use Bash.Interop, namespace: "jido" - - alias Jido.Shell.CommandRunner - alias Jido.Shell.ShellSession.State - - defbash(echo(args, session_state), do: __MODULE__.dispatch("echo", args, session_state)) - defbash(pwd(args, session_state), do: __MODULE__.dispatch("pwd", args, session_state)) - defbash(ls(args, session_state), do: __MODULE__.dispatch("ls", args, session_state)) - defbash(cat(args, session_state), do: __MODULE__.dispatch("cat", args, session_state)) - defbash(cd(args, session_state), do: __MODULE__.dispatch("cd", args, session_state)) - defbash(mkdir(args, session_state), do: __MODULE__.dispatch("mkdir", args, session_state)) - defbash(write(args, session_state), do: __MODULE__.dispatch("write", args, session_state)) - defbash(sleep(args, session_state), do: __MODULE__.dispatch("sleep", args, session_state)) - defbash(seq(args, session_state), do: __MODULE__.dispatch("seq", args, session_state)) - defbash(env(args, session_state), do: __MODULE__.dispatch("env", args, session_state)) - defbash(rm(args, session_state), do: __MODULE__.dispatch("rm", args, session_state)) - defbash(cp(args, session_state), do: __MODULE__.dispatch("cp", args, session_state)) - - @doc false - @spec dispatch(String.t(), [String.t()], map()) :: - :ok | {:ok, binary()} | {:error, binary()} - def dispatch(command, args, session_state) do - with {:ok, state} <- build_state(session_state), - line <- build_line(command, args), - {stdout, stderr, result} <- run_command(state, line) do - finalize(result, stdout, stderr) - else - {:error, :missing_workspace} -> - {:error, "jido.#{command}: workspace not configured\n"} - - {:error, reason} -> - {:error, "jido.#{command}: invalid session state: #{inspect(reason)}\n"} - end +defmodule Jido.Shell.Backend.Bash.JidoInterop do + @moduledoc """ + `Bash.Interop` bridge that exposes registered Jido shell commands to scripts + running inside a `Bash.Session`. + + For each entry in `Jido.Shell.Command.Registry.commands/0` (minus `bash` and + `help`), this module defines a `defbash` handler named after the command. When + bash calls `jido.echo hello`, the handler reconstructs a transient + `Jido.Shell.ShellSession.State` from the current bash session state plus the + captured workspace id, invokes `Jido.Shell.CommandRunner.execute/3`, and + funnels the buffered output back through `Bash.puts/2`. + + The bash session's working directory and environment are kept in sync with + Jido state transitions: a `cd` command emits `{:state_update, %{cwd: …}}`, + and the bridge forwards that via `Bash.update_state/1` so subsequent bash + builtins (and the outer backend) see the new cwd. + + Workspace id is taken from the bash session state under the + `:jido_workspace_id` variable, which `Jido.Shell.Backend.Bash` sets during + initialisation. + + ## Security — interop trust boundary + + `defbash` handlers execute as **unrestricted Elixir code** in the same BEAM + process as the `Bash.Session` GenServer. The `:bash` library does not sandbox + interop function bodies — a handler may call `File.*`, `System.cmd`, + `System.get_env`, `spawn`, or any other BEAM API. + + This module is safe because every handler delegates to + `Jido.Shell.CommandRunner.execute/3`, which routes through the VFS and the + command registry. If you add a new interop module or modify a handler, ensure + it does **not** perform direct host I/O or spawn OS processes — doing so would + bypass the filesystem virtualisation and command policy that the rest of the + backend enforces. + """ + + use Bash.Interop, namespace: "jido" + + alias Jido.Shell.CommandRunner + alias Jido.Shell.ShellSession.State + + defbash(echo(args, session_state), do: __MODULE__.dispatch("echo", args, session_state)) + defbash(pwd(args, session_state), do: __MODULE__.dispatch("pwd", args, session_state)) + defbash(ls(args, session_state), do: __MODULE__.dispatch("ls", args, session_state)) + defbash(cat(args, session_state), do: __MODULE__.dispatch("cat", args, session_state)) + defbash(cd(args, session_state), do: __MODULE__.dispatch("cd", args, session_state)) + defbash(mkdir(args, session_state), do: __MODULE__.dispatch("mkdir", args, session_state)) + defbash(write(args, session_state), do: __MODULE__.dispatch("write", args, session_state)) + defbash(sleep(args, session_state), do: __MODULE__.dispatch("sleep", args, session_state)) + defbash(seq(args, session_state), do: __MODULE__.dispatch("seq", args, session_state)) + defbash(env(args, session_state), do: __MODULE__.dispatch("env", args, session_state)) + defbash(rm(args, session_state), do: __MODULE__.dispatch("rm", args, session_state)) + defbash(cp(args, session_state), do: __MODULE__.dispatch("cp", args, session_state)) + + @doc false + @spec dispatch(String.t(), [String.t()], map()) :: + :ok | {:ok, binary()} | {:error, binary()} + def dispatch(command, args, session_state) do + with {:ok, state} <- build_state(session_state), + line <- build_line(command, args), + {stdout, stderr, result} <- run_command(state, line) do + finalize(result, stdout, stderr) + else + {:error, :missing_workspace} -> + {:error, "jido.#{command}: workspace not configured\n"} + + {:error, reason} -> + {:error, "jido.#{command}: invalid session state: #{inspect(reason)}\n"} end + end - defp build_state(%{variables: variables} = session_state) do - case Map.get(variables, "JIDO_WORKSPACE_ID") do - nil -> - {:error, :missing_workspace} - - var -> - workspace_id = variable_value(var) - cwd = Map.get(session_state, :working_dir, "/") - env = variables_to_env(variables) - - State.new(%{ - id: "bash-interop", - workspace_id: workspace_id, - cwd: cwd, - env: env - }) - end + defp build_state(%{variables: variables} = session_state) do + case Map.get(variables, "JIDO_WORKSPACE_ID") do + nil -> + {:error, :missing_workspace} + + var -> + workspace_id = variable_value(var) + cwd = Map.get(session_state, :working_dir, "/") + env = variables_to_env(variables) + + State.new(%{ + id: "bash-interop", + workspace_id: workspace_id, + cwd: cwd, + env: env + }) end + end - defp build_state(_), do: {:error, :missing_workspace} - - defp variable_value(%{value: value}) when is_binary(value), do: value - defp variable_value(value) when is_binary(value), do: value - defp variable_value(_), do: "" - - defp variables_to_env(variables) when is_map(variables) do - variables - |> Enum.flat_map(fn - {"JIDO_WORKSPACE_ID", _} -> [] - {key, %{value: value}} when is_binary(value) -> [{key, value}] - {key, value} when is_binary(value) -> [{key, value}] - _ -> [] - end) - |> Map.new() - end + defp build_state(_), do: {:error, :missing_workspace} + + defp variable_value(%{value: value}) when is_binary(value), do: value + defp variable_value(value) when is_binary(value), do: value + defp variable_value(_), do: "" + + defp variables_to_env(variables) when is_map(variables) do + variables + |> Enum.flat_map(fn + {"JIDO_WORKSPACE_ID", _} -> [] + {key, %{value: value}} when is_binary(value) -> [{key, value}] + {key, value} when is_binary(value) -> [{key, value}] + _ -> [] + end) + |> Map.new() + end - defp build_line(command, []), do: command + 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("\"", "\\\"") + defp build_line(command, args) do + Enum.join([command | Enum.map(args, &escape_arg/1)], " ") + end - "\"" <> escaped <> "\"" - end + defp escape_arg(arg) when is_binary(arg) do + escaped = + arg + |> String.replace("\\", "\\\\") + |> String.replace("\"", "\\\"") - defp escape_arg(other), do: other |> to_string() |> escape_arg() + "\"" <> escaped <> "\"" + end - defp run_command(state, line) do - parent = self() - ref = make_ref() + defp escape_arg(other), do: other |> to_string() |> escape_arg() - emit = fn - {:output, chunk} -> - send(parent, {ref, :stdout, IO.iodata_to_binary(chunk)}) - :ok + defp run_command(state, line) do + parent = self() + ref = make_ref() - {:output_stderr, chunk} -> - send(parent, {ref, :stderr, IO.iodata_to_binary(chunk)}) - :ok + emit = fn + {:output, chunk} -> + send(parent, {ref, :stdout, IO.iodata_to_binary(chunk)}) + :ok - _ -> - :ok - end + {:output_stderr, chunk} -> + send(parent, {ref, :stderr, IO.iodata_to_binary(chunk)}) + :ok - result = CommandRunner.execute(state, line, emit) - {stdout, stderr} = drain(ref, [], []) - {stdout, stderr, result} + _ -> + :ok end - defp drain(ref, stdout, stderr) do - receive do - {^ref, :stdout, chunk} -> drain(ref, [chunk | stdout], stderr) - {^ref, :stderr, chunk} -> drain(ref, stdout, [chunk | stderr]) - after - 0 -> {stdout |> Enum.reverse() |> IO.iodata_to_binary(), stderr |> Enum.reverse() |> IO.iodata_to_binary()} - end - end + result = CommandRunner.execute(state, line, emit) + {stdout, stderr} = drain(ref, [], []) + {stdout, stderr, result} + end - defp finalize({:ok, {:state_update, changes}}, stdout, _stderr) do - apply_state_update(changes) - emit_ok(stdout) + defp drain(ref, stdout, stderr) do + receive do + {^ref, :stdout, chunk} -> drain(ref, [chunk | stdout], stderr) + {^ref, :stderr, chunk} -> drain(ref, stdout, [chunk | stderr]) + after + 0 -> {stdout |> Enum.reverse() |> IO.iodata_to_binary(), stderr |> Enum.reverse() |> IO.iodata_to_binary()} end + end - defp finalize({:ok, _result}, stdout, _stderr) do - emit_ok(stdout) - end + defp finalize({:ok, {:state_update, changes}}, stdout, _stderr) do + apply_state_update(changes) + emit_ok(stdout) + end - defp finalize({:error, %Jido.Shell.Error{} = error}, _stdout, stderr) do - message = - case stderr do - "" -> error.message <> "\n" - s -> s - end + defp finalize({:ok, _result}, stdout, _stderr) do + emit_ok(stdout) + end - {:error, message} - end + defp finalize({:error, %Jido.Shell.Error{} = error}, _stdout, stderr) do + message = + case stderr do + "" -> error.message <> "\n" + s -> s + end - defp finalize({:error, other}, _stdout, stderr) when is_binary(stderr) and stderr != "" do - _ = other - {:error, stderr} - end + {:error, message} + end - defp finalize({:error, other}, _stdout, _stderr) do - {:error, inspect(other) <> "\n"} - end + defp finalize({:error, other}, _stdout, stderr) when is_binary(stderr) and stderr != "" do + _ = other + {:error, stderr} + end - defp emit_ok(""), do: :ok - defp emit_ok(stdout) when is_binary(stdout), do: {:ok, stdout} + defp finalize({:error, other}, _stdout, _stderr) do + {:error, inspect(other) <> "\n"} + end - defp apply_state_update(%{cwd: cwd} = changes) when is_binary(cwd) do - updates = %{working_dir: cwd} - updates = maybe_add_env(updates, changes) - Bash.update_state(updates) - end + defp emit_ok(""), do: :ok + defp emit_ok(stdout) when is_binary(stdout), do: {:ok, stdout} - defp apply_state_update(%{env: _} = changes) do - Bash.update_state(maybe_add_env(%{}, changes)) - end + defp apply_state_update(%{cwd: cwd} = changes) when is_binary(cwd) do + updates = %{working_dir: cwd} + updates = maybe_add_env(updates, changes) + Bash.update_state(updates) + end - defp apply_state_update(_), do: :ok + defp apply_state_update(%{env: _} = changes) do + Bash.update_state(maybe_add_env(%{}, changes)) + end - defp maybe_add_env(updates, %{env: env}) when is_map(env) do - variables = - Map.new(env, fn {k, v} -> - {to_string(k), Bash.Variable.new(to_string(v))} - end) + defp apply_state_update(_), do: :ok - Map.put(updates, :variables, variables) - end + defp maybe_add_env(updates, %{env: env}) when is_map(env) do + variables = + Map.new(env, fn {k, v} -> + {to_string(k), Bash.Variable.new(to_string(v))} + end) - defp maybe_add_env(updates, _), do: updates - end -else - defmodule Jido.Shell.Backend.Bash.JidoInterop do - @moduledoc false + Map.put(updates, :variables, variables) end + + defp maybe_add_env(updates, _), do: updates end diff --git a/lib/jido_shell/backend/bash/vfs_adapter.ex b/lib/jido_shell/backend/bash/vfs_adapter.ex index dad0a41..c9dc63f 100644 --- a/lib/jido_shell/backend/bash/vfs_adapter.ex +++ b/lib/jido_shell/backend/bash/vfs_adapter.ex @@ -1,267 +1,261 @@ -if Code.ensure_loaded?(Bash.Filesystem) do - defmodule Jido.Shell.Backend.Bash.VfsAdapter do - @moduledoc """ - `Bash.Filesystem` implementation that routes calls to `Jido.Shell.VFS`. +defmodule Jido.Shell.Backend.Bash.VfsAdapter do + @moduledoc """ + `Bash.Filesystem` implementation that routes calls to `Jido.Shell.VFS`. - Used exclusively by `Jido.Shell.Backend.Bash` so that scripts executed by the - `:bash` library see the same virtual filesystem as Jido shell commands. The - adapter is configured with the workspace id at session init time: + Used exclusively by `Jido.Shell.Backend.Bash` so that scripts executed by the + `:bash` library see the same virtual filesystem as Jido shell commands. The + adapter is configured with the workspace id at session init time: - {Jido.Shell.Backend.Bash.VfsAdapter, %{workspace_id: "ws1"}} + {Jido.Shell.Backend.Bash.VfsAdapter, %{workspace_id: "ws1"}} - The adapter is stateless on its own — it looks up mounts from - `Jido.Shell.VFS.MountTable` for every call — and buffers `open/write/close` - streams in the calling process dictionary before flushing on close. + The adapter is stateless on its own — it looks up mounts from + `Jido.Shell.VFS.MountTable` for every call — and buffers `open/write/close` + streams in the calling process dictionary before flushing on close. - Only simple `*`/`?` globbing is supported; more elaborate patterns emit a - single warning per pattern and return an empty list. - """ + Only simple `*`/`?` globbing is supported; more elaborate patterns emit a + single warning per pattern and return an empty list. + """ - @behaviour Bash.Filesystem + @behaviour Bash.Filesystem - alias Jido.Shell.VFS + alias Jido.Shell.VFS - @impl true - def exists?(config, path), do: VFS.exists?(ws(config), normalize(path)) + @impl true + def exists?(config, path), do: VFS.exists?(ws(config), normalize(path)) - @impl true - def dir?(config, path) do - case VFS.stat(ws(config), normalize(path)) do - {:ok, %Jido.VFS.Stat.Dir{}} -> true - _ -> false - end + @impl true + def dir?(config, path) do + case VFS.stat(ws(config), normalize(path)) do + {:ok, %Jido.VFS.Stat.Dir{}} -> true + _ -> false end + end - @impl true - def regular?(config, path) do - case VFS.stat(ws(config), normalize(path)) do - {:ok, %Jido.VFS.Stat.File{}} -> true - _ -> false - end + @impl true + def regular?(config, path) do + case VFS.stat(ws(config), normalize(path)) do + {:ok, %Jido.VFS.Stat.File{}} -> true + _ -> false end + end - @impl true - def stat(config, path) do - case VFS.stat(ws(config), normalize(path)) do - {:ok, entry} -> {:ok, to_file_stat(entry)} - {:error, _} -> {:error, :enoent} - end + @impl true + def stat(config, path) do + case VFS.stat(ws(config), normalize(path)) do + {:ok, entry} -> {:ok, to_file_stat(entry)} + {:error, _} -> {:error, :enoent} end + end - @impl true - def lstat(config, path), do: stat(config, path) + @impl true + def lstat(config, path), do: stat(config, path) - @impl true - def read(config, path) do - case VFS.read_file(ws(config), normalize(path)) do - {:ok, _} = ok -> ok - {:error, _} -> {:error, :enoent} - end + @impl true + def read(config, path) do + case VFS.read_file(ws(config), normalize(path)) do + {:ok, _} = ok -> ok + {:error, _} -> {:error, :enoent} end + end - @impl true - def write(config, path, content, opts) do - path = normalize(path) - binary = IO.iodata_to_binary(content) - workspace_id = ws(config) - - final_content = - if Keyword.get(opts, :append, false) do - case VFS.read_file(workspace_id, path) do - {:ok, existing} -> existing <> binary - {:error, _} -> binary - end - else - binary + @impl true + def write(config, path, content, opts) do + path = normalize(path) + binary = IO.iodata_to_binary(content) + workspace_id = ws(config) + + final_content = + if Keyword.get(opts, :append, false) do + case VFS.read_file(workspace_id, path) do + {:ok, existing} -> existing <> binary + {:error, _} -> binary end - - case VFS.write_file(workspace_id, path, final_content) do - :ok -> :ok - {:error, _} -> {:error, :eacces} + else + binary end + + case VFS.write_file(workspace_id, path, final_content) do + :ok -> :ok + {:error, _} -> {:error, :eacces} end + end - @impl true - def mkdir_p(config, path) do - case VFS.mkdir(ws(config), normalize(path)) do - :ok -> - :ok + @impl true + def mkdir_p(config, path) do + case VFS.mkdir(ws(config), normalize(path)) do + :ok -> + :ok - {:error, %{code: {:vfs, :already_exists}}} -> - :ok + {:error, %{code: {:vfs, :already_exists}}} -> + :ok - {:error, _} -> - # Collapse any other VFS failure — `mkdir_p` should be idempotent. - if VFS.exists?(ws(config), normalize(path)), do: :ok, else: {:error, :eacces} - end + {:error, _} -> + # Collapse any other VFS failure — `mkdir_p` should be idempotent. + if VFS.exists?(ws(config), normalize(path)), do: :ok, else: {:error, :eacces} end + end - @impl true - def rm(config, path) do - case VFS.delete(ws(config), normalize(path)) do - :ok -> :ok - {:error, _} -> {:error, :enoent} - end + @impl true + def rm(config, path) do + case VFS.delete(ws(config), normalize(path)) do + :ok -> :ok + {:error, _} -> {:error, :enoent} end + end - @impl true - def ls(config, path) do - path = normalize(path) + @impl true + def ls(config, path) do + path = normalize(path) - case VFS.list_dir(ws(config), path) do - {:ok, entries} -> {:ok, entries |> Enum.map(& &1.name) |> Enum.sort()} - {:error, _} -> {:error, :enoent} - end + case VFS.list_dir(ws(config), path) do + {:ok, entries} -> {:ok, entries |> Enum.map(& &1.name) |> Enum.sort()} + {:error, _} -> {:error, :enoent} end + end - @impl true - def wildcard(config, pattern, _opts) do - dir = Path.dirname(pattern) - base = Path.basename(pattern) - - cond do - # Only handle simple `*`/`?` patterns in the last path segment. - not simple_pattern?(base) -> - require Logger - Logger.warning("Jido.Shell.Backend.Bash.VfsAdapter: unsupported glob #{inspect(pattern)}") - [] - - true -> - case VFS.list_dir(ws(config), normalize(dir)) do - {:ok, entries} -> - regex = compile_glob(base) - - entries - |> Enum.filter(fn entry -> Regex.match?(regex, entry.name) end) - |> Enum.map(fn entry -> Path.join(normalize(dir), entry.name) end) - |> Enum.sort() - - {:error, _} -> - [] - end - end + @impl true + def wildcard(config, pattern, _opts) do + dir = Path.dirname(pattern) + base = Path.basename(pattern) + + cond do + # Only handle simple `*`/`?` patterns in the last path segment. + not simple_pattern?(base) -> + require Logger + Logger.warning("Jido.Shell.Backend.Bash.VfsAdapter: unsupported glob #{inspect(pattern)}") + [] + + true -> + case VFS.list_dir(ws(config), normalize(dir)) do + {:ok, entries} -> + regex = compile_glob(base) + + entries + |> Enum.filter(fn entry -> Regex.match?(regex, entry.name) end) + |> Enum.map(fn entry -> Path.join(normalize(dir), entry.name) end) + |> Enum.sort() + + {:error, _} -> + [] + end end + end - @impl true - def open(config, path, modes) when is_list(modes) do - cond do - :write in modes or :append in modes -> - {:ok, make_device(config, path, :append in modes)} + @impl true + def open(config, path, modes) when is_list(modes) do + cond do + :write in modes or :append in modes -> + {:ok, make_device(config, path, :append in modes)} - :read in modes -> - case VFS.read_file(ws(config), normalize(path)) do - {:ok, content} -> - {:ok, device} = StringIO.open(content) - {:ok, device} + :read in modes -> + case VFS.read_file(ws(config), normalize(path)) do + {:ok, content} -> + {:ok, device} = StringIO.open(content) + {:ok, device} - {:error, _} -> - {:error, :enoent} - end + {:error, _} -> + {:error, :enoent} + end - true -> - {:error, :einval} - end + true -> + {:error, :einval} end + end - def open(_config, _path, _modes), do: {:error, :einval} + def open(_config, _path, _modes), do: {:error, :einval} - @impl true - def handle_write(_config, device, data) do - case Process.get({__MODULE__, device}) do - %{kind: :write, path: _path, buffer: buffer} = state -> - Process.put({__MODULE__, device}, %{state | buffer: buffer <> IO.iodata_to_binary(data)}) - :ok + @impl true + def handle_write(_config, device, data) do + case Process.get({__MODULE__, device}) do + %{kind: :write, path: _path, buffer: buffer} = state -> + Process.put({__MODULE__, device}, %{state | buffer: buffer <> IO.iodata_to_binary(data)}) + :ok - _ -> - IO.binwrite(device, data) - end + _ -> + IO.binwrite(device, data) end + end - @impl true - def handle_close(config, device) do - case Process.get({__MODULE__, device}) do - %{kind: :write, path: path, append?: append?, buffer: buffer} -> - Process.delete({__MODULE__, device}) - _ = StringIO.close(device) - workspace_id = ws(config) - - final = - if append? do - case VFS.read_file(workspace_id, path) do - {:ok, existing} -> existing <> buffer - {:error, _} -> buffer - end - else - buffer + @impl true + def handle_close(config, device) do + case Process.get({__MODULE__, device}) do + %{kind: :write, path: path, append?: append?, buffer: buffer} -> + Process.delete({__MODULE__, device}) + _ = StringIO.close(device) + workspace_id = ws(config) + + final = + if append? do + case VFS.read_file(workspace_id, path) do + {:ok, existing} -> existing <> buffer + {:error, _} -> buffer end - - case VFS.write_file(workspace_id, path, final) do - :ok -> :ok - {:error, _} -> {:error, :eacces} + else + buffer end - _ -> - _ = StringIO.close(device) - :ok - end + case VFS.write_file(workspace_id, path, final) do + :ok -> :ok + {:error, _} -> {:error, :eacces} + end + + _ -> + _ = StringIO.close(device) + :ok end + end - @impl true - def read_link(_config, _path), do: {:error, :enotsup} + @impl true + def read_link(_config, _path), do: {:error, :enotsup} - @impl true - def read_link_all(_config, _path), do: {:error, :enotsup} + @impl true + def read_link_all(_config, _path), do: {:error, :enotsup} - # === helpers === + # === helpers === - defp make_device(_config, path, append?) do - {:ok, device} = StringIO.open("") - Process.put({__MODULE__, device}, %{kind: :write, path: normalize(path), append?: append?, buffer: ""}) - device - end + defp make_device(_config, path, append?) do + {:ok, device} = StringIO.open("") + Process.put({__MODULE__, device}, %{kind: :write, path: normalize(path), append?: append?, buffer: ""}) + device + end - defp ws(%{workspace_id: wid}) when is_binary(wid), do: wid + defp ws(%{workspace_id: wid}) when is_binary(wid), do: wid - defp normalize(path) when is_binary(path) do - case Path.type(path) do - :absolute -> Path.expand(path) - _ -> Path.expand(path, "/") - end + defp normalize(path) when is_binary(path) do + case Path.type(path) do + :absolute -> Path.expand(path) + _ -> Path.expand(path, "/") end + end - defp to_file_stat(%Jido.VFS.Stat.Dir{}) do - now = :calendar.universal_time() - %File.Stat{type: :directory, size: 0, access: :read_write, mode: 0o755, mtime: now, atime: now, ctime: now} - end + defp to_file_stat(%Jido.VFS.Stat.Dir{}) do + now = :calendar.universal_time() + %File.Stat{type: :directory, size: 0, access: :read_write, mode: 0o755, mtime: now, atime: now, ctime: now} + end - defp to_file_stat(%Jido.VFS.Stat.File{size: size}) do - now = :calendar.universal_time() - - %File.Stat{ - type: :regular, - size: size || 0, - access: :read_write, - mode: 0o644, - mtime: now, - atime: now, - ctime: now - } - end + defp to_file_stat(%Jido.VFS.Stat.File{size: size}) do + now = :calendar.universal_time() + + %File.Stat{ + type: :regular, + size: size || 0, + access: :read_write, + mode: 0o644, + mtime: now, + atime: now, + ctime: now + } + end - defp simple_pattern?(base), do: String.match?(base, ~r/^[A-Za-z0-9_.*?\-]*$/) + defp simple_pattern?(base), do: String.match?(base, ~r/^[A-Za-z0-9_.*?\-]*$/) - defp compile_glob(base) do - source = - base - |> Regex.escape() - |> String.replace("\\*", ".*") - |> String.replace("\\?", ".") + defp compile_glob(base) do + source = + base + |> Regex.escape() + |> String.replace("\\*", ".*") + |> String.replace("\\?", ".") - Regex.compile!("^" <> source <> "$") - end - end -else - defmodule Jido.Shell.Backend.Bash.VfsAdapter do - @moduledoc false + Regex.compile!("^" <> source <> "$") end end diff --git a/mix.exs b/mix.exs index 203e9a1..9869661 100644 --- a/mix.exs +++ b/mix.exs @@ -73,7 +73,11 @@ defmodule Jido.Shell.MixProject do {:uniq, "~> 0.6"}, {:zoi, "~> 0.17"}, {:jido_vfs, "~> 1.0"}, - bash_dep(), + {:bash, + git: "https://github.com/tv-labs/bash.git", + ref: "c1038ff83e825c29ea131bf8b728bd1672734c01", + only: [:dev, :test], + optional: true}, # Dev/Test dependencies {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, @@ -105,22 +109,9 @@ defmodule Jido.Shell.MixProject do ] end - defp bash_dep do - if Mix.env() in [:dev, :test] do - {:bash, - git: "https://github.com/tv-labs/bash.git", - ref: "c1038ff83e825c29ea131bf8b728bd1672734c01", - only: [:dev, :test], - optional: true} - else - {:bash, "~> 0.5.1", optional: true} - end - end - defp package do [ - files: - ~w(lib mix.exs LICENSE README.md MIGRATION.md CHANGELOG.md CONTRIBUTING.md GUARDRAILS.md AGENTS.md usage-rules.md .formatter.exs), + files: package_files(), maintainers: ["Mike Hostetler"], licenses: ["Apache-2.0"], links: %{ @@ -133,6 +124,21 @@ defmodule Jido.Shell.MixProject do ] end + defp package_files do + lib_files = + "lib/**/*" + |> Path.wildcard() + |> Enum.reject(&File.dir?/1) + |> Enum.reject(&bash_backend_file?/1) + + lib_files ++ + ~w(mix.exs LICENSE README.md MIGRATION.md CHANGELOG.md CONTRIBUTING.md GUARDRAILS.md AGENTS.md usage-rules.md .formatter.exs) + end + + defp bash_backend_file?("lib/jido_shell/backend/bash.ex"), do: true + defp bash_backend_file?("lib/jido_shell/backend/bash/" <> _), do: true + defp bash_backend_file?(_path), do: false + defp docs do [ main: "readme",