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:
parent
3b23ef3d4b
commit
b5e1beff8e
3 changed files with 159 additions and 0 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue