diff --git a/CHANGELOG.md b/CHANGELOG.md index 69d1125..d78e270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,13 @@ ### Breaking Changes +* Child processes now scrub inherited release boot variables and release PATH entries when `RELEASE_ROOT` is set. Set `release_env: :inherit` to opt out. Explicit `Config.env` values apply after the scrub, with release entries still removed from an explicit PATH. * Prompt commands now pass `-` in argv and deliver prompt bytes through stdin by default. Custom runners must deliver `opts[:stdin]` and EOF, or callers must select `prompt_input: :argv`. +### Features + +* Add `CodexWrapper.ChildEnv.release_scrub/1` and `normalize/3` for building child environments from a supplied parent environment. + ### Bug Fixes * Port startup failures now return `{:error, {:spawn, reason}}` instead of raising. Streaming consumers receive a `CodexWrapper.StreamError` item. diff --git a/README.md b/README.md index 7ac91ea..51d5561 100644 --- a/README.md +++ b/README.md @@ -728,6 +728,7 @@ Exception metadata adds the standard `:kind`, `:reason`, and | `:binary` | `String.t()` | Path to `codex` binary (auto-discovered if omitted) | | `:working_dir` | `String.t()` | Working directory for the subprocess | | `:env` | `[{String.t(), String.t() \| nil \| false}]` | Environment variables for the child; `nil` or `false` removes an inherited variable | +| `:release_env` | `:scrub \| :inherit` | Scrub the host release's boot environment by default when `RELEASE_ROOT` is set; use `:inherit` to opt out | | `:timeout` | `pos_integer()` | Whole-command timeout in milliseconds; defaults to no whole-run bound for Port streams | | `:idle_timeout_ms` | `pos_integer() \| nil` | Maximum gap between output frames in a stream; defaults to 300,000 ms, or set `nil` to disable | | `:runner` | module or shorthand | Per-config runner; snapshots application default when absent | @@ -747,6 +748,41 @@ one-shot calls replace the child environment with the computed one (`env_mode: :replace`). `Forcola.Stream` does not take `env_mode`, so a Forcola stream runs `/usr/bin/env -u NAME ...` in front of `codex`. +### Running from a release + +By default, when the parent VM has `RELEASE_ROOT` set, each child process +starts without inherited `RELEASE_*`, `BINDIR`, or `ROOTDIR` variables. The +wrapper also removes complete PATH entries equal to the parent's `BINDIR`, +`ROOTDIR/bin`, `RELEASE_ROOT/bin`, or either root's direct `erts-/bin`. +Other PATH entries retain their order, including duplicates and empty entries. +This lets commands such as `mix` and `elixir` find the project's runtime instead +of booting against the host release. The project's runtime must be available on +the remaining PATH. + +The default applies to execute and stream calls on both runners, sessions, +command modules, and `raw/2`. The parent environment is read when preparing +the spawn and is never modified. Outside a release, nothing is scrubbed. + +`Config.env` entries apply after the scrub, so an explicit value can restore +a removed name. An explicit PATH still has the parent's release entries +removed; `{"PATH", nil}` or `{"PATH", false}` keeps PATH unset. With +`runner_options: [env_mode: :replace]`, no inherited PATH is added. + +```elixir +# Default behavior inside a release +config = CodexWrapper.Config.new(release_env: :scrub) + +# Opt out for a command that deliberately needs the host release environment +config = CodexWrapper.Config.new(release_env: :inherit) +CodexWrapper.exec("inspect the release", release_env: :inherit) + +# Obtain unset tuples and a cleaned PATH for a custom environment builder +scrub = CodexWrapper.ChildEnv.release_scrub(System.get_env()) +``` + +Removing `RELEASE_COOKIE` reduces accidental propagation. It does not prevent +a child running as the same OS user from reading the release's cookie file. + ### Observing native thread identity before completion The opt-in `:session_observer` execution option announces the first valid diff --git a/lib/codex_wrapper.ex b/lib/codex_wrapper.ex index b078f70..8aa3b3b 100644 --- a/lib/codex_wrapper.ex +++ b/lib/codex_wrapper.ex @@ -103,6 +103,7 @@ defmodule CodexWrapper do * `:binary` - Path to codex binary * `:working_dir` - Working directory * `:env` - Environment variables + * `:release_env` - Release boot environment policy: `:scrub` (default) or `:inherit` * `:timeout` - Timeout in ms * `:idle_timeout_ms` - Maximum gap between streaming output frames in ms * `:verbose` - Compatibility option: only `false` is supported; `true` raises @@ -213,6 +214,7 @@ defmodule CodexWrapper do * `:binary` - Path to codex binary * `:working_dir` - Working directory * `:env` - Environment variables + * `:release_env` - Release boot environment policy: `:scrub` (default) or `:inherit` * `:timeout` - Timeout in ms * `:idle_timeout_ms` - Maximum gap between streaming output frames in ms * `:verbose` - Compatibility option: only `false` is supported; `true` raises @@ -273,6 +275,7 @@ defmodule CodexWrapper do :binary, :working_dir, :env, + :release_env, :timeout, :idle_timeout_ms, :verbose diff --git a/lib/codex_wrapper/child_env.ex b/lib/codex_wrapper/child_env.ex new file mode 100644 index 0000000..04bc277 --- /dev/null +++ b/lib/codex_wrapper/child_env.ex @@ -0,0 +1,94 @@ +defmodule CodexWrapper.ChildEnv do + @moduledoc """ + Removes the host release's boot environment from child processes. + + Helpers accept a parent environment map, such as `System.get_env/0`, without + modifying the VM's environment. Removals use `false`, as accepted by the + wrapper's runners. This prevents accidental inheritance, not access to release + files by a child running as the same OS user. + """ + + @type parent_env :: %{String.t() => String.t()} + @type entries :: [{String.t(), String.t() | false}] + + @doc """ + Return release environment removals and a cleaned PATH. + + Does nothing unless `RELEASE_ROOT` is set in `parent`. Removes all inherited + `RELEASE_*` names, `BINDIR`, and `ROOTDIR`. PATH filtering removes complete + entries equal to `BINDIR`, either root's `bin`, or either root's direct + `erts-/bin`. Other entries retain their order, including duplicates + and empty entries. An absent PATH stays absent. + """ + @spec release_scrub(parent_env()) :: entries() + def release_scrub(parent) do + if release?(parent) do + removals = + parent + |> Map.keys() + |> Enum.filter(&String.starts_with?(&1, "RELEASE_")) + |> Kernel.++(["BINDIR", "ROOTDIR"]) + |> Enum.sort() + |> Enum.map(&{&1, false}) + + case parent["PATH"] do + nil -> removals + path -> removals ++ [{"PATH", scrub_path(path, parent)}] + end + else + [] + end + end + + @doc """ + Apply caller overrides after the release scrub using a supplied parent map. + + Explicit values may restore removed names. Explicit PATH values still have + the parent's release entries removed; `nil` or `false` leaves PATH unset. + Duplicate names retain last-entry-wins semantics. With `:inherit`, or without + `RELEASE_ROOT`, returns only the validated overrides. + """ + @spec normalize(Enumerable.t(), parent_env(), :scrub | :inherit) :: entries() + def normalize(env, parent, mode \\ :scrub) when mode in [:scrub, :inherit] do + env = CodexWrapper.Config.validate_env!(env) + + if mode == :scrub and release?(parent) do + release_scrub(parent) ++ + Enum.map(env, fn + {"PATH", value} when is_binary(value) -> {"PATH", scrub_path(value, parent)} + entry -> entry + end) + else + env + end + end + + defp release?(parent), do: not is_nil(parent["RELEASE_ROOT"]) + + defp scrub_path(path, parent) do + roots = Enum.filter([parent["ROOTDIR"], parent["RELEASE_ROOT"]], &nonempty?/1) + bins = Enum.filter([parent["BINDIR"] | Enum.map(roots, &Path.join(&1, "bin"))], &nonempty?/1) + + path + |> String.split(":") + |> Enum.reject(fn entry -> + entry in bins or Enum.any?(roots, &erts_bin?(entry, &1)) + end) + |> Enum.join(":") + end + + defp nonempty?(value), do: is_binary(value) and value != "" + + defp erts_bin?(entry, root) do + prefix = Path.join(root, "erts-") + + if String.starts_with?(entry, prefix) do + case entry |> String.replace_prefix(prefix, "") |> String.split("/") do + [version, "bin"] when version != "" -> true + _ -> false + end + else + false + end + end +end diff --git a/lib/codex_wrapper/config.ex b/lib/codex_wrapper/config.ex index 34aab10..3730a62 100644 --- a/lib/codex_wrapper/config.ex +++ b/lib/codex_wrapper/config.ex @@ -15,6 +15,7 @@ defmodule CodexWrapper.Config do binary: String.t(), working_dir: String.t() | nil, env: [{String.t(), String.t() | false}], + release_env: :scrub | :inherit, timeout: pos_integer() | nil, idle_timeout_ms: pos_integer() | nil, verbose: boolean(), @@ -34,6 +35,7 @@ defmodule CodexWrapper.Config do :runner_options, :runner_default_timeout_ms, env: [], + release_env: :scrub, verbose: false ] @@ -50,6 +52,10 @@ defmodule CodexWrapper.Config do `false` removes an inherited variable, on every runner path. Names must be nonempty and contain no `=`; names and values must contain no NUL. Invalid entries raise `ArgumentError`. `nil` is stored as `false`. + * `:release_env` - `:scrub` (default) removes inherited release boot variables + and release PATH entries when `RELEASE_ROOT` is set. `:env` entries apply + afterward, but explicit PATH values are also scrubbed. Use `:inherit` to + opt out. See `CodexWrapper.ChildEnv`. * `:timeout` - Whole-command timeout in milliseconds * `:idle_timeout_ms` - Maximum gap between output frames while streaming or using evidenced one-shot execution @@ -81,6 +87,7 @@ defmodule CodexWrapper.Config do binary: opts[:binary] || find_binary(), working_dir: opts[:working_dir], env: validate_env!(opts[:env] || []), + release_env: validate_release_env!(Keyword.get(opts, :release_env, :scrub)), timeout: opts[:timeout], idle_timeout_ms: Keyword.get(opts, :idle_timeout_ms, 300_000), verbose: verbose, @@ -226,20 +233,41 @@ defmodule CodexWrapper.Config do "verbose: true is unsupported: the Codex CLI does not define --verbose" end + defp validate_release_env!(mode) when mode in [:scrub, :inherit], do: mode + + defp validate_release_env!(mode), + do: raise(ArgumentError, "release_env must be :scrub or :inherit, got: #{inspect(mode)}") + @doc """ - Build the cmd options (working dir, env) for `System.cmd`. + Build subprocess options using the current parent environment. + + Release scrubbing happens here, when preparing a spawn, rather than when + constructing the config. The parent environment is never modified. """ @spec cmd_opts(t()) :: keyword() def cmd_opts(%__MODULE__{} = config) do + cmd_opts(config, System.get_env()) + end + + defp cmd_opts(config, parent) do + mode = validate_release_env!(config.release_env) + env = CodexWrapper.ChildEnv.normalize(config.env, parent, mode) opts = [stderr_to_stdout: true] opts = if config.working_dir, do: [{:cd, config.working_dir} | opts], else: opts - opts = if config.env != [], do: [{:env, config.env} | opts], else: opts + opts = if env != [], do: [{:env, env} | opts], else: opts opts end @doc false @spec execution_opts(t()) :: keyword() - def execution_opts(config), do: Keyword.merge(cmd_opts(config), runner_options(config)) + def execution_opts(config) do + runner_opts = runner_options(config) + parent = System.get_env() + # A replacement environment must not acquire an inherited PATH merely + # because the host is a release. Keep the roots to scrub explicit PATHs. + parent = if runner_opts[:env_mode] == :replace, do: Map.delete(parent, "PATH"), else: parent + Keyword.merge(cmd_opts(config, parent), runner_opts) + end @doc """ Build the runner options for a streaming run. diff --git a/mix.exs b/mix.exs index b28e7b3..aecb86f 100644 --- a/mix.exs +++ b/mix.exs @@ -60,6 +60,7 @@ defmodule CodexWrapperEx.MixProject do ], "Config, results, support": [ CodexWrapper.Config, + CodexWrapper.ChildEnv, CodexWrapper.Result, CodexWrapper.LastMessage, CodexWrapper.SessionObservation, diff --git a/test/codex_wrapper/child_env_test.exs b/test/codex_wrapper/child_env_test.exs new file mode 100644 index 0000000..0522a66 --- /dev/null +++ b/test/codex_wrapper/child_env_test.exs @@ -0,0 +1,168 @@ +defmodule CodexWrapper.ChildEnvTest do + use ExUnit.Case, async: true + + alias CodexWrapper.{ChildEnv, Config} + + @parent %{ + "RELEASE_ROOT" => "/srv/release", + "ROOTDIR" => "/srv/runtime", + "BINDIR" => "/custom/erts/bin", + "RELEASE_COOKIE" => "host-cookie", + "RELEASE_NODE" => "host-node", + "RELEASE_FUTURE_OPTION" => "host-option", + "HOME" => "/home/app", + "PATH" => "/srv/release/erts-17.1.1/bin:/srv/release/bin:/usr/bin:/bin" + } + + test "scrubs all release names and boot variables without copying unrelated variables" do + assert Map.new(ChildEnv.release_scrub(@parent)) == %{ + "RELEASE_ROOT" => false, + "ROOTDIR" => false, + "BINDIR" => false, + "RELEASE_COOKIE" => false, + "RELEASE_NODE" => false, + "RELEASE_FUTURE_OPTION" => false, + "PATH" => "/usr/bin:/bin" + } + end + + test "matches whole PATH entries under both roots and the explicit bindir" do + removed = [ + "/custom/erts/bin", + "/srv/runtime/bin", + "/srv/release/bin", + "/srv/runtime/erts-15.2/bin", + "/srv/release/erts-17.1.1/bin" + ] + + kept = [ + "", + "/usr/local/bin", + "/srv/release-other/bin", + "/prefix/srv/release/bin", + "/srv/release/binary", + "/srv/release/bin/tools", + "/srv/release/erts-/bin", + "/srv/release/erts-17/lib/bin", + "/srv/release/nested/erts-17/bin", + "/srv/release/erts-17/bin/tools", + "/custom/erts/binary", + "/usr/local/bin", + "" + ] + + parent = Map.put(@parent, "PATH", Enum.join(removed ++ kept ++ removed, ":")) + assert Map.new(ChildEnv.release_scrub(parent))["PATH"] == Enum.join(kept, ":") + end + + test "root paths with a trailing slash and the filesystem root produce exact bin entries" do + parent = %{ + "RELEASE_ROOT" => "/release/", + "ROOTDIR" => "/", + "PATH" => "/release/bin:/release/erts-17/bin:/bin:/erts-15/bin:/usr/bin" + } + + assert Map.new(ChildEnv.release_scrub(parent))["PATH"] == "/usr/bin" + end + + test "empty release markers do not remove relative bin or empty PATH components" do + parent = %{ + "RELEASE_ROOT" => "", + "ROOTDIR" => "", + "BINDIR" => "", + "PATH" => ":bin:erts-17/bin:/bin:" + } + + scrub = Map.new(ChildEnv.release_scrub(parent)) + assert scrub["RELEASE_ROOT"] == false + assert scrub["PATH"] == parent["PATH"] + end + + test "absent PATH is not added, and boot names are removed even if absent" do + assert Map.new(ChildEnv.release_scrub(%{"RELEASE_ROOT" => "/release"})) == %{ + "RELEASE_ROOT" => false, + "BINDIR" => false, + "ROOTDIR" => false + } + end + + test "empty PATH and a PATH consisting entirely of release entries stay empty" do + for path <- ["", "/srv/release/bin:/custom/erts/bin"] do + assert Map.new(ChildEnv.release_scrub(Map.put(@parent, "PATH", path)))["PATH"] == "" + end + end + + test "does nothing outside a release even when other boot variables exist" do + parent = Map.delete(@parent, "RELEASE_ROOT") + overrides = [{"ROOTDIR", "/child"}, {"PATH", @parent["PATH"]}] + assert ChildEnv.release_scrub(parent) == [] + assert ChildEnv.normalize(overrides, parent) == overrides + end + + test "inherit opts out of both variable removal and PATH filtering" do + overrides = [{"RELEASE_COOKIE", "child"}, {"PATH", @parent["PATH"]}] + assert ChildEnv.normalize(overrides, @parent, :inherit) == overrides + assert ChildEnv.normalize([], @parent, :inherit) == [] + end + + test "caller values override removals, with the last value for each name winning" do + overrides = [ + {"RELEASE_COOKIE", "child-cookie"}, + {"RELEASE_NODE", nil}, + {"RELEASE_NODE", "child-node"}, + {"ROOTDIR", "/child/root"}, + {"BINDIR", "/child/bin"}, + {"HOME", "/home/child"} + ] + + env = Map.new(ChildEnv.normalize(overrides, @parent)) + assert env["RELEASE_COOKIE"] == "child-cookie" + assert env["RELEASE_NODE"] == "child-node" + assert env["ROOTDIR"] == "/child/root" + assert env["BINDIR"] == "/child/bin" + assert env["HOME"] == "/home/child" + assert env["RELEASE_ROOT"] == false + end + + test "explicit PATH is scrubbed against parent markers even when those markers are overridden" do + overrides = [ + {"PATH", "/discarded"}, + {"RELEASE_ROOT", "/child"}, + {"ROOTDIR", "/child/runtime"}, + {"BINDIR", "/child/bin"}, + {"PATH", "/srv/release/bin:/child/bin:/custom/erts/bin:/usr/bin"} + ] + + assert Map.new(ChildEnv.normalize(overrides, @parent))["PATH"] == "/child/bin:/usr/bin" + end + + test "explicit PATH removal remains a removal" do + for unset <- [false, nil] do + assert Map.new(ChildEnv.normalize([{"PATH", unset}], @parent))["PATH"] == false + end + end + + test "validates and normalizes map and charlist overrides" do + assert Map.new(ChildEnv.normalize(%{"RELEASE_COOKIE" => nil}, @parent))["RELEASE_COOKIE"] == + false + + assert Map.new(ChildEnv.normalize([{~c"PATH", ~c"/srv/release/bin:/bin"}], @parent))["PATH"] == + "/bin" + + assert_raise ArgumentError, fn -> ChildEnv.normalize([{"PATH", 1}], @parent) end + end + + test "config defaults and direct structs scrub; only explicit inherit disables it" do + assert Config.new().release_env == :scrub + assert %Config{}.release_env == :scrub + assert Config.new(release_env: :inherit).release_env == :inherit + + for mode <- [nil, false, :other, "scrub"] do + assert_raise ArgumentError, ~r/release_env/, fn -> Config.new(release_env: mode) end + + assert_raise ArgumentError, ~r/release_env/, fn -> + Config.cmd_opts(%Config{release_env: mode}) + end + end + end +end diff --git a/test/codex_wrapper/release_env_test.exs b/test/codex_wrapper/release_env_test.exs new file mode 100644 index 0000000..3827dbb --- /dev/null +++ b/test/codex_wrapper/release_env_test.exs @@ -0,0 +1,289 @@ +defmodule CodexWrapper.ReleaseEnvTest do + # These tests temporarily replace the VM's release environment. + use ExUnit.Case, async: false + + alias CodexWrapper.Commands + alias CodexWrapper.{Config, Exec, ExecFork, ExecResume, Review, Runner, Session} + + @moduletag :tmp_dir + @unset "" + @clean_path "/usr/bin:/bin:/unrelated/erts-17.1/bin" + + setup %{tmp_dir: directory} do + root = Path.join(directory, "release") + runtime = Path.join(directory, "runtime") + bindir = Path.join(directory, "custom-erts/bin") + + parent = %{ + "RELEASE_ROOT" => root, + "RELEASE_COOKIE" => "parent-cookie", + "RELEASE_NAME" => "parent-release", + "RELEASE_NODE" => "parent-node", + "RELEASE_VM_ARGS" => "parent-vm-args", + "RELEASE_TMP" => "parent-tmp", + "RELEASE_CUSTOM_SETTING" => "parent-custom", + "BINDIR" => bindir, + "ROOTDIR" => runtime, + "PATH" => + Enum.join( + [ + bindir, + root <> "/bin", + runtime <> "/bin", + root <> "/erts-17.1/bin", + runtime <> "/erts-16.0/bin", + @clean_path + ], + ":" + ), + "CXW_KEEP" => "keep-parent" + } + + keys = Map.keys(parent) ++ ["RELEASE_NEW_OVERRIDE"] + previous = Map.new(keys, &{&1, System.get_env(&1)}) + + on_exit(fn -> + Enum.each(previous, fn + {key, nil} -> System.delete_env(key) + {key, value} -> System.put_env(key, value) + end) + end) + + System.put_env(parent) + System.delete_env("RELEASE_NEW_OVERRIDE") + + binary = Path.join(directory, "fake_codex") + report = Path.join(directory, "child-env") + + report_commands = + Enum.map_join(keys, "\n", fn key -> + ~s(printf '%s\\n' "#{key}=${#{key}-#{@unset}}") + end) + + File.write!(binary, """ + #!/bin/sh + { + #{report_commands} + } > "$CXW_ENV_REPORT" + printf '%s\\n' '{"type":"thread.started","thread_id":"release-probe","text":"codex exec fork"}' + printf '%s\\n' '{"type":"turn.completed","usage":{}}' + """) + + File.chmod!(binary, 0o755) + + expected = + Map.new(keys, fn + "PATH" -> {"PATH", @clean_path} + "CXW_KEEP" -> {"CXW_KEEP", "keep-parent"} + key -> {key, @unset} + end) + + %{binary: binary, report: report, parent: parent, expected: expected} + end + + for runner <- [Runner.Port, Runner.Forcola] do + @runner runner + + describe "#{inspect(runner)}" do + test "scrubs every command builder's execute and stream paths", context do + config = config(context, @runner) + + for command <- builders(), operation <- [:execute, :stream] do + assert_child(context, fn -> run_builder(command, operation, config) end) + end + end + + test "scrubs fresh and resumed session send and stream paths", context do + config = config(context, @runner) + + for session_id <- [nil, "release-probe"] do + session = %{Session.new(config) | session_id: session_id} + + assert_child(context, fn -> + assert {:ok, _session, result} = Session.send(session, "prompt") + {:ok, result} + end) + + assert_child(context, fn -> + {_session, stream} = Session.stream(session, "prompt") + consume(stream) + end) + end + end + + test "scrubs every Commands module and each direct runner call", context do + config = config(context, @runner) + + for call <- command_calls(config), do: assert_child(context, call) + end + + test "scrubs raw commands and the fork capability probe", context do + assert_child(context, fn -> CodexWrapper.raw(["anything"], opts(context, @runner)) end) + + assert_child(context, fn -> + assert ExecFork.supported?(config(context, @runner)) + {:ok, :supported} + end) + end + + test "explicit names override removals while explicit PATH is scrubbed", context do + overrides = [ + {"BINDIR", "caller-bindir"}, + {"ROOTDIR", "caller-rootdir"}, + {"RELEASE_ROOT", "caller-release-root"}, + {"RELEASE_COOKIE", "caller-cookie"}, + {"RELEASE_NEW_OVERRIDE", "caller-new"}, + {"PATH", context.parent["PATH"] <> ":/caller/bin"} + ] + + expected = + context.expected + |> Map.merge(Map.new(overrides)) + |> Map.put("PATH", @clean_path <> ":/caller/bin") + + context = %{context | expected: expected} + config = config(context, @runner, env: overrides) + + for operation <- [:execute, :stream] do + assert_child(context, fn -> run_builder(Exec.new("prompt"), operation, config) end) + end + end + + test "inherit reaches convenience execute and stream paths unchanged", context do + context = %{context | expected: Map.put(context.parent, "RELEASE_NEW_OVERRIDE", @unset)} + opts = opts(context, @runner, release_env: :inherit) + + calls = [ + fn -> CodexWrapper.exec("prompt", opts) end, + fn -> CodexWrapper.exec_json("prompt", opts) end, + fn -> CodexWrapper.stream("prompt", opts) |> consume() end, + fn -> CodexWrapper.review(opts) end, + fn -> CodexWrapper.raw(["anything"], opts) end + ] + + for call <- calls, do: assert_child(context, call) + end + + test "without RELEASE_ROOT execute and stream preserve the environment", context do + System.delete_env("RELEASE_ROOT") + + expected = + context.parent + |> Map.put("RELEASE_ROOT", @unset) + |> Map.put("RELEASE_NEW_OVERRIDE", @unset) + + context = %{context | expected: expected} + config = config(context, @runner) + + for operation <- [:execute, :stream] do + assert_child(context, fn -> run_builder(Exec.new("prompt"), operation, config) end) + end + end + + test "reads the release environment after config and stream construction", context do + System.delete_env("RELEASE_ROOT") + config = config(context, @runner) + command = Exec.new("prompt") + stream = Exec.stream(command, config) + System.put_env("RELEASE_ROOT", context.parent["RELEASE_ROOT"]) + + assert_child(context, fn -> Exec.execute(command, config) end) + assert_child(context, fn -> consume(stream) end) + end + end + end + + test "Forcola observed and evidenced callbacks scrub all command builders", context do + config = config(context, Runner.Forcola) + + for command <- builders(), observer <- [:session_observer, :execution_observer] do + options = [{observer, {self(), make_ref()}}] + module = command.__struct__ + assert_child(context, fn -> module.execute(command, config, options) end) + end + end + + test "Forcola replacement environment does not inject the parent's PATH" do + assert {:ok, "CXW_KEEP=explicit"} = + CodexWrapper.raw([], + binary: "/usr/bin/env", + runner: Runner.Forcola, + runner_options: [env_mode: :replace], + timeout: 10_000, + env: [{"CXW_KEEP", "explicit"}] + ) + end + + defp builders do + [ + Exec.new("prompt"), + ExecResume.new() |> ExecResume.session_id("release-probe") |> ExecResume.prompt("prompt"), + Review.new(), + ExecFork.new("release-probe") + ] + end + + defp command_calls(config) do + [ + fn -> Commands.Apply.execute(config, "task") end, + fn -> Commands.Archive.archive(config, "release-probe") end, + fn -> Commands.Archive.unarchive(config, "release-probe") end, + fn -> Commands.Archive.delete(config, "release-probe", confirm: true) end, + fn -> Commands.Auth.login(config) end, + fn -> Commands.Auth.logout(config) end, + fn -> Commands.Auth.status(config) end, + fn -> Commands.Completion.generate(config) end, + fn -> Commands.Doctor.execute(Commands.Doctor.new(), config) end, + fn -> Commands.Features.list(config) end, + fn -> Commands.Features.enable(config, "feature") end, + fn -> Commands.Features.disable(config, "feature") end, + fn -> Commands.Mcp.list(config) end, + fn -> Commands.Mcp.get(config, "server") end, + fn -> Commands.Mcp.add(config, "server", :stdio, command: "server") end, + fn -> Commands.Mcp.add(config, "server", :http, url: "http://example.invalid") end, + fn -> Commands.Mcp.remove(config, "server") end, + fn -> Commands.Mcp.login(config, "server") end, + fn -> Commands.Mcp.logout(config, "server") end, + fn -> Commands.McpServer.start(config) end, + fn -> Commands.Sandbox.execute(Commands.Sandbox.new("echo"), config) end, + fn -> Commands.Version.execute(config) end + ] + end + + defp run_builder(command, :execute, config), + do: command.__struct__.execute(command, config) + + defp run_builder(command, :stream, config), + do: command.__struct__.stream(command, config) |> consume() + + defp consume(stream) do + events = Enum.to_list(stream) + assert [%CodexWrapper.JsonLineEvent{}, %CodexWrapper.JsonLineEvent{}] = events + {:ok, events} + end + + defp assert_child(context, call) do + File.rm(context.report) + assert {:ok, _} = call.() + + actual = + context.report + |> File.read!() + |> String.split("\n", trim: true) + |> Map.new(fn line -> + [key, value] = String.split(line, "=", parts: 2) + {key, value} + end) + + assert actual == context.expected + end + + defp config(context, runner, overrides \\ []), + do: Config.new(opts(context, runner, overrides)) + + defp opts(context, runner, overrides \\ []) do + [binary: context.binary, runner: runner, timeout: 10_000] + |> Keyword.merge(overrides) + |> Keyword.put(:env, [{"CXW_ENV_REPORT", context.report} | Keyword.get(overrides, :env, [])]) + end +end