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:
@@ -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`.
|
||||||
@@ -52,6 +52,34 @@ stable value so the token survives restarts. See
|
|||||||
[CLI reference → fn dashboard → Authentication](./cli-reference.md#fn-dashboard)
|
[CLI reference → fn dashboard → Authentication](./cli-reference.md#fn-dashboard)
|
||||||
for the full flow.
|
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
|
## Pass additional CLI flags
|
||||||
|
|
||||||
You can append normal CLI arguments after the image name:
|
You can append normal CLI arguments after the image name:
|
||||||
|
|||||||
@@ -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
|
hot zone actually collide. Tablet retains its existing touch geometry and phones remain full-screen
|
||||||
sheets below.
|
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;
|
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);
|
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);
|
right: calc(var(--space-lg) * -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.floating-window--task-detail:not(.floating-window--tablet-viewport) .floating-window__resize-handle--se {
|
/* The window no longer clips, so clipping moves to the surfaces that actually paint. */
|
||||||
right: calc(var(--space-lg) * -1);
|
.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) {
|
@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:
|
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.
|
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.
|
||||||
|
|||||||
@@ -202,14 +202,28 @@ describe("FloatingWindow", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:TaskDetailLayout 2026-08-17-23:47:
|
FNXC:FloatingWindow 2026-08-18-00:26:
|
||||||
FN-8766's outboard east targets survive the gutter removal and are now the sanctioned remedy for a
|
FN-8766's outboard east targets are promoted from a task-detail special case to the SHARED
|
||||||
scrollbar/resize collision, so they stay pinned here.
|
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:not(.floating-window--tablet-viewport)", "overflow: visible;")).toContain("overflow: visible;");
|
||||||
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: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--se", "right")).toContain("right: calc(var(--space-lg) * -1);");
|
// The corner targets share one grouped rule, so match the block rather than a bare selector.
|
||||||
expect(cssRuleContaining(allAppCss, ".floating-window--task-detail", "overflow: hidden !important;")).toContain("overflow: hidden !important;");
|
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:
|
FNXC:GitHubImport 2026-08-17-23:47:
|
||||||
|
|||||||
@@ -3217,12 +3217,19 @@ describe("GitHubImportModal", () => {
|
|||||||
expect(baseDeclarations, "base FloatingWindow must provide inherited sheet clipping").toContain("overflow: hidden");
|
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");
|
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,
|
source,
|
||||||
/\.floating-window--task-detail:not\(\.floating-window--tablet-viewport\)\s*\{([^}]*)\}/,
|
/\n\.floating-window:not\(\.floating-window--tablet-viewport\)\s*\{([^}]*)\}/,
|
||||||
"desktop Task Detail",
|
"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)]
|
const visibleOverflowSheetHosts = [...source.matchAll(/([^{}]+)\{([^{}]*)\}/g)]
|
||||||
.filter(([, selector, body]) => /overflow\s*:\s*visible(?:\s*!important)?\s*(?:;|$)/.test(body))
|
.filter(([, selector, body]) => /overflow\s*:\s*visible(?:\s*!important)?\s*(?:;|$)/.test(body))
|
||||||
|
|||||||
@@ -905,26 +905,27 @@ describe("TaskDetailModal", () => {
|
|||||||
|
|
||||||
it("keeps the floating task header symmetric without sacrificing its resize targets", () => {
|
it("keeps the floating task header symmetric without sacrificing its resize targets", () => {
|
||||||
const floatingCss = readFileSync(resolve(__dirname, "../FloatingWindow.css"), "utf8");
|
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 sharedBody = getExactCssRuleBlock(floatingCss, ".floating-window__body");
|
||||||
const header = getExactCssRuleBlock(readDashboardStylesSource(), ".modal-header");
|
const header = getExactCssRuleBlock(readDashboardStylesSource(), ".modal-header");
|
||||||
const eastResize = getExactCssRuleBlock(floatingCss, `${desktopTaskSelector} .floating-window__resize-handle--e`);
|
const eastResize = getExactCssRuleBlock(floatingCss, `${desktopWindowSelector} .floating-window__resize-handle--e`);
|
||||||
const northEastResize = getExactCssRuleBlock(floatingCss, `${desktopTaskSelector} .floating-window__resize-handle--ne`);
|
const cornerResize = floatingCss.match(
|
||||||
const southEastResize = getExactCssRuleBlock(floatingCss, `${desktopTaskSelector} .floating-window__resize-handle--se`);
|
/\.floating-window:not\(\.floating-window--tablet-viewport\) \.floating-window__resize-handle--ne,[\s\S]*?\}/
|
||||||
|
)?.[0] ?? "";
|
||||||
const onRequestClose = vi.fn();
|
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
|
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
|
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
|
it. FN-8766's outboard resize targets that make the scrollbar grabbable are likewise no longer
|
||||||
header keep matching tokenized edges while the scrollbar stays grabbable.
|
task-detail-scoped: they are the shared desktop rule, and this popup inherits them.
|
||||||
*/
|
*/
|
||||||
expect(sharedBody).not.toMatch(/margin-inline-end\s*:/);
|
expect(sharedBody).not.toMatch(/margin-inline-end\s*:/);
|
||||||
expect(header).toContain("padding: var(--modal-padding);");
|
expect(header).toContain("padding: var(--modal-padding);");
|
||||||
expect(eastResize).toContain("right: calc(var(--space-sm) * -1);");
|
expect(eastResize).toContain("right: calc(var(--space-sm) * -1);");
|
||||||
expect(northEastResize).toContain("right: calc(var(--space-lg) * -1);");
|
expect(cornerResize).toContain("resize-handle--se");
|
||||||
expect(southEastResize).toContain("right: calc(var(--space-lg) * -1);");
|
expect(cornerResize).toContain("right: calc(var(--space-lg) * -1);");
|
||||||
|
|
||||||
const { baseElement, unmount } = render(
|
const { baseElement, unmount } = render(
|
||||||
<FloatingWindow
|
<FloatingWindow
|
||||||
|
|||||||
@@ -1710,9 +1710,14 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
}
|
}
|
||||||
return await pendingLogin.inputPromise;
|
return await pendingLogin.inputPromise;
|
||||||
},
|
},
|
||||||
// AuthStorage.login() forwards callbacks to provider-specific OAuth
|
/*
|
||||||
// implementations verbatim. openai-codex supports this optional hook
|
FNXC:ProviderAuth 2026-08-18-00:26:
|
||||||
// to race pasted codes against the localhost callback server.
|
`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,
|
onManualCodeInput: async () => await pendingLogin.inputPromise,
|
||||||
onProgress: () => {}, // no-op for web UI
|
onProgress: () => {}, // no-op for web UI
|
||||||
onSelect: async (prompt) => selectOauthOption(storageProvider, prompt),
|
onSelect: async (prompt) => selectOauthOption(storageProvider, prompt),
|
||||||
|
|||||||
@@ -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 () => {
|
it("writes to Fusion auth and reads legacy Pi auth as fallback", async () => {
|
||||||
const legacyAgentDir = join(homeDir, ".pi", "agent");
|
const legacyAgentDir = join(homeDir, ".pi", "agent");
|
||||||
mkdirSync(legacyAgentDir, { recursive: true });
|
mkdirSync(legacyAgentDir, { recursive: true });
|
||||||
|
|||||||
@@ -247,8 +247,41 @@ class FusionFileAuthStorage implements FusionAuthStorage {
|
|||||||
(see the Anthropic-aware login seam in provider-auth.ts).
|
(see the Anthropic-aware login seam in provider-auth.ts).
|
||||||
*/
|
*/
|
||||||
const executionProvider = toExecutionModelProviderId(provider);
|
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 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; };
|
||||||
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); } };
|
/*
|
||||||
|
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();
|
await this.modelRuntime.login(executionProvider, "oauth", interaction); this.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user