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");
+});