Skip to content

fix(agent): refresh the gateway token so long runs stop 401ing - #1054

Open
Twixes wants to merge 3 commits into
mainfrom
fix/wizard-gateway-token-rotation
Open

fix(agent): refresh the gateway token so long runs stop 401ing#1054
Twixes wants to merge 3 commits into
mainfrom
fix/wizard-gateway-token-rotation

Conversation

@Twixes

@Twixes Twixes commented Aug 4, 2026

Copy link
Copy Markdown
Member

Problem

Wizard access tokens are good for exactly one hour. The agent runs as a single long-lived subprocess, and its env is fixed at spawn time, so once that token goes stale there's no way to hand it a new one. Any run that crosses the hour mark dies on a 401 from the LLM gateway.

The easiest way to hit it is a wizard_ask that nobody answers. The prompt times out after 30 minutes, so the agent sits idle straight through its own token's expiry and only finds out when it resumes. Two unanswered prompts is enough to get there, and the run is dead by then.

It's also confusing when it lands. All our local diagnostics come back clean (no settings conflict, no stored Claude login), so the auth-error screen falls through to its generic branch and starts advising about key types and scopes, none of which is the actual problem. People reasonably read that as "I passed the wrong key".

Changes

Rather than bake a token into the subprocess env, hand the SDK settings.apiKeyHelper: a small script it re-runs on its own cadence. The script refreshes when the token is nearly out and prints whatever is currently valid.

The thing I like about this over the obvious alternative: it keeps the access-token window at an hour instead of widening it. We could have just given the wizard's OAuth app a longer TTL server-side, but that means a longer-lived bearer token sitting on a laptop, and these tokens carry write scopes on the user's project (feature flags, dashboards, insights). Short-and-rotating is the better trade.

Details worth knowing:

  • We were already being issued a refresh token at login and dropping it on the floor. Now we keep it, along with the expiry, on Credentials.
  • The helper is its own tsdown entry (dist/auth-helper.js), since the SDK execs it as a standalone process. It gets its state file path from an env var on the agent subprocess, because a shebang can't portably take arguments.
  • That state lives in a 0700 temp dir cleaned up at exit, and the file itself is 0600, since a refresh token is a real credential.
  • It takes a lock before refreshing. PostHog rotates refresh tokens with reuse protection on, so two concurrent refreshes using the same token revoke the whole session. Same reason the credential is one-per-process: parallel orchestrator tasks would otherwise each get their own state file holding the same refresh token, and the lock only serializes within a file.
  • The common path doesn't take the lock at all. It reads state, sees a healthy token, prints it. Only a due refresh contends.
  • If a refresh fails it prints the token we already have. That's the same 401 we'd have got anyway, so it can't make things worse.
  • CI runs on a personal API key, which doesn't expire, so they skip all of this and keep the fixed token.

Test plan

src/lib/agent/__tests__/rotating-credential.test.ts runs the real helper as a subprocess against a local token endpoint, covering both branches: refresh when the token is near expiry (and persist the rotated refresh token so the spent one is never reused), pass through untouched when it's healthy. Takes about half a second.

Full suite green at 1718 tests, typecheck at parity with main.

Known gaps, happy to follow up

  • Only the anthropic harness gets rotation. The pi harness and the in-process triage provider still use the spawn-time token.
  • The posthog-wizard MCP server config sends a static Authorization header for the whole run, so it has exactly the same expiry exposure that this PR fixes for model calls.
  • The auth-error screen still shows the generic key-type advice. A dedicated "your session expired, re-run to continue" branch would save people a lot of head-scratching.
  • The refresh token is written to disk. Cleanup runs on graceful exit, so a SIGKILL'd wizard leaves it in /tmp, and refresh tokens are good for 30 days versus one hour for the access token. Worth weighing against the "short-lived credential" argument above. Moving refresh into core (the parent process is alive for the whole run and could answer the helper over a socket) would keep it in memory and fix the two gaps above at the same time.

The first two both fall out naturally if we later move to a session-owned "give me a currently-valid token" accessor, which is probably the right end state. This PR deliberately doesn't go there.

LLM context

Co-authored with Claude Code. The root cause was confirmed by reading the actual OAuth token row for a failing run (created and expires exactly 3600s apart) and matching it against the gateway's 401s, rather than inferred from the client side. Worth stating because the gateway currently logs nothing about why auth failed, which made this much harder to pin down than it should have been.

Wizard access tokens expire after an hour, but the agent runs as one
long-lived subprocess whose env is fixed at spawn, so the token can't be
replaced once it goes stale. Any run past the hour dies on a 401.

Hand the SDK a `settings.apiKeyHelper` script instead of a fixed token.
It refreshes when the token is nearly out and prints whatever is
currently valid, so the access-token window stays at an hour rather than
widening.
@Twixes
Twixes requested a review from a team as a code owner August 4, 2026 03:52
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🧙 Wizard CI

Run the Wizard CI and test your changes against wizard-workbench example apps by replying with a GitHub comment using one of the following commands:

Test all apps:

  • /wizard-ci all

Test all apps in a directory:

  • /wizard-ci basic-integration
  • /wizard-ci mcp-analytics
  • /wizard-ci revenue
  • /wizard-ci self-driving

Test an individual app:

  • /wizard-ci basic-integration/android
  • /wizard-ci basic-integration/angular
  • /wizard-ci basic-integration/astro
Show more apps
  • /wizard-ci basic-integration/django
  • /wizard-ci basic-integration/fastapi
  • /wizard-ci basic-integration/flask
  • /wizard-ci basic-integration/javascript-node
  • /wizard-ci basic-integration/javascript-web
  • /wizard-ci basic-integration/laravel
  • /wizard-ci basic-integration/next-js
  • /wizard-ci basic-integration/nuxt
  • /wizard-ci basic-integration/python
  • /wizard-ci basic-integration/rails
  • /wizard-ci basic-integration/react-native
  • /wizard-ci basic-integration/react-router
  • /wizard-ci basic-integration/sveltekit
  • /wizard-ci basic-integration/swift
  • /wizard-ci basic-integration/tanstack-router
  • /wizard-ci basic-integration/tanstack-start
  • /wizard-ci basic-integration/vue
  • /wizard-ci mcp-analytics/custom-dispatcher
  • /wizard-ci mcp-analytics/typescript-sdk
  • /wizard-ci revenue/stripe
  • /wizard-ci self-driving/astro
  • /wizard-ci self-driving/fastapi
  • /wizard-ci self-driving/nuxt
  • /wizard-ci self-driving/react-router
  • /wizard-ci self-driving/sveltekit

Results will be posted here when complete.

Twixes added 2 commits August 3, 2026 20:58
…s a string

The helper was a template literal written to a temp dir at runtime, which
meant no typecheck, no lint, and hand-escaped source. Make it a real
module with its own tsdown entry, shipped as dist/auth-helper.js.

It gets its state file path from an env var now, since it no longer sits
beside it. Typing the refresh response immediately caught an untyped
`response.json()` the string had hidden.
@edwinyjlim

Copy link
Copy Markdown
Member

okay i totally get where this is coming from, but i'm worried about changing the core auth mechanics. i think this might be annoying to handle for different OSs like windows. i also don't know how i feel about refresh tokens living on disk either

i think we can have token refreshes and keep everything in memory if we create a little token server that talks to each subprocess

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants