diff --git a/.changeset/anthropic-oauth-false-expiry-notification.md b/.changeset/anthropic-oauth-false-expiry-notification.md new file mode 100644 index 0000000000..eb79e90918 --- /dev/null +++ b/.changeset/anthropic-oauth-false-expiry-notification.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Stop false "Anthropic OAuth expired" notifications when the token is actually valid. +category: fix +dev: The OAuth expiry monitor and validity logger iterated the un-aliased `getOAuthProviders()` id `anthropic` and evaluated `get("anthropic")`, which can resolve to a stale legacy/supplemental row (e.g. `~/.pi/agent/auth.json`) even when the fresh, actually-used token lives under `anthropic-subscription`. Both now resolve the freshest of the two aliased ids via a shared `resolveEffectiveOAuthCredential` helper (mirroring the refresh scheduler's `getRefreshCandidateIds` alias handling), so a live subscription token suppresses the false alert. Notification cadence/throttle semantics are unchanged. diff --git a/packages/engine/src/notification/__tests__/oauth-expiry-monitor.test.ts b/packages/engine/src/notification/__tests__/oauth-expiry-monitor.test.ts index 887fdfb98e..45c370a932 100644 --- a/packages/engine/src/notification/__tests__/oauth-expiry-monitor.test.ts +++ b/packages/engine/src/notification/__tests__/oauth-expiry-monitor.test.ts @@ -68,6 +68,84 @@ describe("OAuthExpiryMonitor", () => { monitor.stop(); }); + /* + FNXC:ClaudeOAuth 2026-07-08-20:55: + Regression: getOAuthProviders() only yields base `anthropic`, and get("anthropic") can + return a STALE legacy row (e.g. ~/.pi/agent/auth.json) while the fresh, actually-used + token lives under `anthropic-subscription`. The monitor must evaluate the freshest of + the two aliased ids and NOT fire a false "expired" alert when the subscription token is + live. Reproduces the user-reported false ntfy while the token had refreshed successfully. + */ + it("does not fire for a stale legacy anthropic row when anthropic-subscription is fresh", async () => { + vi.useFakeTimers(); + const now = Date.now(); + const dispatch = vi.fn(async () => undefined); + const authStorage: AuthStorageLike = { + reload: vi.fn(), + getOAuthProviders: () => [{ id: "anthropic", name: "Anthropic" }], + get: (providerId: string) => { + if (providerId === "anthropic") { + return { type: "oauth", expires: now - 60 * 24 * 60 * 60 * 1000 }; // stale (~60d ago) + } + if (providerId === "anthropic-subscription") { + return { type: "oauth", expires: now + 5 * 60 * 60 * 1000 }; // fresh (+5h) + } + return undefined; + }, + }; + + const monitor = new OAuthExpiryMonitor({ + authStorage, + notificationService: { dispatch } as any, + intervalMs: 100, + clock: () => now, + alertState: new OAuthAlertStateStore({ statePath: createStatePath(), clock: () => now }), + }); + + await monitor.start(); + await vi.runOnlyPendingTimersAsync(); + + expect(dispatch).not.toHaveBeenCalled(); + monitor.stop(); + }); + + it("still fires for anthropic when both the legacy row and the subscription alias are expired", async () => { + vi.useFakeTimers(); + const now = Date.now(); + const dispatch = vi.fn(async () => undefined); + const authStorage: AuthStorageLike = { + reload: vi.fn(), + getOAuthProviders: () => [{ id: "anthropic", name: "Anthropic" }], + get: (providerId: string) => { + if (providerId === "anthropic") { + return { type: "oauth", expires: now - 60 * 24 * 60 * 60 * 1000 }; + } + if (providerId === "anthropic-subscription") { + return { type: "oauth", expires: now - 1_000 }; // also expired + } + return undefined; + }, + }; + + const monitor = new OAuthExpiryMonitor({ + authStorage, + notificationService: { dispatch } as any, + intervalMs: 100, + clock: () => now, + alertState: new OAuthAlertStateStore({ statePath: createStatePath(), clock: () => now }), + }); + + await monitor.start(); + await vi.runOnlyPendingTimersAsync(); + + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith( + "oauth-token-expired", + expect.objectContaining({ metadata: expect.objectContaining({ providerId: "anthropic" }) }), + ); + monitor.stop(); + }); + it("does not fire for non-expired/non-oauth credentials", async () => { vi.useFakeTimers(); const dispatch = vi.fn(async () => undefined); diff --git a/packages/engine/src/notification/oauth-expiry-monitor.ts b/packages/engine/src/notification/oauth-expiry-monitor.ts index 1d018d7046..5abfbf7edb 100644 --- a/packages/engine/src/notification/oauth-expiry-monitor.ts +++ b/packages/engine/src/notification/oauth-expiry-monitor.ts @@ -6,6 +6,9 @@ import type { NotificationService } from "./notification-service.js"; const DEFAULT_INTERVAL_MS = 5 * 60_000; const DEFAULT_MIN_NOTIFY_INTERVAL_MS = 12 * 60 * 60 * 1000; +const ANTHROPIC_OAUTH_PROVIDER_ID = "anthropic"; +const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription"; + interface OAuthProviderInfo { id: string; name: string; @@ -22,6 +25,28 @@ export interface AuthStorageLike { get?(providerId: string): OAuthCredential | undefined; } +/* +FNXC:ClaudeOAuth 2026-07-08-20:55: +`getOAuthProviders()` is NOT aliased by the engine auth-storage proxy, so it only yields the base id `anthropic`. But the Anthropic subscription token the runtime actually uses/refreshes lives under `anthropic-subscription`, while `get("anthropic")` can still return a STALE legacy row (e.g. a months-old credential in ~/.pi/agent/auth.json). Evaluating `get("anthropic")` alone made the expiry monitor and validity logger fire a false "Anthropic OAuth expired" alert even though the subscription token had refreshed successfully. Resolve the FRESHEST of the two aliased ids so a live subscription token suppresses the false alert — mirroring the refresh scheduler's alias handling (getRefreshCandidateIds in oauth-refresh-scheduler.ts). +*/ +export function resolveEffectiveOAuthCredential( + authStorage: AuthStorageLike, + providerId: string, +): OAuthCredential | undefined { + const direct = authStorage.get?.(providerId); + if (providerId !== ANTHROPIC_OAUTH_PROVIDER_ID) { + return direct; + } + const subscription = authStorage.get?.(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID); + const candidates = [direct, subscription].filter( + (c): c is OAuthCredential => c?.type === "oauth" && typeof c.expires === "number", + ); + if (candidates.length === 0) { + return direct; + } + return candidates.reduce((latest, c) => (c.expires! > latest.expires! ? c : latest)); +} + export interface OAuthExpiryMonitorOptions { authStorage: AuthStorageLike; notificationService: NotificationService; @@ -84,7 +109,7 @@ export class OAuthExpiryMonitor { const activeExpiryKeys = new Set(); for (const provider of providers) { - const credential = this.opts.authStorage.get?.(provider.id); + const credential = resolveEffectiveOAuthCredential(this.opts.authStorage, provider.id); if (credential?.type !== "oauth" || typeof credential.expires !== "number") { this.alertState.clear([provider.id]); continue; diff --git a/packages/engine/src/notification/oauth-validity-logger.ts b/packages/engine/src/notification/oauth-validity-logger.ts index b236c6aebe..a86f12f9de 100644 --- a/packages/engine/src/notification/oauth-validity-logger.ts +++ b/packages/engine/src/notification/oauth-validity-logger.ts @@ -1,5 +1,5 @@ import { schedulerLog } from "../logger.js"; -import type { AuthStorageLike } from "./oauth-expiry-monitor.js"; +import { resolveEffectiveOAuthCredential, type AuthStorageLike } from "./oauth-expiry-monitor.js"; import { OAuthAlertStateStore } from "./oauth-alert-state.js"; const DEFAULT_INTERVAL_MS = 12 * 60 * 60 * 1000; @@ -57,7 +57,10 @@ export class OAuthValidityLogger { for (const provider of providers) { try { - const credential = this.opts.authStorage.get?.(provider.id); + // FNXC:ClaudeOAuth 2026-07-08-20:55: evaluate the freshest aliased Anthropic + // credential (anthropic / anthropic-subscription) so a stale legacy row does not + // log a false expiry while the subscription token is fresh. See the monitor. + const credential = resolveEffectiveOAuthCredential(this.opts.authStorage, provider.id); if (credential?.type !== "oauth" || typeof credential.expires !== "number") { continue; }