Skip to content

fix(execrunner): PATH across the runner seam — carry the prefix out of band, and a tier for what heph supplies - #451

Open
raphaelvigee wants to merge 2 commits into
masterfrom
raphaelvigee/execrunner-path-prefix-out-of-band
Open

fix(execrunner): PATH across the runner seam — carry the prefix out of band, and a tier for what heph supplies#451
raphaelvigee wants to merge 2 commits into
masterfrom
raphaelvigee/execrunner-path-prefix-out-of-band

Conversation

@raphaelvigee

@raphaelvigee raphaelvigee commented Sep 3, 2026

Copy link
Copy Markdown
Member

Two commits, both about how PATH composes across the runner seam. Found while regrounding the builtin-coreutils stack (#438 and up) against the runner model; the defect is on master and independent of that stack, so it lives here rather than folded into it.

1 · A runner carrying the environment out of band still gets the prefix

PathPolicy.prefix — a target's declared tools — was composed onto spec.env only after the runner's prepare.

That is right for a runner that leaves the environment where it found it: wrap puts its own PATH into spec.env, and session hands spec.env to the client, which forwards it to the agent.

It is wrong for a runner that carries the environment out of band. OciRunner::prepare turns the target's environment into docker exec -e KEY=VALUE arguments and returns the docker client's environment in its place. The composition then decorated the docker client, and the target inside the container never got its declared tools.

It failed silently, which is the part that matters: the container falls back to the image's own tools, so a recipe that reaches for a declared tool keeps working — with a different binary than its cache key names.

The fix composes the prefix onto the environment handed to the runner as well, so it rides whatever wire that runner already uses. Nothing changes for a runner that leaves the environment in place: the composition after prepare still orders the runner's own PATH behind the prefix, and join_path dedupes, so doing both is idempotent.

A runner that replaces PATH outright then owes its own environment's value behind what it was handed — -e PATH replaces the image's rather than extending it. So the oci runner now reads the image's PATH from the container config and restores it behind the target's. Without that, entering a container would strip /usr/bin from every target that declares a tool. Best-effort: an unreadable value costs the image's directories, a degraded environment rather than a wrong one.

2 · PathPolicy.suffix — a tier for what heph supplies

prefix is what the target declared, so it leads and wins over everything. There was no counterpart for what heph supplies, which wants the opposite ordering: fill a gap the environment leaves, never shadow a binary that environment deliberately ships. A workspace that names a devenv or nix runner pinned those tools on purpose, and heph's sed arriving in front of them is not a service.

PATH = the target's tools ++ what it declared ++ the runner's PATH ++ what heph supplies

It is composed into the environment this process spawns, so it deliberately does not reach a runner carrying the environment out of band. That is the wanted behaviour, not a gap: those entries are host paths, and a container's filesystem is not this one — a shim directory of symlinks into a host-platform binary is worse inside an image than absent.

Inert in this PR — nothing populates it. The builtin-coreutils stack is the caller.

Tests

Both halves of (1) are asserted without a daemon, since the failure is in what gets handed over, not in docker:

  • the_path_handed_to_a_runner_leads_with_the_targets_prefix
  • the_exec_argv_carries_the_targets_path_ahead_of_the_images
  • the_image_path_follows_what_the_target_carries, a_directory_in_both_is_not_repeated, an_unreadable_image_path_leaves_the_carried_one_alone

For (2): what_heph_supplies_composes_behind_the_environment and what_heph_supplies_is_not_carried_to_a_runner.

A docker-gated e2e pins the real thing: a_declared_tool_is_on_the_path_inside_the_container runs a declared tool inside the container and checks cat still resolves from the image.

The e2e is unverified locally — no docker daemon on this machine, so it skipped. CI is what exercises it. The three existing seam tests (wrap, agent, local) pass unchanged.


The stack

Merge bottom-up, and gh stack sync after each one lands — master is squash-only, so the rebase will conflict and the resolution rule in CLAUDE.md applies.

PR What
6 #446 drop the host directories from a target's PATHbreaking
5 #445 the toolbox on by default
4 #440 the template rule and the tmpl applet
3 #453 grep, find, xargs, sed, tar, gzip, zstd
2 #438 the crate, the entry points, the shim directory
1 #451 the runner PATH seam ← base, targets master

Only #451 builds automatically: since #449 a stacked PR is skipped unless it carries ci/force-ci. Every layer was checked locally on its own — cargo build --workspace --all-targets, cargo clippy --workspace --all-targets, and its unit tests — not just at the top of the stack.

🤖 Generated with Claude Code

https://claude.ai/code/session_0181d7hhbYWXT42Z1KQPM29Q

raphaelvigee and others added 2 commits September 3, 2026 18:38
…gets the PATH prefix

`PathPolicy.prefix` — the target's declared tools, and (with the builtin
toolbox) heph's own utilities — was composed onto `spec.env` only *after* the
runner's `prepare`. That is correct for every runner that leaves the
environment where it found it: `wrap` puts its own `PATH` in `spec.env`, and
`session` hands `spec.env` to the client, which forwards it to the agent.

