Summary
A role's GitHub installation token is snapshotted into the sandbox box at prep time and never refreshed for the life of the run. Because the identity registry hands out cached tokens that may already be 45 minutes old, any role run longer than ~15 minutes can outlive its own credential and dies with 401 Bad credentials.
Observed on bookwright#181 (ticket 45167), twice, 2026-08-09.
The arithmetic
- A GitHub App installation token lives 60 minutes (GitHub-fixed).
v4/identity.py:181 reuses a cached token until it is within _REFRESH_SAFETY_SECONDS of expiry:
if cached is not None and cached.expires_at - now > _REFRESH_SAFETY_SECONDS:
return cached.token
with _REFRESH_SAFETY_SECONDS = 900 (identity.py:77). ⇒ a handed-out token may already be 45 minutes old.
v4/sandbox_clone.py::prepare_sandbox_clone takes that role_token and freezes it into the box — inlined into the origin URL via tokenized_origin_url(), and forwarded as GH_TOKEN on the subprocess env via _default_runner.
- Nothing re-points it during the run. The docstring's "the
origin re-point runs EVERY call" means every prep, not every minute of a live role.
⇒ 45 min of age + a 35 min run = 80 min against a 60 min TTL. The token expires roughly 15 minutes into the run, and every GitHub call after that 401s.
Evidence
Every Implementing run on bookwright, most recent first:
| issue |
duration |
outcome |
| 181 |
35 min |
error (401 Bad credentials) |
| 181 |
35 min |
needs_help (ProviderAuthError) |
| 226 |
9 min |
clean |
| 225 |
7 min |
clean |
| 225 |
20 min |
clean |
| 224 |
7 min |
clean |
| 201 |
16 min |
clean |
| 188 |
9 min |
clean |
| 53 |
7 min |
clean |
| 64 |
6 min |
clean |
Every clean run is ≤20 minutes. Both failures are 35. No counterexample in ten runs.
Why it presents as a mystery
The failure is indistinguishable from a broken credential, and by the time anyone investigates, the credential tests healthy — the daemon has re-minted it. I verified all of the following after the failure and all passed:
claude -p inside the container → ok, exit 0 (rules out the Anthropic path)
- container vs host clock → 0 seconds drift (rules out JWT skew)
- all five App JWTs against
GET /app → planner, reviewer, fixer, worker, orchestrator all OK
GET /repos/jeffrichley/bookwright/installation + token mint for all five → all installed, all minting
So the diagnostic surface lies: the credential is valid, and the run still failed, because the run was using a copy that had since expired. The worker's own escalation guessed "the container's Compose-mounted secret is likely also stale on the host side" — that is wrong, and it sent me looking in the wrong place first.
Why the margin is the wrong shape
_REFRESH_SAFETY_SECONDS = 900 is generous for the daemon's own sub-second API calls — the consumer finishes in milliseconds, so 15 minutes of headroom is enormous.
It is meaningless for a subprocess that copies the token and runs for half an hour. The margin is sized against the moment of handoff rather than against the consumer's lifetime, and those are different quantities whenever the consumer is a long-lived box.
Suggested fix
Force a fresh mint for any token destined for a sandbox box — bypass the cache on the dispatch path, so the box always starts with a full 60 minutes. That bounds the failure to "role runs longer than an hour" instead of "role runs longer than fifteen minutes," and it does not couple an identity constant to worker behaviour.
Alternatives considered:
- Raise
_REFRESH_SAFETY_SECONDS to exceed max role runtime — works, but makes an identity-layer constant depend on how long the slowest role happens to take, and silently re-breaks when a ticket gets bigger.
- Re-point
origin mid-run — the box is isolated by design; this fights the sandbox rather than working with it.
Acceptance criteria
A test that would fail on today's code: mint a token, advance a clock past its expiry, and assert that the token handed to prepare_sandbox_clone is freshly minted rather than the cached one. Asserting only that "a token is passed" passes on the broken code — the bug is the token's age, not its presence.
Worth also asserting the property directly: for any role dispatch, token.expires_at - now >= max_expected_role_runtime.
Blast radius
Not bookwright-specific. Any project, any role, any run over ~15 minutes — and it worsens as specs grow, since longer specs mean longer Worker runs. bookwright#181 carries a 1,255-line spec, which is why it is the first ticket to hit it reliably.
Summary
A role's GitHub installation token is snapshotted into the sandbox box at prep time and never refreshed for the life of the run. Because the identity registry hands out cached tokens that may already be 45 minutes old, any role run longer than ~15 minutes can outlive its own credential and dies with
401 Bad credentials.Observed on
bookwright#181(ticket 45167), twice, 2026-08-09.The arithmetic
v4/identity.py:181reuses a cached token until it is within_REFRESH_SAFETY_SECONDSof expiry:with
_REFRESH_SAFETY_SECONDS = 900(identity.py:77). ⇒ a handed-out token may already be 45 minutes old.v4/sandbox_clone.py::prepare_sandbox_clonetakes thatrole_tokenand freezes it into the box — inlined into theoriginURL viatokenized_origin_url(), and forwarded asGH_TOKENon the subprocess env via_default_runner.originre-point runs EVERY call" means every prep, not every minute of a live role.⇒ 45 min of age + a 35 min run = 80 min against a 60 min TTL. The token expires roughly 15 minutes into the run, and every GitHub call after that 401s.
Evidence
Every
Implementingrun on bookwright, most recent first:401 Bad credentials)ProviderAuthError)Every clean run is ≤20 minutes. Both failures are 35. No counterexample in ten runs.
Why it presents as a mystery
The failure is indistinguishable from a broken credential, and by the time anyone investigates, the credential tests healthy — the daemon has re-minted it. I verified all of the following after the failure and all passed:
claude -pinside the container →ok, exit 0 (rules out the Anthropic path)GET /app→ planner, reviewer, fixer, worker, orchestrator all OKGET /repos/jeffrichley/bookwright/installation+ token mint for all five → all installed, all mintingSo the diagnostic surface lies: the credential is valid, and the run still failed, because the run was using a copy that had since expired. The worker's own escalation guessed "the container's Compose-mounted secret is likely also stale on the host side" — that is wrong, and it sent me looking in the wrong place first.
Why the margin is the wrong shape
_REFRESH_SAFETY_SECONDS = 900is generous for the daemon's own sub-second API calls — the consumer finishes in milliseconds, so 15 minutes of headroom is enormous.It is meaningless for a subprocess that copies the token and runs for half an hour. The margin is sized against the moment of handoff rather than against the consumer's lifetime, and those are different quantities whenever the consumer is a long-lived box.
Suggested fix
Force a fresh mint for any token destined for a sandbox box — bypass the cache on the dispatch path, so the box always starts with a full 60 minutes. That bounds the failure to "role runs longer than an hour" instead of "role runs longer than fifteen minutes," and it does not couple an identity constant to worker behaviour.
Alternatives considered:
_REFRESH_SAFETY_SECONDSto exceed max role runtime — works, but makes an identity-layer constant depend on how long the slowest role happens to take, and silently re-breaks when a ticket gets bigger.originmid-run — the box is isolated by design; this fights the sandbox rather than working with it.Acceptance criteria
A test that would fail on today's code: mint a token, advance a clock past its expiry, and assert that the token handed to
prepare_sandbox_cloneis freshly minted rather than the cached one. Asserting only that "a token is passed" passes on the broken code — the bug is the token's age, not its presence.Worth also asserting the property directly: for any role dispatch,
token.expires_at - now >= max_expected_role_runtime.Blast radius
Not bookwright-specific. Any project, any role, any run over ~15 minutes — and it worsens as specs grow, since longer specs mean longer Worker runs.
bookwright#181carries a 1,255-line spec, which is why it is the first ticket to hit it reliably.