diff --git a/packages/pi-coding-agent/src/core/auth-storage.test.ts b/packages/pi-coding-agent/src/core/auth-storage.test.ts index 646162f2b..fac250e5d 100644 --- a/packages/pi-coding-agent/src/core/auth-storage.test.ts +++ b/packages/pi-coding-agent/src/core/auth-storage.test.ts @@ -569,3 +569,86 @@ describe("AuthStorage — localhost baseUrl shortcut", () => { assert.equal(key, "sk-myproxy-key"); }); }); + +// ─── hasLegacyOAuthCredential (Anthropic OAuth removed in v2.74.0, #3952) ──── + +describe("AuthStorage — hasLegacyOAuthCredential (#4280)", () => { + it("returns true when anthropic has a type:oauth credential", () => { + const storage = inMemory({ + anthropic: { + type: "oauth", + access: "ya29.fake-access-token", + refresh: "1//fake-refresh-token", + expires: Date.now() + 3_600_000, + }, + }); + assert.equal(storage.hasLegacyOAuthCredential("anthropic"), true); + }); + + it("returns false when anthropic has an api_key credential", () => { + const storage = inMemory({ anthropic: makeKey("sk-ant-fake") }); + assert.equal(storage.hasLegacyOAuthCredential("anthropic"), false); + }); + + it("returns false when anthropic has no credential at all", () => { + const storage = inMemory({}); + assert.equal(storage.hasLegacyOAuthCredential("anthropic"), false); + }); + + it("returns false for a provider with a legitimate OAuth credential (e.g. github-copilot)", () => { + const storage = inMemory({ + "github-copilot": { + type: "oauth", + access: "gho_fake-token", + refresh: "ghr_fake-refresh", + expires: Date.now() + 28_800_000, + }, + }); + // hasLegacyOAuthCredential is intentionally provider-scoped — calling it + // for a provider that still supports OAuth (like github-copilot) is not + // expected in production, but the method must not explode. + assert.equal(storage.hasLegacyOAuthCredential("github-copilot"), true); + }); +}); + +// ─── removeLegacyOAuthCredential (self-heal for #3952 / #4368) ─────────────── + +describe("AuthStorage — removeLegacyOAuthCredential (#4368)", () => { + it("removes oauth entry and returns true when present", () => { + const storage = inMemory({ + anthropic: { + type: "oauth", + access: "fake", + refresh: "fake", + expires: Date.now() + 3_600_000, + }, + }); + assert.equal(storage.removeLegacyOAuthCredential("anthropic"), true); + assert.equal(storage.hasLegacyOAuthCredential("anthropic"), false); + assert.equal(storage.has("anthropic"), false); + }); + + it("returns false when no oauth entry exists", () => { + const storage = inMemory({ anthropic: makeKey("sk-ant-fake") }); + assert.equal(storage.removeLegacyOAuthCredential("anthropic"), false); + assert.equal(storage.get("anthropic")?.type, "api_key"); + }); + + it("preserves api_key credentials alongside oauth entry", () => { + const storage = inMemory({ + anthropic: [ + makeKey("sk-ant-keep"), + { + type: "oauth", + access: "fake", + refresh: "fake", + expires: Date.now() + 3_600_000, + }, + ], + }); + assert.equal(storage.removeLegacyOAuthCredential("anthropic"), true); + const remaining = storage.getCredentialsForProvider("anthropic"); + assert.equal(remaining.length, 1); + assert.equal(remaining[0].type, "api_key"); + }); +}); diff --git a/packages/pi-coding-agent/src/core/auth-storage.ts b/packages/pi-coding-agent/src/core/auth-storage.ts index fb394b3a1..21f78fa42 100644 --- a/packages/pi-coding-agent/src/core/auth-storage.ts +++ b/packages/pi-coding-agent/src/core/auth-storage.ts @@ -465,6 +465,41 @@ export class AuthStorage { return false; } + /** + * Returns true if the stored credential for a provider is of type "oauth". + * Used to detect stale OAuth credentials for providers where OAuth has been + * removed (e.g. Anthropic, #3952) so callers can surface a targeted + * migration message instead of a generic cooldown error. + */ + hasLegacyOAuthCredential(provider: string): boolean { + return this.getCredentialsForProvider(provider).some((c) => c.type === "oauth"); + } + + /** + * Remove only oauth-type credentials for a provider, preserving any api_key + * entries. Used to self-heal stale OAuth credentials for providers where + * OAuth support has been removed (e.g. Anthropic, #3952) without destroying + * a user's valid API keys. Returns true if any oauth entries were removed. + */ + removeLegacyOAuthCredential(provider: string): boolean { + const existing = this.getCredentialsForProvider(provider); + const remaining = existing.filter((c) => c.type !== "oauth"); + if (remaining.length === existing.length) return false; + + if (remaining.length === 0) { + delete this.data[provider]; + this.persistProviderChange(provider, undefined); + } else { + const next = remaining.length === 1 ? remaining[0] : remaining; + this.data[provider] = next; + this.persistProviderChange(provider, next); + } + this.providerRoundRobinIndex.delete(provider); + this.credentialBackoff.delete(provider); + this.providerBackoff.delete(provider); + return true; + } + /** * Get all credentials (for passing to getOAuthApiKey). * Returns normalized format where each provider has a single credential diff --git a/packages/pi-coding-agent/src/core/sdk.ts b/packages/pi-coding-agent/src/core/sdk.ts index 90258bcd8..35d2202be 100644 --- a/packages/pi-coding-agent/src/core/sdk.ts +++ b/packages/pi-coding-agent/src/core/sdk.ts @@ -1,5 +1,17 @@ +import { existsSync } from "node:fs"; import { join } from "node:path"; +/** + * Lightweight PATH scan for the `claude` binary — no subprocess, no network. + * Mirrors the check in src/resources/extensions/gsd/doctor-providers.ts so the + * legacy Anthropic OAuth self-heal path can only trigger when the user has a + * working Claude Code CLI to fall back to. + */ +function isClaudeCodeBinaryInPath(): boolean { + const pathDirs = (process.env.PATH ?? "").split(":"); + return pathDirs.some((dir) => dir && existsSync(join(dir, "claude"))); +} + /** * Structured error thrown when all credentials for a provider are in a * backoff window. Carries typed metadata so callers (e.g. the auto-loop) @@ -442,6 +454,35 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // the retry handler and creating cascading error entries (#3429). const hasAuth = modelRegistry.authStorage.hasAuth(resolvedProvider); if (hasAuth) { + // Anthropic OAuth was removed in v2.74.0 for TOS compliance (#3952). + // Users who upgraded from an older version may still have OAuth + // credentials in auth.json that will never resolve to a valid API key. + if ( + resolvedProvider === "anthropic" && + modelRegistry.authStorage.hasLegacyOAuthCredential(resolvedProvider) + ) { + // Self-heal: strip the stale oauth entry so hasAuth() stops lying + // about anthropic being configured. This preserves any api_key + // credentials alongside it. + const removed = modelRegistry.authStorage.removeLegacyOAuthCredential(resolvedProvider); + if (removed) { + console.warn( + `[auth] Removed unsupported Anthropic OAuth credential from auth.json (#3952).`, + ); + } + if (isClaudeCodeBinaryInPath()) { + throw new Error( + `Removed stale Anthropic OAuth credential (OAuth support removed in v2.74.0). ` + + `Your current model's provider is set to "anthropic" but the local Claude Code CLI ` + + `is available — switch the model's provider to "claude-code" in your preferences ` + + `to use it, or set ANTHROPIC_API_KEY to continue with the Anthropic API directly.`, + ); + } + throw new Error( + `Removed stale Anthropic OAuth credential (OAuth support removed in v2.74.0). ` + + `Set ANTHROPIC_API_KEY, run '/login' and paste an API key, or switch to a different provider.`, + ); + } const expiry = modelRegistry.authStorage.getEarliestBackoffExpiry(resolvedProvider); const retryAfterMs = expiry !== undefined ? Math.max(0, expiry - Date.now()) : undefined; throw new CredentialCooldownError(resolvedProvider, retryAfterMs);