diff --git a/Dockerfile b/Dockerfile index 03fb0e2..ddb7075 100644 --- a/Dockerfile +++ b/Dockerfile @@ -261,20 +261,41 @@ set -s extended-keys always set -as terminal-features "*:extkeys" EOF +# Go environment. Spelled with a literal /root rather than ${HOME}: Docker does +# not define HOME during the build, so "${HOME}/go" would expand to "/go". /root +# is correct for both paths through the entrypoint — the legacy root fallback, +# and the dropped-privilege user, whose passwd entry is created with -d /root. +# GOROOT is already baked into the go.dev tarball; set explicitly so scripts that +# read the variable directly (rather than `go env GOROOT`) see it too. +# GOBIN is spelled out rather than written as ${GOPATH}/bin for the same class of +# reason: Docker resolves a variable against the value from *before* the current +# instruction, so ${GOPATH}/bin inside this ENV would yield "/bin". +ENV GOBIN=/root/go/bin \ + GOPATH=/root/go \ + GOROOT=/usr/local/go + # DISABLE_AUTOUPDATER=1 keeps the pinned CLAUDE_CODE_VERSION authoritative — # without it, claude auto-replaces itself at runtime, defeating the # --ignore-scripts supply-chain pinning above. Bump the image to upgrade. -# PATH: /usr/local/go/bin is the go.dev-prescribed entry for the toolchain. -# $HOME/go/bin (GOPATH default; HOME=/root for the dropped user) is appended -# LAST on purpose — `go install` targets it and /root persists in the -# claude-code-root volume, so anything a session drops there must never be able -# to shadow a system binary earlier in PATH. +# No TASK_X_REMOTE_TASKFILES here: go-task's `includes:` from a remote URL left +# experimental in 3.53.1, so the feature is on by default and setting the opt-in +# makes every `task` invocation warn that the experiment is released. It stays a +# runtime code-fetch primitive in the same class as `pnpm dlx`/`uvx`/`go install`; +# task still prompts before trusting a new remote Taskfile checksum. +# PATH: /usr/local/go/bin comes first — the go.dev-prescribed entry for the +# toolchain, ahead of the system paths so the image's pinned Go wins. +# /root/.local/bin (the pip --user / `uv tool install` prefix) and /root/go/bin +# (the GOPATH default, where `go install` writes) are appended LAST on purpose: +# both live in the persistent claude-code-root volume and are writable by the +# session, so a binary one session drops there must never be able to shadow a +# system binary (git, gh, aws, …) on a later run. Tools installed into either +# stay runnable by name; only deliberate overrides are given up. ENV CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 \ DISABLE_AUTOUPDATER=1 \ IS_SANDBOX=1 \ LANG=C.UTF-8 \ LC_ALL=C.UTF-8 \ - PATH="/usr/local/go/bin:${PATH}:/root/go/bin" + PATH="/usr/local/go/bin:${PATH}:/root/.local/bin:/root/go/bin" # Container starts as root so the entrypoint can chown /root to the host # UID, then drops privileges via runuser. Steady-state, claude runs as the diff --git a/openspec/changes/archive/2026-08-29-export-go-env/design.md b/openspec/changes/archive/2026-08-29-export-go-env/design.md new file mode 100644 index 0000000..59e7ebe --- /dev/null +++ b/openspec/changes/archive/2026-08-29-export-go-env/design.md @@ -0,0 +1,104 @@ +## Context + +`/root` is the persistent `claude-code-root` named volume (`run.sh:861`): it +survives `docker run --rm`, and it is shared across every session and every +workspace on the host. The agent runs as a dropped-privilege user whose passwd +entry is created with `-d /root`, so `/root` is both `HOME` and writable. + +The image `PATH` reaches that agent verbatim. `entrypoint.sh:89` is +`exec runuser -u claude`, not `runuser -l` — no login shell, no +`/etc/profile`, no re-derivation of `PATH`. Whatever the `ENV` says is what a +session resolves commands against. That makes PATH ordering an image-level +security control, not a convenience setting, and it is why the existing +`go-toolchain` spec pins `/root/go/bin` last. + +## Goals / Non-Goals + +**Goals:** + +- `GOROOT` / `GOPATH` / `GOBIN` readable from the process environment by + non-Go tooling, with the same values `go env` would compute. +- Session-installed tools under `/root/.local/bin` runnable by name. +- No new way for one session to influence a later session's command + resolution. +- The non-shadowing guarantee actually tested, not merely asserted in prose. + +**Non-Goals:** + +- No `GOCACHE` / `GOMODCACHE` / `GOFLAGS` / `GOPRIVATE` export — those have + working defaults under `/root` that the volume already persists, and pinning + them adds surface without a caller that needs it. +- No change to `GOTOOLCHAIN`, which stays at its default `auto`. +- No image-side installation into `/root/.local/bin`. + +## Decisions + +### Literal `/root`, not `${HOME}` + +Docker does not define `HOME` during a build. `ENV GOPATH="${HOME}/go"` expands +against an empty string and bakes `GOPATH=/go` into the image — a directory +that does not exist, with no build-time error. `/root` is correct for both +paths through the entrypoint: the legacy `HOST_UID=0` fallback runs as root, +and the dropped-privilege user's passwd entry is created with `-d /root`. +`smoke/assert-in-container.sh` already asserts `HOME=/root` in both cells, so +the equivalence is enforced rather than assumed. + +### Literal `/root/go/bin` for `GOBIN`, not `${GOPATH}/bin` + +Docker resolves a variable reference in an `ENV` instruction against the value +from *before* that instruction, so `GOBIN="${GOPATH}/bin"` alongside +`GOPATH=/root/go` in the same `ENV` yields `GOBIN=/bin` — silently, and +pointing at a system directory. + +An earlier draft worked around this with a separate `ENV GOHOME=/root/go` +instruction referenced by the next one. Rejected: `GOHOME` is not a Go variable, +`go env` ignores it, and it would ship in every session's environment inviting +`-e GOHOME=/other` overrides that do nothing (both dependants are baked at +build time). Spelling both paths out literally removes the workaround instead of +shipping it; the gotcha survives as a comment. + +### `/root/.local/bin` goes behind every system path + +The tempting framing is that a user-local prefix conventionally wins — that is +what it means on a normal workstation, where `~/.local/bin` is under the +control of the person typing the commands. + +That does not transfer here. `/root/.local/bin` is written by an agent, not by +a person; nothing in the image ever writes to it, so by construction every byte +on that path arrives from a session. And because the directory is in the shared +volume, it is read by *later* sessions, in *other* workspaces, holding *other* +credentials. In first position it is a persistence primitive: one session writes +`/root/.local/bin/gh`, a later unrelated session runs `gh` and executes it. + +There is nothing the image can check that distinguishes `/root/.local/bin` from +`/root/go/bin` — same volume, same writer, same lifetime. Treating them +differently would rest on the installer's intent, which the image cannot see. +So both go behind the system paths, `/root/.local/bin` first of the two on the +weak-but-harmless grounds that it is the more deliberate of the two +destinations. Tools installed either way stay runnable by name; the only +capability given up is overriding a system binary, which is precisely what the +requirement forbids. + +### The requirement is renamed, not edited in place + +The old title — "PATH ordering keeps GOPATH binaries non-shadowing" — and its +single `/root/go/bin` scenario would leave the new entry uncovered: a reader +greps the title, concludes the requirement is about `go install` output, and +misses that another writable directory is on PATH under the same guarantee. The +delta therefore removes the old requirement and adds a renamed one carrying +both directories and four scenarios, following the `REMOVED` + `ADDED` pattern +used by `2026-08-25-container-git-config-overlay`. + +## Risks / Trade-offs + +- **A tool that genuinely needs to override a system binary breaks.** Accepted: + that is the guarantee, and the workaround (invoke by absolute path, or shadow + it per-project) is available to a user who really wants it. +- **The smoke check pins `/usr/bin/git`.** If a future change moves `git` (e.g. + a newer git installed under `/usr/local/bin`), the assertion fails loudly + rather than silently stopping to test anything. That is the intended failure + mode; update the expectation with the move. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-08-29-export-go-env/proposal.md b/openspec/changes/archive/2026-08-29-export-go-env/proposal.md new file mode 100644 index 0000000..0ce5cf3 --- /dev/null +++ b/openspec/changes/archive/2026-08-29-export-go-env/proposal.md @@ -0,0 +1,67 @@ +## Why + +The image installs Go but exports none of its environment. `GOROOT`, `GOPATH`, +and `GOBIN` exist only as `go env` defaults, computed by the toolchain from +`HOME` at invocation time. Anything that is not the `go` binary — a Makefile +that references `$(GOBIN)`, a Taskfile, a CI helper script, a linter wrapper — +reads the process environment, finds the variables unset, and either guesses or +fails. The values are already fixed and known at build time, so leaving them +implicit buys nothing. + +Separately, the `pip install --user` / `uv tool install` prefix +`/root/.local/bin` is not on PATH at all, so a tool a session installs that way +is not runnable by name. That directory sits in the persistent +`claude-code-root` volume and is writable by the agent — exactly the property +that already pins `/root/go/bin` to last position — so it can be added, but only +behind every system path. + +## What Changes + +- Export `GOROOT=/usr/local/go`, `GOPATH=/root/go`, `GOBIN=/root/go/bin` as + image `ENV`, so they hold for `docker run`, `docker exec`, and non-login + shells alike, spelled against a literal `/root`. +- Add `/root/.local/bin` to the default PATH in second-to-last position, ahead + of `/root/go/bin` and behind every system path. +- Broaden the PATH-ordering requirement from "GOPATH binaries" to + volume-persisted directories generally, so the guarantee covers both + agent-writable entries rather than naming only the one that existed first. +- Assert both in `smoke/assert-in-container.sh`. The existing non-shadowing + requirement has never had a test; the new check enforces the ordering, the + resolution of `git` to `/usr/bin/git`, and the three exported values. + +Not in scope: setting `TASK_X_REMOTE_TASKFILES=1`. go-task's remote `includes:` +left experimental in 3.53.1, so the feature is on by default and setting the +opt-in only makes every `task` invocation warn that the experiment is released. +It stays a runtime code-fetch primitive in the same class as `pnpm dlx`, `uvx`, +and `go install`, and `task` still prompts before trusting a new remote +Taskfile checksum. The Dockerfile records this as a comment so the next reader +does not re-derive it. + +Not in scope: making the image itself install anything into `/root/.local/bin`. +Nothing in the build writes there today; the entry is for session-installed +tools only. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `go-toolchain`: adds a requirement that the Go environment is exported by the + image rather than left to `go env` defaults, and replaces the GOPATH-specific + PATH-ordering requirement with one covering every agent-writable, + volume-persisted PATH entry. + +## Impact + +- `Dockerfile` — one new `ENV` instruction for the Go variables and a reordered + `PATH`. No new layer content, no download, no size change beyond the + environment block. +- `smoke/assert-in-container.sh` — a `check_path_order()` function and two + helpers; adds six assertions to every smoke cell. +- No change to `run.sh`, the entrypoint, the capability set, or the credential + model. The privilege drop is unaffected: `entrypoint.sh` execs + `runuser -u claude` (not `-l`), so the image PATH reaches the agent unchanged + either way — which is why its ordering is the security-relevant artifact. diff --git a/openspec/changes/archive/2026-08-29-export-go-env/specs/go-toolchain/spec.md b/openspec/changes/archive/2026-08-29-export-go-env/specs/go-toolchain/spec.md new file mode 100644 index 0000000..751d976 --- /dev/null +++ b/openspec/changes/archive/2026-08-29-export-go-env/specs/go-toolchain/spec.md @@ -0,0 +1,97 @@ +## ADDED Requirements + +### Requirement: Go environment exported by the image + +The image SHALL export `GOROOT=/usr/local/go`, `GOPATH=/root/go`, and +`GOBIN=/root/go/bin` as `ENV`, so the values are present in the process +environment rather than existing only as `go env` defaults computed by the +toolchain. Scripts and non-Go tooling that read the variables directly SHALL +therefore agree with the toolchain. + +The values SHALL be spelled against a literal `/root`, not `${HOME}`: Docker +does not define `HOME` during a build, so `"${HOME}/go"` expands to `/go`. +`/root` is the home directory on both paths through the entrypoint — the +legacy root fallback and the dropped-privilege user, whose passwd entry is +created with `-d /root`. + +#### Scenario: the variables are set in a non-login shell + +- **WHEN** a command runs in the container without a login shell — the + entrypoint's `runuser -u`, a `docker exec`, or a plain `docker run` command +- **THEN** `GOROOT` is `/usr/local/go`, `GOPATH` is `/root/go`, and `GOBIN` is + `/root/go/bin` in that process's environment + +#### Scenario: the exported values match what the toolchain computes + +- **WHEN** `go env GOROOT`, `go env GOPATH`, and `go env GOBIN` are compared + against the exported `GOROOT`, `GOPATH`, and `GOBIN` +- **THEN** each pair agrees, so a script reading the variable and a script + shelling out to `go env` reach the same directory + +### Requirement: PATH ordering keeps volume-persisted directories non-shadowing + +The default PATH SHALL place `/usr/local/go/bin` ahead of every system path, +and SHALL place both agent-writable directories that live in the persistent +`claude-code-root` volume — `/root/.local/bin` (the `pip install --user` / +`uv tool install` prefix) and `/root/go/bin` (the GOPATH `bin` directory, where +`go install` writes) — after every system path. + +Both directories are writable by the agent and survive the session: `/root` is +a named volume shared across sessions and workspaces, and the image PATH +reaches the agent unchanged because the entrypoint execs `runuser -u` rather +than `runuser -l`. A binary one session leaves in either directory MUST NOT be +able to shadow a system binary (`git`, `gh`, `aws`, …) on a later run. They MAY +be on the default PATH for convenience, but only after every system path. + +Neither directory is distinguishable from the other by anything the image can +check — same volume, same writer, same lifetime — so the usual convention that +a user-local prefix precedes the system paths SHALL NOT be applied to +`/root/.local/bin`. `/usr/local/go/bin` precedes the system paths so the +image's pinned toolchain wins over anything installed into the volume. + +#### Scenario: a persisted GOPATH binary cannot shadow a system tool + +- **GIVEN** a previous session left an executable named `git` in `/root/go/bin` + in the persistent volume +- **WHEN** a new container starts and `git` is invoked +- **THEN** the system `git` runs, not the one from `/root/go/bin` + +#### Scenario: a persisted user-prefix binary cannot shadow a system tool + +- **GIVEN** a previous session left an executable named `gh` in + `/root/.local/bin` in the persistent volume +- **WHEN** a new container starts, in any workspace, and `gh` is invoked +- **THEN** the system `gh` runs, not the one from `/root/.local/bin` + +#### Scenario: go install output is runnable without an absolute path + +- **WHEN** the user runs `go install @` and then invokes the + installed command by name +- **THEN** the command resolves from `/root/go/bin` on PATH + +#### Scenario: user-prefix installs are runnable without an absolute path + +- **WHEN** the user installs a tool whose entry point lands in + `/root/.local/bin` (for example `uv tool install` or `pip install --user`) + and then invokes it by name +- **THEN** the command resolves from `/root/.local/bin` on PATH, provided no + system binary of that name exists + +#### Scenario: the smoke suite enforces the ordering + +- **WHEN** `smoke/smoke.sh` runs against a built image +- **THEN** the in-container assertions confirm `/usr/local/go/bin` precedes + `/usr/bin`, that `/usr/bin` precedes both `/root/.local/bin` and + `/root/go/bin`, and that `git` resolves to `/usr/bin/git` +- **AND** they confirm the exported `GOROOT`, `GOPATH`, and `GOBIN` values + +## REMOVED Requirements + +### Requirement: PATH ordering keeps GOPATH binaries non-shadowing + +Superseded by "PATH ordering keeps volume-persisted directories +non-shadowing" above. The guarantee is unchanged and strengthened; the title +and its single `/root/go/bin` scenario described only one of the two +agent-writable, volume-persisted directories now on the default PATH, so a +reader greping the title would conclude the requirement was about `go install` +output alone. diff --git a/openspec/changes/archive/2026-08-29-export-go-env/tasks.md b/openspec/changes/archive/2026-08-29-export-go-env/tasks.md new file mode 100644 index 0000000..299b28f --- /dev/null +++ b/openspec/changes/archive/2026-08-29-export-go-env/tasks.md @@ -0,0 +1,52 @@ +## 1. Go environment + +- [x] 1.1 Add an `ENV` instruction setting `GOBIN=/root/go/bin`, + `GOPATH=/root/go`, `GOROOT=/usr/local/go`, placed after the tmux layer and + before the existing runtime `ENV` block. +- [x] 1.2 Spell every path literally. Comment why `${HOME}` is wrong (Docker + does not define `HOME` at build time, so `"${HOME}/go"` bakes in `/go`) and + why `GOBIN` is not written as `${GOPATH}/bin` (Docker resolves the reference + against the value from before the instruction, yielding `/bin`). +- [x] 1.3 Introduce no helper variable to work around the self-reference rule — + nothing that `go env` does not recognise may ship in the session environment. + +## 2. PATH + +- [x] 2.1 Reorder the runtime `ENV` PATH to + `"/usr/local/go/bin:${PATH}:/root/.local/bin:/root/go/bin"`. +- [x] 2.2 Rewrite the PATH comment: both trailing entries are agent-writable + and volume-persisted, both sit after every system path, and neither may + shadow a system binary. Drop any framing that treats `/root/.local/bin` as + more trusted than `/root/go/bin`. +- [x] 2.3 Keep the `TASK_X_REMOTE_TASKFILES` comment recording why the opt-in + is declined. + +## 3. Smoke assertions + +- [x] 3.1 Add `path_index()` to `smoke/assert-in-container.sh` — the index of + an entry in the colon-separated PATH, `-1` when absent, matching whole + entries only. +- [x] 3.2 Add `assert_path_before()` reporting through the existing + `pass`/`fail` counters, failing distinctly when either entry is missing. +- [x] 3.3 Add `check_path_order()` asserting `/usr/local/go/bin` before + `/usr/bin`, `/usr/bin` before `/root/.local/bin` and before `/root/go/bin`, + and `command -v git` = `/usr/bin/git`. +- [x] 3.4 Assert `GOROOT`, `GOPATH`, `GOBIN` in the same function with the + existing `assert_eq`. +- [x] 3.5 Call `check_path_order` from the Main dispatch after `check_security` + and renumber the section banners that follow. + +## 4. Verification + +- [x] 4.1 `bash -n smoke/assert-in-container.sh` passes and the PATH helpers + behave correctly against a synthetic PATH (ordered pair, reversed pair, + missing entry). +- [ ] 4.2 `shellcheck run.sh entrypoint.sh smoke/*.sh` and + `hadolint --config .hadolint.yaml Dockerfile` pass. +- [ ] 4.3 `docker build -t claude-code:local .` succeeds and + `IMAGE=claude-code:local bash smoke/smoke.sh --uid="$(id -u)" --optins=aws,glab,tfe` + passes, including the new `path-order` and `GO*` assertions. +- [ ] 4.4 Shadowing probe: plant an executable `git` in `/root/.local/bin` in a + throwaway root volume, start a new container against it, and confirm + `command -v git` still reports `/usr/bin/git`. +- [x] 4.5 `openspec validate export-go-env --strict` passes. diff --git a/openspec/specs/go-toolchain/spec.md b/openspec/specs/go-toolchain/spec.md index 0b739bb..5a22a17 100644 --- a/openspec/specs/go-toolchain/spec.md +++ b/openspec/specs/go-toolchain/spec.md @@ -5,6 +5,7 @@ Ship exactly one version-pinned, sha256-verified Go toolchain in the image — the one preinstalled language runtime, since the Go distribution is a single self-contained tree with no per-project variant to select and `go` resolves a project's required toolchain itself (contrast `tfenv`/`uv`, which fetch per-project Terraform/Python at runtime; see `package-managers`). The pin is manual rather than soak-gated under `pins/`, because go.dev's release feed carries no publish dates — so it must stay visible via an operator reminder. PATH ordering keeps the pinned toolchain authoritative while ensuring volume-persisted `go install` output can never shadow a system binary, and the toolchain's runtime code-fetch paths are named in the threat model. ## Requirements + ### Requirement: Pinned Go toolchain installed on the default PATH The container image SHALL ship exactly one Go toolchain, installed from the @@ -102,32 +103,6 @@ refresh run. resolved - **AND** the run continues and exits on the same status it otherwise would -### Requirement: PATH ordering keeps GOPATH binaries non-shadowing - -The default PATH SHALL place `/usr/local/go/bin` first and the GOPATH `bin` -directory — `/root/go/bin` for the dropped-privilege user — last, after every -system path. - -`go install` writes into that GOPATH `bin` directory, which sits inside the -persistent `claude-code-root` volume and is writable by the agent. It MAY be on -the default PATH for convenience, but only in last position: a binary one -session leaves there must not be able to shadow a system binary (`git`, `gh`, -`aws`, …) on a later run. `/usr/local/go/bin` goes first so the image's pinned -toolchain wins over anything installed into the volume. - -#### Scenario: a persisted GOPATH binary cannot shadow a system tool - -- **GIVEN** a previous session left an executable named `git` in `/root/go/bin` - in the persistent volume -- **WHEN** a new container starts and `git` is invoked -- **THEN** the system `git` runs, not the one from `/root/go/bin` - -#### Scenario: go install output is runnable without an absolute path - -- **WHEN** the user runs `go install @` and then invokes the - installed command by name -- **THEN** the command resolves from `/root/go/bin` on PATH - ### Requirement: Go runtime code-fetch documented in the threat model The threat model documentation SHALL note that the Go toolchain adds runtime @@ -160,3 +135,87 @@ loudly instead. The bundled-tools line SHALL list `go`. - **THEN** the Go tarball appears alongside `uv`, `glab`, the AWS CLI, and the `tfenv` source archive +### Requirement: Go environment exported by the image + +The image SHALL export `GOROOT=/usr/local/go`, `GOPATH=/root/go`, and +`GOBIN=/root/go/bin` as `ENV`, so the values are present in the process +environment rather than existing only as `go env` defaults computed by the +toolchain. Scripts and non-Go tooling that read the variables directly SHALL +therefore agree with the toolchain. + +The values SHALL be spelled against a literal `/root`, not `${HOME}`: Docker +does not define `HOME` during a build, so `"${HOME}/go"` expands to `/go`. +`/root` is the home directory on both paths through the entrypoint — the +legacy root fallback and the dropped-privilege user, whose passwd entry is +created with `-d /root`. + +#### Scenario: the variables are set in a non-login shell + +- **WHEN** a command runs in the container without a login shell — the + entrypoint's `runuser -u`, a `docker exec`, or a plain `docker run` command +- **THEN** `GOROOT` is `/usr/local/go`, `GOPATH` is `/root/go`, and `GOBIN` is + `/root/go/bin` in that process's environment + +#### Scenario: the exported values match what the toolchain computes + +- **WHEN** `go env GOROOT`, `go env GOPATH`, and `go env GOBIN` are compared + against the exported `GOROOT`, `GOPATH`, and `GOBIN` +- **THEN** each pair agrees, so a script reading the variable and a script + shelling out to `go env` reach the same directory + +### Requirement: PATH ordering keeps volume-persisted directories non-shadowing + +The default PATH SHALL place `/usr/local/go/bin` ahead of every system path, +and SHALL place both agent-writable directories that live in the persistent +`claude-code-root` volume — `/root/.local/bin` (the `pip install --user` / +`uv tool install` prefix) and `/root/go/bin` (the GOPATH `bin` directory, where +`go install` writes) — after every system path. + +Both directories are writable by the agent and survive the session: `/root` is +a named volume shared across sessions and workspaces, and the image PATH +reaches the agent unchanged because the entrypoint execs `runuser -u` rather +than `runuser -l`. A binary one session leaves in either directory MUST NOT be +able to shadow a system binary (`git`, `gh`, `aws`, …) on a later run. They MAY +be on the default PATH for convenience, but only after every system path. + +Neither directory is distinguishable from the other by anything the image can +check — same volume, same writer, same lifetime — so the usual convention that +a user-local prefix precedes the system paths SHALL NOT be applied to +`/root/.local/bin`. `/usr/local/go/bin` precedes the system paths so the +image's pinned toolchain wins over anything installed into the volume. + +#### Scenario: a persisted GOPATH binary cannot shadow a system tool + +- **GIVEN** a previous session left an executable named `git` in `/root/go/bin` + in the persistent volume +- **WHEN** a new container starts and `git` is invoked +- **THEN** the system `git` runs, not the one from `/root/go/bin` + +#### Scenario: a persisted user-prefix binary cannot shadow a system tool + +- **GIVEN** a previous session left an executable named `gh` in + `/root/.local/bin` in the persistent volume +- **WHEN** a new container starts, in any workspace, and `gh` is invoked +- **THEN** the system `gh` runs, not the one from `/root/.local/bin` + +#### Scenario: go install output is runnable without an absolute path + +- **WHEN** the user runs `go install @` and then invokes the + installed command by name +- **THEN** the command resolves from `/root/go/bin` on PATH + +#### Scenario: user-prefix installs are runnable without an absolute path + +- **WHEN** the user installs a tool whose entry point lands in + `/root/.local/bin` (for example `uv tool install` or `pip install --user`) + and then invokes it by name +- **THEN** the command resolves from `/root/.local/bin` on PATH, provided no + system binary of that name exists + +#### Scenario: the smoke suite enforces the ordering + +- **WHEN** `smoke/smoke.sh` runs against a built image +- **THEN** the in-container assertions confirm `/usr/local/go/bin` precedes + `/usr/bin`, that `/usr/bin` precedes both `/root/.local/bin` and + `/root/go/bin`, and that `git` resolves to `/usr/bin/git` +- **AND** they confirm the exported `GOROOT`, `GOPATH`, and `GOBIN` values diff --git a/smoke/assert-in-container.sh b/smoke/assert-in-container.sh index 2336fb2..e90146d 100644 --- a/smoke/assert-in-container.sh +++ b/smoke/assert-in-container.sh @@ -191,7 +191,68 @@ check_security() { } # --------------------------------------------------------------------------- -# 3. File ownership — write into the workspace, smoke.sh checks host ownership +# 3. PATH ordering and the exported Go environment +# --------------------------------------------------------------------------- + +# Index of an entry in the colon-separated PATH, or -1 when absent. Matches +# whole entries only, so /root/go/bin never matches inside /root/go/bin/foo. +path_index() { + local want="$1" i=0 entry + local IFS=: + for entry in $PATH; do + if [ "$entry" = "$want" ]; then + printf '%s' "$i" + return + fi + i=$((i + 1)) + done + printf '%s' "-1" +} + +assert_path_before() { + local first="$1" second="$2" + local a b + a=$(path_index "$first") + b=$(path_index "$second") + if [ "$a" = "-1" ]; then + fail "path-order: $first is not on PATH (PATH=$PATH)" + elif [ "$b" = "-1" ]; then + fail "path-order: $second is not on PATH (PATH=$PATH)" + elif [ "$a" -lt "$b" ]; then + pass "path-order: $first (#$a) precedes $second (#$b)" + else + fail "path-order: expected $first before $second, got #$a and #$b (PATH=$PATH)" + fi +} + +check_path_order() { + # The image PATH survives the privilege drop — entrypoint.sh execs + # `runuser -u claude` (not -l), so what this script sees IS the shipped + # ordering. /root is the persistent claude-code-root volume, so both + # /root/.local/bin (pip --user / uv tool install prefix) and /root/go/bin + # (GOPATH default, `go install` target) are agent-writable and survive the + # session. Neither may precede a system path: a binary one session leaves + # there must not shadow git/gh/aws on a later run, in an unrelated workspace. + # /usr/bin stands in for "the system paths" — it is where git and gh live. + assert_path_before /usr/local/go/bin /usr/bin + assert_path_before /usr/bin /root/.local/bin + assert_path_before /usr/bin /root/go/bin + + # And the resolution actually lands on the system copy, not merely earlier in + # a string — this is the invariant the ordering exists to buy. + assert_eq "path-git-resolves-system" "$(command -v git)" "/usr/bin/git" + + # Go environment exported by the image (ENV), so it holds for docker run, + # docker exec, and non-login shells alike rather than relying on `go env` + # defaults. Spelled against a literal /root: Docker does not define HOME at + # build time. + assert_eq "GOROOT=/usr/local/go" "${GOROOT:-}" "/usr/local/go" + assert_eq "GOPATH=/root/go" "${GOPATH:-}" "/root/go" + assert_eq "GOBIN=/root/go/bin" "${GOBIN:-}" "/root/go/bin" +} + +# --------------------------------------------------------------------------- +# 4. File ownership — write into the workspace, smoke.sh checks host ownership # --------------------------------------------------------------------------- check_workspace_write() { @@ -213,7 +274,7 @@ check_workspace_write() { } # --------------------------------------------------------------------------- -# 4. Credential plumbing +# 5. Credential plumbing # --------------------------------------------------------------------------- # Mapping: opt-in name → container config path + env var to check. @@ -308,7 +369,7 @@ check_credentials() { } # --------------------------------------------------------------------------- -# 5. Seeded settings — must be a writable copy, not a mount +# 6. Seeded settings — must be a writable copy, not a mount # --------------------------------------------------------------------------- check_settings() { @@ -371,7 +432,7 @@ check_settings() { } # --------------------------------------------------------------------------- -# 6. Robustness — entrypoint reached here, so it did not abort +# 7. Robustness — entrypoint reached here, so it did not abort # --------------------------------------------------------------------------- check_entrypoint_reached() { @@ -388,6 +449,7 @@ echo "=== assert-in-container starting (UID=$(id -u) GID=$(id -g)) ===" check_entrypoint_reached check_identity check_security +check_path_order check_workspace_write check_credentials check_settings