From adc4f8fe5b11c88d92774cff3275bd76f15c18e7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 3 Nov 2026 10:01:20 +0000 Subject: [PATCH 1/5] test(web): reproduce #30073, onboarding Codex card has no way back to ChatGPT After choosing "Use existing CLI" with a signed-out Codex CLI, the onboarding card no longer offers "Continue with ChatGPT". This test reproduces #30073 and fails until the way back exists. Co-Authored-By: Claude Opus 5.5 --- .../WelcomeWizard.import.browser.test.tsx | 141 +++++++++++++++++- 1 file changed, 140 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..b523dc384f3bd 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,14 @@ 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(), connectPairing: vi.fn(), completeOnboarding: vi.fn(), onDone: vi.fn(), @@ -82,6 +93,10 @@ 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, }, }; }); @@ -123,7 +138,13 @@ 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" + | "connectPairing", ) => state[command]) as never, })); vi.mock(import("../settings/ChatGptWelcomeCoordinator"), async (original) => ({ @@ -258,6 +279,83 @@ async function mountImport(remote = false) { return screen; } +const codexId = ProviderInstanceId.make("codex"); + +function authState(phase: ProviderAuthState["phase"]) { + 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: null, + } as ProviderAuthState; +} + +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 mountCodexAgents() { + 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(pageText()).toContain("Connect your agents"); + 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 +373,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 +396,12 @@ 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"); + appAtomRegistry.set(state.authState as Atom.Writable, AsyncResult.success(waiting)); + return AsyncResult.success(waiting); + }); state.connectPairing.mockReset().mockResolvedValue(AsyncResult.success(remoteId)); state.completeOnboarding.mockReset().mockResolvedValue(undefined); state.onDone.mockReset(); @@ -386,3 +505,23 @@ 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("can switch Codex back to ChatGPT sign-in after choosing the existing CLI", async () => { + using _now = setup(); + await using _screen = await mountCodexAgents(); + + await click("Use existing CLI"); + await vi.waitFor(() => expect(codexSetupMode()).toBe("existing")); + await vi.waitFor(() => + expect( + [...document.querySelectorAll("button")] + .map((element) => element.textContent?.trim()) + .join(" | "), + "the signed-out Codex card must still offer ChatGPT sign-in", + ).toContain("Continue with ChatGPT"), + ); + + await click("Continue with ChatGPT"); + await vi.waitFor(() => expect(state.startProviderAuth).toHaveBeenCalledOnce()); + expect(codexSetupMode()).toBe("managed"); +}); From e29f84b129661d738d2ad2638be85cf65472ad05 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 3 Nov 2026 10:07:12 +0000 Subject: [PATCH 2/5] fix(web): onboarding Codex card keeps a way back to ChatGPT after choosing the CLI Choosing "Use existing CLI" in onboarding swapped the Codex card for the generic agent card, which only offered a terminal Install or Sign in. With the CLI missing or signed out there was no way back to ChatGPT sign-in. While the chosen CLI is not ready, the card now also offers "Continue with ChatGPT". It switches the instance to managed setup and starts sign-in in one click; the request is held in OnboardingCodexSetup because switching remounts the ChatGPT card. It honours the managed-policy setupMode lock and the providers:manage permission. AgentCard gains a generic optional secondary control. Fixes pingdotgg/t3code#30073 Co-Authored-By: Claude Opus 5.5 --- .../components/onboarding/WelcomeWizard.tsx | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 2eb6bcd673957..abc043ef48ff9 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -16,6 +16,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { AgentSessionSource, + AuthProvidersManageScope, AuthTerminalOperateScope, CommandId, defaultInstanceIdForDriver, @@ -34,7 +35,7 @@ import { MonitorIcon, TerminalIcon, } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts"; import { useLocalStorage } from "../../hooks/useLocalStorage"; @@ -85,6 +86,8 @@ import { getProviderSummary } from "../settings/providerStatus"; import { providerClients } from "../settings/providerDriverMeta"; import { ChatGptWelcomeCoordinator } from "../settings/ChatGptWelcomeCoordinator"; import { AddManagedCodexAccountDialog, CodexSetupSection } from "../settings/CodexSetupSection"; +import { ChatGptConnectionButton } from "../settings/ChatGptConnectionButton"; +import { ManagedControl } from "../ManagedPolicy"; import { readCodexSetupMode } from "../settings/CodexSetupSection.logic"; import { buildProviderInstanceUpdatePatch } from "../settings/SettingsPanels.logic"; import { TerminalViewport } from "../ThreadTerminalDrawer"; @@ -906,7 +909,10 @@ function OnboardingCodexSetup({ readonly onAutoStartConsumed: () => void; }) { const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); + const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); const update = useAtomCommand(serverEnvironment.updateSettings, "Codex setup settings"); + // Switching remounts the ChatGPT card, so the request to start sign-in lives here. + const [chatGptRequested, setChatGptRequested] = useState(false); const instanceId = createdAccount?.instanceId ?? provider?.instanceId ?? @@ -945,6 +951,15 @@ function OnboardingCodexSetup({ }, }); }; + const existingState = getOnboardingProviderState(provider); + const existingSettled = + provider?.auth.status === "authenticated" || + existingState === "checking" || + ((provider?.enabled ?? true) && existingState === "ready"); + const setupModeLocks = managedInstanceLocks( + serverConfig.managedPolicy, + ProviderDriverKind.make("codex"), + ); return existingChosen ? ( + { + setChatGptRequested(true); + changeMode("managed"); + }} + > + Continue with ChatGPT + + + ) + } /> ) : ( { + setChatGptRequested(false); + onAutoStartConsumed(); + }} environmentId={environmentId} instanceId={instanceId} provider={provider} @@ -975,12 +1012,14 @@ function AgentCard({ terminalOpen, terminalAvailable, onOpenTerminal, + secondaryControl, }: { readonly driver: OnboardingAgentDriver; readonly provider: ServerProvider | undefined; readonly terminalOpen: boolean; readonly terminalAvailable: boolean; readonly onOpenTerminal: () => void; + readonly secondaryControl?: ReactNode; }) { const meta = providerClients.get(ProviderDriverKind.make(driver)); const displayName = @@ -1002,7 +1041,8 @@ function AgentCard({ {providerState !== "ready" && summary.detail ? ` · ${summary.detail}` : ""}

