feat(FN-3434): add Claude OAuth credential interop with verification and st
This merge lands four major features: the desktop app gains shell onboarding with remote mode support via a new `DesktopModeChooser` and `shell-settings` module (FN-3399); the dashboard gains full archived insights support with the `InsightsView` redesign and `useInsights` hook overhaul (FN-3315); C Fusion-Task-Id: FN-3434
This commit is contained in:
@@ -148,8 +148,8 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
|
||||
expect(await storage.getApiKey("openai-codex")).toBe("legacy-access-token");
|
||||
});
|
||||
|
||||
describe("Anthropic reclassification from OAuth to API key", () => {
|
||||
it("filters anthropic out of getOAuthProviders even when upstream reports it as OAuth", () => {
|
||||
describe("Anthropic provider classification", () => {
|
||||
it("keeps anthropic in getOAuthProviders when upstream reports it as OAuth", () => {
|
||||
const fusionAuth = makeAuthStorage();
|
||||
fusionAuth.getOAuthProviders = vi.fn(() => [
|
||||
{ id: "anthropic", name: "Anthropic" },
|
||||
@@ -161,11 +161,11 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
|
||||
const oauthProviders = wrapped.getOAuthProviders();
|
||||
|
||||
const oauthIds = oauthProviders.map((p) => p.id);
|
||||
expect(oauthIds).not.toContain("anthropic");
|
||||
expect(oauthIds).toContain("anthropic");
|
||||
expect(oauthIds).toContain("github-copilot");
|
||||
});
|
||||
|
||||
it("includes anthropic in getApiKeyProviders with correct display name", () => {
|
||||
it("does not duplicate anthropic in getApiKeyProviders when OAuth-backed", () => {
|
||||
const fusionAuth = makeAuthStorage();
|
||||
fusionAuth.getOAuthProviders = vi.fn(() => [
|
||||
{ id: "anthropic", name: "Anthropic" },
|
||||
@@ -176,8 +176,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
|
||||
const apiKeyProviders = wrapped.getApiKeyProviders();
|
||||
|
||||
const anthropic = apiKeyProviders.find((p) => p.id === "anthropic");
|
||||
expect(anthropic).toBeDefined();
|
||||
expect(anthropic!.name).toBe("Anthropic");
|
||||
expect(anthropic).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores anthropic credentials as api_key type", () => {
|
||||
|
||||
@@ -18,6 +18,13 @@ export function getCodexCliAuthPath(home = process.env.HOME || process.env.USERP
|
||||
return join(home, ".codex", "auth.json");
|
||||
}
|
||||
|
||||
export function getClaudeCodeCredentialPaths(home = process.env.HOME || process.env.USERPROFILE || homedir()): string[] {
|
||||
return [
|
||||
join(home, ".claude", ".credentials.json"),
|
||||
join(home, ".config", "claude", ".credentials.json"),
|
||||
];
|
||||
}
|
||||
|
||||
export function getLegacyAuthPaths(home = process.env.HOME || process.env.USERPROFILE || homedir()): string[] {
|
||||
return [
|
||||
join(home, ".pi", "agent", "auth.json"),
|
||||
|
||||
@@ -61,7 +61,7 @@ import {
|
||||
} from "./llama-cpp-extension.js";
|
||||
import { resolveSelfExtension } from "./self-extension.js";
|
||||
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||
import { getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
|
||||
import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js";
|
||||
import { syncStartupModels } from "./startup-model-sync.js";
|
||||
@@ -424,6 +424,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
const supplementalAuthStorage = createReadOnlyAuthFileStorage([
|
||||
...getLegacyAuthPaths(),
|
||||
getCodexCliAuthPath(),
|
||||
...getClaudeCodeCredentialPaths(),
|
||||
]);
|
||||
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]);
|
||||
const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath());
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
import { promptForPort } from "./port-prompt.js";
|
||||
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
|
||||
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||
import { getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
|
||||
import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
import {
|
||||
ensureClaudeSkillsForAllProjectsOnStartup,
|
||||
@@ -1220,6 +1220,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
const supplementalAuthStorage = createReadOnlyAuthFileStorage([
|
||||
...getLegacyAuthPaths(),
|
||||
getCodexCliAuthPath(),
|
||||
...getClaudeCodeCredentialPaths(),
|
||||
]);
|
||||
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]);
|
||||
const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath());
|
||||
|
||||
@@ -41,18 +41,7 @@ interface ReadFallbackAuthStorage {
|
||||
|
||||
type StoredCredential = StoredAuthCredential;
|
||||
|
||||
/**
|
||||
* Provider IDs that should be treated as OAuth-backed by the upstream
|
||||
* pi-coding-agent AuthStorage but which Fusion reclassifies as API-key
|
||||
* providers. These IDs are stripped from getOAuthProviders() results so
|
||||
* the dashboard never offers a browser-based OAuth login for them.
|
||||
*/
|
||||
const OAUTH_TO_API_KEY_RECLASSIFICATIONS: ReadonlySet<string> = new Set([
|
||||
"anthropic",
|
||||
]);
|
||||
|
||||
const BUILT_IN_API_KEY_PROVIDERS: Array<{ id: string; name: string }> = [
|
||||
{ id: "anthropic", name: "Anthropic" },
|
||||
{ id: "brave", name: "Brave Search" },
|
||||
{ id: "kimi-coding", name: "Kimi" },
|
||||
{ id: "minimax", name: "Minimax" },
|
||||
@@ -90,7 +79,6 @@ export function wrapAuthStorageWithApiKeyProviders(
|
||||
getOAuthProviders: () =>
|
||||
mergedAuthStorage
|
||||
.getOAuthProviders()
|
||||
.filter((provider) => !OAUTH_TO_API_KEY_RECLASSIFICATIONS.has(provider.id))
|
||||
.map((provider) => ({ id: provider.id, name: provider.name })),
|
||||
hasAuth: (provider) => mergedAuthStorage.hasAuth(provider),
|
||||
login: (providerId, callbacks) =>
|
||||
@@ -100,12 +88,9 @@ export function wrapAuthStorageWithApiKeyProviders(
|
||||
),
|
||||
logout: (provider) => mergedAuthStorage.logout(provider),
|
||||
getApiKeyProviders: () => {
|
||||
// Use the reclassified (filtered) OAuth provider list so that providers
|
||||
// moved to API-key (e.g. anthropic) are not skipped by the OAuth dedup.
|
||||
const oauthProviderIds = new Set(
|
||||
mergedAuthStorage
|
||||
.getOAuthProviders()
|
||||
.filter((provider) => !OAUTH_TO_API_KEY_RECLASSIFICATIONS.has(provider.id))
|
||||
.map((provider) => provider.id),
|
||||
);
|
||||
const providers = new Map<string, string>();
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
import { promptForPort } from "./port-prompt.js";
|
||||
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
|
||||
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||
import { getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
|
||||
import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
import {
|
||||
ensureClaudeSkillsForAllProjectsOnStartup,
|
||||
@@ -487,6 +487,7 @@ export async function runServe(
|
||||
const supplementalAuthStorage = createReadOnlyAuthFileStorage([
|
||||
...getLegacyAuthPaths(),
|
||||
getCodexCliAuthPath(),
|
||||
...getClaudeCodeCredentialPaths(),
|
||||
]);
|
||||
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]);
|
||||
const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath());
|
||||
|
||||
@@ -4,7 +4,9 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
choosePreferredStoredCredential,
|
||||
extractClaudeCliStoredCredential,
|
||||
extractCodexCliStoredCredential,
|
||||
getClaudeCodeCredentialPaths,
|
||||
readStoredCredentialsFromAuthFile,
|
||||
shouldHydrateStoredCredential,
|
||||
} from "../oauth-credential-interop.js";
|
||||
@@ -86,6 +88,59 @@ describe("oauth credential interop", () => {
|
||||
expect(shouldHydrateStoredCredential({ type: "api_key", key: "sk-live" }, valid)).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts Claude OAuth credentials from .credentials.json payload", () => {
|
||||
const credential = extractClaudeCliStoredCredential({
|
||||
claudeAiOauth: {
|
||||
accessToken: "claude-access",
|
||||
refreshToken: "claude-refresh",
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(credential).toEqual({
|
||||
type: "oauth",
|
||||
access: "claude-access",
|
||||
refresh: "claude-refresh",
|
||||
expires: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("reads Claude credentials from auth file as anthropic OAuth", () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "fusion-oauth-interop-"));
|
||||
|
||||
try {
|
||||
const authPath = join(tempDir, ".credentials.json");
|
||||
writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: "claude-access",
|
||||
refreshToken: "claude-refresh",
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(readStoredCredentialsFromAuthFile(authPath)).toEqual({
|
||||
anthropic: {
|
||||
type: "oauth",
|
||||
access: "claude-access",
|
||||
refresh: "claude-refresh",
|
||||
expires: expect.any(Number),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns both supported Claude credential paths", () => {
|
||||
expect(getClaudeCodeCredentialPaths("/tmp/home")).toEqual([
|
||||
"/tmp/home/.claude/.credentials.json",
|
||||
"/tmp/home/.config/claude/.credentials.json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("gracefully ignores malformed auth files", () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "fusion-oauth-interop-"));
|
||||
|
||||
|
||||
@@ -770,7 +770,9 @@ export { ChatStore } from "./chat-store.js";
|
||||
export type { ChatStoreEvents } from "./chat-store.js";
|
||||
export {
|
||||
choosePreferredStoredCredential,
|
||||
extractClaudeCliStoredCredential,
|
||||
extractCodexCliStoredCredential,
|
||||
getClaudeCodeCredentialPaths,
|
||||
getCodexCliAuthPath,
|
||||
readStoredCredentialsFromAuthFile,
|
||||
shouldHydrateStoredCredential,
|
||||
|
||||
@@ -23,6 +23,13 @@ export function getCodexCliAuthPath(home = getHomeDir()): string {
|
||||
return join(home, ".codex", "auth.json");
|
||||
}
|
||||
|
||||
export function getClaudeCodeCredentialPaths(home = getHomeDir()): string[] {
|
||||
return [
|
||||
join(home, ".claude", ".credentials.json"),
|
||||
join(home, ".config", "claude", ".credentials.json"),
|
||||
];
|
||||
}
|
||||
|
||||
function parseJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const [, payload = ""] = token.split(".", 3);
|
||||
@@ -213,6 +220,34 @@ export function extractCodexCliStoredCredential(raw: unknown): StoredAuthCredent
|
||||
};
|
||||
}
|
||||
|
||||
export function extractClaudeCliStoredCredential(raw: unknown): StoredAuthCredential | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const record = raw as Record<string, unknown>;
|
||||
const oauthRecord =
|
||||
record.claudeAiOauth && typeof record.claudeAiOauth === "object" && !Array.isArray(record.claudeAiOauth)
|
||||
? (record.claudeAiOauth as Record<string, unknown>)
|
||||
: record;
|
||||
|
||||
const access = typeof oauthRecord.accessToken === "string" ? oauthRecord.accessToken : undefined;
|
||||
const refresh = typeof oauthRecord.refreshToken === "string" ? oauthRecord.refreshToken : undefined;
|
||||
const expiresRaw = oauthRecord.expiresAt;
|
||||
const expires = typeof expiresRaw === "number" && Number.isFinite(expiresRaw) ? expiresRaw : undefined;
|
||||
|
||||
if (!access || !refresh || expires === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
access,
|
||||
refresh,
|
||||
expires,
|
||||
};
|
||||
}
|
||||
|
||||
export function readStoredCredentialsFromAuthFile(authPath: string): Record<string, StoredAuthCredential> {
|
||||
if (!existsSync(authPath)) {
|
||||
return {};
|
||||
@@ -225,6 +260,11 @@ export function readStoredCredentialsFromAuthFile(authPath: string): Record<stri
|
||||
return { "openai-codex": codexCliCredential };
|
||||
}
|
||||
|
||||
const claudeCliCredential = extractClaudeCliStoredCredential(parsed);
|
||||
if (claudeCliCredential) {
|
||||
return { anthropic: claudeCliCredential };
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -137,8 +137,10 @@ export function MobileNavBar({
|
||||
experimentalFeatures,
|
||||
onOpenNodes,
|
||||
pluginDashboardViews = [],
|
||||
shellConnectionControl,
|
||||
}: MobileNavBarProps) {
|
||||
const mode = useViewportMode();
|
||||
void shellConnectionControl;
|
||||
const [isMoreOpen, setIsMoreOpen] = useState(false);
|
||||
const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false);
|
||||
const [scripts, setScripts] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -572,6 +572,25 @@ describe("GET /auth/status", () => {
|
||||
expect(authStorage.reload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes Anthropic as oauth when auth storage reports it", async () => {
|
||||
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "anthropic", name: "Anthropic" },
|
||||
]);
|
||||
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => provider === "anthropic");
|
||||
|
||||
const res = await GET(buildApp(), "/api/auth/status");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const anthropic = res.body.providers.find((p: any) => p.id === "anthropic");
|
||||
expect(anthropic).toEqual({
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
authenticated: true,
|
||||
type: "oauth",
|
||||
loginInProgress: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes oauth and model-registry-derived API key providers in one response", async () => {
|
||||
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "github-copilot", name: "GitHub Copilot" },
|
||||
|
||||
@@ -139,6 +139,32 @@ describe("createFusionAuthStorage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reads valid Claude OAuth credentials from Claude credential files", async () => {
|
||||
const claudeDir = join(homeDir, ".claude");
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(claudeDir, ".credentials.json"),
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: "claude-access-token",
|
||||
refreshToken: "claude-refresh-token",
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
|
||||
expect(await authStorage.getApiKey("anthropic")).toBe("claude-access-token");
|
||||
expect(authStorage.get("anthropic")).toEqual({
|
||||
type: "oauth",
|
||||
access: "claude-access-token",
|
||||
refresh: "claude-refresh-token",
|
||||
expires: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates newer Codex CLI OAuth credentials into Fusion auth on reload", async () => {
|
||||
const fusionAgentDir = join(homeDir, ".fusion", "agent");
|
||||
const codexDir = join(homeDir, ".codex");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
choosePreferredStoredCredential,
|
||||
getClaudeCodeCredentialPaths,
|
||||
getCodexCliAuthPath,
|
||||
readStoredCredentialsFromAuthFile,
|
||||
shouldHydrateStoredCredential,
|
||||
@@ -38,6 +39,7 @@ function getSupplementalAuthPaths(home = getHomeDir()): string[] {
|
||||
return [
|
||||
...getLegacyAuthPaths(home),
|
||||
getCodexCliAuthPath(home),
|
||||
...getClaudeCodeCredentialPaths(home),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user