Merge origin/main into feature/fix-triage

Resolves the pnpm-lock.yaml conflict. Main's #1865 (review-checkout routing)
auto-merged cleanly with the completion-summary backstop in executor.ts.

Main independently pinned pi-claude-cli's pi-ai/pi-coding-agent to ^0.80.3
(e15489259) but kept the top-level `getModels` import, which 0.80.3 removed —
this branch's migration to `getBuiltinModels` from `/providers/all` is retained
as the working fix. Lockfile regenerated against the merged package.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-01 17:11:37 -07:00
22 changed files with 722 additions and 226 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix Anthropic Claude subscription chats failing (404/502/429) by restoring direct OAuth execution.
category: fix
dev: Reverts the FN-7391/FN-7396 runtime rerouting that sent subscription OAuth to a `/v1`-based `anthropic-subscription` provider (reintroducing issue #1857). `getApiKey("anthropic")` again resolves subscription/legacy OAuth (raw API key still wins), so `anthropic/*` selections run on pi-ai's built-in provider with Claude Code OAuth headers; the model picker advertises `anthropic` for OAuth users; explicit `pi-claude-cli` and raw `ANTHROPIC_API_KEY` remain separate surfaces.

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Restore Claude Sonnet 5 in the model picker (it had disappeared from every surface).
category: fix
dev: Re-adds `claude-sonnet-5` to SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION and its static pricing (removed by FN-7374). Live-verified: Sonnet 5 returns 200 on api.anthropic.com/v1 with a raw ANTHROPIC_API_KEY and runs via the Claude CLI; it 403s (scope) on subscription-OAuth /v1, where the runtime actionable-failure/fallback path applies.

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Enforce explicit external checkout metadata for review routing.
category: fix
dev: Reviews now use sourceMetadata.externalReviewCheckout only when it points at a valid git checkout, otherwise they fail closed to the task worktree and log the selected review target.

View File

@@ -627,7 +627,7 @@ For Claude/Anthropic OAuth credentials, the same `/auth/status` poll also attemp
If the OAuth credential has no refresh token, the refresh request fails, or the provider is not Anthropic, the provider stays expired and the banner remains visible. Re-authenticate with manual re-login from **Settings → Authentication** or Model Onboarding.
Anthropic also supports a raw `ANTHROPIC_API_KEY` from a separate **Anthropic API Key** card in **Settings → Authentication** and Model Onboarding. Claude subscription OAuth remains on the **Anthropic Subscription** card for auth status, usage/subscription checks, banner clearing, and subscription-backed direct agent execution through the dedicated `anthropic-subscription` path; CLI-backed execution remains the distinct **Claude CLI** provider (`pi-claude-cli`). Saving or clearing an API key does not affect the OAuth sign-in path or turn OAuth tokens into raw API-key material. The dashboard only displays masked key hints after a key is saved.
Anthropic also supports a raw `ANTHROPIC_API_KEY` from a separate **Anthropic API Key** card in **Settings → Authentication** and Model Onboarding. Claude subscription OAuth remains on the **Anthropic Subscription** card for auth status, usage/subscription checks, and banner clearing; it also drives direct agent execution on the `anthropic` provider — a subscription/OAuth token runs `anthropic/*` selections against `https://api.anthropic.com/v1` with Claude Code identity headers, no API key required. CLI-backed execution remains the distinct, explicit **Claude CLI** provider (`pi-claude-cli`); subscription OAuth does not require it. A configured API key takes precedence over OAuth on the direct provider. Saving or clearing an API key does not affect the OAuth sign-in path or turn OAuth tokens into raw API-key material. The dashboard only displays masked key hints after a key is saved.
## Smart Pull

View File

@@ -53,7 +53,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
| `language` | `"en" \| "zh-CN" \| "zh-TW" \| "fr" \| "es" \| "ko"` | `undefined` | UI language for the dashboard and TUI. When unset, the dashboard detects from localStorage → browser language and the CLI from `--lang` flag → environment locale, falling back to `en`. Validated at the store write boundary (`validateLocale`); invalid values are dropped. Reset to auto-detect via the dashboard's "Auto" language option or `fn settings set language auto` (clears the persisted key). |
| `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. |
| `defaultProvider` | `string` | `undefined` | Default AI provider. Anthropic has three distinct surfaces: direct `anthropic` uses raw API-key material only (`ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential); subscription OAuth uses the dedicated `anthropic-subscription` auth/status/usage/banner surface and does not create direct `anthropic/*` selector rows; Claude CLI execution uses the explicit `pi-claude-cli` model provider. Connected subscription OAuth plus an enabled Claude CLI exposes selectable `pi-claude-cli/*` rows, while OAuth-backed execution never stores or resolves subscription tokens as raw `ANTHROPIC_API_KEY` material. |
| `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. |
| `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. |
| `modelPricingFetchedAt` | `string` | `undefined` | ISO timestamp for the last successful one-click pricing refresh from the Settings → Global Models pricing summary. |
@@ -730,9 +730,9 @@ Manual re-login is still required when no refresh token is stored, the refresh r
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. Legacy `anthropic` OAuth rows are treated as this subscription surface.
- **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. When subscription OAuth is connected and Claude CLI is enabled, model selectors show the registered `pi-claude-cli/*` rows (for example `pi-claude-cli/claude-sonnet-5`) instead of treating OAuth as direct `anthropic/*` API-key auth.
- **Anthropic API Key** (`anthropic` direct `/v1`) is raw API-key auth only. It accepts `ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential and is the only path used for `https://api.anthropic.com/v1` requests.
- **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. 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.
- **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 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. `/api/auth/status` returns only masked key hints for the API-key card.

View File

