fix(FN-1952): read legacy pi auth keys
This commit is contained in:
5
.changeset/read-legacy-auth-keys.md
Normal file
5
.changeset/read-legacy-auth-keys.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
83
packages/cli/src/commands/provider-auth.test.ts
Normal file
83
packages/cli/src/commands/provider-auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user