fix(FN-1952): read legacy pi auth keys

This commit is contained in:
gsxdsm
2026-04-16 20:23:48 -07:00
parent 5adb013c4e
commit 95ced20e40
8 changed files with 176 additions and 21 deletions

View File

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": patch
---
Read API keys from legacy Pi auth files while continuing to write new credentials to Fusion auth storage.

View File

@@ -444,7 +444,7 @@ describe("runDashboard — AuthStorage & ModelRegistry wiring", () => {
]);
});
it("creates AuthStorage via AuthStorage.create()", async () => {
it("creates AuthStorage for Fusion writes", async () => {
const { AuthStorage } = await import("@mariozechner/pi-coding-agent");
await runDashboard(0, {});
@@ -938,4 +938,3 @@ describe("runDashboard — multi-project cwd/default engine resolution", () => {
expect(serverOpts.engine).toBeDefined();
});
});

View File

@@ -5,3 +5,9 @@ export function getFusionAuthPath(home = process.env.HOME || process.env.USERPRO
return join(home, ".fusion", "agent", "auth.json");
}
export function getLegacyAuthPaths(home = process.env.HOME || process.env.USERPROFILE || homedir()): string[] {
return [
join(home, ".pi", "agent", "auth.json"),
join(home, ".pi", "auth.json"),
];
}

View File

@@ -37,8 +37,8 @@ import {
} from "./task-lifecycle.js";
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath } from "./auth-paths.js";
import { createReadOnlyAuthFileStorage, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath, getLegacyAuthPaths } from "./auth-paths.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let daemonStartTime = 0;
@@ -327,6 +327,8 @@ export async function runDaemon(opts: DaemonOptions = {}) {
const authStorage = AuthStorage.create(getFusionAuthPath());
const modelRegistry = new ModelRegistry(authStorage);
const legacyAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths());
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry, [legacyAuthStorage]);
// PackageManager may be used for skills adapter even if extension loading fails
let packageManager: DefaultPackageManager | undefined;
@@ -376,8 +378,6 @@ export async function runDaemon(opts: DaemonOptions = {}) {
modelRegistry.refresh();
}
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry);
// ── Skills adapter for skills discovery and execution toggling ─────────────
const skillsAdapter = packageManager
? createSkillsAdapter({

View File

@@ -9,8 +9,8 @@ import {
} from "./task-lifecycle.js";
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView, createProjectSettingsPersistence } from "./provider-settings.js";
import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath } from "./auth-paths.js";
import { createReadOnlyAuthFileStorage, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath, getLegacyAuthPaths } from "./auth-paths.js";
// Re-export for backward compatibility with tests
export { promptForPort };
@@ -364,6 +364,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// tab (login/logout) and Model selector.
const authStorage = AuthStorage.create(getFusionAuthPath());
const modelRegistry = new ModelRegistry(authStorage);
const legacyAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths());
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry, [legacyAuthStorage]);
// PackageManager may be used for skills adapter even if extension loading fails
let packageManager: DefaultPackageManager | undefined;
@@ -409,7 +411,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
try {
const settings = await store.getSettings();
if (settings.openrouterModelSync === false) return;
const hasOrAuth = await authStorage.getApiKey("openrouter");
const hasOrAuth = await dashboardAuthStorage.getApiKey("openrouter");
const headers: Record<string, string> = {};
if (hasOrAuth) headers["Authorization"] = `Bearer ${hasOrAuth}`;
const res = await fetch("https://openrouter.ai/api/v1/models", { headers });
@@ -450,8 +452,6 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
modelRegistry.refresh();
}
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry);
// ── Skills adapter for skills discovery and execution toggling ─────────────
//
// Create the skills adapter using the same DefaultPackageManager instance

View File

