feat(auth): let operators choose which Anthropic credential wins

An operator can hold a raw Anthropic API key and a Claude subscription OAuth
login at once, and the raw key always won silently. A stale or revoked saved
key therefore shadowed a working subscription and failed every direct Anthropic
call with 401 invalid x-api-key, while both Settings cards still read Active.

Add the global anthropicAuthPreference setting ("api-key" default, preserving
the historical precedence, or "subscription"), read in resolveAnthropicRuntimeApiKey
straight from ~/.fusion/settings.json so it applies without a restart and needs
no settings plumbing through createFusionAuthStorage. Neither value removes a
source: with one credential configured, resolution reaches it either way.

Settings -> Authentication now names the credential in use on the two Anthropic
cards and renders the control, but only when both are actually connected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-24 22:32:39 -07:00
parent 8156308695
commit 8dc6598aa0
13 changed files with 473 additions and 11 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Choose whether Anthropic lanes use your API key or your Claude subscription, and see which is in use.
category: feature
dev: New global setting `anthropicAuthPreference` ("api-key" | "subscription", default "api-key" — the historical precedence). Read in `resolveAnthropicRuntimeApiKey` (packages/engine/src/auth-storage.ts) straight from `~/.fusion/settings.json`, so it applies without a restart and needs no settings plumbing through `createFusionAuthStorage`. Settings → Authentication renders the control and an "In use" / "Overridden below" marker only when both Anthropic credentials are connected.

View File

