From 39a2ac1cd3a332e6bb425b239d08a00758671c25 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 05:58:19 +0000 Subject: [PATCH 1/8] test(web): reproduce #29090 cancelled or failed ChatGPT sign-in leaves Codex managed These tests reproduce #29090: starting ChatGPT sign-in from the onboarding Codex card switches setupMode to managed, and cancelling or failing that sign-in never restores the previous mode. Co-Authored-By: Claude Opus 5.5 --- .../WelcomeWizard.import.browser.test.tsx | 168 +++++++++++++++++- 1 file changed, 167 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx index c55691d9ca5cc..6b93c53d30858 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx @@ -2,11 +2,18 @@ import { RegistryContext } from "@effect/atom-react"; import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + DEFAULT_SERVER_SETTINGS, EnvironmentId, ProjectId, + ProviderDriverKind, + ProviderInstanceId, type AgentSessionProjectCandidate, type AuthEnvironmentScope, type AuthSessionState, + type ProviderAuthState, + type ProviderInstallState, + type ServerConfig, + type ServerProvider, } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/reactivity"; import { expect, it, vi } from "vite-plus/test"; @@ -27,10 +34,15 @@ const state = vi.hoisted(() => ({ projects: null as unknown, providers: null as unknown, config: null as unknown, + authState: null as unknown, + installState: null as unknown, candidates: [] as AgentSessionProjectCandidate[], createProject: vi.fn(), importThreads: vi.fn(), refreshProviders: vi.fn(), + updateSettings: vi.fn(), + startProviderAuth: vi.fn(), + cancelProviderAuth: vi.fn(), connectPairing: vi.fn(), completeOnboarding: vi.fn(), onDone: vi.fn(), @@ -82,6 +94,11 @@ vi.mock(import("../../state/server"), async (original) => { providersValueAtom: (() => state.providers) as never, configValueAtom: (() => state.config) as never, refreshProviders: "refreshProviders" as never, + providerAuthState: (() => state.authState) as never, + providerInstallState: (() => state.installState) as never, + updateSettings: "updateSettings" as never, + startProviderAuth: "startProviderAuth" as never, + cancelProviderAuth: "cancelProviderAuth" as never, }, }; }); @@ -123,7 +140,14 @@ vi.mock(import("../../connection/onboarding"), async (original) => ({ vi.mock(import("../../state/use-atom-command"), async (original) => ({ ...(await original()), useAtomCommand: (( - command: "createProject" | "importThreads" | "refreshProviders" | "connectPairing", + command: + | "createProject" + | "importThreads" + | "refreshProviders" + | "updateSettings" + | "startProviderAuth" + | "cancelProviderAuth" + | "connectPairing", ) => state[command]) as never, })); vi.mock(import("../settings/ChatGptWelcomeCoordinator"), async (original) => ({ @@ -258,6 +282,92 @@ async function mountImport(remote = false) { return screen; } +const codexId = ProviderInstanceId.make("codex"); + +function authState(phase: ProviderAuthState["phase"], message: string | null = null) { + const active = phase !== "idle"; + return { + instanceId: codexId, + phase, + flowId: active ? "flow-1" : null, + authorizationUrl: active ? "https://auth.openai.com/oauth/authorize" : null, + expiresAt: null, + message, + } as ProviderAuthState; +} + +function authStateAtom() { + return state.authState as Atom.Writable>; +} + +function configAtom() { + return state.config as Atom.Writable; +} + +// The server replaces its settings with the patch, so later reads see the new setup mode. +async function applySettingsPatch({ + input, +}: { + input: { patch: Partial }; +}) { + const config = appAtomRegistry.get(configAtom())!; + appAtomRegistry.set(configAtom(), { + ...config, + settings: { ...config.settings, ...input.patch }, + }); + return AsyncResult.success(undefined); +} + +function codexSetupMode() { + const config = appAtomRegistry.get(configAtom())?.settings.providerInstances[codexId]?.config; + return config !== null && typeof config === "object" && "setupMode" in config + ? config.setupMode + : undefined; +} + +// A signed-out Codex CLI with no Codex entry in settings, as on a fresh install. +async function mountCodexSignIn() { + appAtomRegistry.set(configAtom(), { + settings: DEFAULT_SERVER_SETTINGS, + cwd: "/projects", + environment: { platform: { os: "linux" } }, + keybindings: [], + } as unknown as ServerConfig); + appAtomRegistry.set(state.providers as Atom.Writable>, [ + { + instanceId: codexId, + driver: ProviderDriverKind.make("codex"), + installed: true, + enabled: true, + version: "0.163.0", + status: "error", + auth: { status: "unauthenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + skills: [], + slashCommands: [], + setup: { canAuthenticate: true, canInstall: true }, + }, + ]); + await using stack = new AsyncDisposableStack(); + const screen = stack.use( + await render( + + + , + ), + ); + await click("Continue"); + expect(codexSetupMode()).toBeUndefined(); + await click("Continue with ChatGPT"); + await vi.waitFor(() => expect(state.startProviderAuth).toHaveBeenCalledOnce()); + // The server only signs in to its own runtime, so the attempt switches to managed first. + expect(codexSetupMode()).toBe("managed"); + await vi.waitFor(() => button("Cancel")); + stack.move(); + return screen; +} + // The mocked modules read `state`, so every test starts by replacing all of it. // Returns the clock spy so each test can `using` it; the spy would otherwise outlive the test. function setup() { @@ -275,6 +385,21 @@ function setup() { state.projects = Atom.make>([]).pipe(Atom.keepAlive); state.providers = Atom.make>([]).pipe(Atom.keepAlive); state.config = Atom.make(null).pipe(Atom.keepAlive); + state.authState = Atom.make(AsyncResult.success(authState("idle"))).pipe(Atom.keepAlive); + state.installState = Atom.make( + AsyncResult.success({ + driver: ProviderDriverKind.make("codex"), + operationId: null, + phase: "idle", + downloadedBytes: 0, + totalBytes: null, + version: "0.163.0", + installedVersion: "0.163.0", + source: "managed", + canRemove: true, + message: null, + }), + ).pipe(Atom.keepAlive); state.candidates = [candidate("/projects/first"), candidate("/projects/second")]; state.createProject .mockReset() @@ -283,6 +408,19 @@ function setup() { .mockReset() .mockResolvedValue(AsyncResult.success({ importedCount: 1, skippedCount: 0 })); state.refreshProviders.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + state.updateSettings.mockReset().mockImplementation(applySettingsPatch); + state.startProviderAuth.mockReset().mockImplementation(async () => { + const waiting = authState("waiting", "Complete sign-in in your browser."); + appAtomRegistry.set(authStateAtom(), AsyncResult.success(waiting)); + return AsyncResult.success(waiting); + }); + state.cancelProviderAuth.mockReset().mockImplementation(async () => { + appAtomRegistry.set( + authStateAtom(), + AsyncResult.success(authState("cancelled", "Sign-in cancelled.")), + ); + return AsyncResult.success(undefined); + }); state.connectPairing.mockReset().mockResolvedValue(AsyncResult.success(remoteId)); state.completeOnboarding.mockReset().mockResolvedValue(undefined); state.onDone.mockReset(); @@ -386,3 +524,31 @@ it("finishes setup without landing when the imported project never reaches the c await vi.waitFor(() => expect(state.onDone).toHaveBeenCalledOnce()); expect(state.onDone).toHaveBeenCalledWith(undefined); }); + +it("restores the Codex setup mode when ChatGPT sign-in from onboarding is cancelled", async () => { + using _now = setup(); + await using _screen = await mountCodexSignIn(); + + await click("Cancel"); + expect(state.cancelProviderAuth).toHaveBeenCalledOnce(); + await vi.waitFor(() => + expect(codexSetupMode(), "a cancelled sign-in must not leave Codex managed").toBeUndefined(), + ); +}); + +it("restores the Codex setup mode when ChatGPT sign-in from onboarding fails", async () => { + using _now = setup(); + await using _screen = await mountCodexSignIn(); + + await act(async () => + appAtomRegistry.set( + authStateAtom(), + AsyncResult.success( + authState("failed", "ChatGPT sign-in was declined. Sign in again when you are ready."), + ), + ), + ); + await vi.waitFor(() => + expect(codexSetupMode(), "a failed sign-in must not leave Codex managed").toBeUndefined(), + ); +}); From 2b299cd5601ad99837bf219096a4465d9f2abde5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 06:08:48 +0000 Subject: [PATCH 2/8] fix(web): unfinished ChatGPT sign-in in onboarding no longer leaves Codex managed Continue with ChatGPT on the onboarding Codex card saves setupMode "managed" before signing in, because the server only installs and signs in to its own runtime in that mode. If the attempt was cancelled or failed, the mode stayed managed and the server ignored a later `codex login`. ManagedCodexSetup now reports an attempt that ends without signing in (Cancel, a failed or cancelled flow for the flow it started, or a failed install or start request). The wizard remembers the setup mode from before the click and saves it back, and the restored card keeps the failure reason. Co-Authored-By: Claude Opus 5.5 --- .../WelcomeWizard.import.browser.test.tsx | 3 + .../components/onboarding/WelcomeWizard.tsx | 34 ++++--- .../settings/CodexSetupSection.logic.test.ts | 10 ++ .../settings/CodexSetupSection.logic.ts | 12 +++ .../components/settings/CodexSetupSection.tsx | 93 ++++++++++++++++--- 5 files changed, 124 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx index 6b93c53d30858..e5c780918b653 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx @@ -551,4 +551,7 @@ it("restores the Codex setup mode when ChatGPT sign-in from onboarding fails", a await vi.waitFor(() => expect(codexSetupMode(), "a failed sign-in must not leave Codex managed").toBeUndefined(), ); + await vi.waitFor(() => + expect(pageText()).toContain("ChatGPT sign-in was declined. Sign in again when you are ready."), + ); }); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 2eb6bcd673957..b27aa6c716b8b 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -917,13 +917,14 @@ function OnboardingCodexSetup({ config: createdAccount ? { enabled: true, setupMode: "managed" } : {}, }; const mode = readCodexSetupMode(instance.config); - const existingChosen = - mode === "existing" && - instance.config !== null && - typeof instance.config === "object" && - "setupMode" in instance.config && - instance.config.setupMode === "existing"; - const changeMode = (setupMode: "managed" | "existing") => { + const config: Record = + instance.config !== null && typeof instance.config === "object" ? { ...instance.config } : {}; + const existingChosen = config.setupMode === "existing"; + // The server only signs in to its own runtime, so ChatGPT sign-in saves "managed" first. + // An attempt that ends without signing in puts back what was saved before it. + const modeBeforeSignIn = useRef<{ readonly setupMode: unknown } | null>(null); + const saveMode = (setupMode: unknown) => { + const { setupMode: _saved, ...rest } = config; void update({ environmentId, input: { @@ -933,18 +934,22 @@ function OnboardingCodexSetup({ instance: { ...instance, enabled: true, - config: { - ...(instance.config !== null && typeof instance.config === "object" - ? instance.config - : {}), - enabled: true, - setupMode, - }, + config: { ...rest, enabled: true, ...(setupMode === undefined ? {} : { setupMode }) }, }, }), }, }); }; + const changeMode = (setupMode: "managed" | "existing") => { + modeBeforeSignIn.current = + setupMode === "managed" && mode !== "managed" ? { setupMode: config.setupMode } : null; + saveMode(setupMode); + }; + const restoreMode = () => { + if (modeBeforeSignIn.current === null) return; + saveMode(modeBeforeSignIn.current.setupMode); + modeBeforeSignIn.current = null; + }; return existingChosen ? ( ); } diff --git a/apps/web/src/components/settings/CodexSetupSection.logic.test.ts b/apps/web/src/components/settings/CodexSetupSection.logic.test.ts index 5297d6e4fbbd4..e6f0eb0b4cdd9 100644 --- a/apps/web/src/components/settings/CodexSetupSection.logic.test.ts +++ b/apps/web/src/components/settings/CodexSetupSection.logic.test.ts @@ -5,6 +5,7 @@ import { settleAuthorizationPage, settleHandoffAuthorizationPage, settleProviderInstall, + settleSignIn, } from "./CodexSetupSection.logic"; const install = (overrides: Partial) => @@ -119,3 +120,12 @@ describe("settleAuthorizationPage", () => { expect(settleHandoffAuthorizationPage({ phase: "finished", profile: {} as never })).toBeNull(); }); }); + +describe("settleSignIn", () => { + it("ends only with its own flow", () => { + expect(settleSignIn(auth({ phase: "waiting" }), "flow-1")).toBeUndefined(); + expect(settleSignIn(auth({ flowId: "flow-0", phase: "failed" }), "flow-1")).toBeUndefined(); + const failed = auth({ phase: "failed" }); + expect(settleSignIn(failed, "flow-1")).toBe(failed); + }); +}); diff --git a/apps/web/src/components/settings/CodexSetupSection.logic.ts b/apps/web/src/components/settings/CodexSetupSection.logic.ts index 71e935a8e6863..01858e8deae79 100644 --- a/apps/web/src/components/settings/CodexSetupSection.logic.ts +++ b/apps/web/src/components/settings/CodexSetupSection.logic.ts @@ -53,6 +53,18 @@ export function settleAuthorizationPage( return url ?? undefined; } +/** A sign-in flow's final state, `undefined` while it runs. `flowId` as above. */ +export function settleSignIn( + state: ProviderAuthState | null, + flowId: string | null, +): ProviderAuthState | undefined { + if (state === null || state.flowId === null) return undefined; + if (flowId !== null && state.flowId !== flowId) return undefined; + return state.phase === "succeeded" || state.phase === "failed" || state.phase === "cancelled" + ? state + : undefined; +} + export function settleHandoffAuthorizationPage( state: ChatGptHandoffState | null, ): string | null | undefined { diff --git a/apps/web/src/components/settings/CodexSetupSection.tsx b/apps/web/src/components/settings/CodexSetupSection.tsx index 0dfd68dcdc378..8ecd6e2724cd4 100644 --- a/apps/web/src/components/settings/CodexSetupSection.tsx +++ b/apps/web/src/components/settings/CodexSetupSection.tsx @@ -9,6 +9,7 @@ import type { ChatGptHandoffInput, ChatGptTransferredProfile, EnvironmentId, + ProviderAuthState, ProviderInstanceId, ServerProvider, } from "@t3tools/contracts"; @@ -52,6 +53,7 @@ import { settleAuthorizationPage, settleHandoffAuthorizationPage, settleProviderInstall, + settleSignIn, } from "./CodexSetupSection.logic"; const noop = () => undefined; @@ -100,10 +102,14 @@ interface CodexSetupSectionProps { readonly displayName?: string | undefined; readonly onAutoStartConsumed?: () => void; readonly onSignInCancelled?: (() => void) | undefined; + /** A setup or sign-in ended without signing in; `failure` is `null` when the user cancelled. */ + readonly onSignInUnfinished?: ((failure: string | null) => void) | undefined; } export function CodexSetupSection(props: CodexSetupSectionProps) { const [requested, setRequested] = useState(false); + // Outlives the managed card, which unmounts when an unfinished attempt restores the setup mode. + const [failure, setFailure] = useState(null); // A policy that owns Codex's launch settings also rules out ChatGPT's own runtime. const codexLocks = managedInstanceLocks( useManagedPolicy(props.environmentId), @@ -160,6 +166,7 @@ export function CodexSetupSection(props: CodexSetupSectionProps) { size="sm" disabled={props.readOnly} onClick={() => { + setFailure(null); setRequested(true); props.onModeChange("managed"); }} @@ -176,7 +183,13 @@ export function CodexSetupSection(props: CodexSetupSectionProps) { ) : null } - /> + > + {failure ? ( +

