diff --git a/.changeset/fix-legacy-oauth-auth.md b/.changeset/fix-legacy-oauth-auth.md new file mode 100644 index 000000000..fe64df75d --- /dev/null +++ b/.changeset/fix-legacy-oauth-auth.md @@ -0,0 +1,5 @@ +--- +"@gsxdsm/fusion": patch +--- + +Read non-expired legacy Pi OAuth credentials when Fusion auth has no matching credential. diff --git a/packages/cli/src/commands/provider-auth.test.ts b/packages/cli/src/commands/provider-auth.test.ts index 144c9b1bb..0094149de 100644 --- a/packages/cli/src/commands/provider-auth.test.ts +++ b/packages/cli/src/commands/provider-auth.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; -function makeAuthStorage(credentials: Record = {}) { +function makeAuthStorage(credentials: Record = {}) { return { reload: vi.fn(), getOAuthProviders: vi.fn(() => []), @@ -99,4 +99,26 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { expect(await storage.getApiKey("openrouter")).toBe("legacy-key"); expect(existsSync(missingLegacyAuth)).toBe(false); }); + + it("reads non-expired OAuth credentials from legacy auth JSON", async () => { + const tempDir = join(tmpdir(), `fusion-provider-auth-oauth-${process.pid}-${Date.now()}`); + const legacyAgentDir = join(tempDir, ".pi", "agent"); + const legacyAgentAuth = join(legacyAgentDir, "auth.json"); + mkdirSync(legacyAgentDir, { recursive: true }); + writeFileSync( + legacyAgentAuth, + JSON.stringify({ + "openai-codex": { + type: "oauth", + access: "legacy-access-token", + refresh: "legacy-refresh-token", + expires: Date.now() + 60_000, + }, + }), + ); + + const storage = createReadOnlyAuthFileStorage([legacyAgentAuth]); + + expect(await storage.getApiKey("openai-codex")).toBe("legacy-access-token"); + }); }); diff --git a/packages/cli/src/commands/provider-auth.ts b/packages/cli/src/commands/provider-auth.ts index 5f9d3a278..1a0792454 100644 --- a/packages/cli/src/commands/provider-auth.ts +++ b/packages/cli/src/commands/provider-auth.ts @@ -3,6 +3,8 @@ import type { AuthStorage, ModelRegistry, } from "@mariozechner/pi-coding-agent"; +import { getOAuthProvider } from "@mariozechner/pi-ai/oauth"; +import type { OAuthCredentials } from "@mariozechner/pi-ai/oauth"; export type LoginCallbacks = Parameters[1]; @@ -24,11 +26,20 @@ interface ReadFallbackAuthStorage { reload(): void; hasAuth(provider: string): boolean; getApiKey(providerId: string): Promise; - get(providerId: string): { type?: string; key?: string } | undefined; - getAll(): Record; + get(providerId: string): StoredCredential | undefined; + getAll(): Record; list(): string[]; } +type StoredCredential = { + type?: string; + key?: string; + access?: string; + refresh?: string; + expires?: number; + [key: string]: unknown; +}; + const BUILT_IN_API_KEY_PROVIDERS: Array<{ id: string; name: string }> = [ { id: "kimi-coding", name: "Kimi" }, { id: "minimax", name: "Minimax" }, @@ -171,17 +182,46 @@ export function mergeAuthStorageReads( }) as AuthStorage; } +function resolveStoredApiKey(key: string | undefined): string | undefined { + if (!key) return undefined; + return process.env[key] ?? key; +} + +function resolveOAuthApiKey(providerId: string, credential: StoredCredential): string | undefined { + if ( + credential.type !== "oauth" || + typeof credential.access !== "string" || + typeof credential.refresh !== "string" || + typeof credential.expires !== "number" || + Date.now() >= credential.expires + ) { + return undefined; + } + + return getOAuthProvider(providerId)?.getApiKey(credential as OAuthCredentials); +} + +function resolveStoredCredentialApiKey(providerId: string, credential: StoredCredential | undefined): string | undefined { + if (credential?.type === "api_key") { + return resolveStoredApiKey(credential.key); + } + if (credential?.type === "oauth") { + return resolveOAuthApiKey(providerId, credential); + } + return undefined; +} + export function createReadOnlyAuthFileStorage(authPaths: string[]): ReadFallbackAuthStorage { - let credentials: Record = {}; + let credentials: Record = {}; const reload = () => { - const nextCredentials: Record = {}; + const nextCredentials: Record = {}; for (const authPath of authPaths) { if (!existsSync(authPath)) { continue; } try { - const parsed = JSON.parse(readFileSync(authPath, "utf-8")) as Record; + const parsed = JSON.parse(readFileSync(authPath, "utf-8")) as Record; for (const [provider, credential] of Object.entries(parsed)) { nextCredentials[provider] ??= credential; } @@ -201,8 +241,7 @@ export function createReadOnlyAuthFileStorage(authPaths: string[]): ReadFallback getAll: () => ({ ...credentials }), list: () => Object.keys(credentials), getApiKey: async (provider) => { - const credential = credentials[provider]; - return credential?.type === "api_key" ? credential.key : undefined; + return resolveStoredCredentialApiKey(provider, credentials[provider]); }, }; } diff --git a/packages/engine/src/auth-storage.test.ts b/packages/engine/src/auth-storage.test.ts index 845f57403..052f24c38 100644 --- a/packages/engine/src/auth-storage.test.ts +++ b/packages/engine/src/auth-storage.test.ts @@ -42,6 +42,46 @@ describe("createFusionAuthStorage", () => { expect(existsSync(getFusionAuthPath(homeDir))).toBe(true); }); + it("reads non-expired legacy Pi OAuth credentials as fallback", async () => { + const legacyAgentDir = join(homeDir, ".pi", "agent"); + mkdirSync(legacyAgentDir, { recursive: true }); + writeFileSync( + join(legacyAgentDir, "auth.json"), + JSON.stringify({ + "openai-codex": { + type: "oauth", + access: "legacy-access-token", + refresh: "legacy-refresh-token", + expires: Date.now() + 60_000, + }, + }), + ); + + const authStorage = createFusionAuthStorage(); + + expect(await authStorage.getApiKey("openai-codex")).toBe("legacy-access-token"); + }); + + it("does not use expired legacy Pi OAuth credentials", async () => { + const legacyAgentDir = join(homeDir, ".pi", "agent"); + mkdirSync(legacyAgentDir, { recursive: true }); + writeFileSync( + join(legacyAgentDir, "auth.json"), + JSON.stringify({ + "openai-codex": { + type: "oauth", + access: "expired-access-token", + refresh: "legacy-refresh-token", + expires: Date.now() - 60_000, + }, + }), + ); + + const authStorage = createFusionAuthStorage(); + + expect(await authStorage.getApiKey("openai-codex")).toBeUndefined(); + }); + it("does not create missing legacy Pi auth files", async () => { const authStorage = createFusionAuthStorage(); diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts index 80f4eaf29..f1ea9f485 100644 --- a/packages/engine/src/auth-storage.ts +++ b/packages/engine/src/auth-storage.ts @@ -2,8 +2,17 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { AuthStorage } from "@mariozechner/pi-coding-agent"; +import { getOAuthProvider } from "@mariozechner/pi-ai/oauth"; +import type { OAuthCredentials } from "@mariozechner/pi-ai/oauth"; -type StoredCredential = { type?: string; key?: string }; +type StoredCredential = { + type?: string; + key?: string; + access?: string; + refresh?: string; + expires?: number; + [key: string]: unknown; +}; function getHomeDir(): string { return process.env.HOME || process.env.USERPROFILE || homedir(); @@ -45,6 +54,30 @@ function resolveStoredApiKey(key: string | undefined): string | undefined { return process.env[key] ?? key; } +function resolveOAuthApiKey(providerId: string, credential: StoredCredential): string | undefined { + if ( + credential.type !== "oauth" || + typeof credential.access !== "string" || + typeof credential.refresh !== "string" || + typeof credential.expires !== "number" || + Date.now() >= credential.expires + ) { + return undefined; + } + + return getOAuthProvider(providerId)?.getApiKey(credential as OAuthCredentials); +} + +function resolveStoredCredentialApiKey(providerId: string, credential: StoredCredential | undefined): string | undefined { + if (credential?.type === "api_key") { + return resolveStoredApiKey(credential.key); + } + if (credential?.type === "oauth") { + return resolveOAuthApiKey(providerId, credential); + } + return undefined; +} + export function createFusionAuthStorage(): AuthStorage { const primary = AuthStorage.create(getFusionAuthPath()); let legacyCredentials = readLegacyCredentials(); @@ -83,8 +116,7 @@ export function createFusionAuthStorage(): AuthStorage { const primaryKey = await target.getApiKey(provider); if (primaryKey) return primaryKey; - const credential = legacyCredentials[provider]; - return credential?.type === "api_key" ? resolveStoredApiKey(credential.key) : undefined; + return resolveStoredCredentialApiKey(provider, legacyCredentials[provider]); }; }