-
+
+ {secondaryControl} {providerState === "ready" ? ( From 6f511203e8634babf78e1f70b6b737e5e3cd77d7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 3 Nov 2026 10:14:36 +0000 Subject: [PATCH 3/5] fix(web): hide the onboarding ChatGPT fallback when policy pins Codex to the CLI Co-Authored-By: Claude Opus 5.5 --- .../components/onboarding/WelcomeWizard.tsx | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index abc043ef48ff9..51b281e042c83 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -87,7 +87,6 @@ import { providerClients } from "../settings/providerDriverMeta"; import { ChatGptWelcomeCoordinator } from "../settings/ChatGptWelcomeCoordinator"; import { AddManagedCodexAccountDialog, CodexSetupSection } from "../settings/CodexSetupSection"; import { ChatGptConnectionButton } from "../settings/ChatGptConnectionButton"; -import { ManagedControl } from "../ManagedPolicy"; import { readCodexSetupMode } from "../settings/CodexSetupSection.logic"; import { buildProviderInstanceUpdatePatch } from "../settings/SettingsPanels.logic"; import { TerminalViewport } from "../ThreadTerminalDrawer"; @@ -956,10 +955,12 @@ function OnboardingCodexSetup({ provider?.auth.status === "authenticated" || existingState === "checking" || ((provider?.enabled ?? true) && existingState === "ready"); - const setupModeLocks = managedInstanceLocks( - serverConfig.managedPolicy, - ProviderDriverKind.make("codex"), - ); + // A policy that owns Codex's launch settings also rules out ChatGPT's own runtime. + const chatGptAllowed = + managedInstanceLocks( + serverConfig.managedPolicy, + ProviderDriverKind.make("codex"), + )?.configKeys.has("setupMode") !== true; return existingChosen ? ( { + setChatGptRequested(true); + changeMode("managed"); + }} > - { - setChatGptRequested(true); - changeMode("managed"); - }} - > - Continue with ChatGPT - - - ) + Continue with ChatGPT + + ) : null } /> ) : ( From 5228ab9cf65fee2304f8fa13583cf84189999590 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 3 Nov 2026 10:20:52 +0000 Subject: [PATCH 4/5] fix(web): let onboarding agent rows wrap their controls on narrow screens Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/onboarding/WelcomeWizard.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 51b281e042c83..dc78cb143843d 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -1025,20 +1025,20 @@ function AgentCard({ const providerState = getOnboardingProviderState(provider); return ( -
+
-
+
{displayName}

{providerState === "ready" ? "Ready to code." : summary.headline} {providerState !== "ready" && summary.detail ? ` · ${summary.detail}` : ""}

-
+
{secondaryControl} {providerState === "ready" ? ( From 3e2b5d59cdefd3d2c77d52cb04dab56f626aa8f9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 3 Nov 2026 11:03:50 +0000 Subject: [PATCH 5/5] test(web): Codex ChatGPT fallback stays disabled without providers:manage Co-Authored-By: Claude Opus 5.5 --- .../WelcomeWizard.import.browser.test.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) 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 b523dc384f3bd..595f64847f24b 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx @@ -2,6 +2,7 @@ import { RegistryContext } from "@effect/atom-react"; import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + AuthSettingsWriteScope, DEFAULT_SERVER_SETTINGS, EnvironmentId, ProjectId, @@ -525,3 +526,29 @@ it("can switch Codex back to ChatGPT sign-in after choosing the existing CLI", a await vi.waitFor(() => expect(state.startProviderAuth).toHaveBeenCalledOnce()); expect(codexSetupMode()).toBe("managed"); }); + +it("keeps the Codex ChatGPT fallback disabled without permission to manage providers", async () => { + using _now = setup(); + await using _screen = await mountCodexAgents(); + + await click("Use existing CLI"); + await vi.waitFor(() => expect(codexSetupMode()).toBe("existing")); + await act(async () => + appAtomRegistry.set( + sessionAtom(primaryId), + AsyncResult.success({ + ...session([AuthOrchestrationOperateScope]), + permissions: [ + AuthOrchestrationReadScope, + AuthOrchestrationOperateScope, + AuthSettingsWriteScope, + ], + }), + ), + ); + await vi.waitFor(() => expect(button("Continue with ChatGPT").disabled).toBe(true)); + + await act(async () => button("Continue with ChatGPT").click()); + expect(state.startProviderAuth).not.toHaveBeenCalled(); + expect(codexSetupMode()).toBe("existing"); +});