diff --git a/claude/action.yaml b/claude/action.yaml index 3c91cffe..b8f35aae 100644 --- a/claude/action.yaml +++ b/claude/action.yaml @@ -194,8 +194,10 @@ runs: # plugins, and the run all execute as the sandbox user. Cache the proxy's # mitmproxy download (a sizeable dependency) in an isolated uv cache dir, # keyed on the pinned version. First run populates; every run after restores - # in seconds instead of a cold ~20s resolve. Scoped to the proxy's uv; a - # skill that installs its own uv gets that uv's default cache, untouched. + # instead of pulling ~100MB from PyPI again (measured 2026-08-09 on a + # GitHub-hosted runner: 2.2s cold, 0.5s restored — the saving is the reach, + # more than the seconds). Scoped to the proxy's uv; a skill that installs + # its own uv gets that uv's default cache, untouched. - name: Cache mitmproxy uses: actions/cache@v4 with: diff --git a/generator/tests/test_shared_steps.py b/generator/tests/test_shared_steps.py index cbe6fbb6..eb4425d6 100644 --- a/generator/tests/test_shared_steps.py +++ b/generator/tests/test_shared_steps.py @@ -1691,3 +1691,79 @@ def test_install_codex_cli_installs_first_try_without_sleeping( ) assert _sleeps(codex_cli_env) == [], _sleeps(codex_cli_env) assert "codex-cli 0.131.0-alpha.22" in result.stdout, result.stdout + + +# --------------------------------------------------------------------------- +# proxy/setup-sandbox.sh — the mitmproxy cache warm, the reach that isn't an +# installer +# --------------------------------------------------------------------------- + +SETUP_SANDBOX = REPO_ROOT / "proxy" / "setup-sandbox.sh" + + +def _logical_lines(path: Path) -> list[str]: + """Script lines with backslash continuations folded, so a call reads as one.""" + out: list[str] = [] + buf = "" + for raw in path.read_text().splitlines(): + stripped = raw.strip() + if stripped.endswith("\\"): + buf += stripped[:-1].rstrip() + " " + continue + out.append(buf + stripped) + buf = "" + if buf: + out.append(buf) + return out + + +def test_setup_sandbox_resolves_the_shared_retry_window() -> None: + """The lib path has to resolve from ACTION_PATH the way the action passes it. + + The action points ACTION_PATH at the repo root, and the script runs under + `set -euo pipefail`, so a path that doesn't resolve doesn't degrade to an + unretried warm — it exits the step, and then every run is lost, not only + the ones that hit a blip. Sourcing the script's own line (matched by + content) is what makes this a check on the shipped path rather than on a + copy of it. + """ + sources = [ + line + for line in _logical_lines(SETUP_SANDBOX) + if line.startswith(". ") and "lib/retry.sh" in line + ] + assert len(sources) == 1, sources + + result = subprocess.run( + ["bash", "-c", f"set -euo pipefail; {sources[0]}; type -t retry_install"], + env={"PATH": "/usr/bin:/bin", "ACTION_PATH": str(REPO_ROOT)}, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "function", result.stdout + + +def test_setup_sandbox_warms_the_mitmproxy_cache_through_the_window() -> None: + """The warm resolves mitmproxy from PyPI on a miss, so it rides the window. + + A miss is scheduled rather than rare — the actions/cache key is scoped to + `mitmproxy_version`, so every bump misses on each workflow's first run — + and the warm sits three steps ahead of the agent, so a PyPI blip that isn't + ridden out costs the whole run, the failure the window exists for. + + Asserted at the call site instead of by running the script: setup-sandbox.sh + creates the sandbox user, chowns the workspace and installs a CA before it + reaches this line, so the behavioural suites the three installers get don't + transfer. What the window itself does is covered there. + """ + warms = [ + line + for line in _logical_lines(SETUP_SANDBOX) + if "mitmdump --version" in line and not line.startswith("#") + ] + assert len(warms) == 1, warms + assert warms[0].startswith("retry_install "), ( + f"the cache warm reaches PyPI outside the retry window: {warms[0]}" + ) diff --git a/proxy/setup-sandbox.sh b/proxy/setup-sandbox.sh index 815d21ce..e5baf8ce 100755 --- a/proxy/setup-sandbox.sh +++ b/proxy/setup-sandbox.sh @@ -61,6 +61,12 @@ if [ -z "${MITMPROXY_VERSION:-}" ]; then exit 1 fi +# The window the pre-agent third-party reaches share; the mitmproxy cache warm +# below is one of them. Sourced up here rather than at the point of use so a +# wrong path fails now, ahead of the user creation and the workspace chown. +# shellcheck source=../shared/steps/lib/retry.sh +. "${ACTION_PATH}/shared/steps/lib/retry.sh" + # 1. Non-sudo sandbox user. -m gives it /home/tend-sandbox (0755, so the # runner can still read the session logs it writes). if ! id "$SANDBOX" >/dev/null 2>&1; then @@ -309,14 +315,30 @@ chmod 700 "$CONFDIR" # Warm the uvx cache first so the backgrounded launch starts immediately and # the readiness wait below measures startup, not a cold dependency resolve. # Pinned + UV_CACHE_DIR (set by the action) point at the actions/cache-backed -# dir, so this is a fast restore after the first run. +# dir. +# +# On a miss this resolves and downloads mitmproxy from PyPI, which makes it the +# proxy's own third-party reach, three steps ahead of the agent — so a blip +# that isn't ridden out costs the whole run. The miss is scheduled rather than +# rare: the cache key is version-scoped, so every mitmproxy_version bump misses +# on each workflow's first run, which is when a lost run is most expensive (it +# reddens the bump PR itself). Hence retry_install. +# +# The window covers the backgrounded launch below too: it shares UV_CACHE_DIR, +# and a cache warmed here serves that same `--from` with no network at all +# (checked with `uvx --offline`), so PyPI is off the launch path once this line +# succeeds. Measured on a GitHub-hosted runner, 2026-08-09: 2.2s cold (43 +# packages, ~100MB) and 0.5s restored — far enough inside the 60s timeout that +# this shares the lib's window rather than needing one of its own. +# # $TEND_UV_DIR holds tend's own pinned uv (shared/steps/install-proxy-uv.sh), # addressed absolutely rather than through PATH: the binary that launches the # credential-holding process is tend's, not whatever the adopter's `setup:` # left on PATH. MITMPROXY="mitmproxy==${MITMPROXY_VERSION}" UVX="${TEND_UV_DIR}/uvx" -"$UVX" --from "$MITMPROXY" mitmdump --version >/dev/null +retry_install "mitmproxy ${MITMPROXY_VERSION}" \ + "'$UVX' --from '$MITMPROXY' mitmdump --version >/dev/null" log "starting proxy" # The --allow-hosts regex scopes which hosts mitmproxy TLS-intercepts. It must # cover every host the addon injects into — keep it in sync with the diff --git a/shared/steps/lib/retry.sh b/shared/steps/lib/retry.sh index 5ea2ae20..83f5c257 100644 --- a/shared/steps/lib/retry.sh +++ b/shared/steps/lib/retry.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash -# The retry window the pre-agent installers share: install-claude-binary.sh, -# install-proxy-uv.sh and install-codex-cli.sh each reach a third party — two -# CDNs and the npm registry — before the agent step exists, so a blip that -# exhausts their retries costs the whole run: the step goes red having done -# none of the work the trigger asked for. The lost run is what justifies a -# window this wide, independent of how the failure is reported afterwards. +# The retry window the pre-agent third-party reaches share: three installers — +# install-claude-binary.sh, install-proxy-uv.sh and install-codex-cli.sh (two +# CDNs and the npm registry) — plus proxy/setup-sandbox.sh's mitmproxy cache +# warm (PyPI). Each runs before the agent step exists, so a blip that exhausts +# its retries costs the whole run: the step goes red having done none of the +# work the trigger asked for. The lost run is what justifies a window this +# wide, independent of how the failure is reported afterwards. # # Sourced, not executed.