Skip to main content

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):

  1. EnvironmentANTHROPIC_API_KEY / OPENAI_API_KEY. Highest priority, so nothing changes for existing shell-export and CI setups.
  2. Settings — a key entered in Settings ▸ Providers, stored in the macOS Keychain (Keychain.swift), never in @AppStorage, the .kiln config, or the app bundle.
  3. 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 (via NWListener) 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.