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();
|
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 () => {
|
it("does not fire for non-expired/non-oauth credentials", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const dispatch = vi.fn(async () => undefined);
|
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_INTERVAL_MS = 5 * 60_000;
|
||||||
const DEFAULT_MIN_NOTIFY_INTERVAL_MS = 12 * 60 * 60 * 1000;
|
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 {
|
interface OAuthProviderInfo {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -22,6 +25,28 @@ export interface AuthStorageLike {
|
|||||||
get?(providerId: string): OAuthCredential | undefined;
|
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 {
|
export interface OAuthExpiryMonitorOptions {
|
||||||
authStorage: AuthStorageLike;
|
authStorage: AuthStorageLike;
|
||||||
notificationService: NotificationService;
|
notificationService: NotificationService;
|
||||||
@@ -84,7 +109,7 @@ export class OAuthExpiryMonitor {
|
|||||||
const activeExpiryKeys = new Set<string>();
|
const activeExpiryKeys = new Set<string>();
|
||||||
|
|
||||||
for (const provider of providers) {
|
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") {
|
if (credential?.type !== "oauth" || typeof credential.expires !== "number") {
|
||||||
this.alertState.clear([provider.id]);
|
this.alertState.clear([provider.id]);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { schedulerLog } from "../logger.js";
|
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";
|
import { OAuthAlertStateStore } from "./oauth-alert-state.js";
|
||||||
|
|
||||||
const DEFAULT_INTERVAL_MS = 12 * 60 * 60 * 1000;
|
const DEFAULT_INTERVAL_MS = 12 * 60 * 60 * 1000;
|
||||||
@@ -57,7 +57,10 @@ export class OAuthValidityLogger {
|
|||||||
|
|
||||||
for (const provider of providers) {
|
for (const provider of providers) {
|
||||||
try {
|
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") {
|
if (credential?.type !== "oauth" || typeof credential.expires !== "number") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user