fix(auth): restore Codex login and promote outboard resize targets

Operator report from a containerized dashboard: OpenAI Codex login never opened
a browser window at all, and floating windows still needed the FN-8015 follow-up.

- pi's `AuthPrompt` is a discriminated union — text, secret, select, manual_code —
  and FusionAuthStorage.login's interaction shim flattened every variant into
  `onPrompt({message, placeholder})`, discarding `type` and a select's `options`.
  pi's Codex `login()` OPENS with `prompt({type:"select"})` (Browser vs Device
  code) before emitting any auth URL, so the dashboard answered the method picker
  with the promise that waits for a pasted code — input the UI never solicits,
  because nothing had been surfaced yet. The flow hung until the route's 30s
  kickoff timeout: "Login initiation timed out", no window. The route's
  onSelect/selectOauthOption has had the right answer since FN-5917, but the
  callback was dead code from the moment login moved to pi's ModelRuntime.
  Verified against a real container: the login endpoint now returns Codex's
  auth.openai.com URL in 0.03s instead of timing out after 30s.
- Promote FN-8766's outboard east/NE/SE resize targets from Task Detail to every
  desktop window. With FN-8015's body gutter deleted, a hosted scrollbar sits
  flush against the painted edge where those hit zones used to cover it (issue
  #2140); moving the targets outside the shell keeps it grabbable without
  insetting anything. That needs the host to stop clipping, so the body and its
  direct child inherit the corner radius — only 8 of ~30 callers set that
  themselves — and phones re-assert clipping since they hide every handle.
- Document the fixed OAuth callback ports (Anthropic 53692, Codex 1455) and
  PI_OAUTH_CALLBACK_HOST for Docker: without them the browser callback cannot
  reach the container's loopback listener, which is why subscription logins
  appeared to fail there.

Verified: 14989 dashboard tests, 58 engine auth-storage tests (4 new, covering
each prompt type), pnpm test:gate, eslint, and both typechecks all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-17 17:38:26 -07:00
parent 9db2565e99
commit bb11e493f7
9 changed files with 237 additions and 30 deletions

View File

@@ -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`.

View File

@@ -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:

View File

@@ -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.

View File

@@ -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:

View File

@@ -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))

View File

@@ -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(
<FloatingWindow

View File

@@ -1710,9 +1710,14 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
}
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),

View File

@@ -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<string, unknown>,
callbacks: Record<string, unknown>,
): Promise<string> {
const authStorage = createFusionAuthStorage();
let answer = "";
authStorage.setModelRuntime({
login: async (_provider: string, _type: string, interaction: { prompt: (p: unknown) => Promise<string> }) => {
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<string>(() => {}) 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 });

View File

@@ -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<string>; 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<string>; onSelect?: (prompt: { message: string; options: readonly { id: string; label?: string; description?: string }[] }) => Promise<string | undefined> | string | undefined; onManualCodeInput?: () => Promise<string>; 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();
}
}