fix(sync): merge auth providers across all candidate files
readStoredAuthProvidersFromDisk() previously returned only the first successfully-parsed auth file, missing providers that existed only in fallback locations (e.g. github-copilot in ~/.pi/agent/auth.json when another provider was in ~/.fusion/agent/auth.json). Now iterates all candidates and merges entries with first-found-wins priority.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
// Mock getAuthFileCandidates to control which paths are checked
|
||||
const mockAuthCandidates: string[] = [];
|
||||
vi.mock("../auth-paths.js", () => ({
|
||||
getAuthFileCandidates: () => mockAuthCandidates,
|
||||
}));
|
||||
|
||||
// Re-import after mock
|
||||
const { readStoredAuthProvidersFromDisk } = await import(
|
||||
"../routes/register-settings-sync-helpers.js"
|
||||
);
|
||||
|
||||
describe("readStoredAuthProvidersFromDisk", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = join(tmpdir(), `fn-sync-helpers-test-${Date.now()}`);
|
||||
await mkdir(tempDir, { recursive: true });
|
||||
mockAuthCandidates.length = 0;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns empty object when no auth files exist", async () => {
|
||||
mockAuthCandidates.push(join(tempDir, "nonexistent.json"));
|
||||
const result = await readStoredAuthProvidersFromDisk();
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("reads providers from a single auth file", async () => {
|
||||
const authPath = join(tempDir, "auth.json");
|
||||
await writeFile(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
openrouter: { type: "api_key", key: "sk-or-123" },
|
||||
}),
|
||||
);
|
||||
mockAuthCandidates.push(authPath);
|
||||
|
||||
const result = await readStoredAuthProvidersFromDisk();
|
||||
expect(result).toEqual({
|
||||
openrouter: { type: "api_key", key: "sk-or-123" },
|
||||
});
|
||||
});
|
||||
|
||||
it("merges providers across multiple auth files", async () => {
|
||||
const fusionAuth = join(tempDir, "fusion-auth.json");
|
||||
const piAuth = join(tempDir, "pi-auth.json");
|
||||
|
||||
await writeFile(
|
||||
fusionAuth,
|
||||
JSON.stringify({
|
||||
openrouter: { type: "api_key", key: "sk-or-fusion" },
|
||||
github: { type: "oauth", access: "gh-fusion-token" },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
piAuth,
|
||||
JSON.stringify({
|
||||
minimax: { type: "api_key", key: "mm-pi-key" },
|
||||
github: { type: "oauth", access: "gh-pi-token" },
|
||||
}),
|
||||
);
|
||||
|
||||
mockAuthCandidates.push(fusionAuth, piAuth);
|
||||
const result = await readStoredAuthProvidersFromDisk();
|
||||
|
||||
expect(result).toEqual({
|
||||
openrouter: { type: "api_key", key: "sk-or-fusion" },
|
||||
github: { type: "oauth", access: "gh-fusion-token" },
|
||||
minimax: { type: "api_key", key: "mm-pi-key" },
|
||||
});
|
||||
});
|
||||
|
||||
it("gives priority to first-found file for duplicate providers", async () => {
|
||||
const firstAuth = join(tempDir, "first.json");
|
||||
const secondAuth = join(tempDir, "second.json");
|
||||
|
||||
await writeFile(
|
||||
firstAuth,
|
||||
JSON.stringify({ openrouter: { type: "api_key", key: "first-key" } }),
|
||||
);
|
||||
await writeFile(
|
||||
secondAuth,
|
||||
JSON.stringify({ openrouter: { type: "api_key", key: "second-key" } }),
|
||||
);
|
||||
|
||||
mockAuthCandidates.push(firstAuth, secondAuth);
|
||||
const result = await readStoredAuthProvidersFromDisk();
|
||||
|
||||
expect(result.openrouter).toEqual({ type: "api_key", key: "first-key" });
|
||||
});
|
||||
|
||||
it("skips invalid JSON files and continues to next candidate", async () => {
|
||||
const badAuth = join(tempDir, "bad.json");
|
||||
const goodAuth = join(tempDir, "good.json");
|
||||
|
||||
await writeFile(badAuth, "not valid json{{{");
|
||||
await writeFile(
|
||||
goodAuth,
|
||||
JSON.stringify({ openrouter: { type: "api_key", key: "sk-good" } }),
|
||||
);
|
||||
|
||||
mockAuthCandidates.push(badAuth, goodAuth);
|
||||
const result = await readStoredAuthProvidersFromDisk();
|
||||
|
||||
expect(result).toEqual({
|
||||
openrouter: { type: "api_key", key: "sk-good" },
|
||||
});
|
||||
});
|
||||
|
||||
it("picks up github-copilot from fallback when not in primary", async () => {
|
||||
const fusionAuth = join(tempDir, "fusion-auth.json");
|
||||
const piAuth = join(tempDir, "pi-auth.json");
|
||||
|
||||
await writeFile(
|
||||
fusionAuth,
|
||||
JSON.stringify({
|
||||
openrouter: { type: "api_key", key: "sk-or-fusion" },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
piAuth,
|
||||
JSON.stringify({
|
||||
"github-copilot": { type: "oauth", access: "copilot-token", refresh: "ghu_refresh", expires: 9999999999999 },
|
||||
}),
|
||||
);
|
||||
|
||||
mockAuthCandidates.push(fusionAuth, piAuth);
|
||||
const result = await readStoredAuthProvidersFromDisk();
|
||||
|
||||
expect(result).toEqual({
|
||||
openrouter: { type: "api_key", key: "sk-or-fusion" },
|
||||
"github-copilot": { type: "oauth", access: "copilot-token", refresh: "ghu_refresh", expires: 9999999999999 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,15 +4,19 @@ import { ApiError } from "../api-error.js";
|
||||
import { getAuthFileCandidates, type StoredAuthProvider } from "../auth-paths.js";
|
||||
|
||||
export async function readStoredAuthProvidersFromDisk(): Promise<Record<string, StoredAuthProvider>> {
|
||||
const merged: Record<string, StoredAuthProvider> = {};
|
||||
for (const authJsonPath of getAuthFileCandidates()) {
|
||||
try {
|
||||
const authContent = await fsReadFile(authJsonPath, "utf-8");
|
||||
return JSON.parse(authContent) as Record<string, StoredAuthProvider>;
|
||||
const parsed = JSON.parse(authContent) as Record<string, StoredAuthProvider>;
|
||||
for (const [provider, credential] of Object.entries(parsed)) {
|
||||
merged[provider] ??= credential;
|
||||
}
|
||||
} catch {
|
||||
// Try next candidate.
|
||||
}
|
||||
}
|
||||
return {};
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user