@@ -57,6 +57,16 @@ vi.mock("@fusion/dashboard", () => ({
vi.mock("@fusion/engine", () => ({
createFnAgent: vi.fn(),
fetchWebContent: vi.fn(),
emitGoalRetrievalAudit: vi.fn(),
createWorkflowAuthoringTools: vi.fn(() => ({})),
workflowListParams: {},
workflowGetParams: {},
workflowSelectParams: {},
workflowCreateParams: {},
workflowUpdateParams: {},
workflowDeleteParams: {},
workflowSettingsParams: {},
traitListParams: {},
defaultGitOps: vi.fn(() => ({})),
ExperimentFinalizeService: makeConstructibleMock(() => ({ previewPlan: previewPlanMock, finalize: finalizeMock })),
ExperimentFinalizeStateError: mockErrors.StateError,

View File

@@ -13,6 +13,16 @@ vi.mock("@fusion/engine", () => ({
createFnAgent: vi.fn(),
fetchWebContent: vi.fn(),
assertNoSecretPlaintext: assertNoSecretPlaintextMock,
emitGoalRetrievalAudit: vi.fn(),
createWorkflowAuthoringTools: vi.fn(() => ({})),
workflowListParams: {},
workflowGetParams: {},
workflowSelectParams: {},
workflowCreateParams: {},
workflowUpdateParams: {},
workflowDeleteParams: {},
workflowSettingsParams: {},
traitListParams: {},
}));
vi.mock("@fusion/core", async () => {

View File

@@ -31,6 +31,16 @@ vi.mock("@fusion/engine", () => ({
createFnAgent: vi.fn(),
fetchWebContent: vi.fn(),
assertNoSecretPlaintext: vi.fn(),
emitGoalRetrievalAudit: vi.fn(),
createWorkflowAuthoringTools: vi.fn(() => ({})),
workflowListParams: {},
workflowGetParams: {},
workflowSelectParams: {},
workflowCreateParams: {},
workflowUpdateParams: {},
workflowDeleteParams: {},
workflowSettingsParams: {},
traitListParams: {},
}));
async function loadExtension() {

View File

@@ -9,6 +9,16 @@ vi.mock("@fusion/dashboard", () => ({
vi.mock("@fusion/engine", () => ({
createFnAgent: vi.fn(),
fetchWebContent: fetchWebContentMock,
emitGoalRetrievalAudit: vi.fn(),
createWorkflowAuthoringTools: vi.fn(() => ({})),
workflowListParams: {},
workflowGetParams: {},
workflowSelectParams: {},
workflowCreateParams: {},
workflowUpdateParams: {},
workflowDeleteParams: {},
workflowSettingsParams: {},
traitListParams: {},
}));
import kbExtension from "../extension.js";

View File

@@ -36,7 +36,7 @@ describe("model-pricing", () => {
expect(result.usd).toBeCloseTo(10.0, 2);
});
it("reports direct Anthropic Claude Sonnet 5 pricing as unavailable without a static catalog row", () => {
it("prices direct Anthropic Claude Sonnet 5 from the restored static catalog row", () => {
const usage = {
inputTokens: 1_000_000,
outputTokens: 200_000,
@@ -45,12 +45,12 @@ describe("model-pricing", () => {
};
const anthropic = costFor(usage, { provider: "anthropic", model: "claude-sonnet-5" });
expect(anthropic.unavailable).toBe(true);
expect(anthropic.usd).toBeNull();
expect(anthropic.unavailable).toBe(false);
expect(anthropic.usd).toBeGreaterThan(0);
const bare = costFor(usage, { model: "claude-sonnet-5" });
expect(bare.unavailable).toBe(true);
expect(bare.usd).toBeNull();
expect(bare.unavailable).toBe(false);
expect(bare.usd).toBeGreaterThan(0);
});
it("prices OpenAI Codex GPT-5 models instead of reporting unavailable", () => {
@@ -204,11 +204,11 @@ describe("model-pricing", () => {
).toBe(MODEL_PRICING["openai-codex:gpt-5-codex"]);
});
it("does not resolve static pricing for direct Anthropic Claude Sonnet 5", () => {
it("resolves restored static pricing for direct Anthropic Claude Sonnet 5", () => {
expect(
lookupPricing({ provider: " Anthropic ", model: " Claude-Sonnet-5 " }),
).toBeUndefined();
expect(lookupPricing({ model: "claude-sonnet-5" })).toBeUndefined();
).toBe(MODEL_PRICING["anthropic:claude-sonnet-5"]);
expect(lookupPricing({ model: "claude-sonnet-5" })).toBe(MODEL_PRICING["anthropic:claude-sonnet-5"]);
});
it("falls back to a bare model id when provider is unset", () => {

View File

@@ -28,15 +28,33 @@ export interface AnthropicProviderRegistration {
}
/*
* FNXC:ModelCatalog 2026-07-01-18:05:
* Anthropic's official model overview lists `claude-sonnet-5` as a Claude API ID, but FN-7374 observed sparse provider `404 not_found_error` responses for direct Anthropic accounts after Fusion force-added that ID from static supplemental metadata. Fusion cannot encode per-account/model-surface availability from static docs, so direct Anthropic pickers must rely on the live/upstream registry for Sonnet 5 and only dedupe rows the registry already provides. Existing saved selections keep runtime fallback/actionable failure handling instead of being newly advertised here.
* FNXC:ModelCatalog 2026-07-01-22:40:
* Re-advertise `claude-sonnet-5`: the pinned pi-ai builtin registry ships opus-4-8/sonnet-4-6/fable-5 but NOT sonnet-5, and FN-7374 removed the static row expecting the live registry to carry it — so Sonnet 5 was left visible on no surface at all. FN-7374's "404 for direct accounts" premise is disproven by a live probe: `claude-sonnet-5` returns 200 on `api.anthropic.com/v1` with a raw `ANTHROPIC_API_KEY`, and runs via the Claude CLI/`pi-claude-cli` (claude.ai backend). It DOES 403 (scope) on subscription-OAuth `/v1`, so OAuth-only users fall back to the runtime actionable-failure path; keep it advertised so API-key and CLI users can select it.
*/
export const SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION: AnthropicProviderRegistration = {
name: "Anthropic",
baseUrl: "https://api.anthropic.com/v1",
apiKey: "$ANTHROPIC_API_KEY",
api: "anthropic-messages",
models: [],
models: [
{
id: CLAUDE_SONNET_5_MODEL_ID,
name: "Claude Sonnet 5",
reasoning: true,
input: ["text", "image"],
cost: {
input: 2,
output: 10,
cacheRead: 0.2,
cacheWrite: 2.5,
},
contextWindow: 1_000_000,
maxTokens: 128_000,
compat: {
supportsDeveloperRole: false,
},
},
],
};
type AnthropicModelLike = Partial<Omit<AnthropicModelRegistration, "name" | "compat">> & {

View File

@@ -105,9 +105,16 @@ export const MODEL_PRICING: Readonly<Record<string, ModelPricing>> = {
// ── Anthropic Claude ────────────────────────────────────────────────
// input / output / cacheRead(0.1×) / cacheWrite(1.25×, 5-min TTL)
/*
* FNXC:ModelCatalog 2026-07-01-18:10:
* Do not maintain static pricing for `anthropic:claude-sonnet-5` while Fusion cannot prove that a direct Anthropic account can call the model. Saved selections that hit Anthropic's sparse `not_found_error` should be treated as unavailable/fallback candidates rather than receiving a confident cost from a model row Fusion no longer force-advertises.
* FNXC:ModelCatalog 2026-07-01-22:40:
* `anthropic:claude-sonnet-5` is advertised again (works on raw API key + Claude CLI; live-verified), so restore its static pricing. Matches the cost in SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION.
*/
"anthropic:claude-sonnet-5": {
inputPer1M: 2,
outputPer1M: 10,
cacheReadPer1M: 0.2,
cacheWritePer1M: 2.5,
source: "platform.claude.com/docs/en/pricing",
},
"anthropic:claude-opus-4-8": {
inputPer1M: 5,
outputPer1M: 25,

View File

@@ -398,7 +398,7 @@ describe("GET /models", () => {
expect(res.body.models).toEqual([]);
});
it("does not force-add Claude Sonnet 5 for configured direct Anthropic users", async () => {
it("advertises Claude Sonnet 5 for configured direct Anthropic users", async () => {
const modelRegistry = createMutableModelRegistry([
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", provider: "anthropic", reasoning: true, contextWindow: 200000 },
{ id: "gpt-4o", name: "GPT-4o", provider: "openai", reasoning: false, contextWindow: 128000 },
@@ -407,13 +407,12 @@ describe("GET /models", () => {
const res = await GET(buildApp(modelRegistry), "/api/models");
expect(res.status).toBe(200);
// Sonnet 5 is re-advertised via SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION (works on API key + CLI).
expect(res.body.models).toEqual(expect.arrayContaining([
expect.objectContaining({ provider: "anthropic", id: "claude-sonnet-4-5" }),
]));
expect(res.body.models).not.toEqual(expect.arrayContaining([
expect.objectContaining({ provider: "anthropic", id: "claude-sonnet-5" }),
]));
expect(modelRegistry.registerProvider).not.toHaveBeenCalledWith("anthropic", expect.objectContaining({
expect(modelRegistry.registerProvider).toHaveBeenCalledWith("anthropic", expect.objectContaining({
models: expect.arrayContaining([expect.objectContaining({ id: "claude-sonnet-5" })]),
}));
});
@@ -434,7 +433,10 @@ describe("GET /models", () => {
expect(res.status).toBe(200);
expect(res.body.models).toEqual([]);
expect(modelRegistry.models.some((model) => model.id === "claude-sonnet-5")).toBe(false);
// Sonnet 5 is merged into the registry from supplemental metadata, but stays hidden
// from the response because no Anthropic auth is configured (provider-visibility filter).
expect(modelRegistry.models.some((model) => model.id === "claude-sonnet-5")).toBe(true);
expect(res.body.models.some((model: { id: string }) => model.id === "claude-sonnet-5")).toBe(false);
} finally {
readFileSpy.mockRestore();
}
@@ -531,7 +533,7 @@ describe("GET /models", () => {
}));
});
it("uses authStorage subscription OAuth plus Claude CLI to expose selectable CLI models only", async () => {
it("uses authStorage subscription OAuth plus Claude CLI to expose both direct Anthropic and CLI rows", async () => {
await withNoFilesystemProviders(async () => {
const authStorage = createMockAuthStorage({
getOAuthProviders: vi.fn().mockReturnValue([{ id: "anthropic", name: "Anthropic" }]),
@@ -549,15 +551,16 @@ describe("GET /models", () => {
expect(res.status).toBe(200);
expect(res.body.models).not.toEqual([]);
const providers = res.body.models.map((m: { provider: string }) => m.provider);
// Subscription OAuth exposes the direct `anthropic` provider AND the CLI is selectable.
expect(providers).toContain("anthropic");
expect(providers).toContain("pi-claude-cli");
expect(providers).not.toContain("anthropic");
expect(providers).not.toContain("anthropic-subscription");
const cliSonnetFiveRows = res.body.models.filter((m: { provider: string; id: string }) => m.provider === "pi-claude-cli" && m.id === "claude-sonnet-5");
expect(cliSonnetFiveRows).toHaveLength(1);
});
});
it("keeps legacy Anthropic OAuth from exposing direct rows while Claude CLI remains selectable", async () => {
it("exposes direct Anthropic rows for legacy OAuth while Claude CLI remains selectable", async () => {
await withNoFilesystemProviders(async () => {
const authStorage = createMockAuthStorage({
getOAuthProviders: vi.fn().mockReturnValue([{ id: "anthropic", name: "Anthropic" }]),
@@ -574,8 +577,11 @@ describe("GET /models", () => {
expect(res.status).toBe(200);
const providers = res.body.models.map((m: { provider: string }) => m.provider);
// Restored v0.51.0: legacy OAuth makes the direct `anthropic` provider selectable
// (pi-ai runs it on /v1 via Claude Code impersonation). `anthropic-subscription` is
// an auth/status id only, never a picker row.
expect(providers).toContain("anthropic");
expect(providers).toContain("pi-claude-cli");
expect(providers).not.toContain("anthropic");
expect(providers).not.toContain("anthropic-subscription");
expect(res.body.models).toEqual(expect.arrayContaining([
expect.objectContaining({ provider: "pi-claude-cli", id: "claude-sonnet-5" }),
@@ -604,7 +610,7 @@ describe("GET /models", () => {
});
});
it("does not expose Anthropic rows for authStorage OAuth-only auth when Claude CLI is disabled", async () => {
it("exposes the direct Anthropic provider for subscription OAuth even when Claude CLI is disabled", async () => {
await withNoFilesystemProviders(async () => {
const authStorage = createMockAuthStorage({
getOAuthProviders: vi.fn().mockReturnValue([{ id: "anthropic", name: "Anthropic" }]),
@@ -621,13 +627,15 @@ describe("GET /models", () => {
expect(res.status).toBe(200);
const providers = res.body.models.map((m: { provider: string }) => m.provider);
expect(providers).not.toContain("anthropic");
// Subscription OAuth drives the direct `anthropic` provider; the CLI stays hidden
// (disabled) and `anthropic-subscription` is never advertised as its own picker row.
expect(providers).toContain("anthropic");
expect(providers).not.toContain("anthropic-subscription");
expect(providers).not.toContain("pi-claude-cli");
});
});
it("hides direct Anthropic rows for OAuth-only subscription auth while showing distinct Claude CLI rows", async () => {
it("exposes direct Anthropic rows for OAuth-only subscription auth alongside distinct Claude CLI rows", async () => {
await vi.mocked(fsPromises.readFile).withImplementation(async (path: unknown) => {
const value = String(path);
if (value.endsWith("auth.json")) {
@@ -653,14 +661,16 @@ describe("GET /models", () => {
const res = await GET(buildAppWithSetting(true, registry), "/api/models");
expect(res.status).toBe(200);
const providers = res.body.models.map((m: { provider: string }) => m.provider);
// auth.json legacy + subscription OAuth both make the direct `anthropic` provider
// selectable; `anthropic-subscription` remains an auth id, not a picker row.
expect(providers).toContain("anthropic");
expect(providers).toContain("pi-claude-cli");
expect(providers).toContain("openai");
expect(providers).not.toContain("anthropic");
expect(providers).not.toContain("anthropic-subscription");
});
});
it("hides all Anthropic model rows for OAuth-only auth when Claude CLI picker visibility is disabled", async () => {
it("exposes direct Anthropic rows for OAuth-only auth even when the Claude CLI picker is disabled", async () => {
await vi.mocked(fsPromises.readFile).withImplementation(async (path: unknown) => {
const value = String(path);
if (value.endsWith("auth.json")) {
@@ -676,7 +686,9 @@ describe("GET /models", () => {
const res = await GET(buildAppWithSetting(false, registryWithCli()), "/api/models");
expect(res.status).toBe(200);
const providers = res.body.models.map((m: { provider: string }) => m.provider);
expect(providers).not.toContain("anthropic");
// Subscription OAuth (file-scan path) advertises the direct `anthropic` provider; the
// CLI stays hidden while disabled, and `anthropic-subscription` is not a picker row.
expect(providers).toContain("anthropic");
expect(providers).not.toContain("anthropic-subscription");
expect(providers).not.toContain("pi-claude-cli");
});

View File

@@ -59,9 +59,11 @@ function addAuthStorageConfiguredProviders(authStorage: AuthStorageLike | undefi
}
}
const anthropicCredential = authStorage.get?.(ANTHROPIC_PROVIDER_ID);
const anthropicApiKeyCredential = authStorage.get?.(ANTHROPIC_API_KEY_PROVIDER_ID);
if (isRawAnthropicApiKeyCredential(anthropicCredential) || isRawAnthropicApiKeyCredential(anthropicApiKeyCredential)) {
/*
FNXC:ProviderAuth 2026-07-01-15:10:
Advertise the direct `anthropic` provider whenever auth storage reports usable anthropic auth — raw API key, subscription OAuth, legacy OAuth, or fallback. Restored v0.51.0 behavior (issue #1857): a subscription/OAuth token executes on the built-in `anthropic` provider via pi-ai's Claude Code impersonation, so OAuth-only users must be able to pick Claude models. `hasAuth("anthropic")` already unifies these sources.
*/
if (authStorage.hasAuth?.(ANTHROPIC_PROVIDER_ID) || authStorage.hasAuth?.(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID)) {
providers.add(ANTHROPIC_PROVIDER_ID);
}
}
@@ -85,13 +87,19 @@ async function getConfiguredProviderNames(authStorage?: AuthStorageLike): Promis
const parsed = JSON.parse(await readFile(authPath, "utf-8")) as Record<string, unknown>;
for (const [key, credential] of Object.entries(parsed)) {
if (key === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
// A separated subscription OAuth row makes the direct `anthropic` provider usable.
providers.add(ANTHROPIC_PROVIDER_ID);
continue;
}
if (key !== ANTHROPIC_PROVIDER_ID) {
providers.add(key);
continue;
}
if (credential && typeof credential === "object" && (credential as { type?: unknown }).type === "api_key") {
// Raw API key OR OAuth (legacy subscription) both configure the direct `anthropic` provider.
const credType = credential && typeof credential === "object"
? (credential as { type?: unknown }).type
: undefined;
if (credType === "api_key" || credType === "oauth") {
providers.add(key);
}
}
@@ -101,14 +109,11 @@ async function getConfiguredProviderNames(authStorage?: AuthStorageLike): Promis
}
/*
FNXC:ProviderAuth 2026-07-01-12:06:
The model picker must not advertise direct `anthropic` rows for OAuth-only Claude subscription setups. Only raw API-key material (auth.json `type: api_key`, models.json apiKey, or ANTHROPIC_API_KEY) configures the direct api.anthropic.com/v1 provider.
FNXC:ProviderAuth 2026-07-01-12:18:
Keep Anthropic's three surfaces distinct in discovery: raw API-key auth configures direct `anthropic`, subscription OAuth stays an auth/usage credential (`anthropic-subscription`) and is not a model provider row, and Claude CLI models appear only as `pi-claude-cli` when the CLI picker toggle is enabled.
FNXC:ProviderAuth 2026-07-01-15:10:
Anthropic's three surfaces in discovery (restored v0.51.0 behavior, issue #1857): the direct `anthropic` provider is advertised for raw API-key auth (auth.json `type: api_key`, models.json apiKey, `ANTHROPIC_API_KEY`) AND for subscription/legacy OAuth (which executes on the built-in `anthropic` provider via pi-ai's Claude Code impersonation to /v1). `anthropic-subscription` is an auth/usage credential id, never its own picker row. Claude CLI models appear as `pi-claude-cli` only when the CLI picker toggle is enabled.
FNXC:ModelCatalog 2026-07-01-13:41:
`/api/models` must follow the same connected-state source as Settings/auth status when ServerOptions.authStorage is injected. Use auth storage first for OAuth/API-key surfaces, keep OAuth-only Anthropic out of direct `anthropic/*`, and then fall back to legacy files/env so v0.50-style local API-key discovery still works.
`/api/models` must follow the same connected-state source as Settings/auth status when ServerOptions.authStorage is injected. Use auth storage first for OAuth/API-key surfaces, then fall back to legacy files/env so v0.50-style local API-key discovery still works.
*/
if (process.env.ANTHROPIC_API_KEY) {
providers.add(ANTHROPIC_PROVIDER_ID);

View File

@@ -165,7 +165,9 @@ describe("createFusionAuthStorage", () => {
const authStorage = createFusionAuthStorage();
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
// Restored v0.51.0 behavior: a Claude subscription OAuth token resolves for the
// direct `anthropic` provider (pi-ai POSTs it to /v1 with Claude Code impersonation).
expect(await authStorage.getApiKey("anthropic")).toBe("claude-access-token");
expect(authStorage.get("anthropic")).toEqual({
type: "oauth",
access: "claude-access-token",
@@ -216,7 +218,7 @@ describe("createFusionAuthStorage", () => {
expect(authStorage.hasAuth("anthropic")).toBe(true);
});
it("does not use Anthropic subscription OAuth for direct model runtime auth when no raw API key exists", async () => {
it("uses Anthropic subscription OAuth for direct model runtime auth when no raw API key exists", async () => {
writeFusionAuth(homeDir, {
"anthropic-subscription": {
type: "oauth",
@@ -228,7 +230,8 @@ describe("createFusionAuthStorage", () => {
const authStorage = createFusionAuthStorage();
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
// The direct `anthropic` provider resolves the separated subscription OAuth token.
expect(await authStorage.getApiKey("anthropic")).toBe("subscription-access-token");
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("subscription-access-token");
expect(authStorage.hasAuth("anthropic")).toBe(true);
expect(authStorage.list()).toEqual(expect.arrayContaining(["anthropic", "anthropic-subscription"]));
@@ -246,7 +249,8 @@ describe("createFusionAuthStorage", () => {
const authStorage = createFusionAuthStorage();
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
// Legacy `anthropic` OAuth rows still drive the direct provider at runtime.
expect(await authStorage.getApiKey("anthropic")).toBe("legacy-subscription-access-token");
expect(authStorage.get("anthropic-subscription")).toEqual({
type: "oauth",
access: "legacy-subscription-access-token",
@@ -258,7 +262,7 @@ describe("createFusionAuthStorage", () => {
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("legacy-subscription-access-token");
});
it("refreshes legacy Anthropic OAuth into the subscription provider id", async () => {
it("refreshes legacy Anthropic OAuth in place for direct runtime auth", async () => {
writeFusionAuth(homeDir, {
anthropic: {
type: "oauth",
@@ -271,8 +275,8 @@ describe("createFusionAuthStorage", () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
access_token: "refreshed-legacy-as-subscription-access-token",
refresh_token: "rotated-legacy-subscription-refresh-token",
access_token: "refreshed-legacy-access-token",
refresh_token: "rotated-legacy-refresh-token",
expires_in: 3600,
scope: "user:profile",
}),
@@ -281,19 +285,13 @@ describe("createFusionAuthStorage", () => {
const authStorage = createFusionAuthStorage();
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("refreshed-legacy-as-subscription-access-token");
expect(authStorage.get("anthropic-subscription")).toEqual({
type: "oauth",
access: "refreshed-legacy-as-subscription-access-token",
refresh: "rotated-legacy-subscription-refresh-token",
expires: expect.any(Number),
scopes: ["user:profile"],
});
// A legacy `anthropic` OAuth row refreshes and persists back under `anthropic`
// (the v0.51.0 direct-provider path); it is not re-homed into a subscription slot.
expect(await authStorage.getApiKey("anthropic")).toBe("refreshed-legacy-access-token");
expect(authStorage.get("anthropic")).toEqual({
type: "oauth",
access: "expired-legacy-subscription-access-token",
refresh: "legacy-subscription-refresh-token",
access: "refreshed-legacy-access-token",
refresh: "rotated-legacy-refresh-token",
expires: expect.any(Number),
scopes: ["user:profile"],
});
@@ -363,7 +361,9 @@ describe("createFusionAuthStorage", () => {
const authStorage = createFusionAuthStorage();
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
// Refreshed subscription OAuth drives the direct provider and persists under the
// subscription id only — the raw `anthropic` slot stays empty.
expect(await authStorage.getApiKey("anthropic")).toBe("refreshed-subscription-access-token");
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("refreshed-subscription-access-token");
expect(fetchMock).toHaveBeenCalledWith(
"https://platform.claude.com/v1/oauth/token",
@@ -429,11 +429,13 @@ describe("createFusionAuthStorage", () => {
const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic");
// Raw-key logout removes only the raw slot; subscription OAuth still powers
// the direct `anthropic` runtime provider.
expect(authStorage.get("anthropic")).toBeUndefined();
expect(authStorage.has("anthropic")).toBe(true);
expect(authStorage.hasAuth("anthropic")).toBe(true);
expect(authStorage.list()).toEqual(expect.arrayContaining(["anthropic", "anthropic-subscription"]));
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
expect(await authStorage.getApiKey("anthropic")).toBe("subscription-access-token");
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("subscription-access-token");
});
@@ -454,7 +456,7 @@ describe("createFusionAuthStorage", () => {
});
expect(authStorage.get("anthropic")).toBeUndefined();
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
expect(await authStorage.getApiKey("anthropic")).toBe("subscription-access-token");
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("subscription-access-token");
});
@@ -488,10 +490,12 @@ describe("createFusionAuthStorage", () => {
});
const authStorage = createFusionAuthStorage();
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
// Before logout the legacy OAuth row drives direct runtime auth…
expect(await authStorage.getApiKey("anthropic")).toBe("legacy-subscription-access-token");
authStorage.logout("anthropic-subscription");
// …and subscription logout suppresses the legacy OAuth alias everywhere.
expect(authStorage.get("anthropic")).toBeUndefined();
expect(authStorage.has("anthropic")).toBe(false);
expect(authStorage.hasAuth("anthropic")).toBe(false);
@@ -590,12 +594,12 @@ describe("createFusionAuthStorage", () => {
});
authStorage.reload();
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
expect(await authStorage.getApiKey("anthropic")).toBe("subscription-access-token");
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("subscription-access-token");
});
});
it("does not refresh Claude OAuth credentials as direct Anthropic API keys", async () => {
it("refreshes and persists expired Claude OAuth credentials from Claude credential files", async () => {
const claudeDir = join(homeDir, ".claude");
mkdirSync(claudeDir, { recursive: true });
@@ -611,24 +615,43 @@ describe("createFusionAuthStorage", () => {
}),
);
const fetchMock = vi.fn().mockResolvedValue({ ok: true } as Response);
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
access_token: "refreshed-claude-access-token",
refresh_token: "rotated-claude-refresh-token",
expires_in: 3600,
scope: "user:profile org:create_api_key",
}),
} as Response);
globalThis.fetch = fetchMock as typeof fetch;
const authStorage = createFusionAuthStorage();
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
expect(await authStorage.getApiKey("anthropic")).toBe("refreshed-claude-access-token");
expect(fetchMock).toHaveBeenCalledWith(
"https://platform.claude.com/v1/oauth/token",
expect.objectContaining({
method: "POST",
body: expect.stringContaining("\"scope\":\"user:profile org:create_api_key\""),
}),
);
expect(authStorage.get("anthropic")).toEqual({
type: "oauth",
access: "expired-claude-access-token",
refresh: "claude-refresh-token",
access: "refreshed-claude-access-token",
refresh: "rotated-claude-refresh-token",
expires: expect.any(Number),
scopes: ["user:profile", "org:create_api_key"],
});
const persisted = JSON.parse(readFileSync(getFusionAuthPath(homeDir), "utf-8"));
expect(persisted.anthropic).toEqual({
type: "oauth",
access: "refreshed-claude-access-token",
refresh: "rotated-claude-refresh-token",
expires: expect.any(Number),
scopes: ["user:profile", "org:create_api_key"],
});
const persisted = existsSync(getFusionAuthPath(homeDir))
? JSON.parse(readFileSync(getFusionAuthPath(homeDir), "utf-8"))
: {};
expect(persisted.anthropic?.access).not.toBe("refreshed-claude-access-token");
});
it("does not persist an invalid Claude OAuth refresh response", async () => {
@@ -680,7 +703,8 @@ describe("createFusionAuthStorage", () => {
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
// Failed refresh is attempted once, then cooled down (second call does not re-hit the endpoint).
expect(fetchMock).toHaveBeenCalledTimes(1);
const persisted = existsSync(getFusionAuthPath(homeDir)) ? JSON.parse(readFileSync(getFusionAuthPath(homeDir), "utf-8")) : {};
expect(persisted.anthropic).toBeUndefined();
});
@@ -716,14 +740,14 @@ describe("createFusionAuthStorage", () => {
authStorage.getApiKey("anthropic"),
authStorage.getApiKey("anthropic"),
])).resolves.toEqual([
undefined,
undefined,
undefined,
"refreshed-claude-access-token",
"refreshed-claude-access-token",
"refreshed-claude-access-token",
]);
expect(fetchMock).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("does not refresh stale Claude OAuth material for direct Anthropic API auth", async () => {
it("does not let a stale Claude OAuth refresh overwrite a newer login", async () => {
const claudeDir = join(homeDir, ".claude");
mkdirSync(claudeDir, { recursive: true });
@@ -738,13 +762,39 @@ describe("createFusionAuthStorage", () => {
}),
);
const fetchMock = vi.fn().mockResolvedValue({ ok: true } as Response);
let resolveJson: ((value: unknown) => void) | undefined;
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => new Promise((resolve) => {
resolveJson = resolve;
}),
} as Response);
globalThis.fetch = fetchMock as typeof fetch;
const authStorage = createFusionAuthStorage();
const pendingRefresh = authStorage.getApiKey("anthropic");
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
await expect(authStorage.getApiKey("anthropic")).resolves.toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
authStorage.set("anthropic", {
type: "oauth",
access: "fresh-login-access-token",
refresh: "fresh-login-refresh-token",
expires: Date.now() + 3_600_000,
});
resolveJson?.({
access_token: "stale-refresh-access-token",
refresh_token: "stale-refresh-refresh-token",
expires_in: 3600,
});
await expect(pendingRefresh).resolves.toBe("fresh-login-access-token");
expect(authStorage.get("anthropic")).toEqual({
type: "oauth",
access: "fresh-login-access-token",
refresh: "fresh-login-refresh-token",
expires: expect.any(Number),
});
});
it("hydrates newer Codex CLI OAuth credentials into Fusion auth on reload", async () => {
@@ -996,10 +1046,10 @@ describe("createFusionAuthStorage", () => {
const authStorage = createFusionAuthStorage();
// Before logout, supplemental credentials are visible
// Before logout, supplemental credentials are visible and drive runtime auth
expect(authStorage.has("anthropic")).toBe(true);
expect(authStorage.hasAuth("anthropic")).toBe(true);
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
expect(await authStorage.getApiKey("anthropic")).toBe("claude-access-token");
// Log out
authStorage.logout("anthropic");

View File

@@ -1651,28 +1651,27 @@ describe("createFnAgent", () => {
});
});
it("does not synthesize direct Anthropic Claude Sonnet 5 when the registry lacks it", async () => {
getAllMock.mockReturnValueOnce([]);
findMock.mockImplementation((provider: string, modelId: string) => {
if (provider === "anthropic" && modelId === "claude-sonnet-5") {
return undefined;
}
return { provider, id: modelId };
});
it("synthesizes direct Anthropic Claude Sonnet 5 from supplemental metadata when the live registry lacks it", async () => {
// Live registry has no anthropic models; mergeSupplementalAnthropicModels re-adds Sonnet 5.
getAllMock.mockReturnValue([]);
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
const { createFnAgent } = await import("../pi.js");
await expect(createFnAgent({
await createFnAgent({
cwd: "/tmp",
systemPrompt: "test",
tools: "readonly",
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-5",
})).rejects.toThrow("Configured model anthropic/claude-sonnet-5 (primary selection) was not found in the pi model registry");
});
expect(registerProviderMock).not.toHaveBeenCalledWith("anthropic", expect.objectContaining({
// SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION advertises claude-sonnet-5 on the direct provider again.
expect(registerProviderMock).toHaveBeenCalledWith("anthropic", expect.objectContaining({
models: expect.arrayContaining([expect.objectContaining({ id: "claude-sonnet-5" })]),
}));
expect(createAgentSessionMock).not.toHaveBeenCalled();
expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({
model: { provider: "anthropic", id: "claude-sonnet-5" },
}));
});
it("does not duplicate Claude Sonnet 5 when the Anthropic registry already has it", async () => {
@@ -1703,25 +1702,19 @@ describe("createFnAgent", () => {
expect(anthropicRegistrations).toHaveLength(0);
});
it("routes subscription-OAuth persisted Anthropic selections to the direct subscription provider even when the CLI picker toggle is unset", async () => {
// Restored v0.51.0 behavior: a subscription-OAuth `anthropic/<model>` selection stays on
// the built-in `anthropic` provider (pi-ai POSTs the OAuth token to /v1 with Claude Code
// impersonation). No `/v1`-based `anthropic-subscription` provider is registered, and there
// is no runtime reroute to `pi-claude-cli`.
it("keeps subscription-OAuth Anthropic selections on the direct anthropic provider", async () => {
authStorageGetMock.mockImplementation((provider: string) => provider === "anthropic-subscription"
? { type: "oauth", access: "subscription-access-token", refresh: "refresh", expires: Date.now() + 3_600_000 }
: undefined);
authStorageHasAuthMock.mockImplementation((provider: string) => provider === "anthropic-subscription");
authStorageGetApiKeyMock.mockResolvedValue(undefined);
// Model selection no longer reads getApiKey (the reroute was removed); in production
// getApiKey("anthropic") returns the OAuth token, resolved later at session execution.
getAllMock.mockReturnValue([{ provider: "anthropic", id: "claude-opus-4-8", name: "Claude Opus 4.8" }]);
findMock.mockImplementation((provider: string, modelId: string) => {
if (provider === "anthropic" && modelId === "claude-opus-4-8") {
return { provider, id: modelId, baseUrl: "https://api.anthropic.com/v1" };
}
if (provider === "anthropic-subscription" && modelId === "claude-opus-4-8") {
return { provider, id: modelId, baseUrl: "https://api.anthropic.com/v1" };
}
if (provider === "pi-claude-cli" && modelId === "claude-opus-4-8") {
return { provider, id: modelId };
}
return { provider, id: modelId };
});
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
@@ -1732,14 +1725,10 @@ describe("createFnAgent", () => {
defaultModelId: "claude-opus-4-8",
});
expect(registerProviderMock).toHaveBeenCalledWith("anthropic-subscription", expect.objectContaining({
api: "anthropic-messages",
apiKey: "$ANTHROPIC_SUBSCRIPTION_API_KEY",
models: [expect.objectContaining({ id: "claude-opus-4-8" })],
}));
expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({
model: { provider: "anthropic-subscription", id: "claude-opus-4-8", baseUrl: "https://api.anthropic.com/v1" },
model: { provider: "anthropic", id: "claude-opus-4-8" },
}));
expect(registerProviderMock).not.toHaveBeenCalledWith("anthropic-subscription", expect.anything());
expect(createAgentSessionMock).not.toHaveBeenCalledWith(expect.objectContaining({
model: expect.objectContaining({ provider: "pi-claude-cli" }),
}));
@@ -1783,16 +1772,16 @@ describe("createFnAgent", () => {
}));
});
// Subscription OAuth must NOT depend on the Claude CLI provider being present — direct
// OAuth to /v1 is its own surface. With pi-claude-cli unavailable it still runs on `anthropic`.
it("does not require the Claude CLI provider for subscription-OAuth Anthropic execution", async () => {
authStorageGetMock.mockImplementation((provider: string) => provider === "anthropic-subscription"
? { type: "oauth", access: "subscription-access-token", refresh: "refresh", expires: Date.now() + 3_600_000 }
: undefined);
authStorageHasAuthMock.mockImplementation((provider: string) => provider === "anthropic-subscription");
authStorageGetApiKeyMock.mockResolvedValue(undefined);
// Model selection no longer reads getApiKey (the reroute was removed); production
// resolves the OAuth token later, at session execution.
findMock.mockImplementation((provider: string, modelId: string) => {
if (provider === "anthropic" && modelId === "claude-opus-4-8") {
return { provider, id: modelId };
}
if (provider === "pi-claude-cli") {
return undefined;
}
@@ -1810,7 +1799,7 @@ describe("createFnAgent", () => {
});
expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({
model: { provider: "anthropic-subscription", id: "claude-opus-4-8" },
model: { provider: "anthropic", id: "claude-opus-4-8" },
}));
});

View File

@@ -0,0 +1,261 @@
/*
FNXC:ReviewRouting 2026-07-01-16:36:
External review checkout contract tests pin fail-closed resolution: absent, blank, relative, or non-git metadata resolves to the task worktree fallback; valid explicit absolute git checkouts resolve to their realpath; sourceMetadata.externalReviewCheckout is the canonical external field; invalid higher-priority metadata must not silently widen to lower-priority metadata.
*/
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveReviewCheckoutCwd, getTaskReviewCheckoutPath } from "../review-checkout.js";
const FALLBACK = "/some/fallback/worktree";
const cleanupDirs: string[] = [];
function makeGitCheckout(): string {
const dir = mkdtempSync(join(tmpdir(), "review-git-checkout-"));
cleanupDirs.push(dir);
execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" });
return dir;
}
function makeNonGitDir(): string {
const dir = mkdtempSync(join(tmpdir(), "review-nongit-"));
cleanupDirs.push(dir);
return dir;
}
function makeRegularFile(): string {
const dir = mkdtempSync(join(tmpdir(), "review-file-"));
cleanupDirs.push(dir);
const file = join(dir, "file.txt");
writeFileSync(file, "not a directory");
return file;
}
beforeEach(() => {
cleanupDirs.length = 0;
});
afterEach(() => {
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (dir) rmSync(dir, { recursive: true, force: true });
}
});
describe("getTaskReviewCheckoutPath — extracts metadata path candidates", () => {
it("returns undefined for null/undefined task", () => {
expect(getTaskReviewCheckoutPath(null)).toBeUndefined();
expect(getTaskReviewCheckoutPath(undefined)).toBeUndefined();
});
it("returns undefined when no metadata fields are present", () => {
expect(getTaskReviewCheckoutPath({})).toBeUndefined();
expect(getTaskReviewCheckoutPath({ id: "TASK-1", title: "T" })).toBeUndefined();
});
it("returns undefined for blank/whitespace-only reviewCheckoutPath", () => {
expect(getTaskReviewCheckoutPath({ customFields: { reviewCheckoutPath: "" } })).toBeUndefined();
expect(getTaskReviewCheckoutPath({ customFields: { reviewCheckoutPath: " " } })).toBeUndefined();
expect(getTaskReviewCheckoutPath({ customFields: { externalReviewCheckoutPath: "" } })).toBeUndefined();
});
it("returns undefined for non-string reviewCheckoutPath", () => {
expect(getTaskReviewCheckoutPath({ customFields: { reviewCheckoutPath: 42 } })).toBeUndefined();
expect(getTaskReviewCheckoutPath({ customFields: { reviewCheckoutPath: true } })).toBeUndefined();
expect(getTaskReviewCheckoutPath({ customFields: { reviewCheckoutPath: {} } })).toBeUndefined();
});
it("reads reviewCheckoutPath from customFields", () => {
const task = { customFields: { reviewCheckoutPath: "/custom/path" } };
expect(getTaskReviewCheckoutPath(task)).toBe("/custom/path");
});
it("reads externalReviewCheckoutPath from customFields", () => {
const task = { customFields: { externalReviewCheckoutPath: "/external/path" } };
expect(getTaskReviewCheckoutPath(task)).toBe("/external/path");
});
it("reads nested reviewCheckout.path from customFields", () => {
const task = { customFields: { reviewCheckout: { path: "/nested/path" } } };
expect(getTaskReviewCheckoutPath(task)).toBe("/nested/path");
});
it("reads sourceMetadata.externalReviewCheckout as a candidate", () => {
const task = { sourceMetadata: { externalReviewCheckout: "/meta/checkout" } };
expect(getTaskReviewCheckoutPath(task)).toBe("/meta/checkout");
});
it("trims whitespace from metadata values", () => {
const task = { customFields: { reviewCheckoutPath: " /trimmed/path " } };
expect(getTaskReviewCheckoutPath(task)).toBe("/trimmed/path");
});
it("customFields takes priority over branchContext, sourceMetadata, and root", () => {
const task = {
customFields: { reviewCheckoutPath: "/custom" },
branchContext: { reviewCheckoutPath: "/branch" },
sourceMetadata: { externalReviewCheckout: "/meta" },
reviewCheckoutPath: "/root",
};
expect(getTaskReviewCheckoutPath(task)).toBe("/custom");
});
it("branchContext takes priority over sourceMetadata and root-level fields", () => {
const task = {
branchContext: { reviewCheckoutPath: "/branch" },
sourceMetadata: { externalReviewCheckout: "/meta" },
reviewCheckoutPath: "/root",
};
expect(getTaskReviewCheckoutPath(task)).toBe("/branch");
});
it("sourceMetadata takes priority over root-level fields", () => {
const task = {
sourceMetadata: { externalReviewCheckout: "/meta" },
reviewCheckoutPath: "/root",
};
expect(getTaskReviewCheckoutPath(task)).toBe("/meta");
});
});
describe("resolveReviewCheckoutCwd — fail-closed defaults", () => {
it("returns fallback when task has no metadata", () => {
expect(resolveReviewCheckoutCwd({}, FALLBACK)).toBe(FALLBACK);
});
it("returns fallback for null/undefined task", () => {
expect(resolveReviewCheckoutCwd(null, FALLBACK)).toBe(FALLBACK);
expect(resolveReviewCheckoutCwd(undefined, FALLBACK)).toBe(FALLBACK);
});
it("returns fallback when reviewCheckoutPath is blank", () => {
const task = { customFields: { reviewCheckoutPath: "" } };
expect(resolveReviewCheckoutCwd(task, FALLBACK)).toBe(FALLBACK);
});
it("returns fallback for relative path (not absolute)", () => {
const task = { customFields: { reviewCheckoutPath: "relative/path" } };
expect(resolveReviewCheckoutCwd(task, FALLBACK)).toBe(FALLBACK);
});
it("returns fallback for non-existent path", () => {
const task = { customFields: { reviewCheckoutPath: "/nonexistent/path/that/does/not/exist" } };
expect(resolveReviewCheckoutCwd(task, FALLBACK)).toBe(FALLBACK);
});
it("returns fallback for path that is a regular file, not a directory", () => {
const file = makeRegularFile();
const task = { customFields: { reviewCheckoutPath: file } };
expect(resolveReviewCheckoutCwd(task, FALLBACK)).toBe(FALLBACK);
});
it("returns fallback for non-git directory", () => {
const dir = makeNonGitDir();
const task = { customFields: { reviewCheckoutPath: dir } };
expect(resolveReviewCheckoutCwd(task, FALLBACK)).toBe(FALLBACK);
});
it("returns resolved realpath for valid git checkout", () => {
const checkout = makeGitCheckout();
const expected = realpathSync(checkout);
const task = { customFields: { reviewCheckoutPath: checkout } };
expect(resolveReviewCheckoutCwd(task, FALLBACK)).toBe(expected);
});
it("resolves sourceMetadata.externalReviewCheckout for valid git checkout", () => {
const checkout = makeGitCheckout();
const expected = realpathSync(checkout);
const task = { sourceMetadata: { externalReviewCheckout: checkout } };
expect(resolveReviewCheckoutCwd(task, FALLBACK)).toBe(expected);
});
it("does not infer external checkout from prompt text or task description", () => {
const task = {
description: "Please review changes in /tmp/external-runtime",
prompt: "Look at /tmp/some-checkout",
};
expect(resolveReviewCheckoutCwd(task, FALLBACK)).toBe(FALLBACK);
});
it("conflicting metadata between customFields and sourceMetadata: customFields wins when valid", () => {
const checkoutA = makeGitCheckout();
const checkoutB = makeGitCheckout();
const expectedA = realpathSync(checkoutA);
const task = {
customFields: { reviewCheckoutPath: checkoutA },
sourceMetadata: { externalReviewCheckout: checkoutB },
};
expect(resolveReviewCheckoutCwd(task, FALLBACK)).toBe(expectedA);
});
it("valid customFields + invalid sourceMetadata: uses customFields", () => {
const checkout = makeGitCheckout();
const expected = realpathSync(checkout);
const task = {
customFields: { reviewCheckoutPath: checkout },
sourceMetadata: { externalReviewCheckout: "/nonexistent" },
};
expect(resolveReviewCheckoutCwd(task, FALLBACK)).toBe(expected);
});
// Priority-based resolution means the first candidate from the highest-priority
// source (customFields > branchContext > sourceMetadata > root) is the only
// candidate validated. When that candidate is invalid, the resolver fails
// closed to the fallback instead of trying lower-priority sources. That keeps a
// stale high-priority value from being bypassed by a coincidentally valid lower
// priority path.
it("invalid customFields → falls back even when sourceMetadata has a valid checkout (fail-closed priority)", () => {
const checkout = makeGitCheckout();
const task = {
customFields: { reviewCheckoutPath: "/nonexistent" },
sourceMetadata: { externalReviewCheckout: checkout },
};
// customFields wins priority; /nonexistent fails validation → fallback
expect(resolveReviewCheckoutCwd(task, FALLBACK)).toBe(FALLBACK);
});
it("workspace-mode task without explicit metadata: returns fallback (task worktree)", () => {
const task = {
workspaceWorktrees: {
"repo-a": { worktreePath: "/tmp/ws/repo-a/.worktrees/task-1" },
},
};
expect(resolveReviewCheckoutCwd(task, FALLBACK)).toBe(FALLBACK);
});
});
describe("resolveReviewCheckoutCwd — does NOT fabricate approval or widen scope", () => {
it("metadata pointing to a parent directory of the fallback: still validates as git dir", () => {
const parent = makeGitCheckout();
const task = { customFields: { reviewCheckoutPath: parent } };
const result = resolveReviewCheckoutCwd(task, FALLBACK);
expect(result).toBe(realpathSync(parent));
expect(result).not.toBe(FALLBACK);
});
it("empty sourceMetadata object: returns fallback", () => {
const task = { sourceMetadata: {} };
expect(resolveReviewCheckoutCwd(task, FALLBACK)).toBe(FALLBACK);
});
it("sourceMetadata with unrelated fields only: returns fallback", () => {
const task = { sourceMetadata: { fileScope: ["src/**"], contentFingerprint: "abc" } };
expect(resolveReviewCheckoutCwd(task, FALLBACK)).toBe(FALLBACK);
});
it("blank sourceMetadata.externalReviewCheckout: returns fallback", () => {
const task = { sourceMetadata: { externalReviewCheckout: "" } };
expect(resolveReviewCheckoutCwd(task, FALLBACK)).toBe(FALLBACK);
});
it("non-string sourceMetadata.externalReviewCheckout: returns fallback", () => {
const task = { sourceMetadata: { externalReviewCheckout: 42 } };
expect(resolveReviewCheckoutCwd(task, FALLBACK)).toBe(FALLBACK);
});
it("relative sourceMetadata.externalReviewCheckout: returns fallback", () => {
const task = { sourceMetadata: { externalReviewCheckout: "relative/path" } };
expect(resolveReviewCheckoutCwd(task, FALLBACK)).toBe(FALLBACK);
});
});

View File

@@ -229,7 +229,7 @@ describe("U2 KTD3 — in-session fn_review_step (createReviewStepTool) loops per
expect(seen).toEqual([WT_A]);
});
it("explicit external review checkout overrides the Atlas task worktree for fn_review_step", async () => {
it("explicit external review checkout overrides the task worktree for fn_review_step", async () => {
const externalCheckout = makeGitCheckout();
const expectedCheckout = realpathSync(externalCheckout);
const task = makeTask({ customFields: { reviewCheckoutPath: externalCheckout } } as any);
@@ -334,3 +334,137 @@ describe("U2 KTD3 — step-inversion review seam (executor.ts:5668) loops per su
expect(seen).toEqual([expectedCheckout]);
});
});
describe("sourceMetadata.externalReviewCheckout for fn_review_step", () => {
it("sourceMetadata.externalReviewCheckout overrides the task worktree for fn_review_step", async () => {
const externalCheckout = makeGitCheckout();
const expectedCheckout = realpathSync(externalCheckout);
const task = makeTask({ sourceMetadata: { externalReviewCheckout: externalCheckout } } as any);
const store = makeStore(task);
const executor = new TaskExecutor(store, ROOT);
const seen = scriptReviewByCwd({ [expectedCheckout]: { verdict: "APPROVE", review: "external ok", summary: "external" } });
const tool = (executor as any).createReviewStepTool(
task.id,
WT_A,
"PROMPT",
new Map(),
{ current: null },
new Map(),
task,
undefined,
);
await tool.execute("call-1", { step: 1, type: "code", step_name: "Step 1", baseline: "base" });
expect(seen).toEqual([expectedCheckout]);
});
it("no metadata → fn_review_step reviews the task worktree (default fallback)", async () => {
const task = makeTask(); // no customFields, no sourceMetadata
const store = makeStore(task);
const executor = new TaskExecutor(store, ROOT);
const seen = scriptReviewByCwd({ [WT_A]: { verdict: "APPROVE", review: "ok", summary: "ok" } });
const tool = (executor as any).createReviewStepTool(
task.id,
WT_A,
"PROMPT",
new Map(),
{ current: null },
new Map(),
task,
undefined,
);
await tool.execute("call-1", { step: 1, type: "code", step_name: "Step 1", baseline: "base" });
expect(seen).toEqual([WT_A]);
});
it("invalid external metadata (non-existent path) → falls back to task worktree", async () => {
const task = makeTask({ sourceMetadata: { externalReviewCheckout: "/nonexistent/path/918" } } as any);
const store = makeStore(task);
const executor = new TaskExecutor(store, ROOT);
const seen = scriptReviewByCwd({ [WT_A]: { verdict: "APPROVE", review: "ok", summary: "ok" } });
const tool = (executor as any).createReviewStepTool(
task.id,
WT_A,
"PROMPT",
new Map(),
{ current: null },
new Map(),
task,
undefined,
);
await tool.execute("call-1", { step: 1, type: "code", step_name: "Step 1", baseline: "base" });
expect(seen).toEqual([WT_A]);
});
it("invalid external metadata (non-git directory) → falls back to task worktree", async () => {
const nonGitDir = mkdtempSync(join(tmpdir(), "review-nongit-integ-"));
cleanupDirs.push(nonGitDir);
const task = makeTask({ sourceMetadata: { externalReviewCheckout: nonGitDir } } as any);
const store = makeStore(task);
const executor = new TaskExecutor(store, ROOT);
const seen = scriptReviewByCwd({ [WT_A]: { verdict: "APPROVE", review: "ok", summary: "ok" } });
const tool = (executor as any).createReviewStepTool(
task.id,
WT_A,
"PROMPT",
new Map(),
{ current: null },
new Map(),
task,
undefined,
);
await tool.execute("call-1", { step: 1, type: "code", step_name: "Step 1", baseline: "base" });
expect(seen).toEqual([WT_A]);
});
});
describe("sourceMetadata.externalReviewCheckout for workflow stepReview", () => {
it("sourceMetadata.externalReviewCheckout overrides the active graph worktree for stepReview", async () => {
const externalCheckout = makeGitCheckout();
const expectedCheckout = realpathSync(externalCheckout);
const task = makeTask({ worktree: WT_A, sourceMetadata: { externalReviewCheckout: externalCheckout } } as any);
const store = makeStore(task);
const executor = new TaskExecutor(store, ROOT);
const seen = scriptReviewByCwd({ [expectedCheckout]: { verdict: "APPROVE", review: "external", summary: "external" } });
const seams = executor.createAuthoritativeWorkflowSeams({ autoMerge: false } as any);
const context = { [FOREACH_ACTIVE_CONTEXT_KEY]: { stepIndex: 1, worktreePath: WT_A, baselineSha: "base" } } as any;
await seams.stepReview!(task as any, context, { type: "code", advisory: true } as any);
expect(seen).toEqual([expectedCheckout]);
});
it("no metadata → stepReview reviews the task worktree (default fallback)", async () => {
const task = makeTask({ worktree: WT_A });
const store = makeStore(task);
const executor = new TaskExecutor(store, ROOT);
const seen = scriptReviewByCwd({ [WT_A]: { verdict: "APPROVE", review: "a", summary: "a" } });
const seams = executor.createAuthoritativeWorkflowSeams({ autoMerge: false } as any);
const context = { [FOREACH_ACTIVE_CONTEXT_KEY]: { stepIndex: 1, worktreePath: WT_A, baselineSha: "base" } } as any;
await seams.stepReview!(task as any, context, { type: "code", advisory: true } as any);
expect(seen).toEqual([WT_A]);
});
it("invalid external metadata (non-existent path) → stepReview falls back to task worktree", async () => {
const task = makeTask({ worktree: WT_A, sourceMetadata: { externalReviewCheckout: "/nonexistent/path/918b" } } as any);
const store = makeStore(task);
const executor = new TaskExecutor(store, ROOT);
const seen = scriptReviewByCwd({ [WT_A]: { verdict: "APPROVE", review: "a", summary: "a" } });
const seams = executor.createAuthoritativeWorkflowSeams({ autoMerge: false } as any);
const context = { [FOREACH_ACTIVE_CONTEXT_KEY]: { stepIndex: 1, worktreePath: WT_A, baselineSha: "base" } } as any;
await seams.stepReview!(task as any, context, { type: "code", advisory: true } as any);
expect(seen).toEqual([WT_A]);
});
it("workspace-mode task without explicit metadata: stepReview still reviews per sub-repo", async () => {
const task = makeTask({ workspaceWorktrees: TWO_REPO_WORKTREES, worktree: ROOT });
const store = makeStore(task);
const executor = workspaceExecutor(store);
const seen = scriptReviewByCwd({
[WT_A]: { verdict: "APPROVE", review: "a", summary: "a" },
[WT_B]: { verdict: "APPROVE", review: "b", summary: "b" },
});
const seams = executor.createAuthoritativeWorkflowSeams({ autoMerge: false } as any);
const context = { [FOREACH_ACTIVE_CONTEXT_KEY]: { stepIndex: 1, worktreePath: ROOT, baselineSha: "base" } } as any;
await seams.stepReview!(task as any, context, { type: "code", advisory: true } as any);
expect(seen).toEqual([WT_A, WT_B]);
expect(seen).not.toContain(ROOT);
});
});

View File

@@ -514,19 +514,43 @@ export function createFusionAuthStorage(): AuthStorage {
const resolveAnthropicRuntimeApiKey = async (): Promise<string | undefined> => {
const rawProviderLoggedOut = isAnthropicRawProviderLoggedOut();
/*
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).
*/
if (!rawProviderLoggedOut) {
const anthropicApiKeyCredential = selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "api_key");
if (anthropicApiKeyCredential) {
return resolveStoredCredentialApiKey(ANTHROPIC_PROVIDER_ID, anthropicApiKeyCredential);
}
}
const subscriptionLoggedOut = loggedOutProviders.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
const legacyAnthropicOAuthCredential = rawProviderLoggedOut
? undefined
: selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "oauth");
if (!subscriptionLoggedOut && legacyAnthropicOAuthCredential) {
const legacyKey = await resolveRefreshableCredentialApiKey(ANTHROPIC_PROVIDER_ID, legacyAnthropicOAuthCredential);
if (legacyKey) return legacyKey;
}
if (!subscriptionLoggedOut) {
const subscriptionCredential = selectStoredCredential(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
if (subscriptionCredential?.type === "oauth") {
/*
FNXC:ProviderAuth 2026-06-30-11:26:
The separated subscription login stores OAuth material under `anthropic-subscription` so the API-key card stays raw-key-only, but Anthropic model execution still requests provider `anthropic`. Resolve and refresh the subscription credential (persisting rotated tokens back to `anthropic-subscription`) so a subscription user's `anthropic/<model>` selection runs on their OAuth token.
*/
const subscriptionKey = await resolveRefreshableCredentialApiKey(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential);
if (subscriptionKey) return subscriptionKey;
}
}
if (!rawProviderLoggedOut) {
/*
FNXC:ProviderAuth 2026-07-01-11:55:
Subscription/OAuth Anthropic tokens must never authenticate the direct `api.anthropic.com/v1` provider because Anthropic blocks Claude Pro/Max OAuth material on that public API surface. Keep provider `anthropic` raw-key-only here; the `anthropic-subscription` OAuth getter below remains available for Claude CLI and usage endpoints that intentionally consume subscription OAuth.
FNXC:ProviderAuth 2026-06-30-13:28:
Logging out of the raw Anthropic provider must suppress raw-key sources consistently across status and runtime resolution.
Treat models.json Anthropic keys and ModelRegistry fallback resolver keys as raw-key fallback material, while subscription OAuth remains governed by the separate `anthropic-subscription` logout state.
Logging out of the raw Anthropic provider must suppress raw-key sources consistently across status and runtime resolution. Treat models.json Anthropic keys and ModelRegistry fallback resolver keys as raw-key fallback material; subscription OAuth stays governed by the separate `anthropic-subscription` logout state above.
*/
const modelsJsonApiKey = modelsJsonApiKeys.get(ANTHROPIC_PROVIDER_ID);
if (modelsJsonApiKey) return modelsJsonApiKey;

View File

@@ -24,7 +24,7 @@ import {
import { WorkflowGraphTaskRunner, type WorkflowGraphTaskRunResult } from "./workflow-graph-task-runner.js";
import { ensureWorkflowCompletionSummary } from "./workflow-completion-summary.js";
import { createCodeNodeRunner } from "./code-node-runner.js";
import { resolveReviewCheckoutCwd } from "./review-checkout.js";
import { getTaskReviewCheckoutPath, resolveReviewCheckoutCwd } from "./review-checkout.js";
import { getActiveNotificationService } from "./notifier.js";
import type { ParseStepsHandlerDeps, CodeNodeRunner } from "./workflow-node-handlers.js";
import type { WorkflowBranchPersistence, WorkflowBranchRunState } from "./workflow-graph-branches.js";
@@ -1736,6 +1736,26 @@ export class TaskExecutor {
}
}
/**
* FNXC:ReviewRouting 2026-07-01-16:36:
* Review routing must expose whether the reviewer is using an explicit external checkout or the task worktree, but the invalid-sourceMetadata warning is only valid when sourceMetadata supplied the selected candidate. Higher-priority metadata can fail closed before sourceMetadata is considered, so centralize the logging to keep both review seams consistent and avoid false invalid-path warnings.
*/
private logReviewCheckoutRouting(taskId: string, task: unknown, reviewCwd: string, worktreePath: string): void {
if (reviewCwd !== worktreePath) {
reviewerLog.log(`${taskId}: review routed to external checkout ${reviewCwd} (task worktree: ${worktreePath})`);
return;
}
const selectedCandidate = getTaskReviewCheckoutPath(task);
const sourceMetadata = task && typeof task === "object" ? (task as Record<string, unknown>).sourceMetadata : undefined;
const sourceRecord = sourceMetadata && typeof sourceMetadata === "object" ? sourceMetadata as Record<string, unknown> : undefined;
const sourceExternalReviewCheckout = sourceRecord?.externalReviewCheckout;
const sourceExternalReviewCheckoutPath = typeof sourceExternalReviewCheckout === "string" ? sourceExternalReviewCheckout.trim() : undefined;
if (sourceExternalReviewCheckoutPath && selectedCandidate === sourceExternalReviewCheckoutPath) {
reviewerLog.warn(`${taskId}: external review checkout metadata present (${sourceExternalReviewCheckoutPath}) but invalid — reviewing task worktree ${worktreePath}`);
}
}
private markCompletionFinalized(taskId: string): void {
this.markPausedAborted(taskId, "completion-finalize", "completion-finalize");
this.completionFinalizedTaskIds.add(taskId);
@@ -6363,6 +6383,7 @@ export class TaskExecutor {
// Worktree isolation (KTD-11): review the instance's OWN worktree when set.
const worktreePath = active.worktreePath || detail.worktree || this.rootDir;
const reviewCwd = resolveReviewCheckoutCwd(detail, worktreePath);
this.logReviewCheckoutRouting(seamTask.id, detail, reviewCwd, worktreePath);
const stepName = detail.steps[stepIndex]?.name ?? `Step ${stepIndex}`;
const promptContent = detail.prompt ?? "";
const userComments = selectUserCommentsForAgentContext(detail, { limit: null });
@@ -12990,6 +13011,7 @@ export class TaskExecutor {
const userComments = selectUserCommentsForAgentContext(latestDetailForReview, { limit: null });
const settings = await mergeEffectiveSettings(store, latestDetailForReview, await store.getSettings());
const reviewCwd = resolveReviewCheckoutCwd(latestDetailForReview, worktreePath);
this.logReviewCheckoutRouting(taskId, latestDetailForReview, reviewCwd, worktreePath);
// Run the reviewer via semaphore.runNested so its slot accounting
// is honest: activeCount transiently bumps to reflect the second
// agent session, but the reviewer doesn't enter the wait queue

View File

@@ -31,7 +31,6 @@ import {
ModelRegistry,
SessionManager,
SettingsManager,
type AuthStorage,
type AgentSession,
type ToolDefinition,
} from "@earendil-works/pi-coding-agent";
@@ -83,8 +82,6 @@ const RTK_ACCEPTED_REWRITE_EXIT_CODES = new Set([0, 3]);
const RTK_EXPECTED_PASSTHROUGH_EXIT_CODES = new Set([1, 2]);
const RTK_EXPECTED_FAIL_OPEN_ERROR_CODES = new Set(["ABORT_ERR", "ENOENT", "ETIMEDOUT"]);
const RTK_REWRITE_MAX_BUFFER_BYTES = 64 * 1024;
const ANTHROPIC_PROVIDER_ID = "anthropic";
const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
export type RtkRewriteMode = "off" | "rewrite";
@@ -1174,79 +1171,10 @@ function readJsonObject(path: string): Record<string, any> {
}
}
function resolveAnthropicSubscriptionModelForAnthropicSelection(
modelRegistry: ModelRegistry,
kind: "primary" | "fallback",
model: ReturnType<typeof resolveConfiguredModel>,
) {
if (!model) return model;
const subscriptionModel = modelRegistry.find(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, model.id);
if (subscriptionModel) {
return subscriptionModel;
}
piLog.warn(`${kind} model ${ANTHROPIC_SUBSCRIPTION_PROVIDER_ID}/${model.id} not in registry; using the resolved Anthropic model as a subscription provider template`);
return { ...model, provider: ANTHROPIC_SUBSCRIPTION_PROVIDER_ID };
}
function registerAnthropicSubscriptionProvider(modelRegistry: ModelRegistry): void {
const models = modelRegistry.getAll()
.filter((model) => model.provider === ANTHROPIC_PROVIDER_ID)
.map((model) => ({
id: model.id,
name: model.name ?? model.id,
reasoning: model.reasoning ?? false,
input: Array.isArray(model.input) ? model.input : ["text" as const],
cost: model.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: model.contextWindow ?? 0,
maxTokens: model.maxTokens ?? 0,
...(model.compat ? { compat: model.compat } : {}),
}));
if (models.length === 0) {
return;
}
/*
FNXC:ProviderAuth 2026-07-01-13:06:
Claude subscription OAuth is an execution-capable Anthropic surface, but it must not be saved or resolved as raw `ANTHROPIC_API_KEY` material for the public `anthropic` provider. Register a separate hidden `anthropic-subscription` runtime provider so persisted Anthropic selections can execute with OAuth through their own provider id while explicit `pi-claude-cli` selections remain CLI-only.
*/
modelRegistry.registerProvider(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, {
name: "Anthropic Subscription",
baseUrl: "https://api.anthropic.com/v1",
apiKey: "$ANTHROPIC_SUBSCRIPTION_API_KEY",
api: "anthropic-messages",
models,
});
modelRegistry.refresh();
}
async function routeAnthropicSelectionForAvailableAuth(
authStorage: AuthStorage,
modelRegistry: ModelRegistry,
kind: "primary" | "fallback",
model: ReturnType<typeof resolveConfiguredModel>,
) {
if (!model || model.provider !== ANTHROPIC_PROVIDER_ID) {
return model;
}
const rawApiKey = await authStorage.getApiKey(ANTHROPIC_PROVIDER_ID);
if (rawApiKey) {
return model;
}
const hasSubscriptionOAuth = authStorage.hasAuth?.(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) === true;
if (!hasSubscriptionOAuth) {
return model;
}
/*
FNXC:ProviderAuth 2026-07-01-13:08:
OAuth subscription credentials are execution credentials for the dedicated `anthropic-subscription` path, not status-only credentials and not raw `anthropic` `/v1` API keys. Route persisted `anthropic/<model>` selections without a raw key to the subscription provider; explicit `pi-claude-cli` selections remain the only CLI path.
*/
return resolveAnthropicSubscriptionModelForAnthropicSelection(modelRegistry, kind, model);
}
/*
FNXC:ProviderAuth 2026-07-01-14:55:
Anthropic has three independent execution surfaces with NO runtime rerouting: direct OAuth and raw API key both run on pi-ai's built-in `anthropic` provider (OAuth → `/v1` with Claude Code impersonation; raw key → x-api-key), and explicit `pi-claude-cli/<model>` runs the vendored CLI. Do NOT register or route through an `/v1`-based `anthropic-subscription` provider — that reroute reintroduced the #1857 regression (FN-7391/FN-7396).
*/
function normalizeSessionHistoryEntries(sessionManager: SessionManagerLike): void {
const entries = sessionManager.fileEntries;
@@ -2144,7 +2072,6 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
}
modelRegistry.refresh();
mergeSupplementalAnthropicModels(modelRegistry, (message) => extensionsLog.warn(message));
registerAnthropicSubscriptionProvider(modelRegistry);
// Build the pi built-in tool set. We deliberately do NOT use the bundled
// `createCodingTools` / `createReadOnlyTools` presets — they're missing
@@ -2242,19 +2169,6 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
);
}
selectedModel = await routeAnthropicSelectionForAvailableAuth(
authStorage,
modelRegistry,
"primary",
selectedModel,
);
fallbackModel = await routeAnthropicSelectionForAvailableAuth(
authStorage,
modelRegistry,
"fallback",
fallbackModel,
);
// Resolve skill selection: explicit skillSelection wins over convenience `skills`
let effectiveSkillSelection: SkillSelectionContext | undefined = options.skillSelection;
if (!effectiveSkillSelection && options.skills && options.skills.length > 0) {

View File

@@ -6,11 +6,10 @@ function readMetadataPath(value: unknown): string | undefined {
if (!value || typeof value !== "object") return undefined;
const record = value as Record<string, unknown>;
/*
FNXC:ReviewCheckout 2026-06-29-14:05:
Explicit external review checkout metadata must survive legacy empty reviewCheckoutPath fields and symlinked checkout directories.
Treat blank/non-string direct metadata as absent before falling back, then verify the resolved target directory so external worktrees mounted via symlinks can still be reviewed.
FNXC:ReviewRouting 2026-07-01-16:36:
External review routing must be explicit metadata, not inferred from prompt text or task descriptions. The resolver only accepts known metadata fields, treats blank/non-string values as absent, and later validates that the chosen path is an absolute git checkout. Source priority is fixed (customFields > branchContext > sourceMetadata > root); an invalid higher-priority candidate fails closed to the task worktree rather than silently falling through to a lower-priority path.
*/
for (const direct of [record.reviewCheckoutPath, record.externalReviewCheckoutPath]) {
for (const direct of [record.reviewCheckoutPath, record.externalReviewCheckoutPath, record.externalReviewCheckout]) {
if (typeof direct === "string" && direct.trim()) return direct.trim();
}
const nested = record.reviewCheckout;
@@ -24,7 +23,7 @@ function readMetadataPath(value: unknown): string | undefined {
export function getTaskReviewCheckoutPath(task: unknown): string | undefined {
if (!task || typeof task !== "object") return undefined;
const record = task as Record<string, unknown>;
return readMetadataPath(record.customFields) ?? readMetadataPath(record.branchContext) ?? readMetadataPath(record);
return readMetadataPath(record.customFields) ?? readMetadataPath(record.branchContext) ?? readMetadataPath(record.sourceMetadata) ?? readMetadataPath(record);
}
export function resolveReviewCheckoutCwd(task: unknown, fallbackCwd: string): string {