It is wrong for a runner that carries the environment out of band. `oci` turns
the target's environment into `docker exec -e KEY=VALUE` arguments and returns
the *docker client's* environment in its place, so the composition decorated
the client process and the target inside the container got neither its declared
tools nor the builtins.

It failed silently, which is the part that matters: the container falls back to
the image's own tools, so a recipe that reaches for a declared tool keeps
working — with a different binary than its cache key names.

Compose the prefix onto the environment handed *to* the runner as well. For a
runner that leaves the environment in place nothing changes: the composition
after `prepare` still orders the runner's own `PATH` behind the prefix, and
`join_path` dedupes, so doing both is idempotent.

A runner that replaces `PATH` outright then owes its environment's own value
behind what it was handed. The oci runner reads the image's `PATH` from the
container config (`docker inspect`) and restores it behind the target's —
without that, entering a container would strip `/usr/bin` from every target
that declares a tool. Best-effort: an unreadable value costs the image's
directories, which is a degraded environment rather than a wrong one.

Found while regrounding the builtin-coreutils work against the runner model:
the toolbox puts heph's utilities in exactly this prefix, so the gap would have
shipped as "the builtins are on every target's PATH, except inside a
container", with nothing to see.

Tests: the seam's carried `PATH` and the `docker exec` argv are both asserted
without a daemon, and a docker-gated e2e pins the real thing — a declared tool
runs inside the container, and `cat` still resolves from the image.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0181d7hhbYWXT42Z1KQPM29Q
…ronment

`PathPolicy.prefix` is what the *target* declared, so it leads and wins over
everything the environment ships. There is no counterpart for what *heph*
supplies — the builtin utilities — which wants the opposite ordering: fill a
gap the environment leaves, never shadow a binary that environment deliberately
ships. A workspace that names a devenv or nix runner pinned those tools on
purpose, and heph's `sed` arriving in front of them is not a service.

Add `PathPolicy.suffix`, composed last:

    the target's tools ++ what it declared ++ the runner's PATH ++ heph's

It is composed into the environment *this process* spawns, so it deliberately
does not reach a runner that carries the environment out of band. That is the
wanted behaviour rather than a gap: those entries are host paths, and a
container's filesystem is not this one — a shim directory of symlinks into a
binary built for the host platform is worse inside an image than absent. The
prefix still is carried, because it is the target's own and lives on paths the
runner is responsible for making visible.

Inert here: nothing populates it yet. The builtin-coreutils work is the caller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0181d7hhbYWXT42Z1KQPM29Q
@raphaelvigee raphaelvigee changed the title fix(execrunner): a runner carrying the environment out of band still gets the PATH prefix fix(execrunner): PATH across the runner seam — carry the prefix out of band, and a tier for what heph supplies Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant