diff --git a/docs/user-docs/claude-code-auth-compliance.md b/docs/user-docs/claude-code-auth-compliance.md index f930afd46..0c6b77466 100644 --- a/docs/user-docs/claude-code-auth-compliance.md +++ b/docs/user-docs/claude-code-auth-compliance.md @@ -86,18 +86,15 @@ Implication for GSD2: These are directionally correct because GSD is using the user's own local Claude Code installation as the authenticated Anthropic surface. -### Medium/high-risk pieces +### Medium/high-risk pieces — RESOLVED -- `packages/pi-ai/src/utils/oauth/anthropic.ts` - Still implements a first-party-looking Anthropic OAuth flow for GSD itself using `claude.ai/oauth/authorize` and `platform.claude.com/v1/oauth/token`. -- `packages/pi-ai/src/utils/oauth/index.ts` - Still registers `anthropicOAuthProvider` as a built-in OAuth provider. -- `src/web/onboarding-service.ts` - Still advertises Anthropic as `supportsOAuth: true`, which keeps the web onboarding surface inconsistent with the TUI stance. -- `packages/daemon/src/orchestrator.ts` - Reads Anthropic OAuth credentials from `~/.gsd/agent/auth.json`, refreshes them, and then uses the access token for Anthropic API calls. +All Anthropic OAuth code paths have been removed: -The key risk is not just stale UI. The repo still contains code paths where GSD can behave as a third-party Anthropic OAuth client and then convert that credential into direct API access. +- `packages/pi-ai/src/utils/oauth/anthropic.ts` — **Deleted.** No longer implements Anthropic OAuth flow. +- `packages/pi-ai/src/utils/oauth/index.ts` — **Updated.** `anthropicOAuthProvider` removed from built-in registry. +- `src/web/onboarding-service.ts` — **Updated.** Anthropic set to `supportsOAuth: false`. +- `packages/daemon/src/orchestrator.ts` — **Updated.** OAuth token refresh removed; requires `ANTHROPIC_API_KEY` env var. +- `packages/pi-ai/src/providers/anthropic.ts` — **Updated.** OAuth client branch removed; `isOAuthToken` always returns false. ## Recommended Policy For GSD2 @@ -149,14 +146,14 @@ This is the best long-term UX because it separates: - API-billed usage - cloud-routed usage -## Concrete Repo Follow-ups +## Concrete Repo Follow-ups — COMPLETED -1. Delete or disable `packages/pi-ai/src/utils/oauth/anthropic.ts`. -2. Remove `anthropicOAuthProvider` from `packages/pi-ai/src/utils/oauth/index.ts`. -3. Change `src/web/onboarding-service.ts` so Anthropic does not claim OAuth support. -4. Audit `packages/daemon/src/orchestrator.ts` and any other callers that treat Anthropic OAuth access tokens as API credentials. -5. Update docs/UI labels to prefer `anthropic-api` for direct API usage and `claude-code` for subscription usage. -6. Add tests that fail if Anthropic subscription OAuth is reintroduced through the onboarding/provider registry. +1. ~~Delete or disable `packages/pi-ai/src/utils/oauth/anthropic.ts`.~~ **Done** — file deleted. +2. ~~Remove `anthropicOAuthProvider` from `packages/pi-ai/src/utils/oauth/index.ts`.~~ **Done.** +3. ~~Change `src/web/onboarding-service.ts` so Anthropic does not claim OAuth support.~~ **Done.** +4. ~~Audit `packages/daemon/src/orchestrator.ts` and any other callers that treat Anthropic OAuth access tokens as API credentials.~~ **Done** — daemon now requires `ANTHROPIC_API_KEY`. +5. ~~Update docs/UI labels to prefer `anthropic-api` for direct API usage and `claude-code` for subscription usage.~~ **Done** — providers.md and getting-started.md updated. +6. Add tests that fail if Anthropic subscription OAuth is reintroduced through the onboarding/provider registry. — **TODO.** ## Decision Rule diff --git a/docs/user-docs/getting-started.md b/docs/user-docs/getting-started.md index 6fbcf2422..f64d3f241 100644 --- a/docs/user-docs/getting-started.md +++ b/docs/user-docs/getting-started.md @@ -53,7 +53,7 @@ gsd GSD displays a welcome screen showing your version, active model, and available tool keys. Then on first launch, it runs a setup wizard: -1. **LLM Provider** — select from 20+ providers (Anthropic, OpenAI, Google, OpenRouter, GitHub Copilot, Amazon Bedrock, Azure, and more). OAuth flows handle Claude Max and Copilot subscriptions automatically; otherwise paste an API key. +1. **LLM Provider** — select from 20+ providers (Anthropic, OpenAI, Google, OpenRouter, GitHub Copilot, Amazon Bedrock, Azure, and more). Paste an API key, or use OAuth for supported providers like GitHub Copilot. Claude subscription users should authenticate through the local Claude Code CLI. 2. **Tool API Keys** (optional) — Brave Search, Context7, Jina, Slack, Discord. Press Enter to skip any. If you have an existing Pi installation, provider credentials are imported automatically. diff --git a/docs/user-docs/providers.md b/docs/user-docs/providers.md index 984ee369c..1602f6437 100644 --- a/docs/user-docs/providers.md +++ b/docs/user-docs/providers.md @@ -30,7 +30,7 @@ Step-by-step setup instructions for every LLM provider GSD supports. If you ran | Provider | Auth Method | Env Variable | Config File | |----------|-------------|-------------|-------------| -| Anthropic | OAuth or API key | `ANTHROPIC_API_KEY` | — | +| Anthropic | API key | `ANTHROPIC_API_KEY` | — | | OpenAI | API key | `OPENAI_API_KEY` | — | | Google Gemini | API key | `GEMINI_API_KEY` | — | | OpenRouter | API key | `OPENROUTER_API_KEY` | Optional `models.json` | @@ -55,25 +55,31 @@ Built-in providers have models pre-registered in GSD. You only need to supply cr **Recommended.** Anthropic models have the deepest integration: built-in web search, extended thinking, and prompt caching. -**Option A — Browser sign-in (recommended):** - -```bash -gsd config -# Choose "Sign in with your browser" → "Anthropic (Claude)" -``` - -Or inside a session: `/login` - -**Option B — API key:** +**Option A — API key (recommended):** ```bash export ANTHROPIC_API_KEY="sk-ant-..." ``` -Or paste it during `gsd config` when prompted. +Or run `gsd config` and paste your key when prompted. **Get a key:** [console.anthropic.com/settings/keys](https://console.anthropic.com/settings/keys) +**Option B — Claude Code CLI:** + +If you have a Claude Pro or Max subscription, you can authenticate through Anthropic's official Claude Code CLI. Install it, sign in with `claude`, then GSD will detect and route through it automatically: + +```bash +# Install Claude Code CLI (see https://docs.anthropic.com/en/docs/claude-code) +claude +# Sign in when prompted, then start GSD +gsd +``` + +GSD detects your local Claude Code installation and uses it as the authenticated Anthropic surface. This is the TOS-compliant path for subscription users — GSD never handles your subscription credentials directly. + +> **Note:** GSD does not support browser-based OAuth sign-in for Anthropic. Use an API key or the Claude Code CLI instead. + ### OpenAI ```bash diff --git a/packages/daemon/src/orchestrator.ts b/packages/daemon/src/orchestrator.ts index 678874cec..fe2998d8f 100644 --- a/packages/daemon/src/orchestrator.ts +++ b/packages/daemon/src/orchestrator.ts @@ -12,9 +12,6 @@ */ import { z } from 'zod'; -import { readFileSync, writeFileSync, chmodSync } from 'node:fs'; -import { join } from 'node:path'; -import { homedir } from 'node:os'; import type Anthropic from '@anthropic-ai/sdk'; import type { MessageParam, @@ -30,90 +27,18 @@ import type { ProjectInfo, ManagedSession } from './types.js'; import type { Logger } from './logger.js'; // --------------------------------------------------------------------------- -// OAuth token resolution — reads GSD's auth.json, refreshes if expired +// API key resolution — requires ANTHROPIC_API_KEY env var +// Anthropic OAuth removed per TOS compliance (see docs/user-docs/claude-code-auth-compliance.md) // --------------------------------------------------------------------------- -interface OAuthCredentials { - type: 'oauth'; - refresh: string; - access: string; - expires: number; -} - -const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'; -const CLIENT_ID = atob('OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl'); - -/** - * Read the Anthropic OAuth access token from GSD's auth.json. - * If expired, refresh it and write the new credentials back. - * Falls back to ANTHROPIC_API_KEY env var if no OAuth credential exists. - */ -async function resolveAnthropicApiKey(logger?: Logger): Promise { - // Try env var first (explicit override) - if (process.env.ANTHROPIC_API_KEY) { - return process.env.ANTHROPIC_API_KEY; - } - - const authPath = join(homedir(), '.gsd', 'agent', 'auth.json'); - let authData: Record; - try { - authData = JSON.parse(readFileSync(authPath, 'utf-8')); - } catch { +function resolveAnthropicApiKey(): string { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { throw new Error( - 'No Anthropic auth found. Run `gsd login` to authenticate, or set ANTHROPIC_API_KEY.', + 'ANTHROPIC_API_KEY is required. Set it in your environment or run `gsd config`.', ); } - - const cred = authData.anthropic as OAuthCredentials | undefined; - if (!cred || cred.type !== 'oauth' || !cred.access) { - throw new Error( - 'No Anthropic OAuth credential in auth.json. Run `gsd login` to authenticate.', - ); - } - - // If token is still valid, use it - if (Date.now() < cred.expires) { - return cred.access; - } - - // Token expired — refresh it - logger?.info('orchestrator: refreshing Anthropic OAuth token'); - const response = await fetch(TOKEN_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - grant_type: 'refresh_token', - client_id: CLIENT_ID, - refresh_token: cred.refresh, - }), - signal: AbortSignal.timeout(30_000), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Anthropic token refresh failed: ${error}`); - } - - const data = (await response.json()) as { - access_token: string; - refresh_token: string; - expires_in: number; - }; - - const newCred: OAuthCredentials = { - type: 'oauth', - refresh: data.refresh_token, - access: data.access_token, - expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, - }; - - // Write back to auth.json - authData.anthropic = newCred; - writeFileSync(authPath, JSON.stringify(authData, null, 2), 'utf-8'); - chmodSync(authPath, 0o600); - logger?.info('orchestrator: Anthropic OAuth token refreshed'); - - return newCred.access; + return apiKey; } // --------------------------------------------------------------------------- @@ -254,11 +179,11 @@ export class Orchestrator { /** * Lazily initialise the Anthropic client. Dynamic import handles K007 module resolution. - * Resolves auth from GSD's OAuth credentials (auth.json), refreshing if needed. + * Requires ANTHROPIC_API_KEY environment variable. */ private async getClient(): Promise { if (this.client) return this.client; - const apiKey = await resolveAnthropicApiKey(this.deps.logger); + const apiKey = resolveAnthropicApiKey(); const { default: AnthropicSDK } = await import('@anthropic-ai/sdk'); this.client = new AnthropicSDK({ apiKey }); return this.client; diff --git a/packages/pi-ai/src/providers/anthropic.ts b/packages/pi-ai/src/providers/anthropic.ts index 21c0da707..57ee1b5be 100644 --- a/packages/pi-ai/src/providers/anthropic.ts +++ b/packages/pi-ai/src/providers/anthropic.ts @@ -34,9 +34,6 @@ async function getAnthropicClass(): Promise { return _AnthropicClass; } -// Stealth mode: Mimic Claude Code's tool naming exactly -const claudeCodeVersion = "2.1.62"; - function mergeHeaders(...headerSources: (Record | undefined)[]): Record { const merged: Record = {}; for (const headers of headerSources) { @@ -47,10 +44,6 @@ function mergeHeaders(...headerSources: (Record | undefined)[]): return merged; } -function isOAuthToken(apiKey: string): boolean { - return apiKey.includes("sk-ant-oat"); -} - async function createClient( model: Model<"anthropic-messages">, apiKey: string, @@ -97,30 +90,7 @@ async function createClient( betaFeatures.push("interleaved-thinking-2025-05-14"); } - // OAuth: Bearer auth, Claude Code identity headers - if (isOAuthToken(apiKey)) { - const client = new AnthropicClass({ - apiKey: null, - authToken: apiKey, - baseURL: model.baseUrl, - dangerouslyAllowBrowser: true, - defaultHeaders: mergeHeaders( - { - accept: "application/json", - "anthropic-dangerous-direct-browser-access": "true", - ...(betaFeatures.length > 0 ? { "anthropic-beta": `claude-code-20250219,oauth-2025-04-20,${betaFeatures.join(",")}` } : {}), - "user-agent": `claude-cli/${claudeCodeVersion}`, - "x-app": "cli", - }, - model.headers, - optionsHeaders, - ), - }); - - return { client, isOAuthToken: true }; - } - - // API key auth + // API key auth (Anthropic OAuth removed per TOS compliance — use API keys or Claude CLI) // Alibaba Coding Plan uses Bearer token auth instead of x-api-key const isAlibabaProvider = model.provider === "alibaba-coding-plan"; const client = new AnthropicClass({ diff --git a/packages/pi-ai/src/utils/oauth/anthropic.ts b/packages/pi-ai/src/utils/oauth/anthropic.ts deleted file mode 100644 index 861e26409..000000000 --- a/packages/pi-ai/src/utils/oauth/anthropic.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Anthropic OAuth flow (Claude Pro/Max) - */ - -import { generatePKCE } from "./pkce.js"; -import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js"; - -const decode = (s: string) => atob(s); -const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl"); -const AUTHORIZE_URL = "https://claude.ai/oauth/authorize"; -const TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; -const REDIRECT_URI = "https://platform.claude.com/oauth/code/callback"; -const SCOPES = "org:create_api_key user:profile user:inference"; - -/** - * Login with Anthropic OAuth (device code flow) - * - * @param onAuthUrl - Callback to handle the authorization URL (e.g., open browser) - * @param onPromptCode - Callback to prompt user for the authorization code - */ -export async function loginAnthropic( - onAuthUrl: (url: string) => void, - onPromptCode: () => Promise, -): Promise { - const { verifier, challenge } = await generatePKCE(); - - // Build authorization URL - const authParams = new URLSearchParams({ - code: "true", - client_id: CLIENT_ID, - response_type: "code", - redirect_uri: REDIRECT_URI, - scope: SCOPES, - code_challenge: challenge, - code_challenge_method: "S256", - state: verifier, - }); - - const authUrl = `${AUTHORIZE_URL}?${authParams.toString()}`; - - // Notify caller with URL to open - onAuthUrl(authUrl); - - // Wait for user to paste authorization code (format: code#state) - const authCode = await onPromptCode(); - const splits = authCode.split("#"); - const code = splits[0]; - const state = splits[1]; - - // Exchange code for tokens - const tokenResponse = await fetch(TOKEN_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - grant_type: "authorization_code", - client_id: CLIENT_ID, - code: code, - state: state, - redirect_uri: REDIRECT_URI, - code_verifier: verifier, - }), - signal: AbortSignal.timeout(30_000), - }); - - if (!tokenResponse.ok) { - const error = await tokenResponse.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - const tokenData = (await tokenResponse.json()) as { - access_token: string; - refresh_token: string; - expires_in: number; - }; - - // Calculate expiry time (current time + expires_in seconds - 5 min buffer) - const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000; - - // Save credentials - return { - refresh: tokenData.refresh_token, - access: tokenData.access_token, - expires: expiresAt, - }; -} - -/** - * Refresh Anthropic OAuth token - */ -export async function refreshAnthropicToken(refreshToken: string): Promise { - const response = await fetch(TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - grant_type: "refresh_token", - client_id: CLIENT_ID, - refresh_token: refreshToken, - }), - signal: AbortSignal.timeout(30_000), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Anthropic token refresh failed: ${error}`); - } - - const data = (await response.json()) as { - access_token: string; - refresh_token: string; - expires_in: number; - }; - - return { - refresh: data.refresh_token, - access: data.access_token, - expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, - }; -} - -export const anthropicOAuthProvider: OAuthProviderInterface = { - id: "anthropic", - name: "Anthropic (Claude Pro/Max)", - - async login(callbacks: OAuthLoginCallbacks): Promise { - return loginAnthropic( - (url) => callbacks.onAuth({ url }), - () => callbacks.onPrompt({ message: "Paste the authorization code:" }), - ); - }, - - async refreshToken(credentials: OAuthCredentials): Promise { - return refreshAnthropicToken(credentials.refresh); - }, - - getApiKey(credentials: OAuthCredentials): string { - return credentials.access; - }, -}; diff --git a/packages/pi-ai/src/utils/oauth/index.ts b/packages/pi-ai/src/utils/oauth/index.ts index a91decf4a..715b4910c 100644 --- a/packages/pi-ai/src/utils/oauth/index.ts +++ b/packages/pi-ai/src/utils/oauth/index.ts @@ -3,14 +3,14 @@ * * This module handles login, token refresh, and credential storage * for OAuth-based providers: - * - Anthropic (Claude Pro/Max) * - GitHub Copilot * - Google Cloud Code Assist (Gemini CLI) * - Antigravity (Gemini 3, Claude, GPT-OSS via Google Cloud) + * + * Note: Anthropic OAuth was removed per TOS compliance (see docs/user-docs/claude-code-auth-compliance.md). + * Use API keys or the local Claude Code CLI for Anthropic access. */ -// Anthropic -export { anthropicOAuthProvider, loginAnthropic, refreshAnthropicToken } from "./anthropic.js"; // GitHub Copilot export { getGitHubCopilotBaseUrl, @@ -32,7 +32,6 @@ export * from "./types.js"; // Provider Registry // ============================================================================ -import { anthropicOAuthProvider } from "./anthropic.js"; import { githubCopilotOAuthProvider } from "./github-copilot.js"; import { antigravityOAuthProvider } from "./google-antigravity.js"; import { geminiCliOAuthProvider } from "./google-gemini-cli.js"; @@ -40,7 +39,6 @@ import { openaiCodexOAuthProvider } from "./openai-codex.js"; import type { OAuthCredentials, OAuthProviderId, OAuthProviderInterface } from "./types.js"; const BUILT_IN_OAUTH_PROVIDERS: OAuthProviderInterface[] = [ - anthropicOAuthProvider, githubCopilotOAuthProvider, geminiCliOAuthProvider, antigravityOAuthProvider, diff --git a/src/tests/integration/web-onboarding-contract.test.ts b/src/tests/integration/web-onboarding-contract.test.ts index 3ed833368..91173eef9 100644 --- a/src/tests/integration/web-onboarding-contract.test.ts +++ b/src/tests/integration/web-onboarding-contract.test.ts @@ -348,7 +348,7 @@ test("boot and onboarding routes expose locked required state plus explicitly sk ]); const anthropicProvider = bootPayload.onboarding.required.providers.find((provider: any) => provider.id === "anthropic"); assert.equal(anthropicProvider.supports.apiKey, true); - assert.equal(anthropicProvider.supports.oauthAvailable, true); + assert.equal(anthropicProvider.supports.oauthAvailable, false); const onboardingResponse = await onboardingRoute.GET(projectRequest(fixture.projectCwd, "/api/onboarding")); assert.equal(onboardingResponse.status, 200); diff --git a/src/web/onboarding-service.ts b/src/web/onboarding-service.ts index 259865da5..764949c58 100644 --- a/src/web/onboarding-service.ts +++ b/src/web/onboarding-service.ts @@ -142,7 +142,7 @@ type ProviderFlowRuntime = { }; const REQUIRED_PROVIDER_CATALOG: RequiredProviderCatalogEntry[] = [ - { id: "anthropic", label: "Anthropic (Claude)", supportsApiKey: true, supportsOAuth: true, recommended: true }, + { id: "anthropic", label: "Anthropic (Claude)", supportsApiKey: true, supportsOAuth: false, recommended: true }, { id: "openai", label: "OpenAI", supportsApiKey: true, supportsOAuth: false }, { id: "github-copilot", label: "GitHub Copilot", supportsApiKey: false, supportsOAuth: true }, { id: "openai-codex", label: "ChatGPT Plus/Pro (Codex Subscription)", supportsApiKey: false, supportsOAuth: true },