fix(api): derive agent mode from secret names on public /setup/status#477
Merged
jonwiggins merged 1 commit intoApr 22, 2026
Merged
Conversation
… /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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
/setupredirect loop on fresh installs with auth enabled.retrieveSecret()calls from the public/api/setup/statusendpoint — it can't decrypt workspace-scoped rows because it has noreq.usercontext.Root cause
PR #319 added AAD binding
name|scope|workspaceIdto AES-256-GCM secrets. The setup wizard, now that the user is authenticated before completing it (and especially after #474's user-scoped secrets), POSTsCLAUDE_AUTH_MODEto/api/secretswithworkspaceId = req.user.workspaceId. The AAD baked in at write becomesCLAUDE_AUTH_MODE|global|<workspaceId>./api/setup/statusis listed inPUBLIC_ROUTES(auth.ts:50), so it has noreq.user. Its call toretrieveSecret("CLAUDE_AUTH_MODE")falls through tobuildSecretAAD(name, "global", undefined)which producesCLAUDE_AUTH_MODE|global|global. AAD mismatch →decryptthrows →.catch(() => null)swallows it silently →authMode = null→hasOauthTokennever set →isSetUp = false. Frontend loops on/setup.Same problem applies to
CODEX_AUTH_MODEandGEMINI_AUTH_MODE. #472 touched this file but only decoupledisSetUpfrom runtime health — the AAD mismatch persisted.Fix
The endpoint already has
secretNamesfromlistSecrets(). Each mode has a unique sibling secret that's written alongside the*_AUTH_MODErow:oauth-tokenCLAUDE_CODE_OAUTH_TOKENapi-keyANTHROPIC_API_KEYmax-subscriptionisSubscriptionAvailable()app-serverCODEX_APP_SERVER_URLapi-keyOPENAI_API_KEYvertex-aiGOOGLE_CLOUD_PROJECTapi-keyGEMINI_API_KEYNo decryption needed. The public endpoint stays public and read-after-write symmetric regardless of whether auth is enabled.
Addresss #476
Test plan
setup.test.tsreproduce the AAD-mismatch scenario (mockretrieveSecretto always reject) — all three modes still reportisSetUp: truewith the fixOPTIO_AUTH_DISABLED=falseand OAuth token mode