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:
7
.changeset/anthropic-credential-precedence.md
Normal file
7
.changeset/anthropic-credential-precedence.md
Normal 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.
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user