fix(auth): self-heal stale Anthropic OAuth credential (#4399)

Anthropic OAuth was removed in v2.74.0 for TOS compliance (#3952). Users
who upgraded through that version still have type:"oauth" entries under
`anthropic` in auth.json which cannot resolve to a valid API key.

stale entry, so hasAuth("anthropic") kept reporting true and masked the
claude-code fallback path. Users had to hand-edit auth.json to recover.

Self-heal instead:

- AuthStorage.removeLegacyOAuthCredential(provider) strips only
  type:"oauth" entries and preserves any api_key credentials.
- sdk.ts getApiKey() calls it when the legacy-OAuth branch triggers,
  logs a one-line warning, and throws a message pointing the user at
  the "claude-code" provider when the `claude` binary is in PATH, or
  at ANTHROPIC_API_KEY otherwise.

Closes #4399

(cherry picked from commit b8ef6604617fda239a037cf5d5e6020b168d2e62)
This commit is contained in:
Jeremy 2026-04-17 10:59:49 -05:00 committed by Mikael Hugo
parent 3b23ef3d4b
commit b5e1beff8e
3 changed files with 159 additions and 0 deletions

View file

@ -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");
});
});

View file

@ -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

View file

@ -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);