@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from "vitest";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createReadOnlyAuthFileStorage, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
function makeAuthStorage(credentials: Record<string, { type: string; key?: string }> = {}) {
return {
reload: vi.fn(),
getOAuthProviders: vi.fn(() => []),
hasAuth: vi.fn((provider: string) => Boolean(credentials[provider])),
login: vi.fn(),
logout: vi.fn(),
set: vi.fn((provider: string, credential: { type: string; key?: string }) => {
credentials[provider] = credential;
}),
remove: vi.fn((provider: string) => {
delete credentials[provider];
}),
get: vi.fn((provider: string) => credentials[provider]),
getApiKey: vi.fn(async (provider: string) => credentials[provider]?.key),
} as any;
}
describe("wrapAuthStorageWithApiKeyProviders", () => {
it("reads API keys from Fusion auth first and legacy auth fallbacks second", async () => {
const fusionAuth = makeAuthStorage({
openrouter: { type: "api_key", key: "fusion-key" },
});
const legacyAuth = makeAuthStorage({
openrouter: { type: "api_key", key: "legacy-openrouter-key" },
minimax: { type: "api_key", key: "legacy-minimax-key" },
});
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry, [legacyAuth]);
expect(await wrapped.getApiKey("openrouter")).toBe("fusion-key");
expect(await wrapped.getApiKey("minimax")).toBe("legacy-minimax-key");
expect(wrapped.hasApiKey("minimax")).toBe(true);
expect(wrapped.get("minimax")).toEqual({ type: "api_key", key: "legacy-minimax-key" });
});
it("writes API keys only to Fusion auth storage", () => {
const fusionAuth = makeAuthStorage();
const legacyAuth = makeAuthStorage({
openrouter: { type: "api_key", key: "legacy-key" },
});
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry, [legacyAuth]);
wrapped.setApiKey("openrouter", "fusion-key");
expect(fusionAuth.set).toHaveBeenCalledWith("openrouter", { type: "api_key", key: "fusion-key" });
expect(legacyAuth.set).not.toHaveBeenCalled();
});
it("reloads all read stores so status reflects both locations", () => {
const fusionAuth = makeAuthStorage();
const legacyAuth = makeAuthStorage();
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry, [legacyAuth]);
wrapped.reload();
expect(fusionAuth.reload).toHaveBeenCalledTimes(1);
expect(legacyAuth.reload).toHaveBeenCalledTimes(1);
});
it("reads legacy auth JSON without creating missing files", async () => {
const tempDir = join(tmpdir(), `fusion-provider-auth-${process.pid}-${Date.now()}`);
const legacyAgentDir = join(tempDir, ".pi", "agent");
const legacyAgentAuth = join(legacyAgentDir, "auth.json");
const missingLegacyAuth = join(tempDir, ".pi", "auth.json");
mkdirSync(legacyAgentDir, { recursive: true });
writeFileSync(legacyAgentAuth, JSON.stringify({ openrouter: { type: "api_key", key: "legacy-key" } }));
const storage = createReadOnlyAuthFileStorage([legacyAgentAuth, missingLegacyAuth]);
expect(await storage.getApiKey("openrouter")).toBe("legacy-key");
expect(existsSync(missingLegacyAuth)).toBe(false);
});
});

View File

