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)
|
||||
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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user