Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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-<version>/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
Expand Down
3 changes: 3 additions & 0 deletions lib/codex_wrapper.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -273,6 +275,7 @@ defmodule CodexWrapper do
:binary,
:working_dir,
:env,
:release_env,
:timeout,
:idle_timeout_ms,
:verbose
Expand Down
94 changes: 94 additions & 0 deletions lib/codex_wrapper/child_env.ex
Original file line number Diff line number Diff line change
@@ -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-<version>/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
34 changes: 31 additions & 3 deletions lib/codex_wrapper/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -34,6 +35,7 @@ defmodule CodexWrapper.Config do
:runner_options,
:runner_default_timeout_ms,
env: [],
release_env: :scrub,
verbose: false
]

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ defmodule CodexWrapperEx.MixProject do
],
"Config, results, support": [
CodexWrapper.Config,
CodexWrapper.ChildEnv,
CodexWrapper.Result,
CodexWrapper.LastMessage,
CodexWrapper.SessionObservation,
Expand Down
Loading
Loading