ci(tend): reach the pre-merge gate's tools from inside the sandbox (#3897)

The agent tend runs as, `tend-sandbox`, could not reach `cargo-insta`,
`cargo-nextest`, or `pre-commit` — all three of the tools `cargo run --
hook pre-merge --yes` invokes. That command is what `CLAUDE.md` and
`.claude/skills/running-tend/SKILL.md` tell the bot to verify its work
with, and in the sandbox it died at `✗ pre-merge command failed:
pre-commit: exit status: 127`.

Tend derives the sandbox's PATH from the runner's, rewriting a leading
`/home/runner` to `/home/tend-sandbox` and keeping an entry only where
the rewritten directory exists. `useradd -m` seeds the sandbox home from
`/etc/skel`, so a toolchain baked into the runner image has a sibling
there and survives; anything `tend-setup` installs at runtime into the
runner's home has none and is dropped, with nothing logged.
`baptiste0928/cargo-install` forces `--root
$HOME/.cargo-install/<crate>` and `uv tool install` writes to the
runner's `~/.local/bin`, so all three went the second way. `uv` itself
stayed reachable only because `astral-sh/setup-uv` puts it under
`/opt/hostedtoolcache`, a system path the rewrite leaves alone.

`sandbox_setup:` runs as the sandbox user once its PATH is built, with
`~/.local/bin` already first on it. `pre-commit` installs into the
sandbox's own home so uv's shim shebang resolves there; the cargo
binaries are copied from where `tend-setup` already built them, at the
versions pinned there. The closing probe is the assertion —
`sandbox-setup.sh` runs the block under `bash -eo pipefail`, so a tool
that goes missing again fails the job and files a `tend-outage` issue
naming it, rather than degrading quietly.

A fourth tool needed the same treatment by a different route. The gate's
`insta` step runs `--all-features`, which turns on
`shell-integration-tests`; that needs `nu` as much as zsh and fish, and
two tests `.expect()` it outright. `tend-setup` apt-installed only zsh
and fish, so the probe would have cleared the agent to run a gate that
then died on a missing shell. `hustcer/setup-nu` installs under
`/opt/hostedtoolcache`, a system path the derivation carries across
verbatim, so `nu` needs no `sandbox_setup:` line — it just needed
installing. Pinned at 0.115.0, level with `test-setup` and
`dev/codex.sh`; `running-tend`'s weekly pin-bump recipe enumerates the
`setup-nu` call sites, so it gains the fifth.

The remaining `shell-integration-tests` prerequisites — bash, zsh, fish,
pwsh, jq — all resolve from `/usr/bin`, which the derivation keeps, so
they were never affected. The probe deliberately covers only the four
tools `tend-setup` provisions for the agent rather than everything the
gate touches.

The generated `tend-*.yaml` files are `uvx tend@latest init` output
(still 0.1.18); the only change in each is the `sandbox_setup:` block.

#3891 landed mid-review and appended its Nix steps at the same
end-of-file anchor as the nushell step, so this branch carries a merge
resolving that — keeping both, nushell next to the shells it belongs
with. Its Nix comment restated the "system PATH entries carry into the
sandbox verbatim" rule that this PR centralizes in `.github/CLAUDE.md`,
which would have left the rule written three times in one file, so it
now points there and keeps only what's specific to Nix. Every #3891 step
is unchanged.

<details><summary>Evidence, and what it cost</summary>

Sandbox PATH, captured from the agent's own launch env in tend-weekly
run 32630901712:

```
/home/tend-sandbox/.local/bin:/opt/hostedtoolcache/uv/0.12.5/x86_64:/opt/pipx_bin:/home/tend-sandbox/.cargo/bin:/usr/local/.ghcup/bin:/home/tend-sandbox/.dotnet/tools:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games
```

`error: no such command: insta` appears in runs 31332910927, 31465475753
and 32436240803; `pre-commit: command not found` in 32662391023. Across
3,057 captured Bash calls the bot never once invoked `cargo nextest`,
and substituted `cargo test` with `INSTA_UPDATE=always` or
`INSTA_FORCE_UPDATE=1` fifteen times — which writes snapshots rather
than checking them, and never runs `--check` or `--unreferenced reject`.

Since 2026-06-18, when #3122 moved the repo to tend 0.1.6 and the agent
first ran sandboxed, 16 of 197 bot PRs pushed a first commit with the
`lint` job red. Over the same window 4 of 350 of the maintainer's own
PRs did. #3845 is the shape: a closure `cargo fmt` wanted on one line,
caught by CI rather than locally.