@@ -1,3 +1,4 @@
import { existsSync, readFileSync } from "node:fs";
import type {
AuthStorage,
ModelRegistry,
@@ -19,6 +20,13 @@ export interface DashboardAuthStorage {
get(providerId: string): { type?: string; key?: string } | undefined;
}
interface ReadFallbackAuthStorage {
reload(): void;
hasAuth(provider: string): boolean;
getApiKey(providerId: string): Promise<string | undefined>;
get(providerId: string): { type?: string; key?: string } | undefined;
}
const BUILT_IN_API_KEY_PROVIDERS: Array<{ id: string; name: string }> = [
{ id: "kimi-coding", name: "Kimi" },
{ id: "minimax", name: "Minimax" },
@@ -44,14 +52,28 @@ function getProviderDisplayName(providerId: string): string {
export function wrapAuthStorageWithApiKeyProviders(
authStorage: AuthStorage,
modelRegistry: ModelRegistry,
readFallbackAuthStorages: ReadFallbackAuthStorage[] = [],
): DashboardAuthStorage {
const readAuthStorages = [authStorage, ...readFallbackAuthStorages];
const getCredential = (providerId: string) => {
for (const storage of readAuthStorages) {
const credential = storage.get(providerId);
if (credential) return credential;
}
return undefined;
};
return {
reload: () => authStorage.reload(),
reload: () => {
for (const storage of readAuthStorages) {
storage.reload();
}
},
getOAuthProviders: () =>
authStorage
.getOAuthProviders()
.map((provider) => ({ id: provider.id, name: provider.name })),
hasAuth: (provider) => authStorage.hasAuth(provider),
hasAuth: (provider) => readAuthStorages.some((storage) => storage.hasAuth(provider)),
login: (providerId, callbacks) =>
authStorage.login(providerId as Parameters<AuthStorage["login"]>[0], callbacks),
logout: (provider) => authStorage.logout(provider),
@@ -86,10 +108,50 @@ export function wrapAuthStorageWithApiKeyProviders(
authStorage.remove(providerId);
},
hasApiKey: (providerId) => {
const credential = authStorage.get(providerId);
const credential = getCredential(providerId);
return credential?.type === "api_key" && !!credential.key;
},
getApiKey: (providerId) => authStorage.getApiKey(providerId),
get: (providerId) => authStorage.get(providerId),
getApiKey: async (providerId) => {
for (const storage of readAuthStorages) {
const apiKey = await storage.getApiKey(providerId);
if (apiKey) return apiKey;
}
return undefined;
},
get: getCredential,
};
}
export function createReadOnlyAuthFileStorage(authPaths: string[]): ReadFallbackAuthStorage {
let credentials: Record<string, { type?: string; key?: string }> = {};
const reload = () => {
const nextCredentials: Record<string, { type?: string; key?: string }> = {};
for (const authPath of authPaths) {
if (!existsSync(authPath)) {
continue;
}
try {
const parsed = JSON.parse(readFileSync(authPath, "utf-8")) as Record<string, { type?: string; key?: string }>;
for (const [provider, credential] of Object.entries(parsed)) {
nextCredentials[provider] ??= credential;
}
} catch {
// Ignore unreadable legacy auth files and continue with other candidates.
}
}
credentials = nextCredentials;
};
reload();
return {
reload,
hasAuth: (provider) => Boolean(credentials[provider]),
get: (provider) => credentials[provider],
getApiKey: async (provider) => {
const credential = credentials[provider];
return credential?.type === "api_key" ? credential.key : undefined;
},
};
}

View File

@@ -38,8 +38,8 @@ import {
} from "./task-lifecycle.js";
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView, createProjectSettingsPersistence } from "./provider-settings.js";
import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath } from "./auth-paths.js";
import { createReadOnlyAuthFileStorage, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath, getLegacyAuthPaths } from "./auth-paths.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null;
@@ -386,6 +386,8 @@ export async function runServe(
const authStorage = AuthStorage.create(getFusionAuthPath());
const modelRegistry = new ModelRegistry(authStorage);
const legacyAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths());
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry, [legacyAuthStorage]);
// PackageManager may be used for skills adapter even if extension loading fails
let packageManager: DefaultPackageManager | undefined;
@@ -433,7 +435,7 @@ export async function runServe(
try {
const settings = await store.getSettings();
if (settings.openrouterModelSync === false) return;
const hasOrAuth = await authStorage.getApiKey("openrouter");
const hasOrAuth = await dashboardAuthStorage.getApiKey("openrouter");
const headers: Record<string, string> = {};
if (hasOrAuth) headers["Authorization"] = `Bearer ${hasOrAuth}`;
const res = await fetch("https://openrouter.ai/api/v1/models", {
@@ -513,8 +515,6 @@ export async function runServe(
modelRegistry.refresh();
}
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry);
// ── Daemon token resolution ─────────────────────────────────────────────
//
// When --daemon flag is set, resolve the daemon token using the same