@@ -51,6 +51,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
| Setting | Type | Default | Description | | Setting | Type | Default | Description |
|---|---|---:|---| |---|---|---:|---|
| <a id="anthropicauthpreference"></a>`anthropicAuthPreference` | `"api-key" \| "subscription"` | `"api-key"` | Which Anthropic credential wins when BOTH a raw API key and a Claude subscription OAuth login are configured. `"api-key"` keeps the historical precedence; `"subscription"` moves the raw key below the OAuth steps so a stale or revoked saved key cannot shadow a working subscription (the `401 invalid x-api-key` failure mode). Global, because credentials live in the global `~/.fusion/agent/auth.json`. Never removes a source — with one credential configured, resolution reaches it under either value. Surfaced in Settings → Authentication, where the two Anthropic cards show which credential is in use; the control appears only when both are connected. |
| `themeMode` | `"dark" \| "light" \| "system"` | `"system"` | Dashboard theme mode. Fresh installs follow the operating system light/dark preference until the user chooses Light, Dark, or System. | | `themeMode` | `"dark" \| "light" \| "system"` | `"system"` | Dashboard theme mode. Fresh installs follow the operating system light/dark preference until the user chooses Light, Dark, or System. |
| `colorTheme` | `ColorTheme` | `"shadcn-ember"` | Dashboard color theme preset. Aurora is a built-in navy/teal/violet palette with a misty light counterpart; Calm is a low-stimulation slate/sage palette with a misty light counterpart; Dawn uses indigo/plum dark surfaces, amber accents, and dawn-white light surfaces. Use `"shadcn-custom"` to show the separate custom shadcn color picker in Settings → Appearance and the Command Center theme card. | | `colorTheme` | `ColorTheme` | `"shadcn-ember"` | Dashboard color theme preset. Aurora is a built-in navy/teal/violet palette with a misty light counterpart; Calm is a low-stimulation slate/sage palette with a misty light counterpart; Dawn uses indigo/plum dark surfaces, amber accents, and dawn-white light surfaces. Use `"shadcn-custom"` to show the separate custom shadcn color picker in Settings → Appearance and the Command Center theme card. |
| `shadcnCustomColors` | `Record<string, string>` | `undefined` | Optional shadcn design-token override map for `"shadcn-custom"` only. Keys are CSS token names such as `--accent`, `--bg`, `--surface`, `--card`, `--border`, `--text`, `--text-muted`, workflow status tokens, and `--color-success`/`--color-warning`/`--color-error`; values must be sanitized `#RGB` or `#RRGGBB` hex colors. Missing or invalid entries fall back to the `shadcn-custom` base defaults and are not applied to other themes. | | `shadcnCustomColors` | `Record<string, string>` | `undefined` | Optional shadcn design-token override map for `"shadcn-custom"` only. Keys are CSS token names such as `--accent`, `--bg`, `--surface`, `--card`, `--border`, `--text`, `--text-muted`, workflow status tokens, and `--color-success`/`--color-warning`/`--color-error`; values must be sanitized `#RGB` or `#RRGGBB` hex colors. Missing or invalid entries fall back to the `shadcn-custom` base defaults and are not applied to other themes. |
@@ -58,7 +59,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
| `dashboardFontScalePct` | `number` | `100` | Dashboard font scale percentage used by Appearance settings. Valid range: `85` to `125`; applied pre-hydration via document root font-size so board typography (column headers/counts, task cards, and quick-entry text) scales with the setting from first paint. | | `dashboardFontScalePct` | `number` | `100` | Dashboard font scale percentage used by Appearance settings. Valid range: `85` to `125`; applied pre-hydration via document root font-size so board typography (column headers/counts, task cards, and quick-entry text) scales with the setting from first paint. |
| `dismissModalsOnOutsideClick` | `boolean` | `false` | Global dashboard preference for closing fixed modal overlays by clicking/tapping the backdrop. Off by default to prevent accidental modal dismissal; explicit close, cancel, and Escape paths remain available. | | `dismissModalsOnOutsideClick` | `boolean` | `false` | Global dashboard preference for closing fixed modal overlays by clicking/tapping the backdrop. Off by default to prevent accidental modal dismissal; explicit close, cancel, and Escape paths remain available. |
| `skipConfirmationDialogs` | `boolean` | `false` | Global-only operator preference that skips centralized confirmation dialogs for critical actions. When enabled, destructive actions such as deleting a task or resetting progress immediately take the dialog's primary/default action; project settings cannot enable it for shared-project collaborators. | | `skipConfirmationDialogs` | `boolean` | `false` | Global-only operator preference that skips centralized confirmation dialogs for critical actions. When enabled, destructive actions such as deleting a task or resetting progress immediately take the dialog's primary/default action; project settings cannot enable it for shared-project collaborators. |
| `defaultProvider` | `string` | `undefined` | Default AI provider. Anthropic has three independent surfaces, all executing on the direct `anthropic` provider except the CLI: (1) **direct OAuth** — a Claude subscription/OAuth login drives `anthropic/*` selections; Fusion sends the OAuth token to `https://api.anthropic.com/v1` with Claude Code identity headers (the same path the Claude Code CLI uses), so a subscription needs no API key. Credentials live under the `anthropic-subscription` auth/status/usage/banner id but are resolved for the direct provider at runtime; they are never stored or resolved as raw `ANTHROPIC_API_KEY` material. (2) **raw API key** — `ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential uses `x-api-key` on the same direct provider and takes precedence over OAuth. (3) **Claude CLI** — the explicit `pi-claude-cli` model provider runs sessions through the local `claude` CLI. There is no runtime rerouting between these surfaces. | | `defaultProvider` | `string` | `undefined` | Default AI provider. Anthropic has three independent surfaces, all executing on the direct `anthropic` provider except the CLI: (1) **direct OAuth** — a Claude subscription/OAuth login drives `anthropic/*` selections; Fusion sends the OAuth token to `https://api.anthropic.com/v1` with Claude Code identity headers (the same path the Claude Code CLI uses), so a subscription needs no API key. Credentials live under the `anthropic-subscription` auth/status/usage/banner id but are resolved for the direct provider at runtime; they are never stored or resolved as raw `ANTHROPIC_API_KEY` material. (2) **raw API key** — `ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential uses `x-api-key` on the same direct provider and takes precedence over OAuth by default (see [`anthropicAuthPreference`](#anthropicauthpreference) to invert this). (3) **Claude CLI** — the explicit `pi-claude-cli` model provider runs sessions through the local `claude` CLI. There is no runtime rerouting between these surfaces. |
| `defaultModelId` | `string` | `undefined` | Default AI model ID. | | `defaultModelId` | `string` | `undefined` | Default AI model ID. |
| `modelPricingOverrides` | `Record<string, ModelPricing>` | `undefined` | Optional global Command Center pricing overrides keyed by lowercased `provider:model` or bare `:model`. Values store USD per 1M input, output, cache-read, and cache-write tokens plus optional `source`; they override the built-in pricing table for cost estimates only and are editable from Settings → Global Models → View pricing table. | | `modelPricingOverrides` | `Record<string, ModelPricing>` | `undefined` | Optional global Command Center pricing overrides keyed by lowercased `provider:model` or bare `:model`. Values store USD per 1M input, output, cache-read, and cache-write tokens plus optional `source`; they override the built-in pricing table for cost estimates only and are editable from Settings → Global Models → View pricing table. |
| `modelPricingFetchedAt` | `string` | `undefined` | ISO timestamp for the last successful one-click pricing refresh from the Settings → Global Models pricing summary. | | `modelPricingFetchedAt` | `string` | `undefined` | ISO timestamp for the last successful one-click pricing refresh from the Settings → Global Models pricing summary. |
@@ -851,7 +852,7 @@ Anthropic has three independent authentication/routing paths:
- **Anthropic Subscription** (`anthropic-subscription`) is Claude subscription OAuth. It powers login/logout, `/api/auth/status`, usage/subscription checks through `https://api.anthropic.com/api/oauth/usage`, and the OAuth re-login banner when no authenticated Anthropic API-key/CLI fallback is present. Legacy `anthropic` OAuth rows are treated as this subscription surface. It is **also an execution surface**: subscription OAuth resolves for the direct `anthropic` provider at runtime, so `anthropic/*` selections run on `https://api.anthropic.com/v1` with Claude Code identity headers (Bearer OAuth, no API key required), and `anthropic/*` rows appear in the model picker when subscription OAuth is connected. - **Anthropic Subscription** (`anthropic-subscription`) is Claude subscription OAuth. It powers login/logout, `/api/auth/status`, usage/subscription checks through `https://api.anthropic.com/api/oauth/usage`, and the OAuth re-login banner when no authenticated Anthropic API-key/CLI fallback is present. Legacy `anthropic` OAuth rows are treated as this subscription surface. It is **also an execution surface**: subscription OAuth resolves for the direct `anthropic` provider at runtime, so `anthropic/*` selections run on `https://api.anthropic.com/v1` with Claude Code identity headers (Bearer OAuth, no API key required), and `anthropic/*` rows appear in the model picker when subscription OAuth is connected.
- **Claude CLI** (`pi-claude-cli`) is the CLI-backed execution provider. Use it when you want sessions to run through the local `claude` CLI; CLI availability does not prove the subscription OAuth status is valid. It is a separate, explicit choice — subscription OAuth does not require or reroute to it. When Claude CLI is enabled, model selectors show the registered `pi-claude-cli/*` rows (for example `pi-claude-cli/claude-sonnet-5`) in addition to the direct `anthropic/*` rows. - **Claude CLI** (`pi-claude-cli`) is the CLI-backed execution provider. Use it when you want sessions to run through the local `claude` CLI; CLI availability does not prove the subscription OAuth status is valid. It is a separate, explicit choice — subscription OAuth does not require or reroute to it. When Claude CLI is enabled, model selectors show the registered `pi-claude-cli/*` rows (for example `pi-claude-cli/claude-sonnet-5`) in addition to the direct `anthropic/*` rows.
- **Anthropic API Key** (`anthropic` direct `/v1`) is raw API-key auth. It accepts `ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential, uses `x-api-key`, and takes precedence over subscription OAuth when both are configured for the direct `https://api.anthropic.com/v1` provider. - **Anthropic API Key** (`anthropic` direct `/v1`) is raw API-key auth. It accepts `ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential, uses `x-api-key`, and by default takes precedence over subscription OAuth when both are configured for the direct `https://api.anthropic.com/v1` provider. That default is operator-selectable: set the global [`anthropicAuthPreference`](#anthropicauthpreference) to `"subscription"` to make the Claude subscription login win instead — useful when a stale or revoked saved key would otherwise shadow a working subscription and fail with `401 invalid x-api-key`.
Anthropic can be connected with a raw API key from both Model Onboarding and **Settings → Authentication**. Anthropic API-key auth appears as a separate **Anthropic API Key** card, while Claude subscription OAuth appears as **Anthropic Subscription** with Login/Logout controls. On Fusion desktop, Anthropic Subscription OAuth login URLs are delegated to the operating system browser instead of an Electron child window so the existing polling/callback flow can complete. `/api/auth/status` returns only masked key hints for the API-key card. Anthropic can be connected with a raw API key from both Model Onboarding and **Settings → Authentication**. Anthropic API-key auth appears as a separate **Anthropic API Key** card, while Claude subscription OAuth appears as **Anthropic Subscription** with Login/Logout controls. On Fusion desktop, Anthropic Subscription OAuth login URLs are delegated to the operating system browser instead of an Electron child window so the existing polling/callback flow can complete. `/api/auth/status` returns only masked key hints for the API-key card.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -88,6 +88,12 @@ export const DEFAULT_GLOBAL_SETTINGS = {
FNXC:DashboardTheming 2026-07-03-00:00: FNXC:DashboardTheming 2026-07-03-00:00:
Fresh installs must follow the operating system theme until the user explicitly chooses Light, Dark, or System. Keep this global default aligned with dashboard and desktop pre-hydration fallbacks. Fresh installs must follow the operating system theme until the user explicitly chooses Light, Dark, or System. Keep this global default aligned with dashboard and desktop pre-hydration fallbacks.
*/ */
/*
FNXC:ProviderAuth 2026-07-24-17:05:
Default to the historical precedence (raw API key beats subscription OAuth, FN-7391/FN-7396)
so upgrading never silently moves an operator's traffic from their key onto their subscription.
*/
anthropicAuthPreference: "api-key",
themeMode: "system", themeMode: "system",
/* /*
FNXC:DashboardTheming 2026-06-30-00:00: FNXC:DashboardTheming 2026-06-30-00:00:

View File

@@ -137,6 +137,7 @@ import {
DEFAULT_PLANNER_OVERSIGHT_LEVEL, DEFAULT_PLANNER_OVERSIGHT_LEVEL,
COMPLETION_DOCUMENTATION_MODES, COMPLETION_DOCUMENTATION_MODES,
REVIEW_ARTIFACTS_MODES, REVIEW_ARTIFACTS_MODES,
ANTHROPIC_AUTH_PREFERENCES,
THEME_MODES, THEME_MODES,
COLOR_THEMES, COLOR_THEMES,
SUPPORTED_LOCALES, SUPPORTED_LOCALES,
@@ -148,6 +149,7 @@ import type {
PlannerOversightLevel, PlannerOversightLevel,
CompletionDocumentationMode, CompletionDocumentationMode,
ReviewArtifactsMode, ReviewArtifactsMode,
AnthropicAuthPreference,
ThemeMode, ThemeMode,
ColorTheme, ColorTheme,
Locale, Locale,
@@ -161,6 +163,7 @@ export {
DEFAULT_PLANNER_OVERSIGHT_LEVEL, DEFAULT_PLANNER_OVERSIGHT_LEVEL,
COMPLETION_DOCUMENTATION_MODES, COMPLETION_DOCUMENTATION_MODES,
REVIEW_ARTIFACTS_MODES, REVIEW_ARTIFACTS_MODES,
ANTHROPIC_AUTH_PREFERENCES,
THEME_MODES, THEME_MODES,
COLOR_THEMES, COLOR_THEMES,
SUPPORTED_LOCALES, SUPPORTED_LOCALES,
@@ -172,6 +175,7 @@ export type {
PlannerOversightLevel, PlannerOversightLevel,
CompletionDocumentationMode, CompletionDocumentationMode,
ReviewArtifactsMode, ReviewArtifactsMode,
AnthropicAuthPreference,
ThemeMode, ThemeMode,
ColorTheme, ColorTheme,
Locale, Locale,

View File

@@ -52,6 +52,21 @@ while user-facing tasks can opt in without making every task generate media.
export const REVIEW_ARTIFACTS_MODES = ["off", "user-facing", "on"] as const; export const REVIEW_ARTIFACTS_MODES = ["off", "user-facing", "on"] as const;
export type ReviewArtifactsMode = (typeof REVIEW_ARTIFACTS_MODES)[number]; export type ReviewArtifactsMode = (typeof REVIEW_ARTIFACTS_MODES)[number];
/*
FNXC:ProviderAuth 2026-07-24-17:05:
Anthropic credentials can be present twice at once: a raw API key AND a Claude
subscription OAuth login. Runtime auth resolution has to pick one, and until now the raw
key always won silently — an operator who logged in with their subscription but had an old
(or revoked) key saved kept getting `401 invalid x-api-key` from lanes that call the
Anthropic endpoint directly, with nothing in the UI explaining which credential was in use.
This preference makes the choice explicit and operator-owned. It is GLOBAL because
credentials live in the global `~/.fusion/agent/auth.json`, not per project.
Default "api-key" preserves the historical precedence (FN-7391/FN-7396), so upgrading
changes no existing behavior.
*/
export const ANTHROPIC_AUTH_PREFERENCES = ["api-key", "subscription"] as const;
export type AnthropicAuthPreference = (typeof ANTHROPIC_AUTH_PREFERENCES)[number];
/** Theme mode for light/dark/system preference */ /** Theme mode for light/dark/system preference */
export const THEME_MODES = ["dark", "light", "system"] as const; export const THEME_MODES = ["dark", "light", "system"] as const;
export type ThemeMode = (typeof THEME_MODES)[number]; export type ThemeMode = (typeof THEME_MODES)[number];

