27cc5a077b
Centralizing OAuth refresh in AuthStorage (e6893515) introduced five
follow-on bugs surfaced by an audit of the commit; this fixes all of
them and updates the tests that relied on the old refresh seam.
1. packages/ai/src/auth-storage.ts (#tryOAuthCredential):
For built-in providers the path went directly to `getOAuthApiKey`
with the (possibly still-expired) selection.credential when the
pre-refresh at line 2587 caught a transient error. `getOAuthApiKey`
then threw the "expired … must be refreshed via AuthStorage"
precondition error, which the disable classifier matched against
`/expired.*refresh/` and soft-disabled the row. A single network
blip during refresh could permanently kill a still-valid Anthropic /
OpenAI / Gemini-CLI / Copilot credential. Built-in providers now
route through the broker-aware single-flighted
`#refreshOAuthCredential` first, so transient failures surface as
network errors (5-min temp block) instead of definitive auth
failures.
2. packages/ai/src/auth-storage.ts (#fetchUsageUncached):
The usage refresh check only fired once `Date.now() >= expiresAt`,
missing the 60-second skew that `getApiKey` honors. A token
expiring inside the skew window was posted to the usage endpoint
and 401'd mid-flight, briefly hiding quota in the UI. Aligned with
`OAUTH_REFRESH_SKEW_MS`.
3. packages/coding-agent/src/web/search/index.ts (webSearchCustomTool):
The CustomTool counterpart of WebSearchTool dropped sessionId so
SDK callers that opted into `web_search` via toolNames lost
per-session credential stickiness — multi-account users saw the
provider round-robin between searches in the same session. Threads
`ctx.sessionManager.getSessionId()` through to `executeSearch`.
4. packages/coding-agent/src/web/search/providers/perplexity.ts
(findOAuthToken):
`authStorage.getApiKey("perplexity")` returns runtime/config
overrides, stored api_key credentials, OAuth bearers, and env keys.
Filtering only env keys meant a config-pinned `pplx-…` API key was
POSTed to `www.perplexity.ai/rest/sse/perplexity_ask` (the OAuth
endpoint) instead of falling through to
`api.perplexity.ai/chat/completions`, producing 401s. Switched to
`getOAuthAccess` so only true OAuth bearers reach the OAuth
branch; api_key credentials/overrides correctly fall through.
5. packages/ai/scripts/generate-models.ts:
`getOAuthApiKey` was being called directly with possibly-expired
credentials. The new contract throws on expired, the broad catch
swallowed it, and the build silently fell back to bundled models
instead of refreshing. Both helpers now route through
AuthStorage's `getApiKey` / `getOAuthAccess`, which trigger the
full broker-aware refresh pipeline.
Test updates:
- auth-storage-credential-disabled-event.test.ts,
sdk-credential-disabled-bridge.test.ts: the `failOAuthRefresh`
helper used to spy on `getOAuthApiKey` to inject invalid_grant.
With refresh now happening before that helper, the spy never fired.
Switched to spying on `refreshOAuthToken` so the simulated failure
reaches the disable classifier.
- auth-storage-rotation.test.ts: stub `refreshOAuthToken` so the test
doesn't hit a real OAuth endpoint when the seeded credential lands
inside the 60s skew window.
99 lines
3.1 KiB
TypeScript
99 lines
3.1 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import type { UsageProvider } from "@oh-my-pi/pi-ai";
|
|
import * as oauth from "@oh-my-pi/pi-ai/utils/oauth";
|
|
import type { OAuthCredentials } from "@oh-my-pi/pi-ai/utils/oauth/types";
|
|
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
|
import { Snowflake } from "@oh-my-pi/pi-utils";
|
|
|
|
describe("AuthStorage account rotation", () => {
|
|
let tempDir: string;
|
|
let authStorage: AuthStorage;
|
|
let usageExhausted = false;
|
|
|
|
const usageProvider: UsageProvider = {
|
|
id: "openai-codex",
|
|
async fetchUsage(params) {
|
|
const accountId = params.credential.accountId ?? "unknown";
|
|
return {
|
|
provider: "openai-codex",
|
|
fetchedAt: Date.now(),
|
|
limits: [
|
|
{
|
|
id: `requests-${accountId}`,
|
|
label: "Requests",
|
|
scope: { provider: "openai-codex", accountId },
|
|
amount: { unit: "requests", used: usageExhausted ? 100 : 10, limit: 100 },
|
|
status: usageExhausted ? "exhausted" : "ok",
|
|
},
|
|
],
|
|
};
|
|
},
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
tempDir = path.join(os.tmpdir(), `pi-test-auth-rotation-${Snowflake.next()}`);
|
|
fs.mkdirSync(tempDir, { recursive: true });
|
|
usageExhausted = false;
|
|
|
|
authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"), {
|
|
usageProviderResolver: provider => (provider === "openai-codex" ? usageProvider : undefined),
|
|
});
|
|
|
|
// Stub the refresh path so AuthStorage doesn't hit a real OAuth endpoint
|
|
// when the credential lands inside the 60s skew. Returning the credential
|
|
// unchanged preserves the test's deterministic accountId routing.
|
|
vi.spyOn(oauth, "refreshOAuthToken").mockImplementation(async (_provider, credential) => {
|
|
return credential;
|
|
});
|
|
vi.spyOn(oauth, "getOAuthApiKey").mockImplementation(async (_provider, credentials) => {
|
|
const credential = credentials["openai-codex"] as OAuthCredentials | undefined;
|
|
if (!credential) return null;
|
|
return {
|
|
apiKey: `api-${credential.accountId ?? "unknown"}`,
|
|
newCredentials: credential,
|
|
};
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
authStorage.close();
|
|
if (tempDir && fs.existsSync(tempDir)) {
|
|
fs.rmSync(tempDir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
test("returns a fallback key when every OAuth account is usage-limited", async () => {
|
|
await authStorage.set("openai-codex", [
|
|
{
|
|
type: "oauth",
|
|
access: "access-1",
|
|
refresh: "refresh-1",
|
|
expires: Date.now() + 60_000,
|
|
accountId: "acct-1",
|
|
},
|
|
{
|
|
type: "oauth",
|
|
access: "access-2",
|
|
refresh: "refresh-2",
|
|
expires: Date.now() + 60_000,
|
|
accountId: "acct-2",
|
|
},
|
|
]);
|
|
|
|
const sessionId = "issue-55-session";
|
|
const firstKey = await authStorage.getApiKey("openai-codex", sessionId);
|
|
expect(firstKey).toMatch(/^api-acct-/);
|
|
|
|
usageExhausted = true;
|
|
const switched = await authStorage.markUsageLimitReached("openai-codex", sessionId);
|
|
expect(switched).toBe(true);
|
|
|
|
const exhaustedFallbackKey = await authStorage.getApiKey("openai-codex", sessionId);
|
|
expect(exhaustedFallbackKey).toMatch(/^api-acct-/);
|
|
});
|
|
});
|