diff --git a/.changeset/fix-codex-login-and-outboard-resize-targets.md b/.changeset/fix-codex-login-and-outboard-resize-targets.md new file mode 100644 index 0000000000..4ff205df1e --- /dev/null +++ b/.changeset/fix-codex-login-and-outboard-resize-targets.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix OpenAI Codex login never opening a browser window, and document OAuth callback ports for Docker. +category: fix +dev: pi's `AuthPrompt` is a discriminated union (text/secret/select/manual_code); `FusionAuthStorage.login`'s interaction shim flattened all four into `onPrompt`, so Codex's opening `select` ("Browser" vs "Device code") was answered with the pasted-code wait and hung until the route's 30s kickoff timeout. The shim now dispatches by type, reviving the route's existing `onSelect`/`onManualCodeInput` handlers. Separately, FN-8766's outboard east/NE/SE resize targets are promoted from Task Detail to every desktop FloatingWindow now that FN-8015's body gutter is gone, with body-level `border-radius: inherit` replacing host clipping and phones re-asserting `overflow: hidden`. diff --git a/docs/docker.md b/docs/docker.md index 3a6b40dde9..2e99630463 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -52,6 +52,34 @@ stable value so the token survives restarts. See [CLI reference → fn dashboard → Authentication](./cli-reference.md#fn-dashboard) for the full flow. +## Provider OAuth logins (Anthropic, OpenAI Codex) + +Subscription logins finish on a **loopback callback server that the container runs itself**, on fixed +ports: `53692` for Anthropic and `1455` for OpenAI Codex. Two things make that unreachable by +default — the port is not published, and the listener binds `127.0.0.1` *inside* the container, so +publishing alone still would not deliver traffic arriving on the container's external interface. +The symptom is a browser that lands on a connection-error page after you approve the login. + +Publish both ports and bind the listener to all interfaces: + +```bash +docker run -p 4040:4040 -p 53692:53692 -p 1455:1455 \ + -e PI_OAUTH_CALLBACK_HOST=0.0.0.0 \ + -v /path/to/project:/workspace \ + -v fusion-home:/home/node/.fusion \ + fusion +``` + +The browser callback then completes on its own, with nothing to paste. Both ports are fixed by the +provider's registered redirect URI, so they cannot be remapped to different host ports — `-p +53692:53693` will not work. + +Without this, the fallback is manual: copy the full URL from the browser's address bar after +approving and paste it into the login card. Note the callback listener accepts connections from +outside the container while a login is in flight; it is short-lived and validates the OAuth `state`, +but prefer publishing these ports only on a trusted network (`-p 127.0.0.1:53692:53692` restricts +them to the host). + ## Pass additional CLI flags You can append normal CLI arguments after the image name: diff --git a/packages/dashboard/app/components/FloatingWindow.css b/packages/dashboard/app/components/FloatingWindow.css index 057e4019e1..decf8f87aa 100644 --- a/packages/dashboard/app/components/FloatingWindow.css +++ b/packages/dashboard/app/components/FloatingWindow.css @@ -271,20 +271,38 @@ outboard targets remain, and they are the pattern to copy wherever a hosted scro hot zone actually collide. Tablet retains its existing touch geometry and phones remain full-screen sheets below. */ -.floating-window--task-detail:not(.floating-window--tablet-viewport) { +/* +FNXC:FloatingWindow 2026-08-18-00:26: +OUTBOARD EAST TARGETS ARE THE SHARED DESKTOP CONTRACT, not a task-detail special case. With +FN-8015's body gutter deleted, a hosted scrollbar sits flush against the painted right edge, where +the east/north-east/south-east hit zones used to cover it — the exact grab conflict (issue #2140) +the gutter existed to prevent. FN-8766 proved the better remedy on Task Detail: let the panel keep +its full width and move the hit areas OUTSIDE the painted shell, so the scrollbar is fully grabbable +and nothing is inset. Promoted here for every desktop window rather than waiting for each caller to +rediscover the conflict. + +Requires `overflow: visible` on the host, which stops the window clipping its children — so the body +and its direct child inherit the corner radius below, since only 8 of ~30 callers used to set that +themselves and the rest would paint square corners over the rounded shell. Phones re-assert clipping +(they hide every resize handle, so they need no outboard room) and tablets keep touch geometry. +*/ +.floating-window:not(.floating-window--tablet-viewport) { overflow: visible; } -.floating-window--task-detail:not(.floating-window--tablet-viewport) .floating-window__resize-handle--e { +.floating-window:not(.floating-window--tablet-viewport) .floating-window__resize-handle--e { right: calc(var(--space-sm) * -1); } -.floating-window--task-detail:not(.floating-window--tablet-viewport) .floating-window__resize-handle--ne { +.floating-window:not(.floating-window--tablet-viewport) .floating-window__resize-handle--ne, +.floating-window:not(.floating-window--tablet-viewport) .floating-window__resize-handle--se { right: calc(var(--space-lg) * -1); } -.floating-window--task-detail:not(.floating-window--tablet-viewport) .floating-window__resize-handle--se { - right: calc(var(--space-lg) * -1); +/* The window no longer clips, so clipping moves to the surfaces that actually paint. */ +.floating-window__body, +.floating-window__body > * { + border-radius: inherit; } /* @@ -331,6 +349,18 @@ all floating affordances so persisted desktop geometry cannot imply a draggable } @media (max-width: 767.98px), (max-height: 480px) { + /* + FNXC:FloatingWindow 2026-08-18-00:26: + Phones re-assert clipping for EVERY window. The desktop rule above drops `overflow: hidden` so the + east resize targets can sit outside the painted shell, and its `:not(--tablet-viewport)` predicate + also matches phones — where those handles are hidden anyway, so the unclipped host buys nothing and + a sheet child could paint past the shell. Task Detail already needed this reassertion for its own + FN-8766 rule; promoting the outboard targets to every window promotes this with them. + */ + .floating-window { + overflow: hidden; + } + /* FNXC:FloatingWindow 2026-07-12-17:35: Mobile keeps the global `styles.css` pan-y lockdown so the dashboard cannot drift, but movable FloatingWindow headers must still resolve to an effective `touch-action: none`. Reassert the drag-handle contract at the mobile breakpoint, excluding full-screen sheet variants, so a single-finger header drag stays on the captured pointermove stream instead of being intersected back into page pan by the ancestor chain. Desktop drag/resize and mobile sheet variants are unchanged. diff --git a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx index 62d9fe072a..74511b1b48 100644 --- a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx +++ b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx @@ -202,14 +202,28 @@ describe("FloatingWindow", () => { } /* - FNXC:TaskDetailLayout 2026-08-17-23:47: - FN-8766's outboard east targets survive the gutter removal and are now the sanctioned remedy for a - scrollbar/resize collision, so they stay pinned here. + FNXC:FloatingWindow 2026-08-18-00:26: + FN-8766's outboard east targets are promoted from a task-detail special case to the SHARED + desktop contract: with the gutter gone a hosted scrollbar sits flush against the painted edge, + and moving the hit areas outside the shell is what keeps it grabbable (issue #2140) without + insetting anything. That needs the host to stop clipping, so the body and its direct child take + over the corner radius — only 8 of ~30 callers set that themselves, and the rest would paint + square corners over the rounded shell. */ - expect(cssRuleContaining(desktopAppCss, ".floating-window--task-detail:not(.floating-window--tablet-viewport) .floating-window__resize-handle--e", "right")).toContain("right: calc(var(--space-sm) * -1);"); - expect(cssRuleContaining(desktopAppCss, ".floating-window--task-detail:not(.floating-window--tablet-viewport) .floating-window__resize-handle--ne", "right")).toContain("right: calc(var(--space-lg) * -1);"); - expect(cssRuleContaining(desktopAppCss, ".floating-window--task-detail:not(.floating-window--tablet-viewport) .floating-window__resize-handle--se", "right")).toContain("right: calc(var(--space-lg) * -1);"); - expect(cssRuleContaining(allAppCss, ".floating-window--task-detail", "overflow: hidden !important;")).toContain("overflow: hidden !important;"); + expect(cssRuleContaining(desktopAppCss, ".floating-window:not(.floating-window--tablet-viewport)", "overflow: visible;")).toContain("overflow: visible;"); + expect(cssRuleContaining(desktopAppCss, ".floating-window:not(.floating-window--tablet-viewport) .floating-window__resize-handle--e", "right")).toContain("right: calc(var(--space-sm) * -1);"); + // The corner targets share one grouped rule, so match the block rather than a bare selector. + const outboardCorners = desktopAppCss.match( + /\.floating-window:not\(\.floating-window--tablet-viewport\) \.floating-window__resize-handle--ne,[\s\S]*?\}/ + )?.[0] ?? ""; + expect(outboardCorners).toContain("right: calc(var(--space-lg) * -1);"); + expect(outboardCorners).toContain("resize-handle--se"); + const paintedClipping = floatingWindowCss.match(/\.floating-window__body,\s*\n\.floating-window__body > \*\s*\{[^}]*\}/)?.[0] ?? ""; + expect(paintedClipping).toContain("border-radius: inherit;"); + + // Phones hide every handle, so they need no outboard room and must keep clipping their sheets. + const phoneSheet = mediaBlockFor(floatingWindowCss, "(max-width: 767.98px), (max-height: 480px)"); + expect(cssRuleFor(phoneSheet, ".floating-window")).toContain("overflow: hidden;"); /* FNXC:GitHubImport 2026-08-17-23:47: diff --git a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx index 83582fd5d0..009fa9a000 100644 --- a/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitHubImportModal.test.tsx @@ -3217,12 +3217,19 @@ describe("GitHubImportModal", () => { expect(baseDeclarations, "base FloatingWindow must provide inherited sheet clipping").toContain("overflow: hidden"); expect(taskSheetDeclarations, "Task Detail must override its desktop visible-overflow rule on phones").toContain("overflow: hidden !important"); - const desktopTaskDetailDeclarations = ruleDeclarations( + /* + * FNXC:FloatingWindow 2026-08-18-00:26: + * The visible-overflow host that phone sheets must re-clip is now the SHARED desktop rule, not + * a task-detail-scoped one: FN-8766's outboard east targets were promoted to every window when + * FN-8015's body gutter was deleted. Chat and GitHub Import still must not carry an override of + * their own (asserted below) — they inherit the shared desktop rule and the phone reassertion. + */ + const desktopVisibleOverflowHost = ruleDeclarations( source, - /\.floating-window--task-detail:not\(\.floating-window--tablet-viewport\)\s*\{([^}]*)\}/, - "desktop Task Detail", + /\n\.floating-window:not\(\.floating-window--tablet-viewport\)\s*\{([^}]*)\}/, + "shared desktop window", ); - expect(desktopTaskDetailDeclarations, "Task Detail's phone clipping reassertion needs the desktop override").toContain("overflow: visible"); + expect(desktopVisibleOverflowHost, "the phone clipping reassertion needs a desktop visible-overflow host").toContain("overflow: visible"); const visibleOverflowSheetHosts = [...source.matchAll(/([^{}]+)\{([^{}]*)\}/g)] .filter(([, selector, body]) => /overflow\s*:\s*visible(?:\s*!important)?\s*(?:;|$)/.test(body)) diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx index 8e66eb1be6..a8bef2ba92 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx @@ -905,26 +905,27 @@ describe("TaskDetailModal", () => { it("keeps the floating task header symmetric without sacrificing its resize targets", () => { const floatingCss = readFileSync(resolve(__dirname, "../FloatingWindow.css"), "utf8"); - const desktopTaskSelector = ".floating-window--task-detail:not(.floating-window--tablet-viewport)"; + const desktopWindowSelector = ".floating-window:not(.floating-window--tablet-viewport)"; const sharedBody = getExactCssRuleBlock(floatingCss, ".floating-window__body"); const header = getExactCssRuleBlock(readDashboardStylesSource(), ".modal-header"); - const eastResize = getExactCssRuleBlock(floatingCss, `${desktopTaskSelector} .floating-window__resize-handle--e`); - const northEastResize = getExactCssRuleBlock(floatingCss, `${desktopTaskSelector} .floating-window__resize-handle--ne`); - const southEastResize = getExactCssRuleBlock(floatingCss, `${desktopTaskSelector} .floating-window__resize-handle--se`); + const eastResize = getExactCssRuleBlock(floatingCss, `${desktopWindowSelector} .floating-window__resize-handle--e`); + const cornerResize = floatingCss.match( + /\.floating-window:not\(\.floating-window--tablet-viewport\) \.floating-window__resize-handle--ne,[\s\S]*?\}/ + )?.[0] ?? ""; const onRequestClose = vi.fn(); /* - FNXC:TaskDetailLayout 2026-08-17-23:47: + FNXC:TaskDetailLayout 2026-08-18-00:26: The shared body reserves NOTHING on its inline end any more — FN-8015's gutter is deleted for every caller, so this popup's symmetric edge no longer depends on a local zeroing that undoes - it. Task Detail keeps its outboard resize hit areas (below), which is what lets the embedded - header keep matching tokenized edges while the scrollbar stays grabbable. + it. FN-8766's outboard resize targets that make the scrollbar grabbable are likewise no longer + task-detail-scoped: they are the shared desktop rule, and this popup inherits them. */ expect(sharedBody).not.toMatch(/margin-inline-end\s*:/); expect(header).toContain("padding: var(--modal-padding);"); expect(eastResize).toContain("right: calc(var(--space-sm) * -1);"); - expect(northEastResize).toContain("right: calc(var(--space-lg) * -1);"); - expect(southEastResize).toContain("right: calc(var(--space-lg) * -1);"); + expect(cornerResize).toContain("resize-handle--se"); + expect(cornerResize).toContain("right: calc(var(--space-lg) * -1);"); const { baseElement, unmount } = render( { } return await pendingLogin.inputPromise; }, - // AuthStorage.login() forwards callbacks to provider-specific OAuth - // implementations verbatim. openai-codex supports this optional hook - // to race pasted codes against the localhost callback server. + /* + FNXC:ProviderAuth 2026-08-18-00:26: + `onManualCodeInput` and `onSelect` are dispatched by prompt TYPE in AuthStorage.login()'s + pi `AuthInteraction` shim (packages/engine/src/auth/auth-storage.ts), not forwarded + verbatim as an older comment here claimed. Both were dead code after login moved to pi's + ModelRuntime, which is what broke Codex login: its `select` prompt fell through to + `onPrompt` and waited on a paste the UI never asked for. + */ onManualCodeInput: async () => await pendingLogin.inputPromise, onProgress: () => {}, // no-op for web UI onSelect: async (prompt) => selectOauthOption(storageProvider, prompt), diff --git a/packages/engine/src/__tests__/auth-storage.test.ts b/packages/engine/src/__tests__/auth-storage.test.ts index fa39a375df..eb39a19577 100644 --- a/packages/engine/src/__tests__/auth-storage.test.ts +++ b/packages/engine/src/__tests__/auth-storage.test.ts @@ -71,6 +71,88 @@ describe("createFusionAuthStorage", () => { ]); }); + /* + FNXC:ProviderAuth 2026-08-18-00:26: + REGRESSION: pi's AuthPrompt is a discriminated union and this seam used to flatten every variant + into `onPrompt`, discarding `type` and a select's `options`. That took OpenAI Codex login out + entirely — its `login()` opens with `prompt({type:"select"})` before any auth URL, so the + dashboard answered the method picker with the promise that waits for a pasted code, hung until + the route's 30s kickoff timeout, and never opened a browser window. + + Enumerated prompt types: select (chooser, and a fallback that never blocks), manual_code + (dedicated channel when present, else the prompt path), text and secret (prompt path). + */ + describe("pi AuthInteraction prompt dispatch", () => { + async function runLoginWithPrompt( + prompt: Record, + callbacks: Record, + ): Promise { + const authStorage = createFusionAuthStorage(); + let answer = ""; + authStorage.setModelRuntime({ + login: async (_provider: string, _type: string, interaction: { prompt: (p: unknown) => Promise }) => { + answer = await interaction.prompt(prompt); + }, + } as never); + await authStorage.login("openai-codex", callbacks); + return answer; + } + + it("routes a select prompt to the caller's chooser, not the manual-code wait", async () => { + const onSelect = vi.fn(async () => "browser"); + const onPrompt = vi.fn(async () => new Promise(() => {}) as unknown as string); + + const answer = await runLoginWithPrompt( + { + type: "select", + message: "Select OpenAI Codex login method:", + options: [ + { id: "browser", label: "Browser login (default)" }, + { id: "device_code", label: "Device code login (headless)" }, + ], + }, + { onSelect, onPrompt }, + ); + + expect(answer).toBe("browser"); + expect(onSelect).toHaveBeenCalledOnce(); + expect(onPrompt).not.toHaveBeenCalled(); + }); + + it("falls back to the first option rather than hanging when no chooser is supplied", async () => { + const answer = await runLoginWithPrompt( + { type: "select", message: "pick", options: [{ id: "browser" }, { id: "device_code" }] }, + {}, + ); + expect(answer).toBe("browser"); + }); + + it("routes a manual_code prompt to the dedicated manual-code channel", async () => { + const onManualCodeInput = vi.fn(async () => "code=abc&state=xyz"); + const onPrompt = vi.fn(async () => "wrong-channel"); + + const answer = await runLoginWithPrompt( + { type: "manual_code", message: "Paste the redirect URL" }, + { onManualCodeInput, onPrompt }, + ); + + expect(answer).toBe("code=abc&state=xyz"); + expect(onPrompt).not.toHaveBeenCalled(); + }); + + it("keeps text and secret prompts on the prompt path", async () => { + for (const type of ["text", "secret"]) { + const onPrompt = vi.fn(async () => `answered-${type}`); + const answer = await runLoginWithPrompt( + { type, message: "enter", placeholder: "here" }, + { onPrompt, onManualCodeInput: async () => "manual" }, + ); + expect(answer, type).toBe(`answered-${type}`); + expect(onPrompt, type).toHaveBeenCalledWith({ message: "enter", placeholder: "here" }); + } + }); + }); + it("writes to Fusion auth and reads legacy Pi auth as fallback", async () => { const legacyAgentDir = join(homeDir, ".pi", "agent"); mkdirSync(legacyAgentDir, { recursive: true }); diff --git a/packages/engine/src/auth/auth-storage.ts b/packages/engine/src/auth/auth-storage.ts index c94c91e846..62d3161b9d 100644 --- a/packages/engine/src/auth/auth-storage.ts +++ b/packages/engine/src/auth/auth-storage.ts @@ -247,8 +247,41 @@ class FusionFileAuthStorage implements FusionAuthStorage { (see the Anthropic-aware login seam in provider-auth.ts). */ const executionProvider = toExecutionModelProviderId(provider); - const legacy = callbacks as { onAuth?: (info: { url: string; instructions?: string }) => void; onDeviceCode?: (info: { userCode: string; verificationUri: string; intervalSeconds?: number; expiresInSeconds?: number }) => void; onPrompt?: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise; onProgress?: (message: string) => void; signal?: AbortSignal; }; - const interaction: AuthInteraction = { signal: legacy.signal, prompt: async prompt => legacy.onPrompt?.({ message: prompt.message, placeholder: "placeholder" in prompt ? prompt.placeholder : undefined }) ?? "", notify: event => { if (event.type === "auth_url") legacy.onAuth?.({ url: event.url, instructions: event.instructions }); else if (event.type === "device_code") legacy.onDeviceCode?.(event); else if (event.type === "progress") legacy.onProgress?.(event.message); } }; + const legacy = callbacks as { onAuth?: (info: { url: string; instructions?: string }) => void; onDeviceCode?: (info: { userCode: string; verificationUri: string; intervalSeconds?: number; expiresInSeconds?: number }) => void; onPrompt?: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise; onSelect?: (prompt: { message: string; options: readonly { id: string; label?: string; description?: string }[] }) => Promise | string | undefined; onManualCodeInput?: () => Promise; onProgress?: (message: string) => void; signal?: AbortSignal; }; + /* + FNXC:ProviderAuth 2026-08-18-00:26: + THE PROMPT TYPE MUST SURVIVE THIS SHIM. pi's `AuthPrompt` is a discriminated union — `text`, + `secret`, `select`, `manual_code` — and this seam used to collapse every one of them into + `onPrompt({message, placeholder})`, discarding `type` and a select's `options`. + + That silently broke OpenAI Codex login entirely: pi's codex `login()` opens with + `prompt({type:"select"})` ("Browser login" vs "Device code login") BEFORE it emits any auth URL. + Collapsed into `onPrompt`, the dashboard answered it with the promise that waits for a + user-pasted code — input the UI never solicits, because no URL or prompt had been surfaced yet. + The flow hung until the route's 30s kickoff timeout fired, so the operator saw a login that + never opened a window ("Login initiation timed out" / "This operation was aborted"). The route + has always had the right answer in its `onSelect`/`selectOauthOption` handler (FN-5917 fixed + exactly this failure once already), but the callback was dead code from the moment login moved + to pi's ModelRuntime. + + Dispatch by type instead: a select resolves through the caller's chooser (falling back to the + first option, never to a wait-forever promise), a manual_code prefers the caller's dedicated + manual-code channel, and text/secret keep the existing prompt path. Do not re-flatten this. + */ + const interaction: AuthInteraction = { + signal: legacy.signal, + prompt: async prompt => { + if (prompt.type === "select") { + const chosen = await legacy.onSelect?.({ message: prompt.message, options: prompt.options }); + return chosen ?? prompt.options[0]?.id ?? ""; + } + if (prompt.type === "manual_code" && legacy.onManualCodeInput) { + return await legacy.onManualCodeInput(); + } + return await legacy.onPrompt?.({ message: prompt.message, placeholder: "placeholder" in prompt ? prompt.placeholder : undefined }) ?? ""; + }, + notify: event => { if (event.type === "auth_url") legacy.onAuth?.({ url: event.url, instructions: event.instructions }); else if (event.type === "device_code") legacy.onDeviceCode?.(event); else if (event.type === "progress") legacy.onProgress?.(event.message); }, + }; await this.modelRuntime.login(executionProvider, "oauth", interaction); this.reload(); } }