fix(runtime): refresh cloud API token in-process for long-running agents (cloud#2307 Half 2)
What
Half 2 of the cloud#2307 fix — the consumer that actually rotates the proactive sandbox's cloud API token before it expires. Stacks on cloud#2313 (Half 1, which mints the token as a refreshable pair + injects the refresh env vars).
Why
/api/v1/slack/post-message is authenticated with CLOUD_API_ACCESS_TOKEN — a relayfile-audience JWT minted once at sandbox creation with a ~2h TTL and no in-sandbox refresh. Posts within 2h work; a long-running proactive agent that posts after the TTL (e.g. this incident, where the Slack post fired only after a multi-hour DB recovery) sends an expired-but-validly-signed bearer → verifier returns null → wholesale 401. Latent expiry bug, not a regression.
How
- New
packages/runtime/src/cloud-api-token-refresh.ts:ensureFreshCloudApiToken(env)— refreshes viaPOST ${CLOUD_API_REFRESH_URL}(relayauth/v1/tokens/refresh) only when the access token is within the 5-min skew of expiry; rotates-and-persists bothCLOUD_API_ACCESS_TOKENandCLOUD_API_TOKEN(the relayflows adapter env fallback) in place; serialized single-in-flight (relayauth refresh tokens are single-use — reuse cascade-revokes the session); throwsCloudApiTokenHorizonErroron a 401 (delegation horizon elapsed → out-of-band re-mint).startCloudApiTokenRefresher(opts)— background loop (setTimeout,unref'd), tick < skew.
- Wired into
startRunner(runner.ts): the refresher runs inside thenode runner.mjssubprocess where the relayflows Slack client is constructed and readsprocess.env.CLOUD_API_TOKEN. (A parent-process refresher would NOT reach it — env is copied at spawn.) Guarded onCLOUD_API_REFRESH_URL, stopped in afinally.
Placement note
The persona's Slack client (relayflows SlackStepExecutor → new SlackClient) is built per step in the runner subprocess and resolves the token config.cloudApiToken ?? env.CLOUD_API_TOKEN. Keeping that subprocess's process.env fresh means each per-step client picks up a live token — no relayflows change, no token-provider needed.
Testing
- 10 new unit tests (fresh-passthrough, within-skew, expired, no-material, concurrent-serialization=single rotation, 401-horizon, non-401 failure, loop rotate/stop/horizon).
- Full runtime package suite 123/123 pass;
tsc --noEmitclean.
Sequencing
No-op without cloud#2313's injected refresh vars, so it's safe to merge in either order, but the 401 only fully clears once both land + deploy.
🤖 Generated with Claude Code