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 d8099e3907bfb..b095637fc1a84 100644 --- a/apps/web/src/components/onboarding/WelcomePage.import.browser.test.tsx +++ b/apps/web/src/components/onboarding/WelcomePage.import.browser.test.tsx @@ -1,9 +1,11 @@ import { RegistryContext } from "@effect/atom-react"; +import * as Cause from "effect/Cause"; import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthRelayReadScope, AuthRelayWriteScope, + DEFAULT_SERVER_SETTINGS, EnvironmentId, ProjectId, ProviderDriverKind, @@ -12,6 +14,10 @@ import { type AuthEnvironmentScope, type AuthSessionState, type EnvironmentCloudLinkStateResult, + type ProviderAuthState, + type ProviderInstallState, + type ProviderInstanceMutation, + type ServerConfig, type ServerProvider, } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -31,10 +37,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: null as unknown, createProject: vi.fn(), importThreads: vi.fn(), refreshProviders: vi.fn(), + mutateProviderInstance: vi.fn(), + startProviderAuth: vi.fn(), + cancelProviderAuth: vi.fn(), completeOnboarding: vi.fn(), onDone: vi.fn(), scan: vi.fn(), @@ -103,6 +114,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, + mutateProviderInstance: "mutateProviderInstance" as never, + startProviderAuth: "startProviderAuth" as never, + cancelProviderAuth: "cancelProviderAuth" as never, }, }; }); @@ -146,8 +162,15 @@ vi.mock(import("../../onboarding/useProjectScans"), async (original) => { }); vi.mock(import("../../state/use-atom-command"), async (original) => ({ ...(await original()), - useAtomCommand: ((command: "createProject" | "importThreads" | "refreshProviders") => - state[command]) as never, + useAtomCommand: (( + command: + | "createProject" + | "importThreads" + | "refreshProviders" + | "mutateProviderInstance" + | "startProviderAuth" + | "cancelProviderAuth", + ) => state[command]) as never, })); import { welcomeImportSelectionKey } from "../../onboarding/welcomeState"; @@ -284,6 +307,93 @@ async function mountWelcome() { ); } +const codexId = ProviderInstanceId.make("codex"); +const signedOutCodex: ServerProvider = { + 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 }, +}; + +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; +} + +function upsertInstance({ + input, +}: { + input: { providerInstanceMutation: Extract }; +}) { + const { instanceId, instance } = input.providerInstanceMutation; + const config = appAtomRegistry.get(configAtom())!; + appAtomRegistry.set(configAtom(), { + ...config, + settings: { + ...config.settings, + providerInstances: { ...config.settings.providerInstances, [instanceId]: instance }, + }, + }); + 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>, [ + readyClaude, + signedOutCodex, + ]); + await using stack = new AsyncDisposableStack(); + const screen = stack.use(await mountWelcome()); + await vi.waitFor(() => button("Set up")); + await click("Set up"); + await vi.waitFor(() => button("Continue with ChatGPT")); + 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() { @@ -300,6 +410,21 @@ function setup() { state.projects = Atom.make>([]).pipe(Atom.keepAlive); state.providers = Atom.make>([readyClaude]).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 = Atom.make>([ candidate("/projects/first"), candidate("/projects/second"), @@ -311,6 +436,21 @@ function setup() { .mockReset() .mockResolvedValue(AsyncResult.success({ importedCount: 1, skippedCount: 0 })); state.refreshProviders.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + state.mutateProviderInstance + .mockReset() + .mockImplementation(async (input) => upsertInstance(input)); + 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.completeOnboarding.mockReset().mockResolvedValue(undefined); state.onDone.mockReset(); state.scan.mockReset(); @@ -739,3 +879,59 @@ it("says the phone is set up only once this computer is shared with the signed-i await vi.waitFor(() => expect(pageText()).toContain("Your phone can reach this computer.")); expect(pageText()).not.toContain("Check T3 Connect"); }); + +it("restores the Codex setup mode when ChatGPT sign-in 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 onto current settings when ChatGPT sign-in fails", async () => { + using _now = setup(); + await using _screen = await mountCodexSignIn(); + // Another client edits Codex while sign-in is pending. + const config = appAtomRegistry.get(configAtom())!; + appAtomRegistry.set(configAtom(), { + ...config, + settings: { + ...config.settings, + providerInstances: { + ...config.settings.providerInstances, + [codexId]: { ...config.settings.providerInstances[codexId]!, displayName: "Work" }, + }, + }, + }); + + await act(async () => + appAtomRegistry.set( + authStateAtom(), + AsyncResult.success(authState("failed", "ChatGPT sign-in was declined.")), + ), + ); + await vi.waitFor(() => + expect(codexSetupMode(), "a failed sign-in must not leave Codex managed").toBeUndefined(), + ); + expect(appAtomRegistry.get(configAtom())?.settings.providerInstances[codexId]?.displayName).toBe( + "Work", + ); + await vi.waitFor(() => expect(pageText()).toContain("ChatGPT sign-in was declined.")); +}); + +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.mutateProviderInstance).toHaveBeenCalledOnce(); + expect(codexSetupMode()).toBe("managed"); +}); diff --git a/apps/web/src/components/onboarding/WelcomePage.tsx b/apps/web/src/components/onboarding/WelcomePage.tsx index 48171ccf94457..7415b6f24d70f 100644 --- a/apps/web/src/components/onboarding/WelcomePage.tsx +++ b/apps/web/src/components/onboarding/WelcomePage.tsx @@ -86,6 +86,7 @@ import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environmen import { managedTelemetryOffMessage, useManagedPolicy } from "../../state/managedPolicy"; import { useProjectScans } from "../../onboarding/useProjectScans"; import { projectEnvironment } from "../../state/projects"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; import { serverEnvironment } from "../../state/server"; import { readEnvironmentScope, @@ -1060,11 +1061,27 @@ function OnboardingCodexSetup({ const config = instance.config !== null && typeof instance.config === "object" ? instance.config : {}; const mode = readCodexSetupMode(config); + // 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"); const unchanged = mode === setupMode && provider.enabled; if (unchanged || !readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; + modeBeforeSignIn.current = + setupMode === "managed" && mode !== "managed" + ? { setupMode: "setupMode" in config ? config.setupMode : undefined } + : null; // One-instance upsert keeps a provider another client added in the meantime. void persistProviderInstance({ operation: "upsert", @@ -1072,18 +1089,31 @@ function OnboardingCodexSetup({ instance: { ...instance, enabled: true, config: { ...config, enabled: true, setupMode } }, }).then((result) => { 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); } }); }; + const restoreMode = () => { + const before = modeBeforeSignIn.current; + if (before === null) return; + modeBeforeSignIn.current = null; + // Sign-in ends after later renders, so restore onto the settings as they are now. + const current = + appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId))?.settings + .providerInstances[instanceId] ?? instance; + const { setupMode: _managed, ...rest }: Record = + current.config !== null && typeof current.config === "object" ? { ...current.config } : {}; + void persistProviderInstance({ + operation: "upsert", + instanceId, + instance: { + ...current, + config: before.setupMode === undefined ? rest : { ...rest, setupMode: before.setupMode }, + }, + }).then(reportSaveFailure); + }; return ( { + modeBeforeSignIn.current = null; + }} /> ); } 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 dea5f40dfea33..05f2f20b465c7 100644 --- a/apps/web/src/components/settings/CodexSetupSection.tsx +++ b/apps/web/src/components/settings/CodexSetupSection.tsx @@ -10,6 +10,7 @@ import type { ChatGptHandoffInput, ChatGptTransferredProfile, EnvironmentId, + ProviderAuthState, ProviderInstanceId, ServerProvider, } from "@t3tools/contracts"; @@ -53,6 +54,7 @@ import { settleAuthorizationPage, settleHandoffAuthorizationPage, settleProviderInstall, + settleSignIn, } from "./CodexSetupSection.logic"; const noop = () => undefined; @@ -103,10 +105,15 @@ 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; + readonly onSignInFinished?: (() => 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), @@ -163,6 +170,7 @@ export function CodexSetupSection(props: CodexSetupSectionProps) { size="sm" disabled={props.readOnly} onClick={() => { + setFailure(null); setRequested(true); props.onModeChange("managed"); }} @@ -184,7 +192,13 @@ export function CodexSetupSection(props: CodexSetupSectionProps) { ) : null } - /> + > + {failure && !existingReady && !existingAuthenticated && !existingChecking ? ( +

+ {failure} +

+ ) : null} + ) : props.mode === "existing" ? null : !props.provider?.setup?.canAuthenticate ? ( props.presentation === "onboarding" ? ( { + setFailure(message); + props.onSignInUnfinished?.(message); + }) + } /> ); return content; @@ -269,6 +290,8 @@ function ManagedCodexSetup({ presentation, displayName, onSignInCancelled, + onSignInUnfinished, + onSignInFinished, }: CodexSetupSectionProps & { readonly autoStart: boolean; readonly onAutoStartConsumed: () => void; @@ -387,6 +410,7 @@ function ManagedCodexSetup({ const run = async ( request: () => Promise>, onSuccess?: (value: A) => void, + onFailure?: (message: string) => void, ) => { if (pendingRef.current) return false; pendingRef.current = true; @@ -398,7 +422,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; @@ -406,6 +433,7 @@ function ManagedCodexSetup({ } } catch { setError("Codex setup failed. Try again."); + onFailure?.("Codex setup failed. Try again."); } pendingRef.current = false; setPending(false); @@ -423,6 +451,7 @@ function ManagedCodexSetup({ if (succeeded) { setAwaitingProvider("handoff"); setHandoff(null); + onSignInFinished?.(); } setTransferFailed(!succeeded); }; @@ -454,7 +483,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! } })); }; @@ -489,6 +518,13 @@ function ManagedCodexSetup({ } }; + const reportSignInEnd = (ended: ProviderAuthState | null | undefined) => { + 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); + }; + const signIn = async (methodId: string, signal: AbortSignal) => { if (pendingRef.current) return; setTransferFailed(false); @@ -515,15 +551,36 @@ 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((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.", + ); + // Success counts once this environment has imported the profile. + else if (ended?.phase !== "succeeded") reportSignInEnd(ended); + }); }, + onSignInUnfinished, ); return; } @@ -541,6 +598,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); @@ -557,6 +621,7 @@ function ManagedCodexSetup({ if (page && !signal.aborted) void openPage(page, startedFlowId, clientCallback); }); }, + onSignInUnfinished, ); }; @@ -573,12 +638,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, ); }; @@ -892,14 +965,21 @@ 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(true) + ).then((cancelled) => { + // A rejected cancel can mean the flow already succeeded. + if (cancelled) onSignInUnfinished?.(null); + }); }} > Cancel