Cloud credentials and OAuth
kiln's cloud providers — AnthropicProvider (Claude) and OpenAIProvider
(Codex/GPT) — need a credential to make a request. Historically that credential
came from one place only: the ANTHROPIC_API_KEY / OPENAI_API_KEY
environment variables, exported before launch. That works for a terminal-savvy
developer and for CI, but it's a poor fit for an app you double-click: there's
no env to export, and a key baked into the build is a leak waiting to happen.
So every cloud call now resolves its credential through one seam,
CredentialStore (Sources/kiln/LLM/CloudCredentials.swift), and a key can
come from Settings as well as the environment.
The seam
CredentialStore.credential(for:) is the single source of truth. The
resolution order is pure and tested (CredentialResolution.choose):
- Environment —
ANTHROPIC_API_KEY/OPENAI_API_KEY. Highest priority, so nothing changes for existing shell-export and CI setups. - Settings — a key entered in Settings ▸ Providers, stored in the macOS
Keychain (
Keychain.swift), never in@AppStorage, the.kilnconfig, or the app bundle. - Otherwise the call throws
AssistantError.noKey.
Availability gating (Backends.cloudAvailable() / codexAvailable()) asks the
same store via isConfigured(_:) — a synchronous presence check, no network —
so a provider with a Settings key shows up everywhere availability is consulted:
the assistant picker, the squad, ambient agents, every routed call.
The providers don't read the environment anymore. They ask the store for a
CloudCredential and apply its headers. For Anthropic that's
AnthropicProvider.authorize(_:with:) — an API key rides x-api-key; an OAuth
bearer token rides Authorization plus the oauth-2025-04-20 beta header.
OpenAI's Chat Completions endpoint is API-key-only, so its provider takes the
.apiKey case and rejects anything else.
Entering a key in Settings
Settings ▸ Providers ▸ Cloud API keys has a masked field per provider
(CloudKeyRow). Save writes to the Keychain; Clear deletes it. When the key is
coming from the environment instead, the row says so and hides the field — the
environment wins, and pretending otherwise would be a lie.
Verify checks the key before you rely on it: CloudKeyVerifier makes the
cheapest authenticated request each provider offers (GET /v1/models — no
tokens spent) and reports back. A 200 is "Key verified", a 401/403 is "Key
rejected", anything else is a transport failure. The status→outcome mapping is
pure (CloudKeyVerifier.interpret) and tested.
The terminal CLI agents are separate
The Claude Code and Codex CLI handoffs (Handoff, CliAgent) are not
governed by this store. They have their own login (claude /login, codex login) and keep their own credentials under ~/.claude / ~/.codex. A key
entered in kiln's Settings lives in the Keychain and is not injected into the
terminal's environment, so a CLI handoff authenticates through the CLI's own
login (or an exported env var), not through Settings. That's deliberate: the
CLIs' subscription logins are richer than a raw API key, and silently
overriding them would be surprising.
OAuth: the foundation, and what's left
For Anthropic specifically there's a real user-OAuth flow (the browser login
Claude Code uses): authorization code + PKCE, yielding a short-lived access
token and a refresh token. The token rides Authorization: Bearer with the
oauth-2025-04-20 beta header — which is exactly the .bearer case the
provider's authorize already handles.
The pure, testable pieces are in place (OAuthPKCE.swift): PKCE verifier and
S256 challenge generation, authorize-URL assembly, and token-response parsing
with expiry math. What remains to make a live login work:
- The verified endpoint constants — Anthropic's authorize and token URLs,
the public client id, and the scopes. These aren't hardcoded here on purpose;
they belong in one confirmed place (
OAuthEndpoints) once verified against the live service. - A loopback listener — a transient
http://127.0.0.1:<port>server (viaNWListener) to catch the redirect, exchange the code for tokens, and store them in the Keychain. - Refresh — when
credential(for:)resolves an OAuth credential, refresh the access token if it's near expiry, with a single-flight guard so concurrent calls don't all refresh at once. That turns the resolution path async; the API-key path stays synchronous.
OpenAI has no general user-OAuth for the Chat Completions endpoint, so the
in-app OpenAIProvider stays API-key. ChatGPT OAuth, where you want it, lives
in the Codex CLI handoff, which manages its own login.