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:
Fusion
2026-05-04 23:23:50 -07:00
committed by gsxdsm
parent 20fc4f8420
commit d91780d171
18 changed files with 179 additions and 28 deletions

View File

@@ -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", () => {

View File

@@ -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"),

View File

@@ -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());

View File

@@ -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());

View File

@@ -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>();

View File

@@ -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());

View File

@@ -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-"));

View File

@@ -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,

View File

@@ -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 {};
}

View File

@@ -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>>({});

View File

@@ -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" },

View File

@@ -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");

View File

@@ -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),
];
}