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:
7
.changeset/anthropic-oauth-false-expiry-notification.md
Normal file
7
.changeset/anthropic-oauth-false-expiry-notification.md
Normal 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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user