Files
fusion/packages/engine/src/auth-storage.ts
Fusion e441398208 feat(FN-3906): add frontend scope helpers for workflow step auto-skip
Adds frontend-UX auto-skip behavior for workflow steps in the executor, with tests covering the flow and documentation of the recovery logic. A changeset is included for this patch release.

Fusion-Task-Id: FN-3906

Fusion-Task-Lineage: 3c50b82b-dd03-4ba3-8d77-5aaabb8734da
2026-05-11 11:21:21 -07:00

311 lines
10 KiB
TypeScript

import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import {
choosePreferredStoredCredential,
getClaudeCodeCredentialPaths,
getCodexCliAuthPath,
readStoredCredentialsFromAuthFile,
shouldHydrateStoredCredential,
type StoredAuthCredential,
} from "@fusion/core";
import { AuthStorage } from "@mariozechner/pi-coding-agent";
import type { AuthCredential } from "@mariozechner/pi-coding-agent";
import { getOAuthProvider } from "@mariozechner/pi-ai/oauth";
import type { OAuthCredentials } from "@mariozechner/pi-ai/oauth";
type StoredCredential = StoredAuthCredential;
function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || homedir();
}
export function getFusionAuthPath(home = getHomeDir()): string {
return join(home, ".fusion", "agent", "auth.json");
}
export function getFusionModelsPath(home = getHomeDir()): string {
return join(home, ".fusion", "agent", "models.json");
}
function getLegacyAuthPaths(home = getHomeDir()): string[] {
return [
join(home, ".pi", "agent", "auth.json"),
join(home, ".pi", "auth.json"),
];
}
function getSupplementalAuthPaths(home = getHomeDir()): string[] {
return [
...getLegacyAuthPaths(home),
getCodexCliAuthPath(home),
...getClaudeCodeCredentialPaths(home),
];
}
function getLegacyModelsPaths(home = getHomeDir()): string[] {
return [
join(home, ".pi", "agent", "models.json"),
join(home, ".pi", "models.json"),
];
}
export function getModelRegistryModelsPath(home = getHomeDir()): string {
const fusionModelsPath = getFusionModelsPath(home);
if (existsSync(fusionModelsPath)) {
return fusionModelsPath;
}
return getLegacyModelsPaths(home).find((modelsPath) => existsSync(modelsPath)) ?? fusionModelsPath;
}
function readSupplementalCredentials(authPaths = getSupplementalAuthPaths()): Record<string, StoredCredential> {
const credentials: Record<string, StoredCredential> = {};
for (const authPath of authPaths) {
const parsed = readStoredCredentialsFromAuthFile(authPath);
for (const [provider, credential] of Object.entries(parsed)) {
credentials[provider] = choosePreferredStoredCredential(credentials[provider], credential) ?? credential;
}
}
return credentials;
}
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;
}
/**
* Reads API keys from the resolved models.json file.
*
* Some providers (e.g., kimi-coding, lmstudio, ollama) store their API keys
* in `models.json` under `providers.<providerId>.apiKey` rather than in
* `auth.json`. This function extracts those keys so the auth storage proxy
* can return them as a fallback when neither Fusion auth nor legacy auth.json
* contains a key for the provider.
*/
function readModelsJsonApiKeys(home = getHomeDir()): Map<string, string> {
const apiKeys = new Map<string, string>();
const modelsPath = getModelRegistryModelsPath(home);
if (!existsSync(modelsPath)) {
return apiKeys;
}
try {
const parsed = JSON.parse(readFileSync(modelsPath, "utf-8")) as {
providers?: Record<string, { apiKey?: string }>;
};
const providers = parsed?.providers;
if (providers) {
for (const [providerId, config] of Object.entries(providers)) {
if (config.apiKey) {
apiKeys.set(providerId, config.apiKey);
}
}
}
} catch {
// Ignore invalid models.json files.
}
return apiKeys;
}
export function createFusionAuthStorage(): AuthStorage {
const primary = AuthStorage.create(getFusionAuthPath());
let supplementalCredentials = readSupplementalCredentials();
// models.json provider API keys — final fallback after primary auth and supplemental auth.json files
let modelsJsonApiKeys = readModelsJsonApiKeys();
// Providers the user has explicitly logged out from. These should not be
// "resurrected" from supplemental credential files (e.g. ~/.claude/.credentials.json).
// Cleared when the user re-authenticates via set().
const loggedOutProviders = new Set<string>();
const syncSupplementalOauthCredentials = () => {
for (const [provider, credential] of Object.entries(supplementalCredentials)) {
if (loggedOutProviders.has(provider)) {
continue;
}
const current = primary.get(provider) as StoredCredential | undefined;
if (!shouldHydrateStoredCredential(current, credential)) {
continue;
}
if (credential.type === "oauth") {
if (typeof credential.expires !== "number" || Date.now() >= credential.expires) {
continue;
}
primary.set(provider, credential as AuthCredential);
continue;
}
if (credential.type === "api_key") {
primary.set(provider, credential as AuthCredential);
}
}
};
syncSupplementalOauthCredentials();
return new Proxy(primary, {
// Forward property writes to the target so that methods like
// `setFallbackResolver` (called by ModelRegistry) correctly update the
// underlying AuthStorage. Without this trap, writes land on the Proxy
// object itself and the target's fallbackResolver stays undefined.
set(target: AuthStorage, prop: string | symbol, value: unknown) {
(target as unknown as Record<string | symbol, unknown>)[prop] = value;
return true;
},
get(target, prop, receiver) {
if (prop === "logout") {
return (provider: string) => {
target.logout(provider);
loggedOutProviders.add(provider);
};
}
if (prop === "remove") {
return (provider: string) => {
target.remove(provider);
loggedOutProviders.add(provider);
};
}
if (prop === "set") {
return (provider: string, credential: AuthCredential) => {
target.set(provider, credential);
loggedOutProviders.delete(provider);
};
}
if (prop === "reload") {
return () => {
target.reload();
supplementalCredentials = readSupplementalCredentials();
syncSupplementalOauthCredentials();
modelsJsonApiKeys = readModelsJsonApiKeys();
};
}
if (prop === "get") {
return (provider: string) => {
if (loggedOutProviders.has(provider)) {
return undefined;
}
return choosePreferredStoredCredential(
target.get(provider) as StoredCredential | undefined,
supplementalCredentials[provider],
);
};
}
if (prop === "has") {
return (provider: string) => {
if (loggedOutProviders.has(provider)) {
return false;
}
return target.has(provider) || provider in supplementalCredentials || modelsJsonApiKeys.has(provider);
};
}
if (prop === "hasAuth") {
return (provider: string) => {
if (loggedOutProviders.has(provider)) {
return false;
}
return target.hasAuth(provider) || Boolean(supplementalCredentials[provider]) || modelsJsonApiKeys.has(provider);
};
}
if (prop === "getAll") {
return () => {
const providerIds = new Set([
...Object.keys(target.getAll() as Record<string, StoredCredential>),
...(loggedOutProviders.size > 0
? Object.keys(supplementalCredentials).filter((p) => !loggedOutProviders.has(p))
: Object.keys(supplementalCredentials)),
]);
const merged: Record<string, StoredCredential> = {};
for (const providerId of providerIds) {
if (loggedOutProviders.has(providerId)) {
continue;
}
const credential = choosePreferredStoredCredential(
(target.get(providerId) as StoredCredential | undefined),
supplementalCredentials[providerId],
);
if (credential) {
merged[providerId] = credential;
}
}
return merged;
};
}
if (prop === "list") {
return () => {
const providers = new Set([...target.list()]);
for (const p of modelsJsonApiKeys.keys()) {
if (!loggedOutProviders.has(p)) {
providers.add(p);
}
}
for (const p of Object.keys(supplementalCredentials)) {
if (!loggedOutProviders.has(p)) {
providers.add(p);
}
}
return Array.from(providers).filter((p) => !loggedOutProviders.has(p));
};
}
if (prop === "getApiKey") {
return async (provider: string) => {
if (loggedOutProviders.has(provider)) {
return undefined;
}
// 1. Primary Fusion auth
const primaryKey = await target.getApiKey(provider);
if (primaryKey) return primaryKey;
// 2. Supplemental auth.json credentials (.pi + .codex)
const supplementalKey = resolveStoredCredentialApiKey(provider, supplementalCredentials[provider]);
if (supplementalKey) return supplementalKey;
// 3. models.json provider API keys (e.g., kimi-coding, lmstudio)
return modelsJsonApiKeys.get(provider);
};
}
return Reflect.get(target, prop, receiver);
},
}) as AuthStorage;
}