From 79d4299be23da17293584f23fea747215b212f22 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 14 Jul 2026 15:07:26 -0700 Subject: [PATCH] fix: preserve provider and workflow behavior after migration Use canonical Anthropic OAuth refresh, keep CLI-backed providers out of API-key auth rows, parse Grok's omitted zero usage, and carry board workflow context into task creation. --- .../fresh-migration-provider-workflows.md | 7 + .../dashboard/app/components/AppModals.tsx | 1 + packages/dashboard/app/components/Board.tsx | 14 +- .../dashboard/app/components/NewTaskModal.tsx | 10 +- .../app/components/__tests__/Board.test.tsx | 27 ++++ .../__tests__/NewTaskModal.test.tsx | 15 ++ .../dashboard/app/hooks/useModalManager.ts | 10 +- .../src/__tests__/routes-auth.test.ts | 16 +++ .../dashboard/src/__tests__/usage.test.ts | 34 +++++ .../src/routes/register-auth-routes.ts | 18 ++- packages/dashboard/src/usage.ts | 15 +- .../engine/src/__tests__/auth-storage.test.ts | 35 +++-- packages/engine/src/auth-storage.ts | 133 ++---------------- 13 files changed, 186 insertions(+), 149 deletions(-) create mode 100644 .changeset/fresh-migration-provider-workflows.md diff --git a/.changeset/fresh-migration-provider-workflows.md b/.changeset/fresh-migration-provider-workflows.md new file mode 100644 index 0000000000..a05d94fe19 --- /dev/null +++ b/.changeset/fresh-migration-provider-workflows.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Preserve OAuth, CLI authentication, and selected workflow behavior after PostgreSQL migration. +category: fix +dev: Refreshes Anthropic OAuth canonically, de-duplicates CLI auth rows, parses zero-use Grok billing, and forwards workflow context. diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index bd6546f3be..937a9f0253 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -452,6 +452,7 @@ export function AppModals({ addToast={addToast} projectId={projectId} initialDescription={modalManager.newTaskInitialDescription ?? ""} + initialWorkflowId={modalManager.newTaskInitialWorkflowId} onPlanningMode={onPlanningMode} onSubtaskBreakdown={onSubtaskBreakdown} /> diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 7a589b0a4d..b632751c54 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -40,7 +40,7 @@ interface BoardProps { onOpenGroupModal?: (groupId: string) => void; addToast: (message: string, type?: ToastType) => void; onQuickCreate?: (input: TaskCreateInput) => Promise; - onNewTask: () => void; + onNewTask: (workflowId?: string | null) => void; autoMerge: boolean; /** Project merge strategy passed to Board-owned card context menus. */ mergeStrategy?: string; @@ -617,6 +617,10 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o ?? selectedWorkflowColumns.find((column) => !column.flags.archived)?.id; }, [selectedWorkflowColumns]); + const handleSelectedWorkflowNewTask = useCallback(() => { + onNewTask(selectedWorkflow?.id); + }, [onNewTask, selectedWorkflow?.id]); + const workflowContextMenuColumnsByWorkflowId = useMemo(() => { const map = new Map(); for (const workflow of boardWorkflows?.workflows ?? []) { @@ -786,6 +790,10 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o return null; }, [boardWorkflows]); + const handleAggregateWorkflowNewTask = useCallback(() => { + onNewTask(aggregateQuickCreateTarget?.workflowId); + }, [aggregateQuickCreateTarget?.workflowId, onNewTask]); + const aggregateVisibleBoardColumns = useMemo( () => aggregateBoardColumns.filter((column) => column.flags.archived !== true), [aggregateBoardColumns], @@ -972,7 +980,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o mergeStrategy={mergeStrategy} // FNXC:PlanApproval 2026-07-07-00:00: FN-7653 — the plan auto-approve shortcut belongs only to the intake/planning column, never to hold (Todo-like) columns; the built-in Coding workflow's Todo column carries the hold trait and was wrongly receiving this prop pair. {...((columnDef.flags.intake && !columnDef.flags.archived && !columnDef.flags.complete && !columnDef.flags.countsTowardWip && !columnDef.flags.mergeBlocker && !columnDef.flags.humanReview) ? { planAutoApproveEnabled, onTogglePlanAutoApprove } : {})} - {...(isCreateColumn && aggregateQuickCreateTarget ? { workflowId: aggregateQuickCreateTarget.workflowId, workflowOptions, defaultWorkflowId: boardWorkflows?.defaultWorkflowId ?? null, onQuickCreate: handleAggregateWorkflowQuickCreate, onNewTask, onSubtaskBreakdown } : {})} + {...(isCreateColumn && aggregateQuickCreateTarget ? { workflowId: aggregateQuickCreateTarget.workflowId, workflowOptions, defaultWorkflowId: boardWorkflows?.defaultWorkflowId ?? null, onQuickCreate: handleAggregateWorkflowQuickCreate, onNewTask: handleAggregateWorkflowNewTask, onSubtaskBreakdown } : {})} {...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})} {...(columnDef.id === "done" ? { onArchiveAllDone } : {})} {...(isDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})} @@ -1055,7 +1063,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o mergeStrategy={mergeStrategy} // FNXC:PlanApproval 2026-07-07-00:00: FN-7653 — the plan auto-approve shortcut belongs only to the intake/planning column, never to hold (Todo-like) columns; the built-in Coding workflow's Todo column carries the hold trait and was wrongly receiving this prop pair. {...((columnDef.flags.intake && !columnDef.flags.archived && !columnDef.flags.complete && !columnDef.flags.countsTowardWip && !columnDef.flags.mergeBlocker && !columnDef.flags.humanReview) ? { planAutoApproveEnabled, onTogglePlanAutoApprove } : {})} - {...(isCreateColumn ? { workflowOptions, defaultWorkflowId: selectedWorkflow.id, onQuickCreate: handleWorkflowQuickCreate, onNewTask, onSubtaskBreakdown } : {})} + {...(isCreateColumn ? { workflowOptions, defaultWorkflowId: selectedWorkflow.id, onQuickCreate: handleWorkflowQuickCreate, onNewTask: handleSelectedWorkflowNewTask, onSubtaskBreakdown } : {})} {...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})} {...(columnDef.id === "done" ? { onArchiveAllDone } : {})} {...(isWorkflowDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})} diff --git a/packages/dashboard/app/components/NewTaskModal.tsx b/packages/dashboard/app/components/NewTaskModal.tsx index 2cca65baf3..63d54f115a 100644 --- a/packages/dashboard/app/components/NewTaskModal.tsx +++ b/packages/dashboard/app/components/NewTaskModal.tsx @@ -48,6 +48,7 @@ interface NewTaskModalProps { onCreateTask: (input: NewTaskCreateInput) => Promise; addToast: (message: string, type?: ToastType) => void; initialDescription?: string; + initialWorkflowId?: string | null; onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void; onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void; } @@ -396,7 +397,7 @@ function NewTaskGitHubReferencePicker({ isOpen, projectId, disabled = false, onS ); } -export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast, initialDescription = "", onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) { +export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast, initialDescription = "", initialWorkflowId, onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) { const { t } = useTranslation("app"); const { confirm } = useConfirm(); const viewportMode = useViewportMode(); @@ -634,9 +635,14 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, useEffect(() => { if (isOpen && !wasOpenRef.current) { setDescription(initialDescription); + /* + FNXC:TaskWorkflowSelection 2026-07-14-14:22: + A New Task dialog opened from a workflow board lane must inherit that viewed workflow atomically; global/sidebar opens omit this value and continue inheriting the project default (Coding). This prevents a task created while viewing Coding (Ideas) from silently landing in plain Coding. + */ + setSelectedWorkflowId(initialWorkflowId); } wasOpenRef.current = isOpen; - }, [initialDescription, isOpen]); + }, [initialDescription, initialWorkflowId, isOpen]); // Load agents for agent picker const loadAgents = useCallback(() => { diff --git a/packages/dashboard/app/components/__tests__/Board.test.tsx b/packages/dashboard/app/components/__tests__/Board.test.tsx index 763b6b0b4e..954e1b704e 100644 --- a/packages/dashboard/app/components/__tests__/Board.test.tsx +++ b/packages/dashboard/app/components/__tests__/Board.test.tsx @@ -111,6 +111,11 @@ vi.mock("../Column", () => ({ quick-create-{column} ) : null} + {onNewTask ? ( + + ) : null} {tasks.map((task) => (
{task.title ?? task.description ?? task.id} @@ -1630,6 +1635,17 @@ describe("Board", () => { expect(JSON.parse(intakeColumn.getAttribute("data-workflow-options") || "[]")).toEqual(["builtin:coding", "wf-custom"]); }); + it("opens the full task dialog with the selected workflow context", async () => { + const onNewTask = vi.fn(); + enableFlag({ "FN-1": CUSTOM_WORKFLOW.id }, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]); + renderBoard({ tasks: [mkTask({ id: "FN-1", column: "intake" })], onNewTask }); + + await selectWorkflow(CUSTOM_WORKFLOW.id); + fireEvent.click(screen.getByTestId("mock-new-task-intake")); + + expect(onNewTask).toHaveBeenCalledWith(CUSTOM_WORKFLOW.id); + }); + it("defaults All workflows quick-add to the default workflow and resolves selected workflow columns", async () => { const onQuickCreate = vi.fn().mockResolvedValue({ id: "FN-new", workflowId: "wf-custom" }); enableFlag({}, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]); @@ -1649,6 +1665,17 @@ describe("Board", () => { expect(onQuickCreate).not.toHaveBeenCalledWith(expect.objectContaining({ workflowId: "__all_workflows__" })); }); + it("opens the All workflows task dialog with its resolved real workflow context", async () => { + const onNewTask = vi.fn(); + enableFlag({}, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]); + renderBoard({ onNewTask }); + + await selectWorkflow("__all_workflows__"); + fireEvent.click(screen.getByTestId("mock-new-task-triage")); + + expect(onNewTask).toHaveBeenCalledWith(DEFAULT_WORKFLOW.id); + }); + it("restores all-workflows after remount and then persists a real workflow selection", async () => { const projectId = "project-board-all-workflows-remount"; enableFlag( diff --git a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx index 3683fa8780..b04cc01743 100644 --- a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx @@ -1552,6 +1552,21 @@ describe("NewTaskModal", () => { }); }); + it("submits the workflow supplied by the contextual board opener", async () => { + await mockWorkflows([{ id: "WF-IDEAS", name: "Coding (Ideas)" }]); + const { props } = renderNewTaskModal({ initialWorkflowId: "WF-IDEAS" }); + + await waitFor(() => { + expect(screen.getByTestId("task-workflow-dropdown-trigger")).toHaveTextContent("Coding (Ideas)"); + }); + fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Stay in Ideas" } }); + fireEvent.click(screen.getByRole("button", { name: "Create Task" })); + + await waitFor(() => { + expect(props.onCreateTask).toHaveBeenCalledWith(expect.objectContaining({ workflowId: "WF-IDEAS" })); + }); + }); + it("sends workflowId: null when 'No workflow' is chosen", async () => { await mockWorkflows([{ id: "WF-1", name: "QA" }]); const { props } = renderNewTaskModal(); diff --git a/packages/dashboard/app/hooks/useModalManager.ts b/packages/dashboard/app/hooks/useModalManager.ts index 82693fffba..b2fba3a85c 100644 --- a/packages/dashboard/app/hooks/useModalManager.ts +++ b/packages/dashboard/app/hooks/useModalManager.ts @@ -46,6 +46,7 @@ export interface ModalManager { // State newTaskModalOpen: boolean; newTaskInitialDescription: string | null; + newTaskInitialWorkflowId: string | null | undefined; isPlanningOpen: boolean; planningInitialPlan: string | null; planningResumeSessionId: string | undefined; @@ -88,7 +89,7 @@ export interface ModalManager { anyModalOpen: boolean; // Handlers - openNewTask: () => void; + openNewTask: (workflowId?: string | null) => void; openNewTaskWithDescription: (description: string) => void; closeNewTask: () => void; @@ -177,6 +178,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager { const [newTaskModalOpen, setNewTaskModalOpen] = useState(false); const [newTaskInitialDescription, setNewTaskInitialDescription] = useState(null); + const [newTaskInitialWorkflowId, setNewTaskInitialWorkflowId] = useState(undefined); const [isPlanningOpen, setIsPlanningOpen] = useState(false); const [planningInitialPlan, setPlanningInitialPlan] = useState(null); const [planningResumeSessionId, setPlanningResumeSessionId] = useState(undefined); @@ -250,17 +252,20 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager { modelOnboardingOpen, ); - const openNewTask = useCallback(() => { + const openNewTask = useCallback((workflowId?: string | null) => { setNewTaskInitialDescription(null); + setNewTaskInitialWorkflowId(workflowId); setNewTaskModalOpen(true); }, []); const openNewTaskWithDescription = useCallback((description: string) => { setNewTaskInitialDescription(description); + setNewTaskInitialWorkflowId(undefined); setNewTaskModalOpen(true); }, []); const closeNewTask = useCallback(() => { setNewTaskModalOpen(false); setNewTaskInitialDescription(null); + setNewTaskInitialWorkflowId(undefined); }, []); const openPlanning = useCallback(() => { @@ -491,6 +496,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager { return { newTaskModalOpen, newTaskInitialDescription, + newTaskInitialWorkflowId, isPlanningOpen, planningInitialPlan, planningResumeSessionId, diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index bb196cc3d5..87b12e5e67 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -1641,6 +1641,22 @@ describe("GET /auth/status", () => { expect(providerIds).not.toContain("anthropic"); expect(providerIds.filter((id: string) => id === "anthropic-subscription")).toHaveLength(1); }); + + it("does not duplicate CLI-backed providers as unauthenticated API-key rows", async () => { + (authStorage.getApiKeyProviders as ReturnType).mockReturnValue([ + { id: "grok-cli", name: "Grok Cli" }, + { id: "omp-cli", name: "OMP Cli" }, + { id: "a-brand-new-api-provider", name: "Brand New API Provider" }, + ]); + + const res = await GET(app, "/api/auth/status"); + + expect(res.status).toBe(200); + expect(res.body.providers.filter((provider: any) => provider.id === "grok-cli")).toHaveLength(1); + expect(res.body.providers.find((provider: any) => provider.id === "grok-cli")).toMatchObject({ type: "cli" }); + expect(res.body.providers.filter((provider: any) => provider.id === "omp-cli")).toHaveLength(1); + expect(res.body.providers.find((provider: any) => provider.id === "a-brand-new-api-provider")).toMatchObject({ type: "api_key" }); + }); }); }); diff --git a/packages/dashboard/src/__tests__/usage.test.ts b/packages/dashboard/src/__tests__/usage.test.ts index c89d3aac28..c17f1e2d72 100644 --- a/packages/dashboard/src/__tests__/usage.test.ts +++ b/packages/dashboard/src/__tests__/usage.test.ts @@ -3653,6 +3653,40 @@ describe("usage", () => { expect(mockRequest).toHaveBeenCalledTimes(1); }); + it("treats an omitted protobuf zero percentage as 0% for a valid weekly CLI billing period", async () => { + mockReadFile.mockImplementation(async (filePath: string) => { + if (String(filePath).includes(".grok/auth.json")) return GROK_CLI_AUTH_JSON; + return Promise.reject(new Error("File not found")); + }); + mockExecFileSync.mockImplementation(() => { + throw new Error("Keychain item not found"); + }); + const periodEnd = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(); + mockGrokBillingResponse(200, { + config: { + currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", start: new Date().toISOString(), end: periodEnd }, + billingPeriodEnd: periodEnd, + onDemandCap: { val: 0 }, + onDemandUsed: { val: 0 }, + prepaidBalance: { val: 0 }, + isUnifiedBillingUser: true, + }, + }); + + const providers = await fetchAllProviderUsage(); + const grok = providers.find((provider) => provider.name === "Grok")!; + + expect(grok.status).toBe("ok"); + expect(grok.windows).toHaveLength(1); + expect(grok.windows[0]).toMatchObject({ + label: "Weekly (credits)", + percentUsed: 0, + percentLeft: 100, + }); + expect(grok.windows[0].resetText).toContain("resets in"); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + it("falls back to the xAI API-key validity card when CLI billing fails", async () => { vi.stubEnv("GROK_API_KEY", "env-grok-key"); mockReadFile.mockImplementation(async (filePath: string) => { diff --git a/packages/dashboard/src/routes/register-auth-routes.ts b/packages/dashboard/src/routes/register-auth-routes.ts index 7bcfa8cb21..917b1bbcb6 100644 --- a/packages/dashboard/src/routes/register-auth-routes.ts +++ b/packages/dashboard/src/routes/register-auth-routes.ts @@ -32,6 +32,20 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { const { router, options, store, getScopedStore, rethrowAsApiError } = ctx; const authStorage = options?.authStorage; + /* + FNXC:ProviderAuth 2026-07-14-14:22: + CLI-backed providers own their credentials and have dedicated status rows below. Runtime model registration can also expose those ids through getApiKeyProviders(); exclude them from the generic API-key union so Grok cannot render twice as both "missing API key" and ready via its authenticated CLI. + */ + const syntheticCliProviderIds = new Set([ + "claude-cli", + "pi-claude-cli", + "droid-cli", + "cursor-cli", + "grok-cli", + "omp-cli", + "llama-cpp", + ]); + // Use injected AuthStorage or fail gracefully if not provided. // When running via the CLI/engine, AuthStorage is passed in via ServerOptions. function getAuthStorage(): AuthStorageLike { @@ -618,7 +632,9 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { // storage.getApiKeyProviders may be absent/narrowed, but the catalog // entries must still surface as present-but-unauthenticated. { - const runtimeApiKeyProviders = storage.getApiKeyProviders ? storage.getApiKeyProviders() : []; + const runtimeApiKeyProviders = storage.getApiKeyProviders + ? storage.getApiKeyProviders().filter((provider) => !syntheticCliProviderIds.has(provider.id)) + : []; const apiKeyProviders = unionProviderCatalog(STATIC_API_KEY_PROVIDER_CATALOG, runtimeApiKeyProviders); for (const p of apiKeyProviders) { let keyHint: string | undefined; diff --git a/packages/dashboard/src/usage.ts b/packages/dashboard/src/usage.ts index b55fb98acb..7343cda15a 100644 --- a/packages/dashboard/src/usage.ts +++ b/packages/dashboard/src/usage.ts @@ -1678,11 +1678,20 @@ async function fetchGrokCliBillingUsage(token: string, usage: ProviderUsage): Pr const config = data?.config; if (!config || typeof config !== "object") return false; - const pctUsed = config.creditUsagePercent; - if (typeof pctUsed !== "number" || !Number.isFinite(pctUsed)) return false; - const parsedReset = _parseResetTimestamp(config.billingPeriodEnd ?? config.currentPeriod?.end); const isWeekly = config.currentPeriod?.type === "USAGE_PERIOD_TYPE_WEEKLY"; + /* + FNXC:UsageProviders 2026-07-14-14:47: + Grok's billing endpoint uses protobuf-style JSON and omits the numeric `creditUsagePercent` field when its value is zero. The Grok Build CLI renders that valid reduced weekly config as “Weekly limit: 0%”; Fusion must mirror the CLI instead of treating an authenticated 200 response as expired auth. Only infer zero when the response still proves a weekly billing period and reset boundary, so malformed payloads continue to fail closed. + */ + const rawPercentUsed = config.creditUsagePercent; + const pctUsed = typeof rawPercentUsed === "number" && Number.isFinite(rawPercentUsed) + ? rawPercentUsed + : isWeekly && parsedReset + ? 0 + : undefined; + if (pctUsed === undefined) return false; + usage.windows.push({ label: isWeekly ? "Weekly (credits)" : "Credits", percentUsed: Math.min(100, Math.max(0, pctUsed)), diff --git a/packages/engine/src/__tests__/auth-storage.test.ts b/packages/engine/src/__tests__/auth-storage.test.ts index bcb56fa352..fa889d3d80 100644 --- a/packages/engine/src/__tests__/auth-storage.test.ts +++ b/packages/engine/src/__tests__/auth-storage.test.ts @@ -324,7 +324,7 @@ describe("createFusionAuthStorage", () => { }); const fetchMock = vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ + text: async () => JSON.stringify({ access_token: "refreshed-legacy-access-token", refresh_token: "rotated-legacy-refresh-token", expires_in: 3600, @@ -400,7 +400,7 @@ describe("createFusionAuthStorage", () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ + text: async () => JSON.stringify({ access_token: "refreshed-subscription-access-token", refresh_token: "rotated-subscription-refresh-token", expires_in: 3600, @@ -469,7 +469,7 @@ describe("createFusionAuthStorage", () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ + text: async () => JSON.stringify({ access_token: "proactively-refreshed-access-token", refresh_token: "rotated-refresh-token", expires_in: 3600, @@ -516,7 +516,11 @@ describe("createFusionAuthStorage", () => { expires: Date.now() - 60_000, }, }); - globalThis.fetch = vi.fn().mockResolvedValue({ ok: false } as Response) as typeof fetch; + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + text: async () => JSON.stringify({ error: "invalid_grant" }), + } as Response) as typeof fetch; const authStorage = createFusionAuthStorage(); @@ -732,7 +736,7 @@ describe("createFusionAuthStorage", () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ + text: async () => JSON.stringify({ access_token: "refreshed-claude-access-token", refresh_token: "rotated-claude-refresh-token", expires_in: 3600, @@ -788,7 +792,7 @@ describe("createFusionAuthStorage", () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ expires_in: 3600 }), + text: async () => JSON.stringify({ expires_in: 3600 }), } as Response) as typeof fetch; const authStorage = createFusionAuthStorage(); @@ -813,7 +817,11 @@ describe("createFusionAuthStorage", () => { }), ); - const fetchMock = vi.fn().mockResolvedValue({ ok: false } as Response); + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + text: async () => JSON.stringify({ error: "invalid_grant" }), + } as Response); globalThis.fetch = fetchMock as typeof fetch; const authStorage = createFusionAuthStorage(); @@ -843,8 +851,9 @@ describe("createFusionAuthStorage", () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ + text: async () => JSON.stringify({ access_token: "refreshed-claude-access-token", + refresh_token: "rotated-claude-refresh-token", expires_in: 3600, }), } as Response); @@ -879,11 +888,11 @@ describe("createFusionAuthStorage", () => { }), ); - let resolveJson: ((value: unknown) => void) | undefined; + let resolveText: ((value: string) => void) | undefined; const fetchMock = vi.fn().mockResolvedValue({ ok: true, - json: () => new Promise((resolve) => { - resolveJson = resolve; + text: () => new Promise((resolve) => { + resolveText = resolve; }), } as Response); globalThis.fetch = fetchMock as typeof fetch; @@ -899,11 +908,11 @@ describe("createFusionAuthStorage", () => { expires: Date.now() + 3_600_000, }); - resolveJson?.({ + resolveText?.(JSON.stringify({ 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({ diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts index faa057f300..855e18976f 100644 --- a/packages/engine/src/auth-storage.ts +++ b/packages/engine/src/auth-storage.ts @@ -46,36 +46,8 @@ apply so a single stuck token doesn't get hammered). const OAUTH_REFRESH_BUFFER_MS = 5 * 60_000; const ANTHROPIC_PROVIDER_ID = "anthropic"; const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription"; -const ANTHROPIC_TOKEN_ENDPOINT = "https://platform.claude.com/v1/oauth/token"; -const ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; -/* -FNXC:ClaudeOAuth 2026-07-05-18:52: -Anthropic subscription login (delegated to pi-ai) grants the full Claude Code scope set — `user:inference` is what authorizes model calls. Earlier this constant was `["user:profile"]`, which was WRONG twice over: (1) it under-describes the token pi-ai actually obtains, and (2) it was fed into the refresh request's `scope` param, which under RFC 6749 §6 NARROWS the refreshed access token to profile-only and strips `user:inference`. The symptom: the account reads "logged in via OAuth" (token present + unexpired) yet every model call 403s with "OAuth token does not meet scope requirement any_of(user:inference, ...)". The default must mirror pi-ai's granted scopes so any fallback describes a usable token, and the refresh path (below) must NOT send it as a narrowing scope. -*/ -const ANTHROPIC_DEFAULT_SCOPES = [ - "org:create_api_key", - "user:profile", - "user:inference", - "user:sessions:claude_code", - "user:mcp_servers", - "user:file_upload", -]; -const OAUTH_REFRESH_TIMEOUT_MS = 10_000; const OAUTH_REFRESH_FAILURE_COOLDOWN_MS = 30_000; -type OAuthTokenResponse = { - access_token?: unknown; - accessToken?: unknown; - refresh_token?: unknown; - refreshToken?: unknown; - expires_in?: unknown; - expiresIn?: unknown; - expires_at?: unknown; - expiresAt?: unknown; - scope?: unknown; - scopes?: unknown; -}; - export function getHomeDir(): string { return process.env.HOME || process.env.USERPROFILE || homedir(); } @@ -179,116 +151,27 @@ function isSameOAuthCredentialIdentity( && left.expires === right.expires; } -function getOAuthScopes(credential: StoredCredential): string[] { - const scopes = Array.isArray(credential.scopes) - ? credential.scopes.filter((scope): scope is string => typeof scope === "string" && scope.trim().length > 0) - : []; - return scopes.length > 0 ? scopes : ANTHROPIC_DEFAULT_SCOPES; -} - -function parseExpiryMs(data: OAuthTokenResponse, now: number): number { - const expiresAt = data.expires_at ?? data.expiresAt; - if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) { - return expiresAt; - } - if (typeof expiresAt === "string") { - const parsed = Date.parse(expiresAt); - if (Number.isFinite(parsed)) { - return parsed; - } - } - - const expiresIn = data.expires_in ?? data.expiresIn; - if (typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn > 0) { - return now + expiresIn * 1000; - } - - return now + 3_600_000; -} - -function parseScopes(data: OAuthTokenResponse, fallback: string[]): string[] { - if (Array.isArray(data.scopes)) { - const scopes = data.scopes.filter((scope): scope is string => typeof scope === "string" && scope.trim().length > 0); - if (scopes.length > 0) { - return scopes; - } - } - if (typeof data.scope === "string") { - const scopes = data.scope.split(/\s+/).filter(Boolean); - if (scopes.length > 0) { - return scopes; - } - } - return fallback; -} - async function refreshAnthropicOAuthCredential(credential: StoredCredential): Promise { - const refresh = credential.refresh; - if (!refresh) { + if (credential.type !== "oauth" || !credential.refresh) { return undefined; } - const scopes = getOAuthScopes(credential); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), OAUTH_REFRESH_TIMEOUT_MS); - try { /* - FNXC:ClaudeOAuth 2026-06-13-22:46: - Fusion must renew expired Claude OAuth credentials with the stored refresh token so users are not forced through repeated manual Claude re-login when the access token expires. - Persist the rotated access token in Fusion auth storage because model execution and dashboard usage resolve credentials through different runtime paths. + FNXC:ClaudeOAuth 2026-07-14-14:25: + Refresh through pi-ai's registered Anthropic provider—the same implementation that performs login and owns the endpoint, client id, expiry buffer, and response contract. Fusion's duplicated HTTP implementation drifted, so expired subscription OAuth degraded into a misleading missing-API-key failure after restart or PostgreSQL migration. Preserve Fusion's recorded scopes because the provider refresh result intentionally contains only runtime token fields. */ - /* - FNXC:ClaudeOAuth 2026-07-05-18:52: - Do NOT send `scope` on refresh. RFC 6749 §6: a refresh request that includes `scope` re-issues the access token with EXACTLY that scope (never broader), so sending our stored/derived scope list can only strip capabilities — and did: it narrowed refreshed tokens to `user:profile` and broke inference. Omitting `scope` makes Anthropic preserve the originally-granted scopes (this is what pi-ai's own `refreshAnthropicToken` does). `scopes` is still resolved above and used only as the parseScopes fallback for the persisted credential record. - */ - const response = await fetch(ANTHROPIC_TOKEN_ENDPOINT, { - method: "POST", - headers: { - "content-type": "application/json", - "user-agent": "claude-code-fusion-dashboard", - }, - body: JSON.stringify({ - grant_type: "refresh_token", - refresh_token: refresh, - client_id: ANTHROPIC_OAUTH_CLIENT_ID, - }), - signal: controller.signal, - }); - - if (!response.ok) { - return undefined; - } - - const data = await response.json() as OAuthTokenResponse; - const access = typeof data.access_token === "string" - ? data.access_token - : typeof data.accessToken === "string" - ? data.accessToken - : undefined; - if (!access) { - return undefined; - } - - const now = Date.now(); - const nextRefresh = typeof data.refresh_token === "string" - ? data.refresh_token - : typeof data.refreshToken === "string" - ? data.refreshToken - : refresh; + const provider = getOAuthProvider(ANTHROPIC_PROVIDER_ID); + if (!provider?.refreshToken) return undefined; + const refreshed = await provider.refreshToken(credential as OAuthCredentials); return { ...credential, - type: "oauth", - access, - refresh: nextRefresh, - expires: parseExpiryMs(data, now), - scopes: parseScopes(data, scopes), + ...refreshed, + ...(credential.scopes ? { scopes: credential.scopes } : {}), }; } catch { return undefined; - } finally { - clearTimeout(timeout); } }