Repository: gsd-build/gsd-2 (2026-04-17T16:02:17Z)

Issue:
Stale Anthropic OAuth credential blocks requests after #3952 removal
## Problem

After upgrading through v2.74.0 (#3952, which removed Anthropic OAuth for TOS compliance), users who still have a `type:"oauth"` credential under `anthropic` in `auth.json` hit a hard error on every request to an Anthropic-provider model:

> Your Anthropic credentials were set up via OAuth, which is no longer supported. Please re-authenticate: run '/login', select 'Paste an API key' → Anthropic. Alternatively, switch to the Claude Code CLI provider.

This error was introduced in #4308 (commit `2dab94ad0`) as a clearer replacement for the previous generic cooldown error. However it does not clean up the stale credential, so:

- `hasAuth("anthropic")` keeps reporting true, masking the correct "no key configured" fallback.
- Users with a working local `claude` CLI can't transparently fall back — the stale OAuth entry keeps the request routed through the Anthropic provider path.
- The only remedy today is to hand-edit `auth.json`.

## Expected

When stale Anthropic OAuth is detected:

1. Self-heal: strip only the `type:"oauth"` entry, preserving any `api_key` credentials.
2. If the `claude` binary is in PATH, surface an actionable message directing the user to switch their model's provider to `claude-code`.
3. Otherwise, surface a "no key configured" style message.

## References

- Anthropic OAuth removal: #3952 / `c2acb1fb4`
- Error introduced: #4308 / `2dab94ad0`


Discussion:

Patch:
diff --git a/packages/pi-coding-agent/src/core/auth-storage.ts b/packages/pi-coding-agent/src/core/auth-storage.ts
index 9eb30c2fa9..23c0019f2c 100644
--- a/packages/pi-coding-agent/src/core/auth-storage.ts
+++ b/packages/pi-coding-agent/src/core/auth-storage.ts
@@ -475,6 +475,31 @@ export class AuthStorage {
 		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 19548be831..a5754b434c 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)
@@ -441,15 +453,30 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
 				// 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.
-				// Surface a targeted migration message instead of the generic cooldown.
 				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(
-						`Your Anthropic credentials were set up via OAuth, which is no longer supported. ` +
-							`Please re-authenticate: run '/login', select 'Paste an API key' → Anthropic. ` +
-							`Alternatively, switch to the Claude Code CLI provider.`,
+						`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);


Test patch:
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 62567d2449..fac250e5d2 100644
--- a/packages/pi-coding-agent/src/core/auth-storage.test.ts
+++ b/packages/pi-coding-agent/src/core/auth-storage.test.ts
@@ -610,3 +610,45 @@ describe("AuthStorage — hasLegacyOAuthCredential (#4280)", () => {
 		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");
+	});
+});