Verified by simulating the block under `bash -eo pipefail` against a
fake runner home, in both directions: all three tools land on PATH and
it exits 0; remove one and it prints the `::error::` naming it and exits
1. Two things changed after that test — `install -D` is GNU-only, so the
parent directory is created explicitly, and `command -v a b c` exits 0
when any one resolves, so the assertion is a loop rather than a single
call.

</details>

> _This was written by Claude Code on behalf of max-sixty_

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maximilian Roos
2026-08-24 09:11:01 -07:00
committed by GitHub
parent 2960bfba2a
commit 68cc911eb2
12 changed files with 97 additions and 5 deletions
+1 -1
View File
@@ -294,7 +294,7 @@ Pinned third-party versions in CI are invisible to Dependabot — it follows `Ca
For each weekly run, check upstream and bump:
- **`baptiste0928/cargo-install@v3` blocks** in `.github/workflows/{affected,ci,coverage,nightly}.yaml` and `.github/actions/{test,tend}-setup/action.yaml` — every `version: "=X.Y.Z"` against `cargo info <crate>`. Today: `cargo-affected`, `cargo-insta`, `cargo-nextest`, `cargo-llvm-cov`, `cargo-msrv`, `cargo-udeps`, `lychee`, `worktrunk`. `cargo-affected` is pinned twice in `affected.yaml`; move both together. Verify each crate's `rust-version` against the pinned toolchain and note compatibility in the PR body (see PR #1657 for the format).
- **`hustcer/setup-nu@v3`** `version:` input — latest from `gh api repos/nushell/nushell/releases/latest --jq '.tag_name'`. Four call sites: `coverage.yaml` (`code-coverage`), `nightly.yaml` (`feature-powerset`), `benchmarks.yaml` (`benchmarks`), and `actions/test-setup/action.yaml`.
- **`hustcer/setup-nu@v3`** `version:` input — latest from `gh api repos/nushell/nushell/releases/latest --jq '.tag_name'`. Five call sites: `coverage.yaml` (`code-coverage`), `nightly.yaml` (`feature-powerset`), `benchmarks.yaml` (`benchmarks`), and `.github/actions/{test,tend}-setup/action.yaml``tend-setup`'s copy is what puts `nu` in the agent's sandbox, so it moves with the others.
- **Codex Cloud tools** — `dev/codex.sh` pins pre-commit, cargo-insta, cargo-nextest, Nushell, and PowerShell; `setup-web` pins Nushell and PowerShell. Keep cargo-insta, cargo-nextest, and Nushell level with `.github/actions/test-setup/action.yaml`, which pins the same three — the gate runs `--all-features`, so Nushell's version moves PTY snapshots. Nothing under `.github/` pins PowerShell (CI runs whatever the runner image ships), so bump that one on its own.
- **Docs site packages** in `docs/package.json` are covered by Dependabot's `/docs` npm entry. Do not duplicate those bumps in this manual pin pass. The one thing that pass does own is the `ignore` entry for `typescript` majors in `.github/dependabot.yaml`: it suppresses the proposal Dependabot would otherwise make, so nothing else can surface it. Check `npm view @astrojs/check peerDependencies.typescript` against the current `typescript` major and delete the entry once the range covers it — the rule is version-agnostic, so left in place it blocks a major `@astrojs/check` fully supports just as silently as the TypeScript 7 it was added for (#3877).
- **Runner images** — `ubuntu-24.04`, `macos-15`, `windows-2022`. Keep `windows-2022` pinned (actions/runner-images#12677 — windows-2025 lacks the D: drive).
+14
View File
@@ -1,6 +1,20 @@
bot_name: worktrunk-bot
setup:
- uses: ./.github/actions/tend-setup
# `setup:` runs as `runner`, so what it installs into the runner's home never
# reaches the agent (see "Sandbox toolchain" in .github/CLAUDE.md). These run as
# the sandbox user once its PATH is built, with ~/.local/bin already first on it:
# pre-commit installs into the sandbox's own home so uv's shim shebang resolves
# there, and the cargo binaries are copied from where `tend-setup` already built
# them, at the versions pinned there. The probe is the assertion — sandbox-setup
# runs the block under `bash -eo pipefail`, so a tool that goes missing again
# fails the job and files a tend-outage issue naming it.
sandbox_setup:
- mkdir -p ~/.local/bin
- install -m755 /home/runner/.cargo-install/cargo-insta/bin/cargo-insta ~/.local/bin/
- install -m755 /home/runner/.cargo-install/cargo-nextest/bin/cargo-nextest ~/.local/bin/
- uv tool install --quiet pre-commit
- 'for t in cargo-insta cargo-nextest pre-commit nu; do command -v "$t" >/dev/null || { echo "::error::tend sandbox is missing $t; wt hook pre-merge cannot run"; exit 1; }; done'
secrets:
allowed:
- CODECOV_TOKEN
+13
View File
@@ -79,6 +79,19 @@ crates.io publishing holds no stored token — it uses Trusted Publishing.
crates.io mints a short-lived one only for an OIDC claim from `release.yaml`
running in that same `release` environment.
## Sandbox toolchain
The agent runs as `tend-sandbox`, whose PATH tend derives from the runner's by
rewriting a leading `/home/runner` to `/home/tend-sandbox` and keeping an entry
only where that directory exists. `useradd -m` seeds the sandbox home from
`/etc/skel`, so an image-baked toolchain has a sibling there and survives;
anything `tend-setup` installs at runtime into the runner's home has none and is
dropped, unlogged. So a tool the agent needs has to land in a system location
(`/opt/hostedtoolcache/...`, `/usr/local/bin`), which carries across verbatim —
the route `nu` takes — or be copied in by `.config/tend.yaml`'s
`sandbox_setup:`, which is what the pre-merge gate's `cargo-insta`,
`cargo-nextest` and `pre-commit` take. Its closing probe asserts all four.
## Build environment
`Swatinem/rust-cache` hashes `CARGO*` and `RUST*` env vars into the cache key.
+21 -4
View File
@@ -6,7 +6,7 @@ inputs:
description: Install cargo-insta and cargo-nextest
default: "true"
install-shells:
description: Install zsh and fish for shell integration tests
description: Install zsh, fish, and nushell for shell integration tests
default: "true"
runs:
@@ -23,6 +23,11 @@ runs:
echo "CARGO_INCREMENTAL=0" >> "$GITHUB_ENV"
echo "RUSTFLAGS=-C debuginfo=0" >> "$GITHUB_ENV"
# `.config/tend.yaml`'s `sandbox_setup:` copies both binaries out of
# `$HOME/.cargo-install/<crate>/bin` — the root this action hardcodes — so
# the sandboxed agent can reach them (it can't see the runner's home on its
# PATH). Swapping this action for one with a different layout, or dropping
# these steps, breaks every tend workflow at that copy; update both sides.
- name: Install cargo-insta
if: inputs.install-cargo-tools == 'true'
uses: baptiste0928/cargo-install@v3
@@ -77,12 +82,24 @@ runs:
sudo timeout -k 30 300 apt-get update
sudo timeout -k 30 300 apt-get install -y zsh fish
# The gate's `insta` step runs `--all-features`, which turns on
# `shell-integration-tests` — that needs `nu` as much as zsh and fish, and
# two tests `.expect()` it outright. `setup-nu` installs into a system
# location, so the agent inherits it (see "Sandbox toolchain" in
# .github/CLAUDE.md) with no `sandbox_setup:` line the way the runner-home
# installs above need. Level with `test-setup`'s pin.
- name: Install nushell
if: inputs.install-shells == 'true'
uses: hustcer/setup-nu@v3
with:
version: '0.115.0'
# Nix, for the weekly toolchain bump's `flake.lock` refresh. The sandboxed
# agent can't install it: that user has no sudo, so it can't create /nix.
# Installed here as `runner`, /nix/var/nix/profiles/default/bin lands on
# $GITHUB_PATH, and tend carries system PATH entries into the sandbox
# verbatim (only runner-home entries are rewritten), so the agent gets
# `nix`. ~4 s, so it isn't worth gating to the one workflow that needs it.
# $GITHUB_PATH — a system path, so the agent inherits `nix` on the same
# rule as `nu` above. ~4 s, so it isn't worth gating to the one workflow
# that needs it.
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
+6
View File
@@ -41,6 +41,12 @@ jobs:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
bot_name: worktrunk-bot
model: opus
sandbox_setup: |
mkdir -p ~/.local/bin
install -m755 /home/runner/.cargo-install/cargo-insta/bin/cargo-insta ~/.local/bin/
install -m755 /home/runner/.cargo-install/cargo-nextest/bin/cargo-nextest ~/.local/bin/
uv tool install --quiet pre-commit
for t in cargo-insta cargo-nextest pre-commit nu; do command -v "$t" >/dev/null || { echo "::error::tend sandbox is missing $t; wt hook pre-merge cannot run"; exit 1; }; done
prompt: |
/tend-ci-runner:ci-fix ${{ github.event.workflow_run.id }}
- Run URL: ${{ github.event.workflow_run.html_url }}
+6
View File
@@ -427,6 +427,12 @@ jobs:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
bot_name: worktrunk-bot
model: opus
sandbox_setup: |
mkdir -p ~/.local/bin
install -m755 /home/runner/.cargo-install/cargo-insta/bin/cargo-insta ~/.local/bin/
install -m755 /home/runner/.cargo-install/cargo-nextest/bin/cargo-nextest ~/.local/bin/
uv tool install --quiet pre-commit
for t in cargo-insta cargo-nextest pre-commit nu; do command -v "$t" >/dev/null || { echo "::error::tend sandbox is missing $t; wt hook pre-merge cannot run"; exit 1; }; done
prompt: >-
${{ steps.delay.outputs.seconds
&& format('This job started {0}s after the triggering event (over ~40s means it was queued). ',
+6
View File
@@ -41,5 +41,11 @@ jobs:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
bot_name: worktrunk-bot
model: opus
sandbox_setup: |
mkdir -p ~/.local/bin
install -m755 /home/runner/.cargo-install/cargo-insta/bin/cargo-insta ~/.local/bin/
install -m755 /home/runner/.cargo-install/cargo-nextest/bin/cargo-nextest ~/.local/bin/
uv tool install --quiet pre-commit
for t in cargo-insta cargo-nextest pre-commit nu; do command -v "$t" >/dev/null || { echo "::error::tend sandbox is missing $t; wt hook pre-merge cannot run"; exit 1; }; done
prompt: |
/tend-ci-runner:nightly
@@ -156,5 +156,11 @@ jobs:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
bot_name: worktrunk-bot
model: opus
sandbox_setup: |
mkdir -p ~/.local/bin
install -m755 /home/runner/.cargo-install/cargo-insta/bin/cargo-insta ~/.local/bin/
install -m755 /home/runner/.cargo-install/cargo-nextest/bin/cargo-nextest ~/.local/bin/
uv tool install --quiet pre-commit
for t in cargo-insta cargo-nextest pre-commit nu; do command -v "$t" >/dev/null || { echo "::error::tend sandbox is missing $t; wt hook pre-merge cannot run"; exit 1; }; done
prompt: |
/tend-ci-runner:notifications
+6
View File
@@ -41,5 +41,11 @@ jobs:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
bot_name: worktrunk-bot
model: opus
sandbox_setup: |
mkdir -p ~/.local/bin
install -m755 /home/runner/.cargo-install/cargo-insta/bin/cargo-insta ~/.local/bin/
install -m755 /home/runner/.cargo-install/cargo-nextest/bin/cargo-nextest ~/.local/bin/
uv tool install --quiet pre-commit
for t in cargo-insta cargo-nextest pre-commit nu; do command -v "$t" >/dev/null || { echo "::error::tend sandbox is missing $t; wt hook pre-merge cannot run"; exit 1; }; done
prompt: |
/tend-ci-runner:review-runs
+6
View File
@@ -156,6 +156,12 @@ jobs:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
bot_name: worktrunk-bot
model: opus
sandbox_setup: |
mkdir -p ~/.local/bin
install -m755 /home/runner/.cargo-install/cargo-insta/bin/cargo-insta ~/.local/bin/
install -m755 /home/runner/.cargo-install/cargo-nextest/bin/cargo-nextest ~/.local/bin/
uv tool install --quiet pre-commit
for t in cargo-insta cargo-nextest pre-commit nu; do command -v "$t" >/dev/null || { echo "::error::tend sandbox is missing $t; wt hook pre-merge cannot run"; exit 1; }; done
prompt: >-
${{ format('/tend-ci-runner:review {0}', github.event.pull_request.number) }}
+6
View File
@@ -53,6 +53,12 @@ jobs:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
bot_name: worktrunk-bot
model: opus
sandbox_setup: |
mkdir -p ~/.local/bin
install -m755 /home/runner/.cargo-install/cargo-insta/bin/cargo-insta ~/.local/bin/
install -m755 /home/runner/.cargo-install/cargo-nextest/bin/cargo-nextest ~/.local/bin/
uv tool install --quiet pre-commit
for t in cargo-insta cargo-nextest pre-commit nu; do command -v "$t" >/dev/null || { echo "::error::tend sandbox is missing $t; wt hook pre-merge cannot run"; exit 1; }; done
prompt: |
/tend-ci-runner:triage ${{ github.event.issue.number }}
+6
View File
@@ -41,5 +41,11 @@ jobs:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
bot_name: worktrunk-bot
model: opus
sandbox_setup: |
mkdir -p ~/.local/bin
install -m755 /home/runner/.cargo-install/cargo-insta/bin/cargo-insta ~/.local/bin/
install -m755 /home/runner/.cargo-install/cargo-nextest/bin/cargo-nextest ~/.local/bin/
uv tool install --quiet pre-commit
for t in cargo-insta cargo-nextest pre-commit nu; do command -v "$t" >/dev/null || { echo "::error::tend sandbox is missing $t; wt hook pre-merge cannot run"; exit 1; }; done
prompt: |
/tend-ci-runner:weekly