+ {failure} +

+ ) : null} + ) : props.mode === "existing" ? null : !props.provider?.setup?.canAuthenticate ? ( props.presentation === "onboarding" ? ( { + setFailure(message); + props.onSignInUnfinished?.(message); + }) + } /> ); return content; @@ -260,6 +280,7 @@ function ManagedCodexSetup({ presentation, displayName, onSignInCancelled, + onSignInUnfinished, }: CodexSetupSectionProps & { readonly autoStart: boolean; readonly onAutoStartConsumed: () => void; @@ -374,6 +395,7 @@ function ManagedCodexSetup({ const run = async ( request: () => Promise>, onSuccess?: (value: A) => void, + onFailure?: (message: string) => void, ) => { if (pendingRef.current) return false; pendingRef.current = true; @@ -385,7 +407,10 @@ function ManagedCodexSetup({ if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { const failure = squashAtomCommandFailure(result); - setError(failure instanceof Error ? failure.message : "Codex setup failed. Try again."); + const message = + failure instanceof Error ? failure.message : "Codex setup failed. Try again."; + setError(message); + onFailure?.(message); } } else { succeeded = true; @@ -393,6 +418,7 @@ function ManagedCodexSetup({ } } catch { setError("Codex setup failed. Try again."); + onFailure?.("Codex setup failed. Try again."); } pendingRef.current = false; setPending(false); @@ -476,6 +502,12 @@ function ManagedCodexSetup({ } }; + const reportSignInEnd = (ended: ProviderAuthState | null | undefined) => { + if (ended?.phase === "failed") + onSignInUnfinished?.(ended.message ?? "ChatGPT sign-in could not finish. Try again."); + else if (ended?.phase === "cancelled") onSignInUnfinished?.(null); + }; + const signIn = async (methodId: string, signal: AbortSignal) => { if (pendingRef.current) return; setTransferFailed(false); @@ -502,15 +534,28 @@ function ManagedCodexSetup({ }, }; setHandoff(nextHandoff); + const handoffState = serverEnvironment.chatGptHandoffState(nextHandoff); void waitForQuerySettled({ registry: appAtomRegistry, - atom: serverEnvironment.chatGptHandoffState(nextHandoff), + atom: handoffState, settle: settleHandoffAuthorizationPage, signal, }).then((page) => { if (page && !signal.aborted) void openPage(page, null, false); }); + void waitForQuerySettled({ + registry: appAtomRegistry, + atom: handoffState, + settle: (state) => + state === null + ? undefined + : state.phase === "finished" + ? null + : settleSignIn(state.state, null), + signal, + }).then(reportSignInEnd); }, + onSignInUnfinished, ); return; } @@ -528,6 +573,13 @@ function ManagedCodexSetup({ (started) => { setAwaitingProvider("sign-in"); const startedFlowId = started.flowId; + if (startedFlowId !== null) + void waitForQuerySettled({ + registry: appAtomRegistry, + atom: serverEnvironment.providerAuthState(target), + settle: (state) => settleSignIn(state, startedFlowId), + signal, + }).then(reportSignInEnd); // A remote browser cannot receive the callback, so the user opens the page from the card. if (remoteWeb || startedFlowId === null) return; const startedPage = settleAuthorizationPage(started, startedFlowId); @@ -544,6 +596,7 @@ function ManagedCodexSetup({ if (page && !signal.aborted) void openPage(page, startedFlowId, clientCallback); }); }, + onSignInUnfinished, ); }; @@ -560,12 +613,20 @@ function ManagedCodexSetup({ void waitForQuerySettled({ registry: appAtomRegistry, atom: serverEnvironment.providerInstallState(target), - settle: (current) => settleProviderInstall(started, current), + settle: (current) => { + const ready = settleProviderInstall(started, current); + return ready === undefined + ? undefined + : { ready, message: current?.message ?? started.message }; + }, signal, - }).then((ready) => { - if (ready && !signal.aborted) void signIn(methodId, signal); + }).then((settled) => { + if (!settled || signal.aborted) return; + if (settled.ready) void signIn(methodId, signal); + else onSignInUnfinished?.(settled.message ?? "Codex setup failed. Try again."); }); }, + onSignInUnfinished, ); }; @@ -873,14 +934,18 @@ function ManagedCodexSetup({ disabled={pending || readOnly} onClick={() => { abortOperation(); - if (handoff || (authActive && auth?.flowId)) void cancelSignIn(); - else if (installation?.operationId) - void run(() => - cancelInstall({ - environmentId, - input: { instanceId, operationId: installation.operationId! }, - }), - ); + void ( + handoff || (authActive && auth?.flowId) + ? cancelSignIn() + : installation?.operationId + ? run(() => + cancelInstall({ + environmentId, + input: { instanceId, operationId: installation.operationId! }, + }), + ) + : Promise.resolve() + ).then(() => onSignInUnfinished?.(null)); }} > Cancel From 03f4ee862dc49cd7c6adaa4597c8e01dd4c188d2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 06:22:16 +0000 Subject: [PATCH 3/8] fix(web): restore Codex setup mode against current settings The restore read the settings captured when sign-in started, so a provider change made during the attempt was overwritten. It now reads the current settings. A failed primary-environment handoff query also restores the mode. Co-Authored-By: Claude Opus 5.5 --- .../WelcomeWizard.import.browser.test.tsx | 31 +++++++++++++++++++ .../components/onboarding/WelcomeWizard.tsx | 12 +++++-- .../components/settings/CodexSetupSection.tsx | 9 +++++- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx index e5c780918b653..3b87b96fdd713 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx @@ -539,6 +539,19 @@ it("restores the Codex setup mode when ChatGPT sign-in from onboarding is cancel it("restores the Codex setup mode when ChatGPT sign-in from onboarding fails", async () => { using _now = setup(); await using _screen = await mountCodexSignIn(); + // Another client adds a provider while sign-in is pending. + const otherId = ProviderInstanceId.make("codex-work"); + const config = appAtomRegistry.get(configAtom())!; + appAtomRegistry.set(configAtom(), { + ...config, + settings: { + ...config.settings, + providerInstances: { + ...config.settings.providerInstances, + [otherId]: { driver: ProviderDriverKind.make("codex"), config: {} }, + }, + }, + }); await act(async () => appAtomRegistry.set( @@ -551,7 +564,25 @@ it("restores the Codex setup mode when ChatGPT sign-in from onboarding fails", a await vi.waitFor(() => expect(codexSetupMode(), "a failed sign-in must not leave Codex managed").toBeUndefined(), ); + expect(appAtomRegistry.get(configAtom())?.settings.providerInstances[otherId]).toBeDefined(); await vi.waitFor(() => expect(pageText()).toContain("ChatGPT sign-in was declined. Sign in again when you are ready."), ); }); + +it("keeps the Codex setup mode managed when ChatGPT sign-in from onboarding succeeds", async () => { + using _now = setup(); + await using _screen = await mountCodexSignIn(); + + await act(async () => + appAtomRegistry.set(authStateAtom(), AsyncResult.success(authState("succeeded"))), + ); + // The card asks for a provider refresh once it sees the flow succeed. + await vi.waitFor(() => + expect(state.refreshProviders).toHaveBeenCalledWith( + expect.objectContaining({ input: { instanceId: codexId } }), + ), + ); + expect(state.updateSettings).toHaveBeenCalledOnce(); + expect(codexSetupMode()).toBe("managed"); +}); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index b27aa6c716b8b..3ca6bad443655 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -72,6 +72,7 @@ import { } from "../../onboarding/targetEnvironment.logic"; import { useProjectScans } from "../../onboarding/useProjectScans"; import { projectEnvironment } from "../../state/projects"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; import { serverEnvironment } from "../../state/server"; import { readEnvironmentScope, @@ -924,15 +925,20 @@ function OnboardingCodexSetup({ // An attempt that ends without signing in puts back what was saved before it. const modeBeforeSignIn = useRef<{ readonly setupMode: unknown } | null>(null); const saveMode = (setupMode: unknown) => { - const { setupMode: _saved, ...rest } = config; + // A restore runs once sign-in ends, so it reads the settings as they are then. + const latest = + appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId))?.settings ?? settings; + const current = latest.providerInstances[instanceId] ?? instance; + const { setupMode: _saved, ...rest }: Record = + current.config !== null && typeof current.config === "object" ? { ...current.config } : {}; void update({ environmentId, input: { patch: buildProviderInstanceUpdatePatch({ - settings, + settings: latest, instanceId, instance: { - ...instance, + ...current, enabled: true, config: { ...rest, enabled: true, ...(setupMode === undefined ? {} : { setupMode }) }, }, diff --git a/apps/web/src/components/settings/CodexSetupSection.tsx b/apps/web/src/components/settings/CodexSetupSection.tsx index 8ecd6e2724cd4..3b546aa05cf1b 100644 --- a/apps/web/src/components/settings/CodexSetupSection.tsx +++ b/apps/web/src/components/settings/CodexSetupSection.tsx @@ -553,7 +553,14 @@ function ManagedCodexSetup({ ? null : settleSignIn(state.state, null), signal, - }).then(reportSignInEnd); + }).then((ended) => { + // Without an abort, `undefined` means the handoff query failed. + if (ended === undefined && !signal.aborted) + onSignInUnfinished?.( + "ChatGPT sign-in on the primary environment was interrupted. Try again.", + ); + else reportSignInEnd(ended); + }); }, onSignInUnfinished, ); From b035c89b90a6e389492b84842ddd33828681dca6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 06:33:00 +0000 Subject: [PATCH 4/8] fix(web): Codex mode is restored only after a confirmed cancel A rejected cancel can mean the flow already succeeded, so the mode stays managed and the error stays on the card. The restored card no longer shows an old ChatGPT failure once the CLI is ready. Co-Authored-By: Claude Opus 5.5 --- .../WelcomeWizard.import.browser.test.tsx | 27 +++++++++++++++++++ .../components/settings/CodexSetupSection.tsx | 11 +++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx index 3b87b96fdd713..bf51317843c9f 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx @@ -1,4 +1,5 @@ import { RegistryContext } from "@effect/atom-react"; +import * as Cause from "effect/Cause"; import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, @@ -568,6 +569,18 @@ it("restores the Codex setup mode when ChatGPT sign-in from onboarding fails", a await vi.waitFor(() => expect(pageText()).toContain("ChatGPT sign-in was declined. Sign in again when you are ready."), ); + + // A later CLI login makes the earlier ChatGPT failure stale. + const [codex] = appAtomRegistry.get( + state.providers as Atom.Writable>, + ); + await act(async () => + appAtomRegistry.set(state.providers as Atom.Writable>, [ + { ...codex!, status: "ready", auth: { status: "authenticated" } }, + ]), + ); + await vi.waitFor(() => expect(pageText()).toContain("Ready")); + expect(pageText()).not.toContain("ChatGPT sign-in was declined"); }); it("keeps the Codex setup mode managed when ChatGPT sign-in from onboarding succeeds", async () => { @@ -586,3 +599,17 @@ it("keeps the Codex setup mode managed when ChatGPT sign-in from onboarding succ expect(state.updateSettings).toHaveBeenCalledOnce(); expect(codexSetupMode()).toBe("managed"); }); + +it("keeps the Codex setup mode managed when sign-in succeeds before Cancel reaches the server", async () => { + using _now = setup(); + await using _screen = await mountCodexSignIn(); + state.cancelProviderAuth.mockImplementationOnce(async () => { + appAtomRegistry.set(authStateAtom(), AsyncResult.success(authState("succeeded"))); + return AsyncResult.failure(Cause.fail(new Error("This sign-in is no longer active."))); + }); + + await click("Cancel"); + await vi.waitFor(() => expect(pageText()).toContain("This sign-in is no longer active.")); + expect(state.updateSettings).toHaveBeenCalledOnce(); + expect(codexSetupMode()).toBe("managed"); +}); diff --git a/apps/web/src/components/settings/CodexSetupSection.tsx b/apps/web/src/components/settings/CodexSetupSection.tsx index 3b546aa05cf1b..9d9066a772a6c 100644 --- a/apps/web/src/components/settings/CodexSetupSection.tsx +++ b/apps/web/src/components/settings/CodexSetupSection.tsx @@ -184,7 +184,7 @@ export function CodexSetupSection(props: CodexSetupSectionProps) { ) : null } > - {failure ? ( + {failure && !existingReady && !existingAuthenticated && !existingChecking ? (

{failure}

@@ -467,7 +467,7 @@ function ManagedCodexSetup({ setAwaitingProvider(null); if (handoff) { setHandoff(null); - return Promise.resolve(); + return Promise.resolve(true); } return run(() => cancelAuth({ environmentId, input: { instanceId, flowId: auth!.flowId! } })); }; @@ -951,8 +951,11 @@ function ManagedCodexSetup({ input: { instanceId, operationId: installation.operationId! }, }), ) - : Promise.resolve() - ).then(() => onSignInUnfinished?.(null)); + : Promise.resolve(true) + ).then((cancelled) => { + // A rejected cancel can mean the flow already succeeded. + if (cancelled) onSignInUnfinished?.(null); + }); }} > Cancel From 9a0711ad3d01ab368d122c3f64529e6fdd20ef74 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 06:50:33 +0000 Subject: [PATCH 5/8] fix(web): Codex restore puts back only setupMode, once per attempt A restore no longer re-enables a provider another client disabled, and a successful sign-in clears the remembered mode so a later failed retry keeps managed setup. Co-Authored-By: Claude Opus 5.5 --- .../WelcomeWizard.import.browser.test.tsx | 38 ++++++++++++++++++- .../components/onboarding/WelcomeWizard.tsx | 31 +++++++++------ .../components/settings/CodexSetupSection.tsx | 6 ++- 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx index bf51317843c9f..72a9d7e3b8809 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx @@ -540,7 +540,7 @@ it("restores the Codex setup mode when ChatGPT sign-in from onboarding is cancel it("restores the Codex setup mode when ChatGPT sign-in from onboarding fails", async () => { using _now = setup(); await using _screen = await mountCodexSignIn(); - // Another client adds a provider while sign-in is pending. + // Another client adds a provider and disables Codex while sign-in is pending. const otherId = ProviderInstanceId.make("codex-work"); const config = appAtomRegistry.get(configAtom())!; appAtomRegistry.set(configAtom(), { @@ -549,6 +549,7 @@ it("restores the Codex setup mode when ChatGPT sign-in from onboarding fails", a ...config.settings, providerInstances: { ...config.settings.providerInstances, + [codexId]: { ...config.settings.providerInstances[codexId]!, enabled: false }, [otherId]: { driver: ProviderDriverKind.make("codex"), config: {} }, }, }, @@ -565,7 +566,9 @@ it("restores the Codex setup mode when ChatGPT sign-in from onboarding fails", a await vi.waitFor(() => expect(codexSetupMode(), "a failed sign-in must not leave Codex managed").toBeUndefined(), ); - expect(appAtomRegistry.get(configAtom())?.settings.providerInstances[otherId]).toBeDefined(); + const instances = appAtomRegistry.get(configAtom())?.settings.providerInstances; + expect(instances?.[otherId]).toBeDefined(); + expect(instances?.[codexId]?.enabled).toBe(false); await vi.waitFor(() => expect(pageText()).toContain("ChatGPT sign-in was declined. Sign in again when you are ready."), ); @@ -613,3 +616,34 @@ it("keeps the Codex setup mode managed when sign-in succeeds before Cancel reach expect(state.updateSettings).toHaveBeenCalledOnce(); expect(codexSetupMode()).toBe("managed"); }); + +it("keeps the Codex setup mode managed when a retry fails after a successful sign-in", async () => { + using _now = setup(); + await using _screen = await mountCodexSignIn(); + const providers = state.providers as Atom.Writable>; + const [codex] = appAtomRegistry.get(providers); + + await act(async () => + appAtomRegistry.set(authStateAtom(), AsyncResult.success(authState("succeeded"))), + ); + await act(async () => + appAtomRegistry.set(providers, [ + { ...codex!, status: "ready", auth: { status: "authenticated" } }, + ]), + ); + await vi.waitFor(() => expect(pageText()).toContain("Ready")); + // Signed out later, the user tries again from the managed card and the retry fails. + await act(async () => appAtomRegistry.set(providers, [codex!])); + await vi.waitFor(() => button("Continue with ChatGPT")); + await click("Continue with ChatGPT"); + await vi.waitFor(() => expect(state.startProviderAuth).toHaveBeenCalledTimes(2)); + await act(async () => + appAtomRegistry.set( + authStateAtom(), + AsyncResult.success(authState("failed", "ChatGPT sign-in could not finish.")), + ), + ); + await vi.waitFor(() => expect(pageText()).toContain("ChatGPT sign-in could not finish.")); + expect(state.updateSettings).toHaveBeenCalledOnce(); + expect(codexSetupMode()).toBe("managed"); +}); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 3ca6bad443655..51d20cdf82feb 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -924,12 +924,14 @@ function OnboardingCodexSetup({ // The server only signs in to its own runtime, so ChatGPT sign-in saves "managed" first. // An attempt that ends without signing in puts back what was saved before it. const modeBeforeSignIn = useRef<{ readonly setupMode: unknown } | null>(null); - const saveMode = (setupMode: unknown) => { - // A restore runs once sign-in ends, so it reads the settings as they are then. + // A restore runs once sign-in ends, so writes read the settings as they are then. + const saveInstance = ( + edit: (current: typeof instance, config: Record) => typeof instance, + ) => { const latest = appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId))?.settings ?? settings; const current = latest.providerInstances[instanceId] ?? instance; - const { setupMode: _saved, ...rest }: Record = + const currentConfig: Record = current.config !== null && typeof current.config === "object" ? { ...current.config } : {}; void update({ environmentId, @@ -937,11 +939,7 @@ function OnboardingCodexSetup({ patch: buildProviderInstanceUpdatePatch({ settings: latest, instanceId, - instance: { - ...current, - enabled: true, - config: { ...rest, enabled: true, ...(setupMode === undefined ? {} : { setupMode }) }, - }, + instance: edit(current, currentConfig), }), }, }); @@ -949,12 +947,20 @@ function OnboardingCodexSetup({ const changeMode = (setupMode: "managed" | "existing") => { modeBeforeSignIn.current = setupMode === "managed" && mode !== "managed" ? { setupMode: config.setupMode } : null; - saveMode(setupMode); + saveInstance((current, currentConfig) => ({ + ...current, + enabled: true, + config: { ...currentConfig, enabled: true, setupMode }, + })); }; const restoreMode = () => { - if (modeBeforeSignIn.current === null) return; - saveMode(modeBeforeSignIn.current.setupMode); + const before = modeBeforeSignIn.current; + if (before === null) return; modeBeforeSignIn.current = null; + saveInstance((current, { setupMode: _managed, ...rest }) => ({ + ...current, + config: before.setupMode === undefined ? rest : { ...rest, setupMode: before.setupMode }, + })); }; return existingChosen ? ( { + modeBeforeSignIn.current = null; + }} /> ); } diff --git a/apps/web/src/components/settings/CodexSetupSection.tsx b/apps/web/src/components/settings/CodexSetupSection.tsx index 9d9066a772a6c..4e22dff9445a3 100644 --- a/apps/web/src/components/settings/CodexSetupSection.tsx +++ b/apps/web/src/components/settings/CodexSetupSection.tsx @@ -104,6 +104,7 @@ interface CodexSetupSectionProps { readonly onSignInCancelled?: (() => void) | undefined; /** A setup or sign-in ended without signing in; `failure` is `null` when the user cancelled. */ readonly onSignInUnfinished?: ((failure: string | null) => void) | undefined; + readonly onSignInFinished?: (() => void) | undefined; } export function CodexSetupSection(props: CodexSetupSectionProps) { @@ -281,6 +282,7 @@ function ManagedCodexSetup({ displayName, onSignInCancelled, onSignInUnfinished, + onSignInFinished, }: CodexSetupSectionProps & { readonly autoStart: boolean; readonly onAutoStartConsumed: () => void; @@ -503,7 +505,8 @@ function ManagedCodexSetup({ }; const reportSignInEnd = (ended: ProviderAuthState | null | undefined) => { - if (ended?.phase === "failed") + if (ended?.phase === "succeeded") onSignInFinished?.(); + else if (ended?.phase === "failed") onSignInUnfinished?.(ended.message ?? "ChatGPT sign-in could not finish. Try again."); else if (ended?.phase === "cancelled") onSignInUnfinished?.(null); }; @@ -559,6 +562,7 @@ function ManagedCodexSetup({ onSignInUnfinished?.( "ChatGPT sign-in on the primary environment was interrupted. Try again.", ); + else if (ended === null) onSignInFinished?.(); else reportSignInEnd(ended); }); }, From c198879196c22caa2312818e1f354d490eebc3ae Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 06:56:43 +0000 Subject: [PATCH 6/8] fix(web): Codex handoff counts as signed in only after the profile import A primary-environment handoff can finish while importing the profile here still fails, so the remembered setup mode is cleared only after the import. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/settings/CodexSetupSection.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/CodexSetupSection.tsx b/apps/web/src/components/settings/CodexSetupSection.tsx index 4e22dff9445a3..d650965e2fee8 100644 --- a/apps/web/src/components/settings/CodexSetupSection.tsx +++ b/apps/web/src/components/settings/CodexSetupSection.tsx @@ -438,6 +438,7 @@ function ManagedCodexSetup({ if (succeeded) { setAwaitingProvider("handoff"); setHandoff(null); + onSignInFinished?.(); } setTransferFailed(!succeeded); }; @@ -562,8 +563,8 @@ function ManagedCodexSetup({ onSignInUnfinished?.( "ChatGPT sign-in on the primary environment was interrupted. Try again.", ); - else if (ended === null) onSignInFinished?.(); - else reportSignInEnd(ended); + // Success counts once this environment has imported the profile. + else if (ended?.phase !== "succeeded") reportSignInEnd(ended); }); }, onSignInUnfinished, From 966ef67ff2b5bc7404bfd09568b808de498499a6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 23:50:24 +0000 Subject: [PATCH 7/8] fix(web): type the Codex setup-mode restore config in WelcomePage Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/onboarding/WelcomePage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/onboarding/WelcomePage.tsx b/apps/web/src/components/onboarding/WelcomePage.tsx index f00221896d0ce..252613bcd86fc 100644 --- a/apps/web/src/components/onboarding/WelcomePage.tsx +++ b/apps/web/src/components/onboarding/WelcomePage.tsx @@ -1101,8 +1101,8 @@ function OnboardingCodexSetup({ const current = appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId))?.settings .providerInstances[instanceId] ?? instance; - const { setupMode: _managed, ...rest } = - current.config !== null && typeof current.config === "object" ? current.config : {}; + const { setupMode: _managed, ...rest }: Record = + current.config !== null && typeof current.config === "object" ? { ...current.config } : {}; void persistProviderInstance({ operation: "upsert", instanceId, From fc32d2767bec89f5836703d07ba51302354663a0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 00:55:20 +0000 Subject: [PATCH 8/8] fix(web): a failed Codex setup-mode restore in onboarding shows an error AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../WelcomePage.import.browser.test.tsx | 17 ---------------- .../src/components/onboarding/WelcomePage.tsx | 20 ++++++++++--------- 2 files changed, 11 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/onboarding/WelcomePage.import.browser.test.tsx b/apps/web/src/components/onboarding/WelcomePage.import.browser.test.tsx index da9263cbcdd45..b095637fc1a84 100644 --- a/apps/web/src/components/onboarding/WelcomePage.import.browser.test.tsx +++ b/apps/web/src/components/onboarding/WelcomePage.import.browser.test.tsx @@ -922,23 +922,6 @@ it("restores the Codex setup mode onto current settings when ChatGPT sign-in fai await vi.waitFor(() => expect(pageText()).toContain("ChatGPT sign-in was declined.")); }); -it("keeps the Codex setup mode managed when ChatGPT sign-in succeeds", async () => { - using _now = setup(); - await using _screen = await mountCodexSignIn(); - - await act(async () => - appAtomRegistry.set(authStateAtom(), AsyncResult.success(authState("succeeded"))), - ); - // The card asks for a provider refresh once it sees the flow succeed. - await vi.waitFor(() => - expect(state.refreshProviders).toHaveBeenCalledWith( - expect.objectContaining({ input: { instanceId: codexId } }), - ), - ); - expect(state.mutateProviderInstance).toHaveBeenCalledOnce(); - expect(codexSetupMode()).toBe("managed"); -}); - it("keeps the Codex setup mode managed when sign-in succeeds before Cancel reaches the server", async () => { using _now = setup(); await using _screen = await mountCodexSignIn(); diff --git a/apps/web/src/components/onboarding/WelcomePage.tsx b/apps/web/src/components/onboarding/WelcomePage.tsx index 252613bcd86fc..7415b6f24d70f 100644 --- a/apps/web/src/components/onboarding/WelcomePage.tsx +++ b/apps/web/src/components/onboarding/WelcomePage.tsx @@ -1064,6 +1064,15 @@ function OnboardingCodexSetup({ // The server only signs in to its own runtime, so ChatGPT sign-in saves "managed" first. // An attempt that ends without signing in puts back what was saved before it. const modeBeforeSignIn = useRef<{ readonly setupMode: unknown } | null>(null); + const reportSaveFailure = (result: Awaited>) => { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const cause = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Could not save Codex setup", + ...(cause instanceof Error ? { description: cause.message } : {}), + }); + }; const changeMode = (setupMode: "managed" | "existing") => { // "Use existing CLI" also opens a terminal to install or sign in to the CLI. onUseCliChange(setupMode === "existing"); @@ -1082,14 +1091,7 @@ function OnboardingCodexSetup({ if (result._tag === "Failure") { modeBeforeSignIn.current = null; onUseCliChange(false); - if (!isAtomCommandInterrupted(result)) { - const cause = squashAtomCommandFailure(result); - toastManager.add({ - type: "error", - title: "Could not save Codex setup", - ...(cause instanceof Error ? { description: cause.message } : {}), - }); - } + reportSaveFailure(result); } }); }; @@ -1110,7 +1112,7 @@ function OnboardingCodexSetup({ ...current, config: before.setupMode === undefined ? rest : { ...rest, setupMode: before.setupMode }, }, - }); + }).then(reportSaveFailure); }; return (