View File

@@ -11,6 +11,7 @@ import type {
Locale, Locale,
ReviewArtifactsMode, ReviewArtifactsMode,
ThemeMode, ThemeMode,
AnthropicAuthPreference,
} from "./execution-and-ui.js"; } from "./execution-and-ui.js";
import type { import type {
AutoRecoverySettings, AutoRecoverySettings,
@@ -291,6 +292,15 @@ export interface BackupSettingsMigrationConflict {
export interface GlobalSettings { export interface GlobalSettings {
/** Maximum PostgreSQL server connections for Fusion's embedded database. Applied on the next Fusion restart. */ /** Maximum PostgreSQL server connections for Fusion's embedded database. Applied on the next Fusion restart. */
embeddedPostgresMaxConnections?: number; embeddedPostgresMaxConnections?: number;
/**
* FNXC:ProviderAuth 2026-07-24-17:05:
* Which Anthropic credential wins when BOTH a raw API key and a Claude subscription OAuth
* login are present. Global because credentials are global. "api-key" (default) preserves
* the historical precedence; "subscription" makes the OAuth login win so a stale or revoked
* saved key can no longer shadow it with `401 invalid x-api-key`. With only one credential
* configured this setting changes nothing — resolution falls through to whatever exists.
*/
anthropicAuthPreference?: AnthropicAuthPreference;
/** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */ /** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */
themeMode?: ThemeMode; themeMode?: ThemeMode;
/** Color theme preference for accent colors and styling. Default: "shadcn-ember"; "default" and "ocean" remain valid explicit legacy selections. */ /** Color theme preference for accent colors and styling. Default: "shadcn-ember"; "default" and "ocean" remain valid explicit legacy selections. */

View File

@@ -4539,6 +4539,15 @@ export function SettingsModal({
handleSubmitManualCode, handleSubmitManualCode,
onReopenOnboarding, onReopenOnboarding,
}} }}
/*
FNXC:ProviderAuth 2026-07-24-17:05:
Authentication is presentational like every other section — the shell keeps
ownership of persistence. It needs the form only for the Anthropic
credential-precedence row, which belongs beside the two Anthropic cards
rather than buried in a general settings list.
*/
form={form}
setForm={setForm}
/> />
); );
case "hermes-runtime": case "hermes-runtime":

View File

@@ -0,0 +1,148 @@
/*
FNXC:ProviderAuth 2026-07-24-17:05:
An operator can hold two live Anthropic credentials at once — a raw API key and a Claude
subscription OAuth login — and runtime auth silently preferred the key. A stale or revoked
saved key therefore produced `401 invalid x-api-key` on every lane that calls Anthropic
directly, while BOTH cards still read "✓ Active" and nothing named the credential in use.
Invariant asserted here: when (and only when) both Anthropic credentials are connected,
Settings names which one is in use, and the operator can change it. The control writes the
global `anthropicAuthPreference` through the shell-owned form, never directly.
*/
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { AuthenticationSection, type AuthenticationSectionData } from "../settings/sections/AuthenticationSection";
import type { AuthProvider } from "../../api";
import type { Settings } from "@fusion/core";
vi.mock("../ProviderIcon", () => ({
ProviderIcon: ({ provider }: { provider: string }) => <span data-testid={`mock-icon-${provider}`}>{provider}</span>,
}));
vi.mock("../PluginSlot", () => ({ PluginSlot: () => null }));
vi.mock("../LoginInstructions", () => ({ LoginInstructions: () => null }));
vi.mock("../LoadingSpinner", () => ({ LoadingSpinner: () => null }));
vi.mock("../OAuthManualCodeForm", () => ({ OAuthManualCodeForm: () => null }));
vi.mock("../CustomProvidersSection", () => ({ CustomProvidersSection: () => null }));
const apiKeyCard: AuthProvider = {
id: "anthropic-api-key",
name: "Anthropic API Key",
type: "api_key",
authenticated: true,
} as AuthProvider;
const subscriptionCard: AuthProvider = {
id: "anthropic-subscription",
name: "Claude Subscription",
type: "oauth",
authenticated: true,
} as AuthProvider;
function renderSection(providers: AuthProvider[], initialForm: Partial<Settings> = {}) {
const observed: { form: Partial<Settings> } = { form: initialForm };
function Harness() {
const [form, setForm] = useState<Partial<Settings>>(initialForm);
observed.form = form;
const auth = {
addToast: vi.fn(),
authProviders: providers,
authLoading: false,
authActionInProgress: null,
apiKeyInputs: {},
setApiKeyInputs: vi.fn(),
apiKeyErrors: {},
opencodeApiKeyRefreshStatus: {},
deviceCodes: {},
loginInstructions: {},
manualCodeConfigs: {},
manualCodeInputs: {},
setManualCodeInputs: vi.fn(),
manualCodeSubmitInProgress: null,
loadAuthStatus: vi.fn(),
handleLogin: vi.fn(),
handleLogout: vi.fn(),
handleCancelLogin: vi.fn(),
handleSaveApiKey: vi.fn(),
handleClearApiKey: vi.fn(),
handleSubmitManualCode: vi.fn(),
} as unknown as AuthenticationSectionData;
return (
<AuthenticationSection
auth={auth}
form={form as never}
setForm={setForm as never}
/>
);
}
render(<Harness />);
return observed;
}
describe("Anthropic credential precedence in Settings → Authentication", () => {
it("names the credential in use when both Anthropic credentials are connected", () => {
renderSection([apiKeyCard, subscriptionCard]);
// Default preference is the API key, matching runtime resolution.
expect(screen.getByTestId("auth-precedence-active-anthropic-api-key")).toBeTruthy();
expect(screen.getByTestId("auth-precedence-overridden-anthropic-subscription")).toBeTruthy();
});
it("moves the in-use marker when the operator prefers the subscription", () => {
renderSection([apiKeyCard, subscriptionCard], { anthropicAuthPreference: "subscription" });
expect(screen.getByTestId("auth-precedence-active-anthropic-subscription")).toBeTruthy();
expect(screen.getByTestId("auth-precedence-overridden-anthropic-api-key")).toBeTruthy();
});
it("writes the operator's choice into the shell-owned settings form", () => {
const observed = renderSection([apiKeyCard, subscriptionCard]);
fireEvent.change(screen.getByLabelText(/Anthropic credential to use/i), {
target: { value: "subscription" },
});
expect(observed.form.anthropicAuthPreference).toBe("subscription");
});
it("hides the control when only one Anthropic credential is connected", () => {
// Nothing to disambiguate — resolution reaches the single credential either way.
renderSection([apiKeyCard, { ...subscriptionCard, authenticated: false }]);
expect(screen.queryByLabelText(/Anthropic credential to use/i)).toBeNull();
expect(screen.queryByTestId("auth-precedence-active-anthropic-api-key")).toBeNull();
});
it("hides the control when the shell does not supply the settings form", () => {
const auth = {
addToast: vi.fn(),
authProviders: [apiKeyCard, subscriptionCard],
authLoading: false,
authActionInProgress: null,
apiKeyInputs: {},
setApiKeyInputs: vi.fn(),
apiKeyErrors: {},
opencodeApiKeyRefreshStatus: {},
deviceCodes: {},
loginInstructions: {},
manualCodeConfigs: {},
manualCodeInputs: {},
setManualCodeInputs: vi.fn(),
manualCodeSubmitInProgress: null,
loadAuthStatus: vi.fn(),
handleLogin: vi.fn(),
handleLogout: vi.fn(),
handleCancelLogin: vi.fn(),
handleSaveApiKey: vi.fn(),
handleClearApiKey: vi.fn(),
handleSubmitManualCode: vi.fn(),
} as unknown as AuthenticationSectionData;
render(<AuthenticationSection auth={auth} />);
expect(screen.queryByLabelText(/Anthropic credential to use/i)).toBeNull();
});
});

View File

@@ -14,6 +14,8 @@ import { LoadingSpinner } from "../../LoadingSpinner";
import { OAuthManualCodeForm } from "../../OAuthManualCodeForm"; import { OAuthManualCodeForm } from "../../OAuthManualCodeForm";
import { CustomProvidersSection } from "../../CustomProvidersSection"; import { CustomProvidersSection } from "../../CustomProvidersSection";
import { SettingsHelpTip } from "../SettingsHelpTip"; import { SettingsHelpTip } from "../SettingsHelpTip";
import { SettingsSelectRow } from "../SettingsSelectRow";
import type { SectionBaseProps } from "./context";
import { copyTextToClipboard } from "../../../utils/copyToClipboard"; import { copyTextToClipboard } from "../../../utils/copyToClipboard";
import { appendTokenQuery } from "../../../auth"; import { appendTokenQuery } from "../../../auth";
import { openExternalUrl } from "../../../utils/open-external"; import { openExternalUrl } from "../../../utils/open-external";
@@ -48,7 +50,12 @@ export interface AuthenticationSectionData {
} }
export interface AuthenticationSectionProps { export interface AuthenticationSectionProps {
auth: AuthenticationSectionData; auth: AuthenticationSectionData;
/** Shell-owned settings form; used only for the Anthropic credential-precedence row. */
form?: SectionBaseProps["form"];
setForm?: SectionBaseProps["setForm"];
} }
const ANTHROPIC_API_KEY_PROVIDER_ID = "anthropic-api-key";
const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
const ANTHROPIC_AUTH_PROVIDER_PRIORITY: Record<string, number> = { const ANTHROPIC_AUTH_PROVIDER_PRIORITY: Record<string, number> = {
"claude-cli": 0, "claude-cli": 0,
"anthropic-subscription": 1, "anthropic-subscription": 1,
@@ -75,7 +82,7 @@ const compareAuthProviderDisplayOrder = (a: AuthProvider, b: AuthProvider) => {
} }
return a.id.localeCompare(b.id); return a.id.localeCompare(b.id);
}; };
export function AuthenticationSection({ auth }: AuthenticationSectionProps) { export function AuthenticationSection({ auth, form, setForm }: AuthenticationSectionProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const { projectId, addToast, authProviders, authLoading, authActionInProgress, apiKeyInputs, setApiKeyInputs, apiKeyErrors, opencodeApiKeyRefreshStatus, deviceCodes, loginInstructions, manualCodeConfigs, manualCodeInputs, setManualCodeInputs, manualCodeSubmitInProgress, loadAuthStatus, handleLogin, handleLogout, handleCancelLogin, handleSaveApiKey, handleClearApiKey, handleSubmitManualCode, onReopenOnboarding, } = auth; const { projectId, addToast, authProviders, authLoading, authActionInProgress, apiKeyInputs, setApiKeyInputs, apiKeyErrors, opencodeApiKeyRefreshStatus, deviceCodes, loginInstructions, manualCodeConfigs, manualCodeInputs, setManualCodeInputs, manualCodeSubmitInProgress, loadAuthStatus, handleLogin, handleLogout, handleCancelLogin, handleSaveApiKey, handleClearApiKey, handleSubmitManualCode, onReopenOnboarding, } = auth;
const hasSeparatedAnthropicProvider = authProviders.some((p) => p.id === "anthropic-subscription" || p.id === "anthropic-api-key"); const hasSeparatedAnthropicProvider = authProviders.some((p) => p.id === "anthropic-subscription" || p.id === "anthropic-api-key");
@@ -127,6 +134,59 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) {
} }
return (<LlamaCppProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={handleCliProviderToggled}/>); return (<LlamaCppProviderCard key={provider.id} compact authenticated={provider.authenticated} onToggled={handleCliProviderToggled}/>);
}; };
/*
FNXC:ProviderAuth 2026-07-24-17:05:
Anthropic is the one provider an operator can hold two live credentials for at once — a
raw API key AND a Claude subscription OAuth login. Runtime auth has to pick one, and the
choice was previously invisible: a saved key silently won, so an operator who logged in
with their subscription but still had a stale/revoked key stored got `401 invalid
x-api-key` from lanes that call the Anthropic endpoint directly, with nothing on this
screen explaining why. Surface the conflict where the credentials are managed and let the
operator pick. The row renders only when BOTH are actually connected — with one credential
there is nothing to disambiguate and the control would be noise.
*/
const anthropicApiKeyConnected = authProviders.some((p) => p.id === ANTHROPIC_API_KEY_PROVIDER_ID && p.authenticated);
const anthropicSubscriptionConnected = authProviders.some((p) => p.id === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID && p.authenticated);
const showAnthropicPrecedence = Boolean(form && setForm) && anthropicApiKeyConnected && anthropicSubscriptionConnected;
const anthropicAuthPreference = form?.anthropicAuthPreference === "subscription" ? "subscription" : "api-key";
const preferenceIsInEffect = (providerId: string) => showAnthropicPrecedence
&& (anthropicAuthPreference === "subscription"
? providerId === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID
: providerId === ANTHROPIC_API_KEY_PROVIDER_ID);
/*
Live state, not description: which of two connected credentials the engine will actually
send. Rendered on the card itself so the answer is where the operator is looking.
*/
const renderAnthropicPrecedenceBadge = (provider: AuthProvider) => {
if (!showAnthropicPrecedence) {
return null;
}
if (provider.id !== ANTHROPIC_API_KEY_PROVIDER_ID && provider.id !== ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
return null;
}
return preferenceIsInEffect(provider.id)
? (<span className="auth-status-badge authenticated" data-testid={`auth-precedence-active-${provider.id}`}>
{t("settings.auth.credentialInUse", "In use")}
</span>)
: (<span className="auth-key-hint" data-testid={`auth-precedence-overridden-${provider.id}`}>
{t("settings.auth.credentialOverridden", "Overridden below")}
</span>);
};
const renderAnthropicPrecedenceRow = () => showAnthropicPrecedence
? (<SettingsSelectRow descriptor={{
key: "anthropicAuthPreference",
label: t("settings.auth.anthropicPreferenceLabel", "Anthropic credential to use"),
help: t("settings.auth.anthropicPreferenceHint", "You have both an Anthropic API key and a Claude subscription connected. Choose which one Fusion sends when a lane calls Anthropic directly. Default: API key."),
scope: "global",
options: [
{ value: "api-key", label: t("settings.auth.anthropicPreferenceApiKey", "API key") },
{ value: "subscription", label: t("settings.auth.anthropicPreferenceSubscription", "Claude subscription") },
],
}} value={anthropicAuthPreference} onChange={(value) => setForm?.((f) => ({
...f,
anthropicAuthPreference: value === "subscription" ? "subscription" : "api-key",
}))}/>)
: null;
const showAuthenticatedGroup = authenticatedProviders.length > 0; const showAuthenticatedGroup = authenticatedProviders.length > 0;
const showAvailableGroup = unauthenticatedProviders.length > 0; const showAvailableGroup = unauthenticatedProviders.length > 0;
const providerSupportsApiKey = (provider: AuthProvider) => provider.type === "api_key"; const providerSupportsApiKey = (provider: AuthProvider) => provider.type === "api_key";
@@ -234,12 +294,14 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) {
<span data-testid={`auth-status-${provider.id}`} className={`auth-status-badge ${provider.authenticated ? "authenticated" : "not-authenticated"}`}> <span data-testid={`auth-status-${provider.id}`} className={`auth-status-badge ${provider.authenticated ? "authenticated" : "not-authenticated"}`}>
{t("settings.auth.statusActive", "✓ Active")} {t("settings.auth.statusActive", "✓ Active")}
</span> </span>
{renderAnthropicPrecedenceBadge(provider)}
{provider.authenticated && provider.keyHint && (<span className="auth-key-hint">{t("settings.authentication.key", "Key: ")}{provider.keyHint}</span>)} {provider.authenticated && provider.keyHint && (<span className="auth-key-hint">{t("settings.authentication.key", "Key: ")}{provider.keyHint}</span>)}
</div> </div>
{provider.type !== "api_key" && <div>{renderAuthenticatedOAuthActions(provider)}{renderProviderAuthError(provider)}</div>} {provider.type !== "api_key" && <div>{renderAuthenticatedOAuthActions(provider)}{renderProviderAuthError(provider)}</div>}
{providerSupportsApiKey(provider) && renderApiKeySection(provider)} {providerSupportsApiKey(provider) && renderApiKeySection(provider)}
</div> </div>
</div>))} </div>))}
{renderAnthropicPrecedenceRow()}
</div>)} </div>)}
{showAvailableGroup && (<div className="auth-provider-group"> {showAvailableGroup && (<div className="auth-provider-group">
<div className="auth-group-label">{t("settings.auth.groupAvailable", "Available")}</div> <div className="auth-group-label">{t("settings.auth.groupAvailable", "Available")}</div>

View File

@@ -0,0 +1,147 @@
/*
FNXC:ProviderAuth 2026-07-24-17:05:
Regression tests for the operator-selectable Anthropic credential precedence.
Reported symptom class: an operator holding BOTH a raw Anthropic API key and a Claude
subscription OAuth login always ran on the raw key, because runtime resolution put it first
unconditionally. When that saved key was stale or revoked, every lane that calls the Anthropic
endpoint directly failed with `401 invalid x-api-key` while the subscription card still showed
"Active" — and nothing in the product explained which credential was in use.
Invariant: `anthropicAuthPreference` decides which credential wins WHEN BOTH EXIST, and
never removes a source — with only one credential configured, resolution reaches it under
either setting. Default stays "api-key" so upgrades do not silently move traffic.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdirSync, writeFileSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createFusionAuthStorage, getFusionAuthPath } from "../auth-storage.js";
const RAW_API_KEY = "sk-ant-api03-raw-key-from-settings-card";
const SUBSCRIPTION_ACCESS_TOKEN = "sk-ant-oat01-subscription-access-token";
function writeAuth(homeDir: string, credentials: Record<string, unknown>): void {
mkdirSync(join(homeDir, ".fusion", "agent"), { recursive: true });
writeFileSync(getFusionAuthPath(homeDir), JSON.stringify(credentials));
}
function writeGlobalSettings(homeDir: string, settings: Record<string, unknown>): void {
mkdirSync(join(homeDir, ".fusion"), { recursive: true });
writeFileSync(join(homeDir, ".fusion", "settings.json"), JSON.stringify(settings));
}
/** A subscription OAuth credential that is still valid, so no refresh is attempted. */
function liveSubscriptionCredential() {
return {
type: "oauth",
access: SUBSCRIPTION_ACCESS_TOKEN,
refresh: "sk-ant-ort01-refresh-token",
expires: Date.now() + 60 * 60_000,
};
}
describe("Anthropic credential precedence (anthropicAuthPreference)", () => {
const originalHome = process.env.HOME;
const originalApiKeyEnv = process.env.ANTHROPIC_API_KEY;
let homeDir: string;
beforeEach(async () => {
homeDir = await mkdtemp(join(tmpdir(), "fusion-anthropic-pref-"));
process.env.HOME = homeDir;
// The env key is a separate fallback source; keep it out of these assertions.
delete process.env.ANTHROPIC_API_KEY;
});
afterEach(async () => {
if (homeDir) {
await rm(homeDir, { recursive: true, force: true });
}
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalApiKeyEnv === undefined) {
delete process.env.ANTHROPIC_API_KEY;
} else {
process.env.ANTHROPIC_API_KEY = originalApiKeyEnv;
}
});
it("defaults to the raw API key when both credentials exist and no preference is stored", async () => {
writeAuth(homeDir, {
anthropic: { type: "api_key", key: RAW_API_KEY },
"anthropic-subscription": liveSubscriptionCredential(),
});
const storage = createFusionAuthStorage();
// Historical precedence (FN-7391/FN-7396) — upgrading must not move traffic.
await expect(storage.getApiKey("anthropic")).resolves.toBe(RAW_API_KEY);
});
it("keeps the raw API key first when the preference is explicitly api-key", async () => {
writeAuth(homeDir, {
anthropic: { type: "api_key", key: RAW_API_KEY },
"anthropic-subscription": liveSubscriptionCredential(),
});
writeGlobalSettings(homeDir, { anthropicAuthPreference: "api-key" });
const storage = createFusionAuthStorage();
await expect(storage.getApiKey("anthropic")).resolves.toBe(RAW_API_KEY);
});
it("resolves the subscription token over a stale saved key when the operator prefers subscription", async () => {
writeAuth(homeDir, {
anthropic: { type: "api_key", key: RAW_API_KEY },
"anthropic-subscription": liveSubscriptionCredential(),
});
writeGlobalSettings(homeDir, { anthropicAuthPreference: "subscription" });
const storage = createFusionAuthStorage();
/*
The exact reported failure: with the raw key winning, this returned a key that pi-ai
sends as `x-api-key` (it lacks the `sk-ant-oat` marker) and Anthropic rejects with
`401 invalid x-api-key`. The OAuth token routes as a Bearer credential instead.
*/
await expect(storage.getApiKey("anthropic")).resolves.toBe(SUBSCRIPTION_ACCESS_TOKEN);
});
it("still falls back to the raw key under the subscription preference when no OAuth credential exists", async () => {
writeAuth(homeDir, { anthropic: { type: "api_key", key: RAW_API_KEY } });
writeGlobalSettings(homeDir, { anthropicAuthPreference: "subscription" });
const storage = createFusionAuthStorage();
// The preference disambiguates; it must never remove the only credential present.
await expect(storage.getApiKey("anthropic")).resolves.toBe(RAW_API_KEY);
});
it("resolves the subscription token under the api-key preference when no raw key exists", async () => {
writeAuth(homeDir, { "anthropic-subscription": liveSubscriptionCredential() });
writeGlobalSettings(homeDir, { anthropicAuthPreference: "api-key" });
const storage = createFusionAuthStorage();
await expect(storage.getApiKey("anthropic")).resolves.toBe(SUBSCRIPTION_ACCESS_TOKEN);
});
it("falls back to the historical precedence when the settings file is unreadable", async () => {
writeAuth(homeDir, {
anthropic: { type: "api_key", key: RAW_API_KEY },
"anthropic-subscription": liveSubscriptionCredential(),
});
mkdirSync(join(homeDir, ".fusion"), { recursive: true });
writeFileSync(join(homeDir, ".fusion", "settings.json"), "{ this is not json");
const storage = createFusionAuthStorage();
// A corrupt settings file must not strand credential resolution.
await expect(storage.getApiKey("anthropic")).resolves.toBe(RAW_API_KEY);
});
});

View File

@@ -421,6 +421,37 @@ function resolveStoredCredentialApiKey(providerId: string, credential: StoredCre
* can return them as a fallback when neither Fusion auth nor legacy auth.json * can return them as a fallback when neither Fusion auth nor legacy auth.json
* contains a key for the provider. * contains a key for the provider.
*/ */
/** Global settings file that carries the operator's Anthropic credential preference. */
export function getFusionGlobalSettingsPath(home = getHomeDir()): string {
return join(home, ".fusion", "settings.json");
}
/*
FNXC:ProviderAuth 2026-07-24-17:05:
Read the operator's `anthropicAuthPreference` straight off the global settings file rather
than threading a Settings object down here. Credential resolution runs deep inside
createFnAgent (via createFusionAuthStorage, which takes no arguments) and is shared by every
host — CLI, dashboard, desktop, daemon — so a settings parameter would have to be plumbed
through all of them. The preference is global by definition (credentials live in the global
auth.json), and this file is already the sibling of the auth/models files this module reads
synchronously. Re-read per resolution so toggling the setting takes effect on the next lane
without a restart; a missing/corrupt file falls back to the historical "api-key" precedence.
*/
function readAnthropicAuthPreference(home = getHomeDir()): "api-key" | "subscription" {
const settingsPath = getFusionGlobalSettingsPath(home);
if (!existsSync(settingsPath)) {
return "api-key";
}
try {
const parsed = JSON.parse(readFileSync(settingsPath, "utf-8")) as {
anthropicAuthPreference?: unknown;
};
return parsed?.anthropicAuthPreference === "subscription" ? "subscription" : "api-key";
} catch {
return "api-key";
}
}
function readModelsJsonApiKeys(home = getHomeDir()): Map<string, string> { function readModelsJsonApiKeys(home = getHomeDir()): Map<string, string> {
const apiKeys = new Map<string, string>(); const apiKeys = new Map<string, string>();
const modelsPath = getModelRegistryModelsPath(home); const modelsPath = getModelRegistryModelsPath(home);
@@ -784,11 +815,27 @@ export function createFusionAuthStorage(): FusionAuthStorage {
FNXC:ProviderAuth 2026-07-01-14:55: FNXC:ProviderAuth 2026-07-01-14:55:
Anthropic runtime auth (`getApiKey("anthropic")`) resolves in precedence order: (1) raw API key, (2) legacy `anthropic` OAuth, (3) separated `anthropic-subscription` OAuth, (4) models.json / ModelRegistry fallback raw key. Raw key wins so an explicit `ANTHROPIC_API_KEY` keeps using x-api-key; subscription/OAuth tokens must resolve here so the built-in provider runs them on `/v1` with Claude Code impersonation. Do NOT gate OAuth behind the CLI or reroute it to an `/v1` `anthropic-subscription` provider — that reintroduced the #1857 regression (FN-7391/FN-7396). Anthropic runtime auth (`getApiKey("anthropic")`) resolves in precedence order: (1) raw API key, (2) legacy `anthropic` OAuth, (3) separated `anthropic-subscription` OAuth, (4) models.json / ModelRegistry fallback raw key. Raw key wins so an explicit `ANTHROPIC_API_KEY` keeps using x-api-key; subscription/OAuth tokens must resolve here so the built-in provider runs them on `/v1` with Claude Code impersonation. Do NOT gate OAuth behind the CLI or reroute it to an `/v1` `anthropic-subscription` provider — that reintroduced the #1857 regression (FN-7391/FN-7396).
*/ */
if (!rawProviderLoggedOut) { /*
FNXC:ProviderAuth 2026-07-24-17:05:
`anthropicAuthPreference` selects which credential wins when BOTH are configured.
"api-key" (default) keeps the order documented above. "subscription" moves the raw-key
step BELOW the OAuth steps so a stale or revoked saved key can no longer shadow a working
Claude subscription login — the failure mode that surfaced as `401 invalid x-api-key` on
lanes that call the Anthropic endpoint directly. Neither setting REMOVES a source: with
only one credential present, resolution falls through to it either way.
*/
const preferSubscription = readAnthropicAuthPreference() === "subscription";
const resolveRawApiKey = (): string | undefined => {
if (rawProviderLoggedOut) return undefined;
const anthropicApiKeyCredential = selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "api_key"); const anthropicApiKeyCredential = selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "api_key");
if (anthropicApiKeyCredential) { return anthropicApiKeyCredential
return resolveStoredCredentialApiKey(ANTHROPIC_PROVIDER_ID, anthropicApiKeyCredential); ? resolveStoredCredentialApiKey(ANTHROPIC_PROVIDER_ID, anthropicApiKeyCredential)
} : undefined;
};
if (!preferSubscription) {
const rawKey = resolveRawApiKey();
if (rawKey) return rawKey;
} }
const subscriptionLoggedOut = loggedOutProviders.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID); const subscriptionLoggedOut = loggedOutProviders.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
@@ -812,6 +859,12 @@ export function createFusionAuthStorage(): FusionAuthStorage {
} }
} }
// Subscription-preferred: the raw key is the fallback once no OAuth credential resolved.
if (preferSubscription) {
const rawKey = resolveRawApiKey();
if (rawKey) return rawKey;
}
if (!rawProviderLoggedOut) { if (!rawProviderLoggedOut) {
/* /*
FNXC:ProviderAuth 2026-06-30-13:28: FNXC:ProviderAuth 2026-06-30-13:28: