Skip to content

fix: treat transient auth failures as retryable, not session-expired#262

Merged
vreshch merged 1 commit into
masterfrom
fix/auth-transient-resilience
Jul 9, 2026
Merged

fix: treat transient auth failures as retryable, not session-expired#262
vreshch merged 1 commit into
masterfrom
fix/auth-transient-resilience

Conversation

@vreshch

@vreshch vreshch commented Jul 8, 2026

Copy link
Copy Markdown
Member

Root cause

agentage status intermittently showed auth ✗ session expired - run: agentage setup on a healthy, refreshable session. The old src/lib/auth/api.ts misclassified TRANSIENT failures as TERMINAL: a 200 + null get-session (which the Better Auth AS also returns under transient load / 429 / 5xx / network blip) called tryRefresh, and tryRefresh swallowed EVERY failure into false (catch -> false), so any blip during that refresh made introspectToken throw AuthRequiredError('no active session') -> rendered "session expired." This is the same transient-as-terminal class fixed server-side in web#219 (get-session resilience), never ported to the CLI.

Classification taxonomy (mirrors web#219)

  • TERMINAL (AuthRequiredError, session truly needs re-auth): HTTP 401, or OAuth invalid_grant / invalid_client on the refresh grant, or a definitive 200 + null get-session whose decisive refresh is itself terminal.
  • TRANSIENT (TransientAuthError, never "expired"): 429, 5xx, network errors, timeouts, unexpected redirects, or any refresh failure that is not an explicit invalid_grant/invalid_client/401.

refreshTokens now throws a typed TokenRequestError { status, oauthError }; isTerminalRefresh (in the new auth-errors.ts) does the split. refreshOrThrow distinguishes the two instead of catch -> false.

Behavior changes

introspectToken (now in introspect.ts):

status auth line, one per state (exit code stays 0 in all three):

  • valid: ✓ signed in (session active)
  • transient (could not re-verify): ~ signed in (could not re-verify - temporary)
  • terminal-expired: ✗ session expired - run: agentage setup (exact literal preserved - e2e greps it)

setup now VALIDATES instead of trusting token presence:

  • valid -> Already signed in. + status (creds intact)
  • terminal-expired -> prints Session expired - signing you in again. and auto-enters the sign-in flow (no --reauth needed)
  • transient -> You appear signed in (could not fully verify - temporary). + status, creds NOT wiped

Test delta

  • api.test.ts: terminal refresh now uses invalid_grant (400 alone is transient); added transient 429/503 cases + the unexpired-token-blip signed-in case.
  • status-info.test.ts: updated the old l.111-147 assertions (400 -> invalid_grant for the expired literal; a 5xx blip on an unexpired token is signed-in, not "could not verify"); added an expired-token transient case.
  • setup.test.ts: added introspect dep; valid -> "Already signed in"; terminal -> auto sign-in; transient -> no forced reauth, creds intact.

npm run verify green (471 tests). Built + smoked dist/cli.js status and setup against an isolated config dir (unexpired/expired token, unreachable host) - each state renders as described, exit 0, creds preserved.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

🎉 PR Validation ✅ PASSED

Commit: fa613b839ae0809ab72075c1511f36372567c792
Branch: fix/auth-transient-resilience

Checks:

  • ✅ Release guard (no version/changelog changes)
  • ✅ Dependencies installed
  • ✅ Type check passed
  • ✅ Linting passed
  • ✅ Format check passed
  • ✅ Tests + coverage passed
  • ✅ Build successful

Ready to merge!


🔗 View workflow run
⏰ Generated at: 2026-07-08T23:49:34.567Z

@vreshch vreshch marked this pull request as ready for review July 9, 2026 18:18
@vreshch vreshch merged commit e57942a into master Jul 9, 2026
3 checks passed
@vreshch vreshch deleted the fix/auth-transient-resilience branch July 9, 2026 18:18
vreshch added a commit that referenced this pull request Jul 9, 2026
## Root cause

The CLI target host comes from `siteFqdn()` =
`normalizeFqdn(process.env.AGENTAGE_SITE_FQDN)`, defaulting to
`agentage.io` (production) when unset. But the stored credential in
`~/.agentage/auth.json` carries its OWN `siteFqdn` (the environment it
was issued for, e.g. `dev.agentage.io`).

`gatherStatus` / `introspectToken` validated the stored token against
the CURRENT target's auth server, ignoring the credential's own
`auth.siteFqdn`. So a credential issued for dev, checked against
production, was rejected and reported as `auth [x] session expired -
run: agentage setup` - misleading: the session is not expired, it
belongs to a DIFFERENT environment than the CLI is pointed at.

## Fix

New helper `src/lib/auth/env-match.ts` `detectEnvMismatch(auth,
targetFqdn)` compares normalized fqdns. `gatherStatus` calls it BEFORE
introspection: on a mismatch it renders a neutral line and does NOT
introspect cross-environment.

### Before (mismatch case)
```
auth       [x] session expired - run: agentage setup
```

### After (mismatch case)
```
auth       ! signed in to dev.agentage.io - CLI targets agentage.io (production); run: agentage setup to sign into production, or set AGENTAGE_SITE_FQDN=dev.agentage.io
```

- New marker: `!` (yellow, neutral - not check, not cross). Exit code
stays 0.
- `status --json` gains an additive `auth.mismatch` field: `{
credentialFqdn, credentialEnv, targetFqdn, targetEnv }`, `signedIn:
false`. Existing fields (`signedIn`, `endpoint`, `fqdn`, `env`,
`version`) unchanged - e2e helpers untouched.

## setup

When already signed in but the stored credential is for a DIFFERENT
environment than the current target, setup no longer says a bare
"Already signed in." It informs the user they are signed into
`<credEnv>` and this run signs them into `<targetEnv>`, then proceeds to
a fresh sign-in (the correct action). Matching-env behavior unchanged.

## Same-env path preserved

When credential env == target env, behavior is exactly as before:
introspect via the #262-hardened path (valid -> session active;
transient -> transient note; terminal -> the literal `session expired -
run: agentage setup` that e2e greps). The mismatch case is a NEW,
distinct message and does not reuse the expired literal.

## Tests

- `env-match.test.ts`: match/mismatch both directions + localhost/dev
detection + fqdn normalization.
- `status-info.test.ts`: mismatch (dev cred vs prod target, and reverse)
-> `mismatch` set, `signedIn: false`, not "session expired", no
cross-env introspection.
- `status.test.ts`: neutral `!` mismatch line naming both sides, never
expired.
- `setup.test.ts`: mismatched-env (both directions) -> informs + signs
into current target, no introspect; matched-env valid -> "Already signed
in" unchanged.

`npm run verify` green (481 tests).
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.

1 participant