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
Conversation
…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
This was referenced Sep 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two commits, both about how
PATHcomposes across the runner seam. Found while regrounding the builtin-coreutils stack (#438 and up) against the runner model; the defect is onmasterand 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 ontospec.envonly after the runner'sprepare.That is right for a runner that leaves the environment where it found it:
wrapputs its ownPATHintospec.env, andsessionhandsspec.envto the client, which forwards it to the agent.It is wrong for a runner that carries the environment out of band.
OciRunner::prepareturns the target's environment intodocker exec -e KEY=VALUEarguments 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
preparestill orders the runner's ownPATHbehind the prefix, andjoin_pathdedupes, so doing both is idempotent.A runner that replaces
PATHoutright then owes its own environment's value behind what it was handed —-e PATHreplaces the image's rather than extending it. So the oci runner now reads the image'sPATHfrom the container config and restores it behind the target's. Without that, entering a container would strip/usr/binfrom 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 suppliesprefixis 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'ssedarriving in front of them is not a service.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_prefixthe_exec_argv_carries_the_targets_path_ahead_of_the_imagesthe_image_path_follows_what_the_target_carries,a_directory_in_both_is_not_repeated,an_unreadable_image_path_leaves_the_carried_one_aloneFor (2):
what_heph_supplies_composes_behind_the_environmentandwhat_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_containerruns a declared tool inside the container and checkscatstill resolves from the image.The stack
Merge bottom-up, and
gh stack syncafter each one lands —masteris squash-only, so the rebase will conflict and the resolution rule in CLAUDE.md applies.PATH— breakingtemplaterule and thetmplappletPATHseam ← base, targetsmasterOnly #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