fix(runtime): refresh cloud API token in-process for long-running agents (cloud#2307 Half 2)

Closed
KH
4mo ago
+497-10

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 via POST ${CLOUD_API_REFRESH_URL} (relayauth /v1/tokens/refresh) only when the access token is within the 5-min skew of expiry; rotates-and-persists both CLOUD_API_ACCESS_TOKEN and CLOUD_API_TOKEN (the relayflows adapter env fallback) in place; serialized single-in-flight (relayauth refresh tokens are single-use — reuse cascade-revokes the session); throws CloudApiTokenHorizonError on 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 the node runner.mjs subprocess where the relayflows Slack client is constructed and reads process.env.CLOUD_API_TOKEN. (A parent-process refresher would NOT reach it — env is copied at spawn.) Guarded on CLOUD_API_REFRESH_URL, stopped in a finally.

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 --noEmit clean.

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