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:
7
.changeset/fix-anthropic-oauth-direct-access.md
Normal file
7
.changeset/fix-anthropic-oauth-direct-access.md
Normal 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.
|
||||
7
.changeset/restore-claude-sonnet-5-catalog.md
Normal file
7
.changeset/restore-claude-sonnet-5-catalog.md
Normal 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.
|
||||
7
.changeset/review-checkout-routing.md
Normal file
7
.changeset/review-checkout-routing.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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">> & {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" },
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
261
packages/engine/src/__tests__/review-checkout.test.ts
Normal file
261
packages/engine/src/__tests__/review-checkout.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user