fix(ai): server-only provider API key + write-only settings entry#111
Merged
Conversation
Inference runs entirely server-side, so no client needs the provider key. Previously GET /api/ai/status returned the un-redacted AiConfig (including providers.claude.apiKey + the legacy flat apiKey) to writer principals, and AiSettings painted it into a password input. Close the leak end to end. - server: redactAiConfig now strips every provider key AND the legacy flat key for EVERY principal (no more canWrite readback branch), replacing each with an apiKeySet flag. Both GET /api/ai/status and the PUT /api/ai/config echo are redacted, so a blank-preserved key is never handed back. - server: AiService.setConfig merges keys — omitted/empty apiKey PRESERVES the stored value, a non-empty string sets it, explicit null CLEARS it; the transport-only apiKeySet flag is never persisted. - sdk: AiProviderSettings/AiConfig gain apiKeySet (response-only) and widen apiKey to string|null for the explicit-clear signal; providerSettings carries apiKeySet through the legacy branch. - ui: AiSettings key field is write-only — masked "key set" placeholder over an empty input, blank-on-save is a no-op, a "Clear key" button does the explicit clear, and the raw entry is scrubbed from state after save. AgentPanel keys provider availability off apiKeySet. Tests: aiGating enumerates guest/viewer/non-member/owner/loopback all redacted, plus preserve/empty/replace/clear/flag-not-persisted for setConfig. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X78XTAemKrFw1uJpDpCEaG
mergeStoredKeys now overlays the incoming providers onto the STORED map
instead of rebuilding from `next`, so a partial PUT that omits a provider
keeps that provider's stored key (previously dropped). resolveKey trims
the new key before the empty check, so a whitespace-only entry is a
blank/preserve (no unclearable ghost key) and a set value is stored
trimmed. A malformed `{claude: null}` provider entry is now ignored
rather than throwing.
AiSettings: associate the Claude key input with its label (id/htmlFor +
aria-label), and render a real, always-present "Key set · leave blank to
keep the current key" status node (aria-describedby) so a screen reader
announces the stored-key state instead of relying on the placeholder.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X78XTAemKrFw1uJpDpCEaG
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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.
Problem
The paid-provider API key was echoed to the browser. Inference already runs entirely server-side (the client only calls
/api/agent/chat+/api/ai/*SSE;AnthropicEngine→/v1/messageswith the server-heldx-api-key), so the client never needs the key — yetGET /api/ai/statusreturned the un-redactedAiConfig(incl.providers.claude.apiKey) to any writer principal so the settings form could pre-fill its draft, and the client painted the raw key into the password input. That readback was the entire exposure.Board: A · AI API key never leaves the server (Epic: Server-only AI keys + admin usage attribution).
Solution
apiKey(legacy flat) + everyproviders.*.apiKeyare stripped inGET /api/ai/statusand thePUT /api/ai/configecho, for every principal (guest / viewer / writer / owner / loopback). The oldcanWrite ? full : redactedbranch is gone.apiKeySetsignal. A per-provider (and legacy top-level) boolean tells the form a key exists without shipping the value.setConfigoverlays incoming keys onto the stored config: omitted/''/whitespace-only → preserve; non-empty → set; explicitnull→ clear. A partial PUT can't drop an unsent provider's key; a malformed{claude: null}entry is ignored (no 500).AiSettingsshows a masked "key set" placeholder + Clear key + a "leave blank to keep the current key" hint, with label/aria-describedbyassociations; the input value is never bound to a stored key.apiKeySet, not the (now absent) key value.Key files:
packages/server/src/ai/{routes,service}.ts,packages/sdk/src/ai.ts,packages/ui/src/components/{AiSettings,AgentPanel}.tsx,packages/ui/src/i18n/messages/en.ts. New deps: none.Before / After
GET /api/ai/statusfor a writer/ownerproviders.claude.apiKeyapiKeySet: trueremains (server test)Test procedure
pnpm verify→ green.packages/server/src/aiGating.test.ts: enumerates guest / viewer / jws-non-member / owner + loopback + PUT-echo and asserts the key string is absent everywhere (not.toContain), plus preserve / clear / whitespace-only / omitted-provider / malformed-null cases.claudewhen a key is set.Operational notes
apiKeytostring | nullto carry the explicit-clear signal;createEnginecoalesces (s.apiKey || '') sonullis never treated as a real key, and the stored config never persistsnull.545fe1f; the only gap tomainis the 1.75.0 release bump (CHANGELOG + version bumps) — no code conflict.Verify: green — sdk 171 · ui 939 · server 596 unit + e2e (251 embedded / 40 MCP). Gates cleared: security ✅ · code ✅ · design ✅.
🤖 Generated with Claude Code
https://claude.ai/code/session_01X78XTAemKrFw1uJpDpCEaG