Skip to content

fix(api): derive agent mode from secret names on public /setup/status#477

Merged
jonwiggins merged 1 commit into
jonwiggins:mainfrom
barrydobson:fix/setup-status-aad-mismatch
Apr 22, 2026
Merged

fix(api): derive agent mode from secret names on public /setup/status#477
jonwiggins merged 1 commit into
jonwiggins:mainfrom
barrydobson:fix/setup-status-aad-mismatch

Conversation

@barrydobson

@barrydobson barrydobson commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Fixes the /setup redirect loop on fresh installs with auth enabled.
  • Drops retrieveSecret() calls from the public /api/setup/status endpoint — it can't decrypt workspace-scoped rows because it has no req.user context.
  • Infers Claude OAuth, Codex app-server, and Gemini Vertex AI modes from secret names alone, matching the pattern already used for API-key modes.

Root cause

PR #319 added AAD binding name|scope|workspaceId to AES-256-GCM secrets. The setup wizard, now that the user is authenticated before completing it (and especially after #474's user-scoped secrets), POSTs CLAUDE_AUTH_MODE to /api/secrets with workspaceId = req.user.workspaceId. The AAD baked in at write becomes CLAUDE_AUTH_MODE|global|<workspaceId>.

/api/setup/status is listed in PUBLIC_ROUTES (auth.ts:50), so it has no req.user. Its call to retrieveSecret("CLAUDE_AUTH_MODE") falls through to buildSecretAAD(name, "global", undefined) which produces CLAUDE_AUTH_MODE|global|global. AAD mismatch → decrypt throws → .catch(() => null) swallows it silently → authMode = nullhasOauthToken never set → isSetUp = false. Frontend loops on /setup.

Same problem applies to CODEX_AUTH_MODE and GEMINI_AUTH_MODE. #472 touched this file but only decoupled isSetUp from runtime health — the AAD mismatch persisted.

Fix

The endpoint already has secretNames from listSecrets(). Each mode has a unique sibling secret that's written alongside the *_AUTH_MODE row:

Mode Signal
Claude oauth-token CLAUDE_CODE_OAUTH_TOKEN
Claude api-key ANTHROPIC_API_KEY
Claude max-subscription isSubscriptionAvailable()
Codex app-server CODEX_APP_SERVER_URL
Codex api-key OPENAI_API_KEY
Gemini vertex-ai GOOGLE_CLOUD_PROJECT
Gemini api-key GEMINI_API_KEY

No decryption needed. The public endpoint stays public and read-after-write symmetric regardless of whether auth is enabled.

Addresss #476

Test plan

  • New regression tests in setup.test.ts reproduce the AAD-mismatch scenario (mock retrieveSecret to always reject) — all three modes still report isSetUp: true with the fix
  • Tests confirmed to fail on the pre-fix code
  • Full API test suite passes (2024/2024)
  • Typecheck clean
  • Verify manually on a fresh install with OPTIO_AUTH_DISABLED=false and OAuth token mode

… /setup/status

Since PR jonwiggins#319 added AAD binding (`name|scope|workspaceId`) to AES-256-GCM
secrets, `/api/setup/status` could no longer decrypt `CLAUDE_AUTH_MODE`,
`CODEX_AUTH_MODE` or `GEMINI_AUTH_MODE` rows written by an authenticated
setup wizard. The endpoint is public (`PUBLIC_ROUTES` in auth.ts) and has
no workspaceId context, so its AAD fallback to "global" mismatched the row
written with the user's workspaceId. Decrypt threw, the `.catch(() => null)`
swallowed it, `hasOauthToken` / `hasCodexAppServer` / `hasGeminiVertexAi`
stayed false, `isSetUp` stayed false, and fresh installs with auth enabled
got trapped in a /setup redirect loop even after completing the wizard.

Drop the `retrieveSecret` calls from the status endpoint entirely and
derive the mode from the name list `listSecrets()` already returns:

- oauth-token mode        → `CLAUDE_CODE_OAUTH_TOKEN` in names
- codex app-server mode   → `CODEX_APP_SERVER_URL` in names
- gemini vertex-ai mode   → `GOOGLE_CLOUD_PROJECT` in names
- max-subscription mode   → `isSubscriptionAvailable()` (host keychain only)

Each of those secrets is written by the wizard alongside its *_AUTH_MODE
sibling, so presence-by-name is an equivalent signal without needing to
decrypt. api-key modes were already name-inferred.

Closes the redirect loop reintroduced on fresh installs where wizard POSTs
hit `/api/secrets` authenticated (baking workspaceId into AAD) while the
polling frontend hits public `/api/setup/status` unauthenticated.
@jonwiggins jonwiggins merged commit 097c05d into jonwiggins:main Apr 22, 2026
7 checks passed
jplorier pushed a commit to jplorier/optio that referenced this pull request May 5, 2026
… /setup/status (jonwiggins#477)

Since PR jonwiggins#319 added AAD binding (`name|scope|workspaceId`) to AES-256-GCM
secrets, `/api/setup/status` could no longer decrypt `CLAUDE_AUTH_MODE`,
`CODEX_AUTH_MODE` or `GEMINI_AUTH_MODE` rows written by an authenticated
setup wizard. The endpoint is public (`PUBLIC_ROUTES` in auth.ts) and has
no workspaceId context, so its AAD fallback to "global" mismatched the row
written with the user's workspaceId. Decrypt threw, the `.catch(() => null)`
swallowed it, `hasOauthToken` / `hasCodexAppServer` / `hasGeminiVertexAi`
stayed false, `isSetUp` stayed false, and fresh installs with auth enabled
got trapped in a /setup redirect loop even after completing the wizard.

Drop the `retrieveSecret` calls from the status endpoint entirely and
derive the mode from the name list `listSecrets()` already returns:

- oauth-token mode        → `CLAUDE_CODE_OAUTH_TOKEN` in names
- codex app-server mode   → `CODEX_APP_SERVER_URL` in names
- gemini vertex-ai mode   → `GOOGLE_CLOUD_PROJECT` in names
- max-subscription mode   → `isSubscriptionAvailable()` (host keychain only)

Each of those secrets is written by the wizard alongside its *_AUTH_MODE
sibling, so presence-by-name is an equivalent signal without needing to
decrypt. api-key modes were already name-inferred.

Closes the redirect loop reintroduced on fresh installs where wizard POSTs
hit `/api/secrets` authenticated (baking workspaceId into AAD) while the
polling frontend hits public `/api/setup/status` unauthenticated.
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