fix: stop false Anthropic OAuth expiry notifications when token is valid

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`.
That fired a false "Anthropic OAuth expired" notification while the real
subscription token had refreshed successfully.

Both surfaces 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 throttle/cadence unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-08 20:59:01 -07:00
parent dcfbee9ae6
commit 409de31e57
4 changed files with 116 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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