defmodule Omni.Tools.Bash.Runner do @moduledoc """ Executes shell commands via a Port and returns captured output. Each invocation spawns a new shell process, captures merged stdout/stderr, and returns the output alongside the exit code. No state carries over between calls. Runner.run("echo hello", {"/bin/bash", ["-c"]}, dir: "/tmp") #=> {:ok, %{output: "hello\\n", exit_code: 0}} Runner.run("exit 1", {"/bin/bash", ["-c"]}, dir: "/tmp") #=> {:error, :nonzero, %{output: "", exit_code: 1}} The runner executes arbitrary shell commands with full system access. It is not a security boundary — OS-level sandboxing (containers, restricted users) is the caller's responsibility. ## Options * `:dir` (required) — working directory for the command * `:env` — extra environment variables as `[{String.t(), String.t()}]`, merged additively with the inherited environment. Default `[]` * `:timeout` — execution timeout in milliseconds. Default `30_000` * `:max_output` — output truncation limit in bytes. Tail-biased, snapped to line boundaries. Default `50_000` * `:command_prefix` — string prepended to every command. Default `nil` ## Return values {:ok, %{output: "hello\\n", exit_code: 0}} {:error, :nonzero, %{output: "error msg\\n", exit_code: 1}} {:error, :timeout, %{output: "partial..."}} On success, `exit_code` is always `0`. On a non-zero exit, the output captured up to that point is included. On timeout, partial output collected before the deadline is returned. """ @default_timeout 30_000 @default_max_output 50_000 @typedoc "Result of a command execution — success, non-zero exit, or timeout." @type result :: {:ok, %{output: String.t(), exit_code: 0}} | {:error, :nonzero, %{output: String.t(), exit_code: pos_integer()}} | {:error, :timeout, %{output: String.t()}} @doc """ Executes `command` in the given `shell` and returns captured output. The `shell` argument is a `{executable, args}` tuple — the command string is appended to `args` when spawning the port. """ @spec run(String.t(), {String.t(), [String.t()]}, keyword()) :: result() def run(command, {shell_exe, shell_args}, opts \\ []) do dir = Keyword.fetch!(opts, :dir) env = opts |> Keyword.get(:env, []) |> to_port_env() timeout = Keyword.get(opts, :timeout, @default_timeout) max_output = Keyword.get(opts, :max_output, @default_max_output) prefix = Keyword.get(opts, :command_prefix) full_command = build_command(command, prefix) port = Port.open({:spawn_executable, shell_exe}, [ :binary, :exit_status, :stderr_to_stdout, {:args, shell_args ++ [full_command]}, {:cd, dir}, {:env, env} ]) deadline = System.monotonic_time(:millisecond) + timeout case collect(port, deadline, []) do {:ok, output, exit_code} -> output = truncate_tail(output, max_output) if exit_code == 0 do {:ok, %{output: output, exit_code: 0}} else {:error, :nonzero, %{output: output, exit_code: exit_code}} end {:timeout, acc} -> close_port(port) remaining = drain_port(port, []) all = Enum.reverse(acc) ++ remaining output = IO.iodata_to_binary(all) output = truncate_tail(output, max_output) {:error, :timeout, %{output: output}} end end @doc """ Resolves the shell to use for command execution. Checks in order: explicit `:shell` option, `/bin/bash`, `/bin/sh`. Returns a `{executable, args}` tuple suitable for `Port.open/2`. Raises `ArgumentError` if no usable shell is found or the option is invalid. Runner.resolve_shell([]) #=> {"/bin/bash", ["-c"]} Runner.resolve_shell(shell: {"/bin/zsh", ["-c"]}) #=> {"/bin/zsh", ["-c"]} """ @spec resolve_shell(keyword()) :: {String.t(), [String.t()]} def resolve_shell(opts) do case Keyword.get(opts, :shell) do {exe, args} when is_binary(exe) and is_list(args) -> {exe, args} nil -> cond do File.exists?("/bin/bash") -> {"/bin/bash", ["-c"]} File.exists?("/bin/sh") -> {"/bin/sh", ["-c"]} true -> raise ArgumentError, "no shell found: /bin/bash and /bin/sh both missing" end other -> raise ArgumentError, "invalid :shell option: expected {executable, args} tuple, got: #{inspect(other)}" end end # ── Port communication ─────────────────────────────────────────── defp collect(port, deadline, acc) do remaining = max(deadline - System.monotonic_time(:millisecond), 0) receive do {^port, {:data, chunk}} -> collect(port, deadline, [chunk | acc]) {^port, {:exit_status, code}} -> output = acc |> Enum.reverse() |> IO.iodata_to_binary() {:ok, output, code} after remaining -> {:timeout, acc} end end defp drain_port(port, acc) do receive do {^port, {:data, chunk}} -> drain_port(port, [chunk | acc]) {^port, {:exit_status, _}} -> acc |> Enum.reverse() after 0 -> acc |> Enum.reverse() end end defp close_port(port) do Port.close(port) catch :error, :badarg -> :ok end # ── Command building ───────────────────────────────────────────── defp build_command(command, nil), do: command defp build_command(command, prefix), do: prefix <> "\n" <> command defp to_port_env(env) do Enum.map(env, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end) end # ── Output truncation ─────────────────────────────────────────── defp truncate_tail(output, :infinity), do: output defp truncate_tail(output, max) when byte_size(output) <= max, do: output defp truncate_tail(output, max) do total = byte_size(output) skip = total - max tail = binary_part(output, skip, max) snapped = case :binary.match(tail, "\n") do {pos, 1} -> binary_part(tail, pos + 1, byte_size(tail) - pos - 1) :nomatch -> tail end "...(truncated, showing last #{format_bytes(byte_size(snapped))} of #{format_bytes(total)})\n#{snapped}" end defp format_bytes(bytes) when bytes < 1_024, do: "#{bytes}B" defp format_bytes(bytes) when bytes < 1_048_576 do kb = Float.round(bytes / 1_024, 1) "#{kb}KB" end defp format_bytes(bytes) do mb = Float.round(bytes / 1_048_576, 1) "#{mb}MB" end end