From ee22930bf53620d06c97c4a501a1028b4362ca1f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 28 Sep 2026 19:00:15 -0700 Subject: [PATCH 1/7] feat(codex): connect ChatGPT accounts with managed authentication --- .../desktop/src/app/CodexAuthCallback.test.ts | 112 + apps/desktop/src/app/CodexAuthCallback.ts | 5 + apps/desktop/src/app/DesktopClerk.test.ts | 148 +- apps/desktop/src/app/DesktopClerk.ts | 63 +- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 3 + apps/desktop/src/ipc/channels.ts | 3 + apps/desktop/src/ipc/methods/providerAuth.ts | 49 + apps/desktop/src/main.ts | 1 + apps/desktop/src/preload.ts | 4 + .../features/threads/ChatGptSharingStatus.tsx | 39 + .../threads/ChatGptUsageLimitNotice.tsx | 40 + .../src/features/threads/ThreadComposer.tsx | 5 + .../features/threads/ThreadSettingsSheet.tsx | 11 +- .../features/usage/ChatGptUsageSummary.tsx | 41 + .../src/features/usage/UsageLimitsPooled.tsx | 26 +- .../src/features/usage/UsageLimitsSection.tsx | 12 +- .../src/features/usage/UsageRouteScreen.tsx | 2 + apps/server/package.json | 3 + apps/server/src/auth/RpcAuthorization.ts | 4 + apps/server/src/auth/ServerSecretStore.ts | 3 + .../Layers/ProviderRuntimeIngestion.test.ts | 2 + .../Layers/ProviderRuntimeIngestion.ts | 1 + .../src/provider/CodexAuthCallback.test.ts | 105 + apps/server/src/provider/CodexAuthCallback.ts | 60 + .../src/provider/CodexAuthCallbackPage.ts | 42 + .../src/provider/CodexChatGptAuth.test.ts | 2063 +++++++++++++++++ apps/server/src/provider/CodexChatGptAuth.ts | 985 ++++++++ .../src/provider/CodexChatGptHandoff.ts | 63 + .../src/provider/CodexChatGptModels.test.ts | 75 + .../server/src/provider/CodexChatGptModels.ts | 47 + .../provider/CodexChatGptSessionLock.test.ts | 75 + .../src/provider/CodexChatGptSessionLock.ts | 45 + .../src/provider/CodexInstallation.test.ts | 364 +++ apps/server/src/provider/CodexInstallation.ts | 734 ++++++ .../src/provider/CodexManagedErrors.test.ts | 64 + .../server/src/provider/CodexManagedErrors.ts | 87 + apps/server/src/provider/CodexManagedHome.ts | 20 + .../src/provider/CodexManagedRuntime.test.ts | 225 ++ .../src/provider/CodexManagedRuntime.ts | 112 + .../src/provider/Drivers/CodexDriver.test.ts | 172 +- .../src/provider/Drivers/CodexDriver.ts | 18 +- .../provider/Drivers/CodexManagedProvider.ts | 280 +++ .../src/provider/Layers/CodexAdapter.test.ts | 109 + .../src/provider/Layers/CodexAdapter.ts | 109 +- .../src/provider/Layers/CodexProvider.ts | 52 +- .../provider/Layers/ProviderAuthService.ts | 29 + .../ProviderInstanceRegistryLive.test.ts | 30 +- .../provider/Layers/ProviderRegistry.test.ts | 15 +- .../provider/Layers/ProviderService.test.ts | 89 +- .../src/provider/Layers/ProviderService.ts | 37 +- .../server/src/provider/ModelManifest.test.ts | 24 + apps/server/src/provider/ModelManifest.ts | 6 +- .../src/provider/ProviderAuthFlow.test.ts | 39 + apps/server/src/provider/ProviderAuthFlow.ts | 86 +- .../provider/Services/ProviderAuthService.ts | 21 + .../src/provider/providerInstallation.test.ts | 55 +- .../src/provider/providerInstallation.ts | 66 +- apps/server/src/server.test.ts | 6 + apps/server/src/server.ts | 30 +- .../src/textGeneration/CodexTextGeneration.ts | 26 +- apps/server/src/usage/UsageService.test.ts | 89 + apps/server/src/usage/UsageService.ts | 25 +- apps/server/src/ws.ts | 14 + apps/web/src/components/ChatView.tsx | 2 + apps/web/src/components/Icons.tsx | 2 +- .../components/chat/ChatGptSharingControl.tsx | 17 + .../components/chat/ComposerUsageLimits.tsx | 13 + .../components/chat/ProviderModelPicker.tsx | 4 + .../src/components/chat/ThreadErrorBanner.tsx | 40 +- .../onboarding/WelcomeWizard.test.tsx | 76 +- .../components/onboarding/WelcomeWizard.tsx | 279 ++- .../settings/AddCodexAccountDialog.test.tsx | 226 ++ .../settings/AddCodexAccountDialog.tsx | 143 ++ .../settings/AddProviderInstanceDialog.tsx | 37 +- .../settings/ChatGptAccountPicker.tsx | 74 + .../settings/ChatGptConnectionButton.tsx | 12 + .../settings/ChatGptUsageButton.tsx | 19 + .../settings/ChatGptWelcomeCoordinator.tsx | 83 + .../settings/CodexSetupSection.logic.ts | 8 + .../settings/CodexSetupSection.test.tsx | 342 +++ .../components/settings/CodexSetupSection.tsx | 1144 +++++++++ .../settings/FoldedSettingsSection.tsx | 34 + .../ProviderAuthCallbackCoordinator.tsx | 50 + .../settings/ProviderInstanceCard.tsx | 81 +- .../ProviderSettingsPanel.logic.test.ts | 8 + .../settings/ProviderSettingsPanel.logic.ts | 26 +- .../settings/ProviderSettingsPanel.tsx | 58 +- .../settings/SettingsScopeContext.tsx | 28 +- .../settings/SettingsScopeSentence.tsx | 34 +- .../settings/settingsScopeAxis.test.ts | 64 + .../components/settings/settingsScopeAxis.ts | 24 +- apps/web/src/components/ui/wizard.tsx | 13 +- .../components/usage/UsageLimitsPooled.tsx | 39 +- apps/web/src/components/usage/UsagePage.tsx | 13 + apps/web/src/main.tsx | 3 + .../providerReadiness.logic.test.ts | 10 + .../src/onboarding/providerReadiness.logic.ts | 2 + apps/web/src/providerAuthDelivery.test.ts | 55 + apps/web/src/providerAuthDelivery.ts | 19 + apps/web/src/routes/__root.tsx | 4 + apps/web/src/routes/settings.providers.tsx | 4 +- apps/web/src/routes/settings.tsx | 1 + apps/web/src/routes/welcome.tsx | 10 + docs/internals/providers.md | 7 + packages/client-runtime/src/rpc/client.ts | 2 + packages/client-runtime/src/state/runtime.ts | 7 +- packages/client-runtime/src/state/server.ts | 22 + packages/contracts/src/ipc.ts | 3 + packages/contracts/src/providerRuntime.ts | 1 + packages/contracts/src/providerSetup.ts | 75 + packages/contracts/src/providerUsageLimits.ts | 7 + packages/contracts/src/rpc.ts | 40 + packages/contracts/src/server.ts | 8 + packages/contracts/src/settings.ts | 3 + packages/shared/package.json | 12 + packages/shared/src/codexAuthCallback.ts | 100 + packages/shared/src/codexAuthHandoff.test.ts | 79 + packages/shared/src/codexAuthHandoff.ts | 149 ++ .../shared/src/providerAuthReturnUrl.test.ts | 26 + packages/shared/src/providerAuthReturnUrl.ts | 34 + packages/shared/src/usageLimits.test.ts | 110 + packages/shared/src/usageLimits.ts | 57 + pnpm-lock.yaml | 21 + 123 files changed, 10765 insertions(+), 269 deletions(-) create mode 100644 apps/desktop/src/app/CodexAuthCallback.test.ts create mode 100644 apps/desktop/src/app/CodexAuthCallback.ts create mode 100644 apps/desktop/src/ipc/methods/providerAuth.ts create mode 100644 apps/mobile/src/features/threads/ChatGptSharingStatus.tsx create mode 100644 apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx create mode 100644 apps/mobile/src/features/usage/ChatGptUsageSummary.tsx create mode 100644 apps/server/src/provider/CodexAuthCallback.test.ts create mode 100644 apps/server/src/provider/CodexAuthCallback.ts create mode 100644 apps/server/src/provider/CodexAuthCallbackPage.ts create mode 100644 apps/server/src/provider/CodexChatGptAuth.test.ts create mode 100644 apps/server/src/provider/CodexChatGptAuth.ts create mode 100644 apps/server/src/provider/CodexChatGptHandoff.ts create mode 100644 apps/server/src/provider/CodexChatGptModels.test.ts create mode 100644 apps/server/src/provider/CodexChatGptModels.ts create mode 100644 apps/server/src/provider/CodexChatGptSessionLock.test.ts create mode 100644 apps/server/src/provider/CodexChatGptSessionLock.ts create mode 100644 apps/server/src/provider/CodexInstallation.test.ts create mode 100644 apps/server/src/provider/CodexInstallation.ts create mode 100644 apps/server/src/provider/CodexManagedErrors.test.ts create mode 100644 apps/server/src/provider/CodexManagedErrors.ts create mode 100644 apps/server/src/provider/CodexManagedHome.ts create mode 100644 apps/server/src/provider/CodexManagedRuntime.test.ts create mode 100644 apps/server/src/provider/CodexManagedRuntime.ts create mode 100644 apps/server/src/provider/Drivers/CodexManagedProvider.ts create mode 100644 apps/web/src/components/chat/ChatGptSharingControl.tsx create mode 100644 apps/web/src/components/settings/AddCodexAccountDialog.test.tsx create mode 100644 apps/web/src/components/settings/AddCodexAccountDialog.tsx create mode 100644 apps/web/src/components/settings/ChatGptAccountPicker.tsx create mode 100644 apps/web/src/components/settings/ChatGptConnectionButton.tsx create mode 100644 apps/web/src/components/settings/ChatGptUsageButton.tsx create mode 100644 apps/web/src/components/settings/ChatGptWelcomeCoordinator.tsx create mode 100644 apps/web/src/components/settings/CodexSetupSection.logic.ts create mode 100644 apps/web/src/components/settings/CodexSetupSection.test.tsx create mode 100644 apps/web/src/components/settings/CodexSetupSection.tsx create mode 100644 apps/web/src/components/settings/ProviderAuthCallbackCoordinator.tsx create mode 100644 apps/web/src/providerAuthDelivery.test.ts create mode 100644 apps/web/src/providerAuthDelivery.ts create mode 100644 packages/shared/src/codexAuthCallback.ts create mode 100644 packages/shared/src/codexAuthHandoff.test.ts create mode 100644 packages/shared/src/codexAuthHandoff.ts create mode 100644 packages/shared/src/providerAuthReturnUrl.test.ts create mode 100644 packages/shared/src/providerAuthReturnUrl.ts diff --git a/apps/desktop/src/app/CodexAuthCallback.test.ts b/apps/desktop/src/app/CodexAuthCallback.test.ts new file mode 100644 index 000000000000..6d8a905885ac --- /dev/null +++ b/apps/desktop/src/app/CodexAuthCallback.test.ts @@ -0,0 +1,112 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetch:off - Tests exercise the real native loopback listener without an OpenAI account. +import * as NodeHttp from "node:http"; +import { describe, expect, it } from "vite-plus/test"; +import { codexAuthDeliveryUrl, readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import { receiveCodexAuthCallback, cancelCodexAuthCallback } from "./CodexAuthCallback.ts"; + +async function freePort() { + const server = NodeHttp.createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("address"); + await new Promise((resolve) => server.close(() => resolve())); + return address.port; +} +function request(port: number, state = "a".repeat(43)) { + const url = new URL("https://auth.openai.com/api/accounts/authorize"); + url.search = new URLSearchParams({ + client_id: "dynamic_agent_client", + response_type: "code", + redirect_uri: `http://127.0.0.1:${port}/auth/callback`, + state, + code_challenge_method: "S256", + code_challenge: "b".repeat(43), + }).toString(); + return url.toString(); +} +function callback(authorizationUrl: string) { + const request = new URL(authorizationUrl); + const url = new URL(request.searchParams.get("redirect_uri")!); + url.search = new URLSearchParams({ + state: request.searchParams.get("state")!, + code: "test-code", + client_id: "oaiapp_test", + }).toString(); + return url.toString(); +} + +describe("desktop Codex callback helper", () => { + it("binds before opening sign-in, ignores a foreign response, and returns only the code callback", async () => { + const authorizationUrl = request(await freePort()); + const expected = callback(authorizationUrl); + const received = await receiveCodexAuthCallback(authorizationUrl, async () => { + const invalid = new URL(expected); + invalid.searchParams.set("state", "foreign"); + expect((await fetch(invalid)).status).toBe(400); + const response = await fetch(expected); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.text()).not.toContain("test-code"); + return true; + }); + expect(received).toBe(expected); + }); + it("returns hosted web to the exact instance and environment without putting the code in its query", async () => { + const authorizationUrl = request(await freePort()); + const expected = callback(authorizationUrl); + const input = { + authorizationUrl, + returnUrl: "https://app.t3.codes/settings/providers?environmentId=remote-one&instanceId=work", + environmentId: EnvironmentId.make("remote-one"), + instanceId: ProviderInstanceId.make("work"), + flowId: "flow-one", + }; + await receiveCodexAuthCallback( + authorizationUrl, + async () => { + const response = await fetch(expected, { redirect: "manual" }); + expect(response.status).toBe(303); + const delivery = response.headers.get("location")!; + expect(new URL(delivery).searchParams.has("code")).toBe(false); + expect(readCodexAuthDelivery(delivery)?.callbackUrl).toBe(expected); + expect(readCodexAuthDelivery(delivery)?.returnUrl).toBe(input.returnUrl); + return true; + }, + (url) => codexAuthDeliveryUrl(input, url), + ); + }); + it("cancels and releases its listener so exact-port reauthorization can run again", async () => { + const authorizationUrl = request(await freePort()); + await expect( + receiveCodexAuthCallback(authorizationUrl, async () => { + cancelCodexAuthCallback(authorizationUrl); + return true; + }), + ).rejects.toThrow("cancelled"); + expect( + await receiveCodexAuthCallback(authorizationUrl, async () => { + await fetch(callback(authorizationUrl)); + return true; + }), + ).toBe(callback(authorizationUrl)); + }); + it("allows two accounts to complete independently", async () => { + const a = request(await freePort(), "a".repeat(43)); + const b = request(await freePort(), "c".repeat(43)); + const openedA = Promise.withResolvers(); + const openedB = Promise.withResolvers(); + const receiveA = receiveCodexAuthCallback(a, async () => { + openedA.resolve(); + await openedB.promise; + await fetch(callback(a)); + return true; + }); + const receiveB = receiveCodexAuthCallback(b, async () => { + openedB.resolve(); + await openedA.promise; + await fetch(callback(b)); + return true; + }); + expect(await Promise.all([receiveA, receiveB])).toEqual([callback(a), callback(b)]); + }); +}); diff --git a/apps/desktop/src/app/CodexAuthCallback.ts b/apps/desktop/src/app/CodexAuthCallback.ts new file mode 100644 index 000000000000..a2fda9ab68cc --- /dev/null +++ b/apps/desktop/src/app/CodexAuthCallback.ts @@ -0,0 +1,5 @@ +export { + CodexAuthCallbackError, + cancelCodexAuthCallback, + receiveCodexAuthCallback, +} from "@t3tools/shared/codexAuthCallback"; diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 1641149e9e35..6c04291b6846 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -1,3 +1,8 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - Hosted handoff test uses a real localhost listener without an OpenAI account. +import * as NodeHttp from "node:http"; +import { codexAuthHandoffUrl, readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import { HostProcessArguments } from "@t3tools/shared/hostProcess"; import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; @@ -22,14 +27,24 @@ vi.mock("@clerk/electron/storage", () => ({ storage: storageMock, })); +import * as Option from "effect/Option"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => { +const makeDesktopClerkLayer = ( + isDevelopment = true, + events: string[] = [], + shell: ElectronShell.ElectronShell["Service"] = { + openExternal: () => Effect.succeed(true), + openSystemSettings: () => Effect.succeed(false), + copyText: () => Effect.void, + }, +) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ stateDir: "/tmp/t3-state", isDevelopment, @@ -51,6 +66,7 @@ const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => { Layer.mergeAll( Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment), Layer.succeed(ElectronApp.ElectronApp, electronApp), + Layer.succeed(ElectronShell.ElectronShell, shell), FileSystem.layerNoop({ exists: () => Effect.succeed(false) }), ), ), @@ -162,7 +178,7 @@ describe("DesktopClerk", () => { assert.isTrue(Exit.isSuccess(exit)); assert.equal(quit.mock.calls.length, 0); - assert.deepEqual(registeredEvents, ["second-instance"]); + assert.deepEqual(registeredEvents, ["open-url", "second-instance"]); }).pipe( Effect.provide(makeDesktopClerkLayer()), Effect.provideService(ElectronApp.ElectronApp, electronApp), @@ -198,3 +214,131 @@ describe("DesktopClerk", () => { ); }); }); + +it.effect( + "provider auth deep links navigate and reveal the running desktop without handling Clerk URLs", + () => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); + const listeners = new Map void>(); + const revealed = Promise.withResolvers(); + const loadURL = vi.fn(async (_url: string) => undefined); + const window = { loadURL }; + const electronApp = { + on: (name: string, listener: (...args: unknown[]) => void) => + Effect.sync(() => { + listeners.set(name, listener); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + const electronWindow = { + currentMainOrFirst: Effect.succeed(Option.some(window)), + reveal: () => Effect.sync(() => revealed.resolve()), + } as unknown as ElectronWindow.ElectronWindow["Service"]; + return Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure; + const event = { preventDefault: vi.fn() }; + listeners.get("open-url")!(event, "t3code-dev://app/auth/callback?code=clerk-code"); + listeners.get("open-url")!(event, "t3code://app/welcome"); + assert.equal(loadURL.mock.calls.length, 0); + assert.equal(event.preventDefault.mock.calls.length, 0); + listeners.get("second-instance")!({}, [ + "t3", + "t3code-dev://app/settings/providers?instanceId=work&code=never-forward", + ]); + yield* Effect.promise(() => revealed.promise); + assert.deepEqual(loadURL.mock.calls, [ + ["t3code-dev://app/settings/providers?instanceId=work"], + ]); + listeners.get("open-url")!(event, "t3code-dev://app/welcome#agents:machine-id"); + assert.equal(event.preventDefault.mock.calls.length, 1); + }).pipe( + Effect.scoped, + Effect.provide(makeDesktopClerkLayer()), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + ); + }, +); + +for (const entry of ["startup", "open-url"] as const) { + it.effect(`receives hosted web sign-in through the desktop ${entry} handler`, () => + Effect.gen(function* () { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); + const port = yield* Effect.promise(async () => { + const server = NodeHttp.createServer(); + await new Promise((resolve) => server.listen(0, "localhost", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("address"); + await new Promise((resolve) => server.close(() => resolve())); + return address.port; + }); + const authorize = new URL("https://auth.openai.com/api/accounts/authorize"); + authorize.search = new URLSearchParams({ + client_id: "dynamic_agent_client", + response_type: "code", + redirect_uri: `http://localhost:${port}/auth/callback`, + state: "a".repeat(43), + code_challenge_method: "S256", + code_challenge: "b".repeat(43), + }).toString(); + const request = { + authorizationUrl: authorize.toString(), + returnUrl: "https://app.t3.codes/welcome#agents:remote-one", + environmentId: EnvironmentId.make("remote-one"), + instanceId: ProviderInstanceId.make("work"), + flowId: "flow-one", + }; + const link = codexAuthHandoffUrl(request, true); + const delivered = Promise.withResolvers(); + const shell = ElectronShell.ElectronShell.of({ + openExternal: (value) => + Effect.promise(async () => { + const url = new URL(String(value)); + const callback = new URL(url.searchParams.get("redirect_uri")!); + callback.search = new URLSearchParams({ + state: url.searchParams.get("state")!, + code: "test-code", + client_id: "oaiapp_test", + }).toString(); + const response = await fetch(callback, { redirect: "manual" }); + delivered.resolve(response.headers.get("location")!); + return true; + }), + openSystemSettings: () => Effect.succeed(false), + copyText: () => Effect.void, + }); + const listeners = new Map void>(); + const electronApp = { + whenReady: Effect.void, + on: (name: string, listener: (...args: unknown[]) => void) => + Effect.sync(() => { + listeners.set(name, listener); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + yield* Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure; + if (entry === "open-url") { + const event = { preventDefault: vi.fn() }; + listeners.get("open-url")!(event, link); + assert.strictEqual(event.preventDefault.mock.calls.length, 1); + } + const delivery = readCodexAuthDelivery(yield* Effect.promise(() => delivered.promise)); + assert.strictEqual(delivery?.environmentId, request.environmentId); + assert.strictEqual(delivery?.instanceId, request.instanceId); + assert.strictEqual(delivery?.flowId, request.flowId); + assert.strictEqual(delivery?.returnUrl, request.returnUrl); + }).pipe( + Effect.provide(makeDesktopClerkLayer(true, [], shell)), + Effect.provideService(HostProcessArguments, entry === "startup" ? ["t3", link] : ["t3"]), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService( + ElectronWindow.ElectronWindow, + {} as ElectronWindow.ElectronWindow["Service"], + ), + ); + }).pipe(Effect.scoped), + ); +} diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 072a1871d986..8087e944f633 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -7,6 +7,11 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import { codexAuthDeliveryUrl, readCodexAuthHandoff } from "@t3tools/shared/codexAuthHandoff"; +import { receiveCodexAuthCallback, CodexAuthCallbackError } from "./CodexAuthCallback.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; +import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl"; +import { HostProcessArguments } from "@t3tools/shared/hostProcess"; import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; @@ -87,6 +92,7 @@ function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const electronApp = yield* ElectronApp.ElectronApp; + const shell = yield* ElectronShell.ElectronShell; // Electron scopes the single-instance lock to the userData directory and // creates that directory when the lock is acquired. The SDK bridge takes @@ -136,13 +142,62 @@ export const make = Effect.gen(function* () { return yield* Effect.interrupt; } - yield* electronApp.on("second-instance", () => { + const startProviderAuthHandoff = (value: string | undefined) => { + if (!value) return false; + const request = readCodexAuthHandoff(value, environment.isDevelopment); + if (!request) return false; + void runPromise( + Effect.gen(function* () { + yield* electronApp.whenReady; + yield* Effect.tryPromise({ + try: () => + receiveCodexAuthCallback( + request.authorizationUrl, + (url) => runPromise(shell.openExternal(url)), + (callbackUrl) => codexAuthDeliveryUrl(request, callbackUrl), + ), + catch: () => + new CodexAuthCallbackError({ + detail: + "Could not receive hosted web ChatGPT sign-in. Retry or use the redirect URL in the web app.", + }), + }); + }).pipe( + Effect.catch(() => Effect.logWarning("Could not complete ChatGPT desktop handoff.")), + ), + ); + return true; + }; + const resumeProviderAuth = (value: string | undefined) => { + const destination = providerAuthReturnUrl(value); + const expectedOrigin = `${ElectronProtocol.getDesktopScheme(environment.isDevelopment)}://app`; + if (!destination?.startsWith(`${expectedOrigin}/`)) return false; + void runPromise( + Effect.gen(function* () { + const mainWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isNone(mainWindow)) return; + yield* Effect.promise(() => mainWindow.value.loadURL(destination)); + yield* electronWindow.reveal(mainWindow.value); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Could not return to provider setup", cause), + ), + ), + ); + return true; + }; + const args = yield* HostProcessArguments; + args.some((value) => startProviderAuthHandoff(value)); + yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => { + if (startProviderAuthHandoff(url) || resumeProviderAuth(url)) event.preventDefault(); + }); + yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => { + if (argv?.some((value) => startProviderAuthHandoff(value) || resumeProviderAuth(value))) + return; void runPromise( Effect.gen(function* () { const mainWindow = yield* electronWindow.currentMainOrFirst; - if (Option.isSome(mainWindow)) { - yield* electronWindow.reveal(mainWindow.value); - } + if (Option.isSome(mainWindow)) yield* electronWindow.reveal(mainWindow.value); }), ); }); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index c97c602552f4..4b43cd0eee96 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; +import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./methods/providerAuth.ts"; import * as DesktopIpc from "./DesktopIpc.ts"; import { installNotificationBadge } from "./methods/notificationBadge.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; @@ -131,6 +132,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(receiveProviderAuthCallback); + yield* ipc.handle(cancelProviderAuthCallback); yield* ipc.handle(openSystemSettings); yield* ipc.handle(checkSystemPermission); yield* ipc.handle(pasteAsText); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index e8a688c189a3..151cd633d04e 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -116,3 +116,6 @@ export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper"; export const CHECK_SYSTEM_PERMISSION_CHANNEL = "desktop:check-system-permission"; export const PREVIEW_RECORDING_INPUT_CHANNEL = "desktop:preview-recording-input"; + +export const RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:receive-provider-auth-callback"; +export const CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:cancel-provider-auth-callback"; diff --git a/apps/desktop/src/ipc/methods/providerAuth.ts b/apps/desktop/src/ipc/methods/providerAuth.ts new file mode 100644 index 000000000000..fbde6a409f3f --- /dev/null +++ b/apps/desktop/src/ipc/methods/providerAuth.ts @@ -0,0 +1,49 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Option from "effect/Option"; +import { + receiveCodexAuthCallback, + cancelCodexAuthCallback, + CodexAuthCallbackError, +} from "../../app/CodexAuthCallback.ts"; +import * as ElectronShell from "../../electron/ElectronShell.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; +import * as IpcChannels from "../channels.ts"; + +const Request = Schema.String.check(Schema.isMaxLength(16_384)); + +export const receiveProviderAuthCallback = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL, + payload: Request, + result: Schema.String, + handler: Effect.fn("desktop.ipc.providerAuth.receive")(function* (authorizationUrl) { + const shell = yield* ElectronShell.ElectronShell; + const windows = yield* ElectronWindow.ElectronWindow; + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + const callbackUrl = yield* Effect.tryPromise({ + try: () => + receiveCodexAuthCallback(authorizationUrl, (url) => runPromise(shell.openExternal(url))), + catch: () => + new CodexAuthCallbackError({ + detail: + "Could not receive ChatGPT sign-in on this computer. Try again or paste the redirect URL.", + }), + }); + const window = yield* windows.currentMainOrFirst; + if (Option.isSome(window)) yield* windows.reveal(window.value); + return callbackUrl; + }), +}); + +export const cancelProviderAuthCallback = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL, + payload: Request, + result: Schema.Void, + handler: (authorizationUrl) => + Effect.try({ + try: () => cancelCodexAuthCallback(authorizationUrl), + catch: () => new CodexAuthCallbackError({ detail: "Invalid ChatGPT sign-in request." }), + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0d626a51955d..35e15e700ff4 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -205,6 +205,7 @@ const desktopApplicationLayer = Layer.mergeAll( ); const desktopClerkLayer = DesktopClerk.layer.pipe( + Layer.provideMerge(ElectronShell.layer), Layer.provideMerge(desktopEnvironmentLayer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ElectronApp.layer), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 53d8cca7b088..e84a5821e9ff 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -182,6 +182,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { items, ...(position === undefined ? {} : { position }), }), + receiveProviderAuthCallback: (url: string) => + ipcRenderer.invoke(IpcChannels.RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL, url), + cancelProviderAuthCallback: (url: string) => + ipcRenderer.invoke(IpcChannels.CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL, url), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), checkSystemPermission: (pane: string) => ipcRenderer.invoke(IpcChannels.CHECK_SYSTEM_PERMISSION_CHANNEL, pane), diff --git a/apps/mobile/src/features/threads/ChatGptSharingStatus.tsx b/apps/mobile/src/features/threads/ChatGptSharingStatus.tsx new file mode 100644 index 000000000000..86361fce4670 --- /dev/null +++ b/apps/mobile/src/features/threads/ChatGptSharingStatus.tsx @@ -0,0 +1,39 @@ +import type { ServerProvider } from "@t3tools/contracts"; +import { CHATGPT_USAGE_URL, usesChatGptSharing } from "@t3tools/shared/usageLimits"; +import { Alert, Linking, Pressable, View } from "react-native"; +import { AppText as Text } from "../../components/AppText"; +import { ProviderIcon } from "../../components/ProviderIcon"; + +export function ChatGptSharingStatus({ provider }: { provider: ServerProvider | null }) { + if (!usesChatGptSharing(provider)) return null; + return ( + + { + Alert.alert( + "ChatGPT sharing is on", + [ + provider?.auth.email, + "Eligible usage uses your ChatGPT plan. Credit settings and limits are managed in ChatGPT.", + ] + .filter(Boolean) + .join("\n\n"), + ); + }} + > + + Using ChatGPT plan + + void Linking.openURL(CHATGPT_USAGE_URL).catch(() => undefined)} + > + Manage usage + + + ); +} diff --git a/apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx b/apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx new file mode 100644 index 000000000000..d668dc50eabf --- /dev/null +++ b/apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx @@ -0,0 +1,40 @@ +import type { EnvironmentId, OrchestrationThreadShell } from "@t3tools/contracts"; +import { CHATGPT_USAGE_URL, isChatGptUsageLimitError } from "@t3tools/shared/usageLimits"; +import * as Option from "effect/Option"; +import { Linking, Pressable, View } from "react-native"; +import { AppText as Text } from "../../components/AppText"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { useThreadDetail } from "../../state/use-thread-detail"; + +export function ChatGptUsageLimitNotice({ + environmentId, + thread, +}: { + environmentId: EnvironmentId; + thread: OrchestrationThreadShell; +}) { + const state = useThreadDetail({ environmentId, threadId: thread.id }); + const detail = Option.getOrNull(state.data); + if (!isChatGptUsageLimitError(detail?.activities ?? [], thread.session?.lastError)) return null; + return ( + + + + ChatGPT usage limit reached + + + Review your usage settings in ChatGPT to continue. + + void Linking.openURL(CHATGPT_USAGE_URL).catch(() => undefined)} + > + Manage usage + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index d4a845c197a0..6c1143205ca8 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,3 +1,4 @@ +import { ChatGptUsageLimitNotice } from "./ChatGptUsageLimitNotice"; import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAtomValue } from "@effect/atom-react"; @@ -637,6 +638,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer className="relative w-full self-center" style={{ maxWidth: props.contentMaxWidth }} > + {!voiceInput.isBusy && composerMenu.trigger && (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request") ? ( diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 97282f6948c4..aedf415d02de 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -61,7 +61,8 @@ import { nativeHeaderScrollEdgeEffects, } from "../../native/StackHeader"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; -import { serverEnvironment } from "../../state/server"; +import { ChatGptSharingStatus } from "./ChatGptSharingStatus"; +import { environmentServerConfigsAtom, serverEnvironment } from "../../state/server"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useAtomCommand } from "../../state/use-atom-command"; import { useNewTaskFlow } from "./new-task-flow-provider"; @@ -683,6 +684,12 @@ function ThreadSettingsOptionsItem(props: { }) { const insets = useSafeAreaInsets(); const session = useThreadSettingsSession(); + const configs = useAtomValue(environmentServerConfigsAtom); + const selectedProvider = session.environmentId + ? (configs + .get(session.environmentId) + ?.providers.find((provider) => provider.instanceId === session.providerInstanceId) ?? null) + : null; const bottomToolbarInset = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET @@ -690,6 +697,7 @@ function ThreadSettingsOptionsItem(props: { return ( + Options flow.setSelectedModelKey(option.key, option.selection.options)} diff --git a/apps/mobile/src/features/usage/ChatGptUsageSummary.tsx b/apps/mobile/src/features/usage/ChatGptUsageSummary.tsx new file mode 100644 index 000000000000..10f18a51acd1 --- /dev/null +++ b/apps/mobile/src/features/usage/ChatGptUsageSummary.tsx @@ -0,0 +1,41 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { CHATGPT_USAGE_URL, collectExternalUsageLinks } from "@t3tools/shared/usageLimits"; +import { Linking, Pressable, View } from "react-native"; +import { AppText as Text } from "../../components/AppText"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { environmentPresentations } from "../../state/presentation"; + +export function ChatGptUsageSummary({ + selectedEnvironmentIds, +}: { + selectedEnvironmentIds: ReadonlySet | null; +}) { + const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const selected = + selectedEnvironmentIds === null + ? presentations + : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); + const usage = collectExternalUsageLinks(selected).find((link) => link.url === CHATGPT_USAGE_URL); + if (!usage) return null; + return ( + + + + + ChatGPT shared usage + + void Linking.openURL(usage.url).catch(() => undefined)} + > + Manage usage + + + + {usage.accounts.join(", ")}. Open ChatGPT with the account you connected. + + + ); +} diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 0bcc0cd74958..36d01838b0e0 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -3,6 +3,7 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native" import { EnvironmentId } from "@t3tools/contracts"; import { collectLimitAccounts, + collectExternalUsageLinks, collectLimitNotices, collectLimitPools, cursorUsageWindowDetails, @@ -14,7 +15,7 @@ import { type LimitPoolWindow, } from "@t3tools/shared/usageLimits"; import { Fragment, type ReactNode, useId, useState } from "react"; -import { Pressable, ScrollView, View } from "react-native"; +import { Linking, Pressable, ScrollView, View } from "react-native"; import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -217,6 +218,7 @@ export function UsageLimitsSection({ : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); const pools = collectLimitPools(collectLimitAccounts(selected), now); const notices = collectLimitNotices(selected); + const externalLinks = collectExternalUsageLinks(selected); const colors = useProviderColors(); const cursorPromptAt = Math.max( @@ -225,7 +227,11 @@ export function UsageLimitsSection({ ) + 1; return ( - {pools.length === 0 && notices.length === 0 && failedLabels.length === 0 && !cursorPrompt ? ( + {pools.length === 0 && + notices.length === 0 && + failedLabels.length === 0 && + !cursorPrompt && + externalLinks.length === 0 ? ( {selected.size === 0 ? "Select an environment to see limits." @@ -266,6 +272,22 @@ export function UsageLimitsSection({ ); })} {cursorPromptAt === pools.length ? cursorPrompt : null} + {externalLinks.map((link) => ( + + {link.label} + {link.accounts.join(", ")} + {link.message ? ( + {link.message} + ) : null} + void Linking.openURL(link.url).catch(() => undefined)} + > + Manage usage + + + ))} {notices.length > 0 || failedLabels.length > 0 ? ( )} + {externalUsage ? ( + void Linking.openURL(externalUsage.url).catch(() => undefined)} + > + Manage usage + + ) : null} {props.footer} ); diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 4582c3135690..e846425af6f6 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,3 +1,4 @@ +import { ChatGptUsageSummary } from "./ChatGptUsageSummary"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native"; @@ -317,6 +318,7 @@ export function UsageRouteScreen() { className="w-full ios:w-36" /> + {merged.duplicateSources.length > 0 ? ( Counted once across environments sharing a transcript directory:{" "} diff --git a/apps/server/package.json b/apps/server/package.json index 105e4d0d96c3..767c1b0c3393 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -31,7 +31,9 @@ "@opencode-ai/sdk": "^1.3.15", "diff": "8.0.3", "effect": "catalog:", + "jose": "catalog:", "node-pty": "^1.2.0-beta.15", + "proper-lockfile": "4.1.2", "stream-chain": "^4.2.5", "stream-json": "3.6.0", "yaml": "catalog:", @@ -45,6 +47,7 @@ "@t3tools/tailscale": "workspace:*", "@t3tools/web": "workspace:*", "@types/node": "catalog:", + "@types/proper-lockfile": "^4.1.4", "@types/yauzl": "^3.4.0", "effect-acp": "workspace:*", "effect-codex-app-server": "workspace:*", diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 8ab1520a6f34..73aaba5190c1 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -37,6 +37,10 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope, [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope, [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope, + [WS_METHODS.chatGptReconnectProfile]: AuthOrchestrationOperateScope, + [WS_METHODS.chatGptImportProfile]: AuthOrchestrationOperateScope, + [WS_METHODS.chatGptHandoffSubscribe]: AuthOrchestrationOperateScope, + [WS_METHODS.codexAuthCallbackSubscribe]: AuthOrchestrationOperateScope, [WS_METHODS.providerAuthRespond]: AuthOrchestrationOperateScope, [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope, [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/auth/ServerSecretStore.ts b/apps/server/src/auth/ServerSecretStore.ts index c386f7e51d3c..1ee11d3e198e 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -138,6 +138,8 @@ export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean => export class ServerSecretStore extends Context.Service< ServerSecretStore, { + /** File-backed stores expose their directory for cross-process credential leases. */ + readonly directory?: string; readonly get: (name: string) => Effect.Effect, SecretStoreError>; readonly set: (name: string, value: Uint8Array) => Effect.Effect; readonly create: (name: string, value: Uint8Array) => Effect.Effect; @@ -303,6 +305,7 @@ export const make = Effect.gen(function* () { ); return ServerSecretStore.of({ + directory: serverConfig.secretsDir, get, set, create, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 9b5b56309749..4c6e3703ba82 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -3944,6 +3944,7 @@ describe("ProviderRuntimeIngestion", () => { turnId: asTurnId("turn-runtime-error-activity"), payload: { message: "runtime activity exploded", + code: "subscription_sharing_usage_limit_exceeded", }, }); @@ -3960,6 +3961,7 @@ describe("ProviderRuntimeIngestion", () => { expect(activity?.kind).toBe("runtime.error"); expect(activityPayload?.message).toBe("runtime activity exploded"); + expect(activityPayload?.code).toBe("subscription_sharing_usage_limit_exceeded"); }); it("keeps the session running when a runtime.warning arrives during an active turn", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 072ac5110b9d..2584a7578200 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -571,6 +571,7 @@ export function runtimeEventToActivities( summary: "Runtime error", payload: { message: truncateDetail(event.payload.message), + ...(event.payload.code ? { code: event.payload.code } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, diff --git a/apps/server/src/provider/CodexAuthCallback.test.ts b/apps/server/src/provider/CodexAuthCallback.test.ts new file mode 100644 index 000000000000..0dafa54cd1f5 --- /dev/null +++ b/apps/server/src/provider/CodexAuthCallback.test.ts @@ -0,0 +1,105 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - Exercise the real local callback receiver without contacting OpenAI. +import { expect, it } from "@effect/vitest"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Stream from "effect/Stream"; +import * as NodeHttp from "node:http"; +import { subscribeCodexAuthCallback } from "./CodexAuthCallback.ts"; + +async function input() { + const server = NodeHttp.createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("address"); + await new Promise((resolve) => server.close(() => resolve())); + const authorizationUrl = new URL("https://auth.openai.com/api/accounts/authorize"); + authorizationUrl.search = new URLSearchParams({ + client_id: "dynamic_agent_client", + response_type: "code", + redirect_uri: `http://127.0.0.1:${address.port}/auth/callback`, + state: "a".repeat(43), + code_challenge_method: "S256", + code_challenge: "b".repeat(43), + }).toString(); + return { + authorizationUrl: authorizationUrl.toString(), + returnUrl: "http://localhost:7001/welcome#agents:remote-nuc", + environmentId: EnvironmentId.make("remote-nuc"), + instanceId: ProviderInstanceId.make("work"), + flowId: "remote-flow", + }; +} +function callback(authorizationUrl: string) { + const request = new URL(authorizationUrl); + const url = new URL(request.searchParams.get("redirect_uri")!); + url.search = new URLSearchParams({ + state: request.searchParams.get("state")!, + code: "test-code", + client_id: "oaiapp_test", + }).toString(); + return url; +} + +it.effect("a local primary environment receives sign-in for a remote secondary environment", () => + Effect.gen(function* () { + const request = yield* Effect.promise(input); + const ready = yield* Deferred.make(); + const states: string[] = []; + let receivedCallbackUrl: string | undefined; + const receiver = yield* subscribeCodexAuthCallback(request).pipe( + Stream.runForEach((state) => + Effect.gen(function* () { + states.push(state.phase); + if (state.phase === "finished") receivedCallbackUrl = state.callbackUrl; + if (state.phase === "ready") yield* Deferred.succeed(ready, undefined); + }), + ), + Effect.forkScoped, + ); + yield* Deferred.await(ready); + const expected = callback(request.authorizationUrl); + const foreign = new URL(expected); + foreign.searchParams.set("state", "foreign"); + expect((yield* Effect.promise(() => fetch(foreign))).status).toBe(400); + const response = yield* Effect.promise(() => fetch(expected, { redirect: "manual" })); + expect(response.status).toBe(303); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("location")).toBe(request.returnUrl); + yield* Fiber.join(receiver); + expect(receivedCallbackUrl).toBe(expected.toString()); + expect(states).toEqual(["ready", "finished"]); + }).pipe(Effect.scoped), +); + +it.effect("disconnecting the receiving client releases the exact callback port for retry", () => + Effect.gen(function* () { + const request = yield* Effect.promise(input); + const ready = yield* Deferred.make(); + const receiver = yield* subscribeCodexAuthCallback(request).pipe( + Stream.runForEach(() => Deferred.succeed(ready, undefined)), + Effect.forkScoped, + ); + yield* Deferred.await(ready); + yield* Fiber.interrupt(receiver); + yield* subscribeCodexAuthCallback(request).pipe( + Stream.runForEach((state) => + state.phase === "ready" + ? Effect.promise(() => fetch(callback(request.authorizationUrl), { redirect: "manual" })) + : Effect.void, + ), + ); + }).pipe(Effect.scoped), +); + +it.effect("the local server refuses nonlocal return destinations", () => + Effect.gen(function* () { + const request = yield* Effect.promise(input); + const result = yield* subscribeCodexAuthCallback({ + ...request, + returnUrl: "https://app.t3.codes/welcome", + }).pipe(Stream.runDrain, Effect.result); + expect(result._tag).toBe("Failure"); + }), +); diff --git a/apps/server/src/provider/CodexAuthCallback.ts b/apps/server/src/provider/CodexAuthCallback.ts new file mode 100644 index 000000000000..3194e179e62c --- /dev/null +++ b/apps/server/src/provider/CodexAuthCallback.ts @@ -0,0 +1,60 @@ +import { ProviderSetupError, type CodexAuthCallbackInput } from "@t3tools/contracts"; +import { receiveCodexAuthCallback } from "@t3tools/shared/codexAuthCallback"; +import { codexAuthorizationRequest } from "@t3tools/shared/codexAuthHandoff"; +import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl"; +import { isLoopbackHost } from "@t3tools/shared/preview"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Stream from "effect/Stream"; + +/** A connected local environment receives the callback; only the remote environment owns tokens. */ +export function subscribeCodexAuthCallback(input: CodexAuthCallbackInput) { + const failure = (error: unknown) => + new ProviderSetupError({ + instanceId: input.instanceId, + operation: "callback", + detail: + error instanceof Error ? error.message : "Could not receive sign-in on this computer.", + }); + return Stream.unwrap( + Effect.gen(function* () { + const destination = yield* Effect.try({ + try: () => { + codexAuthorizationRequest(input.authorizationUrl); + const destination = providerAuthReturnUrl(input.returnUrl); + if (!destination || !isLoopbackHost(new URL(destination).hostname)) + throw new Error("The local sign-in receiver needs a local T3 Code return address."); + return destination; + }, + catch: failure, + }); + const ready = yield* Deferred.make(); + const runSync = Effect.runSyncWith(yield* Effect.context()); + const callback = yield* Effect.tryPromise({ + try: (signal) => + receiveCodexAuthCallback( + input.authorizationUrl, + async () => { + runSync(Deferred.succeed(ready, undefined)); + return true; + }, + () => destination, + signal, + ), + catch: failure, + }).pipe( + Effect.tapError((error) => Deferred.fail(ready, error)), + Effect.forkScoped, + ); + return Stream.fromEffect(Deferred.await(ready)).pipe( + Stream.map(() => ({ phase: "ready" as const })), + Stream.concat( + Stream.fromEffect(Fiber.join(callback)).pipe( + Stream.map((callbackUrl) => ({ phase: "finished" as const, callbackUrl })), + ), + ), + ); + }), + ); +} diff --git a/apps/server/src/provider/CodexAuthCallbackPage.ts b/apps/server/src/provider/CodexAuthCallbackPage.ts new file mode 100644 index 000000000000..66b5543523c0 --- /dev/null +++ b/apps/server/src/provider/CodexAuthCallbackPage.ts @@ -0,0 +1,42 @@ +import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl"; + +export const codexAuthReturnUrl = providerAuthReturnUrl; + +const escapeHtml = (value: string) => + value.replace( + /[&<>"']/gu, + (character) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!, + ); + +export function codexAuthCallbackPage( + success: boolean, + returnUrl: string | undefined, + nonce: string, +) { + const destination = codexAuthReturnUrl(returnUrl); + const title = success ? "You're signed in" : "Sign-in couldn't finish"; + const description = success + ? destination + ? "Returning to T3 Code. You're ready to continue." + : "Return to T3 Code to continue. You can close this tab." + : "Return to T3 Code and try signing in again."; + return ` + +${escapeHtml(title)} ยท T3 Code +${success && destination ? `` : ""} +
T3 Code
+ +

${escapeHtml(title)}

${description}

+${destination ? `Return to T3 Code` : ""} +
`; +} diff --git a/apps/server/src/provider/CodexChatGptAuth.test.ts b/apps/server/src/provider/CodexChatGptAuth.test.ts new file mode 100644 index 000000000000..e7166968b498 --- /dev/null +++ b/apps/server/src/provider/CodexChatGptAuth.test.ts @@ -0,0 +1,2063 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off preferSchemaOverJson:off - Local mock OAuth server validates the browser callback boundary. +import * as NodeHttp from "node:http"; +import * as NodeCrypto from "node:crypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as Fiber from "effect/Fiber"; +import * as Deferred from "effect/Deferred"; +import * as TestClock from "effect/testing/TestClock"; +import { subscribeChatGptHandoff } from "./CodexChatGptHandoff.ts"; +import { FetchHttpClient } from "effect/unstable/http"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts"; +import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; +import { layerTest as settingsLayerTest } from "../serverSettings.ts"; +import { AnalyticsService } from "../telemetry/AnalyticsService.ts"; +import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; + +const assertSameCallback = (actual: string | null, expected: string | null) => { + const left = new URL(actual!); + const right = new URL(expected!); + assert.strictEqual(left.protocol, right.protocol); + assert.strictEqual(left.hostname, right.hostname); + assert.strictEqual(left.pathname, right.pathname); +}; +const environmentIds = new WeakMap, EnvironmentId>(); +const instanceId = ProviderInstanceId.make("managed-codex-test"); +const makeHarnessFor = Effect.fnUntraced(function* ( + instanceId: ProviderInstanceId, + bytes: Map = new Map(), + failAnalytics = false, +) { + const environmentId = environmentIds.get(bytes) ?? EnvironmentId.make(NodeCrypto.randomUUID()); + environmentIds.set(bytes, environmentId); + const environment = ServerEnvironmentIdentity.of({ + getEnvironmentId: Effect.succeed(environmentId), + }); + const keys = yield* Effect.promise(() => generateKeyPair("RS256")); + const jwk = yield* Effect.promise(() => exportJWK(keys.publicKey)); + const untrustedKeys = yield* Effect.promise(() => generateKeyPair("RS256")); + const secrets = ServerSecretStore.of({ + get: (name) => Effect.sync(() => Option.fromUndefinedOr(bytes.get(name))), + set: (name, value) => + Effect.sync(() => { + bytes.set(name, value); + }), + remove: (name) => + Effect.sync(() => { + bytes.delete(name); + }), + create: () => Effect.die("unused"), + getOrCreateRandom: () => Effect.die("Host identity must come from the environment."), + }); + let authorize: URL | undefined; + let refreshes = 0; + let revoked = false; + let refreshError: string | undefined; + let revocationStatus = 200; + let codeError: string | undefined; + const revocations: URLSearchParams[] = []; + let transient = false; + let invalidNonce = false; + let identityFailure: "issuer" | "audience" | "signature" | undefined; + let mismatchedState = false; + let callbackClientId: string | undefined; + let subject = "user-test"; + let email = "hidden@example.test"; + let grantScope = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct"; + let origin = ""; + const exchanges: URLSearchParams[] = []; + const authorizationRequests: URL[] = []; + const callbackResponses: { body: string; headers: Headers }[] = []; + let returnUrl = "http://localhost:7001/welcome"; + const server = yield* Effect.acquireRelease( + Effect.promise( + () => + new Promise((resolve) => { + const server = NodeHttp.createServer(async (request, response) => { + response.setHeader("content-type", "application/json"); + if (request.url === "/discovery") { + response.end( + JSON.stringify({ + issuer: origin, + authorization_endpoint: `${origin}/authorize`, + token_endpoint: `${origin}/token`, + jwks_uri: `${origin}/jwks`, + revocation_endpoint: `${origin}/revoke`, + }), + ); + return; + } + if (request.url === "/jwks") { + response.end( + JSON.stringify({ keys: [{ ...jwk, kid: "test", alg: "RS256", use: "sig" }] }), + ); + return; + } + if (request.url === "/revoke") { + let text = ""; + for await (const chunk of request) text += chunk.toString(); + revocations.push(new URLSearchParams(text)); + response.statusCode = revocationStatus; + response.end(); + return; + } + if (request.url === "/token") { + let text = ""; + for await (const chunk of request) text += chunk.toString(); + assert.strictEqual( + request.headers["content-type"], + "application/x-www-form-urlencoded", + ); + assert.isUndefined(request.headers.authorization); + const body = new URLSearchParams(text); + assert.isFalse(body.has("client_secret")); + assert.strictEqual(body.get("resource"), `${origin}/v1`); + exchanges.push(body); + if (body.get("grant_type") === "refresh_token") { + refreshes++; + if (transient) { + response.statusCode = 503; + response.end(JSON.stringify({ error: "temporarily_unavailable" })); + return; + } + if (revoked || refreshError) { + response.statusCode = 400; + response.end(JSON.stringify({ error: refreshError ?? "invalid_grant" })); + return; + } + response.end( + JSON.stringify({ + access_token: `access-${refreshes}`, + refresh_token: `refresh-${refreshes}`, + token_type: "Bearer", + expires_in: 3600, + scope: grantScope, + }), + ); + return; + } + if (codeError) { + response.statusCode = 400; + response.end(JSON.stringify({ error: codeError })); + return; + } + if (!authorize) { + response.statusCode = 400; + response.end("{}"); + return; + } + assertSameCallback( + body.get("redirect_uri"), + authorize.searchParams.get("redirect_uri"), + ); + assert.strictEqual( + NodeCrypto.createHash("sha256") + .update(body.get("code_verifier")!) + .digest("base64url"), + authorize.searchParams.get("code_challenge"), + ); + const token = await new SignJWT({ + nonce: invalidNonce ? "incorrect" : authorize.searchParams.get("nonce"), + email, + }) + .setProtectedHeader({ alg: "RS256", kid: "test" }) + .setIssuer(identityFailure === "issuer" ? "https://untrusted-issuer.test" : origin) + .setAudience( + identityFailure === "audience" + ? "oaiapp_untrusted_audience" + : body.get("client_id")!, + ) + .setSubject(subject) + .setIssuedAt() + .setExpirationTime("1h") + .sign(identityFailure === "signature" ? untrustedKeys.privateKey : keys.privateKey); + response.end( + JSON.stringify({ + access_token: "initial-access", + refresh_token: "initial-refresh", + id_token: token, + token_type: "Bearer", + expires_in: 3600, + scope: grantScope, + }), + ); + return; + } + response.statusCode = 404; + response.end("{}"); + }); + server.listen(0, "127.0.0.1", () => resolve(server)); + }), + ), + (server) => + Effect.promise( + () => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + ), + ); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("mock address"); + origin = `http://127.0.0.1:${address.port}`; + const analyticsEvents: { + event: string; + properties: Readonly> | undefined; + }[] = []; + const analytics = AnalyticsService.of({ + record: (event, properties) => + failAnalytics + ? Effect.die("analytics unavailable") + : Effect.sync(() => { + analyticsEvents.push({ event, properties }); + }), + flush: Effect.void, + }); + const auth = yield* makeCodexChatGptAuth({ + instanceId, + discoveryUrl: `${origin}/discovery`, + resource: `${origin}/v1`, + }).pipe( + Effect.provideService(AnalyticsService, analytics), + Effect.provideService(ServerSecretStore, secrets), + Effect.provideService(ServerEnvironmentIdentity, environment), + ); + const phase = (phase: string) => + auth.controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === phase), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + const prepareCallback = (waiting: { authorizationUrl: string | null }) => { + authorize = new URL(waiting.authorizationUrl!); + authorizationRequests.push(authorize); + const callback = new URL(authorize.searchParams.get("redirect_uri")!); + callback.search = new URLSearchParams({ + code: "authorization-code", + state: mismatchedState ? "unmatched-state" : authorize.searchParams.get("state")!, + ...(callbackClientId + ? { client_id: callbackClientId } + : authorize.searchParams.get("client_id") === "dynamic_agent_client" + ? { client_id: "oaiapp_test" } + : {}), + }).toString(); + return callback; + }; + const startRemote = (methodId?: string) => + Effect.gen(function* () { + yield* auth.controller.start("owner", Effect.void, methodId, returnUrl, "client"); + const waiting = yield* phase("waiting"); + return { waiting, callbackUrl: prepareCallback(waiting).toString() }; + }); + const signInWithMethod = (methodId?: string) => + Effect.gen(function* () { + yield* auth.controller.start("owner", Effect.void, methodId, returnUrl); + const waiting = yield* phase("waiting"); + assert.strictEqual(waiting.interaction?.type, "browser"); + const callback = prepareCallback(waiting); + yield* Effect.promise(async () => { + const response = await fetch(callback); + callbackResponses.push({ body: await response.text(), headers: response.headers }); + }); + }); + const seedExpired = Effect.gen(function* () { + const stored = yield* auth.read; + const record = Option.getOrThrow(stored); + const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId).pipe( + Effect.provideService(ServerSecretStore, secrets), + ); + yield* store.set(new TextEncoder().encode(JSON.stringify({ ...record, expiresAt: 0 }))); + }); + return { + auth, + analyticsEvents, + secrets, + environmentId, + bytes, + handoff: (profile: Parameters[0]["profile"]) => + subscribeChatGptHandoff( + { + instanceId, + environmentId, + attemptId: "test-handoff", + returnUrl, + profile, + }, + "owner", + { discoveryUrl: `${origin}/discovery`, resource: `${origin}/v1` }, + ).pipe(Stream.provideService(AnalyticsService, analytics)), + finishCallback: (state: { authorizationUrl: string | null }) => + Effect.promise(() => fetch(prepareCallback(state))), + destination: Effect.gen(function* () { + const destinationBytes = new Map(); + const destinationStore = ServerSecretStore.of({ + ...secrets, + get: (name) => Effect.sync(() => Option.fromUndefinedOr(destinationBytes.get(name))), + set: (name, value) => + Effect.sync(() => { + destinationBytes.set(name, value); + }), + remove: (name) => + Effect.sync(() => { + destinationBytes.delete(name); + }), + }); + const destination = yield* makeCodexChatGptAuth({ + instanceId, + discoveryUrl: `${origin}/discovery`, + resource: `${origin}/v1`, + }).pipe( + Effect.provideService(AnalyticsService, analytics), + Effect.provideService(ServerSecretStore, destinationStore), + Effect.provideService(ServerEnvironmentIdentity, environment), + ); + return { auth: destination, bytes: destinationBytes }; + }), + recreateAuth: makeCodexChatGptAuth({ + instanceId, + discoveryUrl: `${origin}/discovery`, + resource: `${origin}/v1`, + }).pipe( + Effect.provideService(ServerSecretStore, secrets), + Effect.provideService(ServerEnvironmentIdentity, environment), + ), + startRemote, + signIn: signInWithMethod(), + changeAccount: signInWithMethod("chatgpt-change-account"), + reconnectProfile: (clientId: string) => signInWithMethod(`chatgpt-profile:${clientId}`), + phase, + seedExpired, + exchanges, + authorizationRequests, + callbackResponses, + setReturnUrl: (value: string) => { + returnUrl = value; + }, + origin, + storedRecords: () => + Array.from(bytes.entries()).flatMap(([, value]) => { + const record = JSON.parse(new TextDecoder().decode(value)); + return record.sessions ?? [record]; + }), + revocations, + setRefreshError: (value: string) => { + refreshError = value; + }, + setRevocationStatus: (value: number) => { + revocationStatus = value; + }, + setCodeError: (value: string | undefined) => { + codeError = value; + }, + refreshes: () => refreshes, + setRevoked: () => { + revoked = true; + }, + setTransient: (value: boolean) => { + transient = value; + }, + setInvalidNonce: () => { + invalidNonce = true; + }, + setIdentityFailure: (value: "issuer" | "audience" | "signature") => { + identityFailure = value; + }, + mismatchCallbackState: () => { + mismatchedState = true; + }, + setCallbackClientId: (value: string) => { + callbackClientId = value; + }, + setIdentity: (newSubject: string, newEmail: string) => { + subject = newSubject; + email = newEmail; + }, + declineSharing: () => { + grantScope = "openid profile email"; + }, + }; +}); +const makeHarness = makeHarnessFor(instanceId); +const provision = (effect: Effect.Effect) => + effect.pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(FetchHttpClient.layer, NodeServices.layer)), + ); +it.effect( + "changing account registers a new user-owned client and then reuses that registration", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + h.setCallbackClientId("oaiapp_other_account"); + h.setIdentity("other-user", "other@example.test"); + yield* h.changeAccount; + yield* h.phase("succeeded"); + assert.strictEqual( + h.authorizationRequests[1]!.searchParams.get("client_id"), + "dynamic_agent_client", + ); + assert.strictEqual(h.exchanges[1]!.get("client_id"), "oaiapp_other_account"); + const saved = Option.getOrThrow(yield* h.auth.read); + assert.strictEqual(saved.clientId, "oaiapp_other_account"); + assert.strictEqual(saved.subject, "other-user"); + assert.strictEqual(saved.email, "other@example.test"); + const redirectUri = h.authorizationRequests[1]!.searchParams.get("redirect_uri"); + yield* h.signIn; + yield* h.phase("succeeded"); + assert.strictEqual( + h.authorizationRequests[2]!.searchParams.get("client_id"), + "oaiapp_other_account", + ); + assertSameCallback( + h.authorizationRequests[2]!.searchParams.get("redirect_uri"), + redirectUri, + ); + }), + ), +); +for (const failure of ["identity", "sharing"] as const) { + it.effect( + `failed account change preserves the original credentials and registration: ${failure}`, + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + const before = h.storedRecords(); + h.setCallbackClientId("oaiapp_other_account"); + if (failure === "identity") h.setInvalidNonce(); + else h.declineSharing(); + yield* h.changeAccount; + yield* h.phase("failed"); + assert.strictEqual( + h.authorizationRequests[1]!.searchParams.get("client_id"), + "dynamic_agent_client", + ); + if (failure === "sharing") { + assert.deepEqual( + Option.getOrThrow(yield* h.auth.read), + before.find((record) => record.accessToken), + ); + assert.lengthOf(h.storedRecords().find((record) => record.profiles).profiles, 2); + } else assert.deepEqual(h.storedRecords(), before); + }), + ), + ); +} +it.effect("retains the callback host and path after controller recreation and token removal", () => + provision( + Effect.gen(function* () { + const bytes = new Map(); + const first = yield* makeHarnessFor(instanceId, bytes); + yield* first.signIn; + yield* first.phase("succeeded"); + const redirectUri = first.authorizationRequests[0]!.searchParams.get("redirect_uri"); + yield* first.auth.revoke; + const restarted = yield* makeHarnessFor(instanceId, bytes); + yield* restarted.signIn; + yield* restarted.phase("succeeded"); + assert.strictEqual( + restarted.authorizationRequests[0]!.searchParams.get("client_id"), + "oaiapp_test", + ); + assertSameCallback( + restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), + redirectUri, + ); + assert.strictEqual( + restarted.exchanges[0]!.get("redirect_uri"), + restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), + ); + }), + ), +); +it.effect("reauthorizes on an available port when the original callback port is occupied", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + const port = Number( + new URL(h.authorizationRequests[0]!.searchParams.get("redirect_uri")!).port, + ); + yield* Effect.acquireRelease( + Effect.promise( + () => + new Promise((resolve, reject) => { + const server = NodeHttp.createServer(); + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve(server)); + }), + ), + (server) => + Effect.promise(() => new Promise((resolve) => server.close(() => resolve()))), + ); + yield* h.signIn; + yield* h.phase("succeeded"); + assert.notStrictEqual( + Number(new URL(h.authorizationRequests[1]!.searchParams.get("redirect_uri")!).port), + port, + ); + assert.strictEqual(h.authorizationRequests[1]!.searchParams.get("client_id"), "oaiapp_test"); + assert.strictEqual(h.exchanges.length, 2); + }), + ), +); + +it.effect("registers a fresh client when the original registration is no longer stored", () => + provision( + Effect.gen(function* () { + const bytes = new Map(); + const h = yield* makeHarnessFor(instanceId, bytes); + yield* h.signIn; + yield* h.phase("succeeded"); + for (const [key, value] of bytes) { + const record = JSON.parse(new TextDecoder().decode(value)); + if (record.profiles) bytes.delete(key); + } + h.setCallbackClientId("oaiapp_replacement"); + yield* h.signIn; + yield* h.phase("succeeded"); + assert.strictEqual( + h.authorizationRequests[1]!.searchParams.get("client_id"), + "dynamic_agent_client", + ); + assert.strictEqual(h.exchanges[1]!.get("client_id"), "oaiapp_replacement"); + assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_replacement"); + }), + ), +); +it.effect("reauthorizes a registration without persisting its original port", () => + provision( + Effect.gen(function* () { + const bytes = new Map(); + const h = yield* makeHarnessFor(instanceId, bytes); + yield* h.signIn; + yield* h.phase("succeeded"); + yield* h.auth.revoke; + for (const [key, value] of bytes) { + const record = JSON.parse(new TextDecoder().decode(value)); + if (record.profiles) + bytes.set(key, new TextEncoder().encode(JSON.stringify({ clientId: "oaiapp_test" }))); + } + yield* h.signIn; + yield* h.phase("succeeded"); + assert.strictEqual(h.authorizationRequests[1]!.searchParams.get("client_id"), "oaiapp_test"); + assert.strictEqual(new URL(h.exchanges[1]!.get("redirect_uri")!).hostname, "127.0.0.1"); + }), + ), +); + +it.effect( + "separate Codex accounts register independently and disconnect only their own tokens", + () => + provision( + Effect.gen(function* () { + const bytes = new Map(); + const personal = yield* makeHarnessFor(ProviderInstanceId.make("codex_personal"), bytes); + const work = yield* makeHarnessFor(ProviderInstanceId.make("codex_work"), bytes); + yield* personal.signIn; + yield* personal.phase("succeeded"); + const personalCredentials = Option.getOrThrow(yield* personal.auth.read); + assert.isTrue(Option.isNone(yield* work.auth.read)); + yield* work.signIn; + yield* work.phase("succeeded"); + assert.strictEqual( + work.authorizationRequests[0]!.searchParams.get("client_id"), + "dynamic_agent_client", + ); + assert.notStrictEqual( + personal.auth.controller.credentialBinding?.key, + work.auth.controller.credentialBinding?.key, + ); + yield* work.auth.controller.logout(Effect.void); + assert.isTrue(Option.isNone(yield* work.auth.read)); + assert.deepEqual(Option.getOrThrow(yield* personal.auth.read), personalCredentials); + }), + ), +); +it.effect( + "emits the guide's first-time authorization request and fresh reauthorization values", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + assert.include( + h.callbackResponses[0]!.body, + 'content="1;url=http://localhost:7001/welcome"', + ); + const first = h.authorizationRequests[0]!; + assert.strictEqual( + first.searchParams.get("ext_agent_host_id"), + `urn:uuid:${h.environmentId}`, + ); + assert.strictEqual(first.origin, h.origin); + assert.strictEqual(first.pathname, "/authorize"); + assert.deepEqual(Array.from(first.searchParams.keys()).sort(), [ + "agent_name_hint", + "client_id", + "code_challenge", + "code_challenge_method", + "ext_agent_host_id", + "nonce", + "redirect_uri", + "resource", + "response_type", + "scope", + "state", + ]); + assert.strictEqual(first.searchParams.get("client_id"), "dynamic_agent_client"); + assert.strictEqual(first.searchParams.get("agent_name_hint"), "T3 Code"); + assert.strictEqual(first.searchParams.get("response_type"), "code"); + assert.strictEqual( + first.searchParams.get("scope"), + "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct", + ); + assert.strictEqual(first.searchParams.get("resource"), `${h.origin}/v1`); + assert.strictEqual(first.searchParams.get("code_challenge_method"), "S256"); + const callback = new URL(first.searchParams.get("redirect_uri")!); + assert.strictEqual(callback.protocol, "http:"); + assert.strictEqual(callback.hostname, "127.0.0.1"); + assert.strictEqual(callback.pathname, "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/auth/callback"); + assert.isAbove(Number(callback.port), 0); + for (const key of ["state", "nonce", "code_challenge"]) { + assert.match(first.searchParams.get(key)!, /^[A-Za-z0-9_-]{43}$/u); + } + assert.notStrictEqual(first.searchParams.get("state"), first.searchParams.get("nonce")); + const originalIdToken = Option.getOrThrow(yield* h.auth.read).idToken; + yield* h.signIn; + yield* h.phase("succeeded"); + const second = h.authorizationRequests[1]!; + assert.strictEqual(second.searchParams.get("client_id"), "oaiapp_test"); + assert.isFalse(second.searchParams.has("agent_name_hint")); + assert.strictEqual(second.searchParams.get("login_hint"), "hidden@example.test"); + assert.strictEqual(second.searchParams.get("id_token_hint"), originalIdToken); + assert.strictEqual( + second.searchParams.get("ext_agent_host_id"), + first.searchParams.get("ext_agent_host_id"), + ); + assertSameCallback( + second.searchParams.get("redirect_uri"), + first.searchParams.get("redirect_uri"), + ); + assert.deepEqual( + Array.from(second.searchParams.keys()).sort(), + [ + ...Array.from(first.searchParams.keys()).filter((key) => key !== "agent_name_hint"), + "login_hint", + "id_token_hint", + ].sort(), + ); + for (const key of ["state", "nonce", "code_challenge"]) { + assert.notStrictEqual(first.searchParams.get(key), second.searchParams.get(key)); + } + yield* h.auth.controller.logout(Effect.void); + yield* h.signIn; + yield* h.phase("succeeded"); + const reconnect = h.authorizationRequests[2]!; + assert.strictEqual(reconnect.searchParams.get("client_id"), "oaiapp_test"); + assertSameCallback( + reconnect.searchParams.get("redirect_uri"), + first.searchParams.get("redirect_uri"), + ); + assert.isFalse(reconnect.searchParams.has("agent_name_hint")); + assert.isFalse(reconnect.searchParams.has("id_token_hint")); + assert.strictEqual(reconnect.searchParams.get("login_hint"), "hidden@example.test"); + assert.strictEqual( + reconnect.searchParams.get("ext_agent_host_id"), + first.searchParams.get("ext_agent_host_id"), + ); + assert.strictEqual( + h.exchanges[2]!.get("redirect_uri"), + reconnect.searchParams.get("redirect_uri"), + ); + }), + ), +); +it.effect( + "returns to the initiating client only after sign-in has verified and saved credentials", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.setReturnUrl( + "http://localhost:7001/settings/providers?instanceId=codex_work&code=secret-code", + ); + yield* h.signIn; + assert.isTrue(Option.isSome(yield* h.auth.read)); + const response = h.callbackResponses[0]!; + assert.include(response.headers.get("content-type")!, "text/html"); + assert.strictEqual(response.headers.get("cache-control"), "no-store"); + assert.strictEqual(response.headers.get("referrer-policy"), "no-referrer"); + assert.include(response.headers.get("content-security-policy")!, "default-src 'none'"); + assert.include(response.body, "You're signed in".replace("'", "'")); + assert.include( + response.body, + 'content="1;url=http://localhost:7001/settings/providers?instanceId=codex_work"', + ); + assert.notInclude(response.body, "secret-code"); + assert.include(response.body, 'history.replaceState(null,"","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/auth/callback")'); + assert.notInclude(response.body, "authorization-code"); + assert.notInclude(response.body, "initial-access"); + assert.notInclude(response.body, "hidden@example.test"); + }), + ), +); +it.effect("preserves the Welcome agents step and strips unrelated callback return parameters", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.setReturnUrl("http://localhost:7001/welcome?code=never-forward#agents:test-environment"); + yield* h.signIn; + assert.include( + h.callbackResponses[0]!.body, + 'content="1;url=http://localhost:7001/welcome#agents:test-environment"', + ); + assert.notInclude(h.callbackResponses[0]!.body, "never-forward"); + }), + ), +); +it.effect( + "does not redirect to an arbitrary return URL or claim success after verification fails", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.setReturnUrl("https://attacker.example/welcome"); + h.setInvalidNonce(); + yield* h.signIn; + yield* h.phase("failed"); + const response = h.callbackResponses[0]!; + assert.include(response.body, "Sign-in couldn't finish"); + assert.notInclude(response.body, 'http-equiv="refresh"'); + assert.notInclude(response.body, "attacker.example"); + assert.isTrue(Option.isNone(yield* h.auth.read)); + }), + ), +); +it.effect( + "verifies registration, PKCE, identity and persists rotating refresh before concurrent access", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "user-test"); + assert.strictEqual(h.exchanges[0]?.get("client_id"), "oaiapp_test"); + yield* h.seedExpired; + const records = yield* Effect.all([h.auth.access, h.auth.access], { + concurrency: "unbounded", + }); + assert.strictEqual(h.refreshes(), 1); + assert.strictEqual(records[0].accessToken, "access-1"); + assert.strictEqual(records[1].refreshToken, "refresh-1"); + assert.isNull(h.exchanges[1]?.get("scope")); + yield* h.auth.controller.logout(Effect.void); + assert.isTrue(Option.isNone(yield* h.auth.read)); + }), + ), +); +it.effect("rejects invalid ID token nonce without saving credentials", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.setInvalidNonce(); + yield* h.signIn; + assert.include((yield* h.phase("failed")).message!, "could not be verified"); + assert.isTrue(Option.isNone(yield* h.auth.read)); + }), + ), +); +it.effect("retains identity after declined sharing but never admits inference", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.declineSharing(); + yield* h.signIn; + yield* h.phase("failed"); + assert.isTrue(Option.isSome(yield* h.auth.read)); + const result = yield* h.auth.access.pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + assert.strictEqual(h.refreshes(), 0); + }), + ), +); +it.effect("clears revoked refresh credentials and does not replay them", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + yield* h.seedExpired; + h.setRevoked(); + yield* h.auth.access.pipe(Effect.result); + yield* h.auth.access.pipe(Effect.result); + assert.strictEqual(h.refreshes(), 1); + assert.isTrue(Option.isNone(yield* h.auth.read)); + }), + ), +); + +it.effect( + "preserves credentials through temporary renewal failure and retries the latest refresh", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + yield* h.seedExpired; + h.setTransient(true); + yield* h.auth.access.pipe(Effect.result); + assert.strictEqual(Option.getOrThrow(yield* h.auth.read).refreshToken, "initial-refresh"); + h.setTransient(false); + const renewed = yield* h.auth.access; + assert.strictEqual(renewed.refreshToken, "refresh-2"); + assert.strictEqual(h.exchanges.at(-1)?.get("refresh_token"), "initial-refresh"); + }), + ), +); + +it.effect( + "disconnect retains the remembered account profile while a different account registers afresh", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + assert.strictEqual( + h.authorizationRequests[0]?.searchParams.get("client_id"), + "dynamic_agent_client", + ); + yield* h.auth.controller.logout(Effect.void); + assert.isTrue(Option.isNone(yield* h.auth.read)); + assert.deepEqual(h.storedRecords(), [ + { + profiles: [ + { + clientId: "oaiapp_test", + connectionLabel: "Connection 1", + sharingEnabled: true, + redirectUri: h.authorizationRequests[0]!.searchParams.get("redirect_uri"), + subject: "user-test", + email: "hidden@example.test", + }, + ], + lastClientId: "oaiapp_test", + }, + ]); + const disconnected = yield* h.auth.controller + .subscribe("owner") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.include( + disconnected.methods!.find((method) => method.id === "chatgpt")!.description!, + "hidden@example.test", + ); + assert.strictEqual( + disconnected.methods!.find((method) => method.id === "chatgpt")!.accountEmail, + "hidden@example.test", + ); + assert.strictEqual( + disconnected.methods!.find((method) => method.id === "chatgpt-profile:oaiapp_test")! + .accountEmail, + "hidden@example.test", + ); + assert.strictEqual((yield* h.auth.access.pipe(Effect.result))._tag, "Failure"); + assert.strictEqual(h.refreshes(), 0); + h.setCallbackClientId("oaiapp_different_workspace"); + h.setIdentity("different-user", "different@example.test"); + yield* h.changeAccount; + yield* h.phase("succeeded"); + assert.strictEqual( + h.authorizationRequests[1]?.searchParams.get("client_id"), + "dynamic_agent_client", + ); + assert.strictEqual(h.exchanges[1]?.get("client_id"), "oaiapp_different_workspace"); + assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "different-user"); + }), + ), +); +it.effect("revoked connection clears tokens but preserves registration for reconnect", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + yield* h.seedExpired; + h.setRevoked(); + yield* h.auth.access.pipe(Effect.result); + assert.isTrue(Option.isNone(yield* h.auth.read)); + assert.deepEqual(h.storedRecords(), [ + { + profiles: [ + { + clientId: "oaiapp_test", + connectionLabel: "Connection 1", + sharingEnabled: true, + redirectUri: h.authorizationRequests[0]!.searchParams.get("redirect_uri"), + subject: "user-test", + email: "hidden@example.test", + }, + ], + lastClientId: "oaiapp_test", + }, + ]); + yield* h.signIn; + yield* h.phase("succeeded"); + assert.strictEqual(h.authorizationRequests[1]?.searchParams.get("client_id"), "oaiapp_test"); + }), + ), +); + +it.effect("fresh sign-in ignores a registration left by the old disconnect behavior", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + yield* h.auth.revoke; + assert.isTrue(Option.isNone(yield* h.auth.read)); + assert.strictEqual(h.storedRecords().length, 1); + h.setCallbackClientId("oaiapp_new_workspace"); + h.setIdentity("new-workspace-user", "new@example.test"); + yield* h.changeAccount; + yield* h.phase("succeeded"); + assert.strictEqual( + h.authorizationRequests[1]!.searchParams.get("client_id"), + "dynamic_agent_client", + ); + assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_new_workspace"); + assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "new-workspace-user"); + }), + ), +); + +it.effect("rejects mismatched callback state before any token exchange or credential write", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.mismatchCallbackState(); + yield* h.signIn; + assert.include((yield* h.phase("failed")).message!, "could not be verified"); + assert.strictEqual(h.exchanges.length, 0); + assert.deepEqual(h.storedRecords(), []); + assert.isTrue(Option.isNone(yield* h.auth.read)); + }), + ), +); +for (const invalidClaim of ["issuer", "audience", "signature"] as const) { + it.effect( + `rejects ID token ${invalidClaim} verification before saving tokens or registration`, + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.setIdentityFailure(invalidClaim); + yield* h.signIn; + assert.include((yield* h.phase("failed")).message!, "could not be verified"); + assert.strictEqual(h.exchanges.length, 1); + assert.deepEqual(h.storedRecords(), []); + assert.isTrue(Option.isNone(yield* h.auth.read)); + }), + ), + ); +} +for (const revoked of [false, true]) { + it.effect( + `rejects conflicting callback client ID on reauthorization ${revoked ? "after token removal" : "without changing the current account"}`, + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + if (revoked) yield* h.auth.revoke; + const before = h.storedRecords(); + h.setCallbackClientId("oaiapp_untrusted_callback"); + yield* h.signIn; + assert.include((yield* h.phase("failed")).message!, "registration is incomplete"); + assert.strictEqual( + h.authorizationRequests[1]?.searchParams.get("client_id"), + "oaiapp_test", + ); + assert.strictEqual(h.exchanges.length, 1); + assert.deepEqual(h.storedRecords(), before); + assert.strictEqual(Option.isNone(yield* h.auth.read), revoked); + }), + ), + ); +} + +it.effect("returns successful desktop sign-in to the original Welcome step", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.setReturnUrl("t3code-dev://app/welcome#agents:test-environment"); + yield* h.signIn; + yield* h.phase("succeeded"); + assert.include( + h.callbackResponses[0]!.body, + 'content="1;url=t3code-dev://app/welcome#agents:test-environment"', + ); + assert.include( + h.callbackResponses[0]!.body, + 'href="t3code-dev://app/welcome#agents:test-environment"', + ); + }), + ), +); + +it.effect( + "completes remote sign-in on the owning environment and reuses its exact callback for reauthorization", + () => + Effect.gen(function* () { + const harness = yield* makeHarnessFor(instanceId); + const first = yield* harness.startRemote(); + assert.isTrue( + first.waiting.interaction?.type === "browser" && first.waiting.interaction.acceptsCallback, + ); + yield* harness.auth.controller.complete("owner", { + flowId: first.waiting.flowId!, + callbackUrl: first.callbackUrl, + }); + yield* harness.phase("succeeded"); + assert.strictEqual(Option.getOrThrow(yield* harness.auth.read).email, "hidden@example.test"); + const second = yield* harness.startRemote(); + assertSameCallback(second.callbackUrl, first.callbackUrl); + assert.strictEqual( + new URL(second.waiting.authorizationUrl!).searchParams.get("client_id"), + "oaiapp_test", + ); + yield* harness.auth.controller.complete("owner", { + flowId: second.waiting.flowId!, + callbackUrl: second.callbackUrl, + }); + yield* harness.phase("succeeded"); + assert.strictEqual(harness.exchanges.length, 2); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), + ), +); + +it.effect( + "rejects foreign clients and malformed remote callbacks without consuming the owner's sign-in", + () => + Effect.gen(function* () { + const harness = yield* makeHarnessFor(instanceId); + const { waiting, callbackUrl } = yield* harness.startRemote(); + assert.isTrue(Option.isNone(yield* harness.auth.read)); + yield* Effect.flip( + harness.auth.controller.complete("other-client", { flowId: waiting.flowId!, callbackUrl }), + ); + const wrongPort = new URL(callbackUrl); + wrongPort.port = wrongPort.port === "65535" ? "65534" : "65535"; + for (const invalid of [ + wrongPort.toString(), + callbackUrl.replace("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/auth/callback", "/other"), + callbackUrl + "&state=foreign", + callbackUrl + "&code=duplicate", + callbackUrl + "&error=access_denied", + callbackUrl.replace("127.0.0.1", "attacker.example"), + ]) { + yield* Effect.flip( + harness.auth.controller.complete("owner", { + flowId: waiting.flowId!, + callbackUrl: invalid, + }), + ); + } + assert.strictEqual(harness.exchanges.length, 0); + assert.isTrue(Option.isNone(yield* harness.auth.read)); + yield* harness.auth.controller.complete("owner", { flowId: waiting.flowId!, callbackUrl }); + yield* harness.phase("succeeded"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), + ), +); + +it.effect("keeps simultaneous remote accounts and environments independent", () => + Effect.gen(function* () { + const first = yield* makeHarnessFor(instanceId); + const second = yield* makeHarnessFor(instanceId); + second.setIdentity("other-user", "other@example.test"); + second.setCallbackClientId("oaiapp_other"); + const a = yield* first.startRemote(); + const b = yield* second.startRemote(); + assert.notStrictEqual( + new URL(a.waiting.authorizationUrl!).searchParams.get("ext_agent_host_id"), + new URL(b.waiting.authorizationUrl!).searchParams.get("ext_agent_host_id"), + ); + yield* Effect.flip( + first.auth.controller.complete("owner", { + flowId: a.waiting.flowId!, + callbackUrl: b.callbackUrl, + }), + ); + yield* first.auth.controller.complete("owner", { + flowId: a.waiting.flowId!, + callbackUrl: a.callbackUrl, + }); + yield* second.auth.controller.complete("owner", { + flowId: b.waiting.flowId!, + callbackUrl: b.callbackUrl, + }); + yield* first.phase("succeeded"); + yield* second.phase("succeeded"); + assert.strictEqual(Option.getOrThrow(yield* first.auth.read).subject, "user-test"); + assert.strictEqual(Option.getOrThrow(yield* second.auth.read).subject, "other-user"); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))), +); + +it.effect( + "reconnects after Disconnect and restart with the same client and host on an available port", + () => + provision( + Effect.gen(function* () { + const bytes = new Map(); + const first = yield* makeHarnessFor(instanceId, bytes); + yield* first.signIn; + yield* first.phase("succeeded"); + const redirectUri = first.authorizationRequests[0]!.searchParams.get("redirect_uri"); + yield* first.auth.controller.logout(Effect.void); + const restarted = yield* makeHarnessFor(instanceId, bytes); + yield* restarted.signIn; + yield* restarted.phase("succeeded"); + assert.strictEqual( + restarted.authorizationRequests[0]!.searchParams.get("client_id"), + "oaiapp_test", + ); + assertSameCallback( + restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), + redirectUri, + ); + assert.strictEqual( + restarted.exchanges[0]!.get("redirect_uri"), + restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), + ); + assert.strictEqual(Option.getOrThrow(yield* restarted.auth.read).subject, "user-test"); + }), + ), +); + +for (const disconnected of [false, true]) { + it.effect( + `rejects a different verified identity during saved-profile reauth ${disconnected ? "after Disconnect" : "while connected"}`, + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + if (disconnected) yield* h.auth.controller.logout(Effect.void); + const before = h.storedRecords(); + h.setIdentity("another-user", "another@example.test"); + yield* h.signIn; + assert.include((yield* h.phase("failed")).message!, "different ChatGPT account"); + assert.deepEqual(h.storedRecords(), before); + assert.strictEqual(Option.isNone(yield* h.auth.read), disconnected); + }), + ), + ); +} + +it.effect( + "retains both profiles and reuses the original account's client and callback when returning from another account", + () => + provision( + Effect.gen(function* () { + const bytes = new Map(); + const first = yield* makeHarnessFor(instanceId, bytes); + yield* first.signIn; + yield* first.phase("succeeded"); + const redirectUri = first.authorizationRequests[0]!.searchParams.get("redirect_uri"); + first.setIdentity("other-user", "other@example.test"); + first.setCallbackClientId("oaiapp_other_account"); + yield* first.changeAccount; + const changed = yield* first.phase("succeeded"); + assert.isTrue( + changed.methods!.some((method) => method.id === "chatgpt-profile:oaiapp_test"), + ); + assert.isTrue( + changed.methods!.some((method) => method.id === "chatgpt-profile:oaiapp_other_account"), + ); + yield* first.auth.controller.logout(Effect.void); + const restarted = yield* makeHarnessFor(instanceId, bytes); + yield* restarted.reconnectProfile("oaiapp_test"); + yield* restarted.phase("succeeded"); + assert.strictEqual( + restarted.authorizationRequests[0]!.searchParams.get("client_id"), + "oaiapp_test", + ); + assertSameCallback( + restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), + redirectUri, + ); + assert.strictEqual(Option.getOrThrow(yield* restarted.auth.read).subject, "user-test"); + }), + ), +); + +it.effect("fresh sign-in to the same identity preserves separate registration profiles", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + h.setIdentity("other-user", "other@example.test"); + h.setCallbackClientId("oaiapp_other_account"); + yield* h.changeAccount; + yield* h.phase("succeeded"); + h.setIdentity("user-test", "hidden@example.test"); + h.setCallbackClientId("oaiapp_replacement"); + yield* h.changeAccount; + const changed = yield* h.phase("succeeded"); + assert.deepEqual( + changed + .methods!.filter((method) => method.id.startsWith("chatgpt-profile:")) + .map((method) => method.id), + [ + "chatgpt-profile:oaiapp_replacement", + "chatgpt-profile:oaiapp_other_account", + "chatgpt-profile:oaiapp_test", + ], + ); + assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_replacement"); + const redirectUri = h.authorizationRequests[2]!.searchParams.get("redirect_uri"); + yield* h.auth.controller.logout(Effect.void); + yield* h.reconnectProfile("oaiapp_replacement"); + yield* h.phase("succeeded"); + assert.strictEqual( + h.authorizationRequests[3]!.searchParams.get("client_id"), + "oaiapp_replacement", + ); + assertSameCallback(h.authorizationRequests[3]!.searchParams.get("redirect_uri"), redirectUri); + }), + ), +); + +it.effect("preserves legacy profiles with the same email and subject", () => + provision( + Effect.gen(function* () { + const bytes = new Map(); + const h = yield* makeHarnessFor(instanceId, bytes); + yield* h.signIn; + yield* h.phase("succeeded"); + const redirectUri = h.authorizationRequests[0]!.searchParams.get("redirect_uri"); + for (const [key, value] of bytes) { + const record = JSON.parse(new TextDecoder().decode(value)); + if (record.profiles) + bytes.set( + key, + new TextEncoder().encode( + JSON.stringify({ + ...record, + profiles: [ + { ...record.profiles[0], clientId: "oaiapp_old_duplicate" }, + ...record.profiles, + ], + }), + ), + ); + } + const restarted = yield* makeHarnessFor(instanceId, bytes); + const state = yield* restarted.auth.controller.subscribe("owner").pipe( + Stream.filter((state) => state.methods != null), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.deepEqual( + state + .methods!.filter((method) => method.id.startsWith("chatgpt-profile:")) + .map((method) => method.id), + ["chatgpt-profile:oaiapp_test", "chatgpt-profile:oaiapp_old_duplicate"], + ); + yield* restarted.signIn; + yield* restarted.phase("succeeded"); + assert.strictEqual( + restarted.authorizationRequests[0]!.searchParams.get("client_id"), + "oaiapp_test", + ); + assertSameCallback( + restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), + redirectUri, + ); + assert.lengthOf(restarted.storedRecords().find((record) => record.profiles).profiles, 2); + }), + ), +); + +it.effect("keeps distinct verified identities even when their email matches", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + h.setIdentity("other-user", "hidden@example.test"); + h.setCallbackClientId("oaiapp_other_account"); + yield* h.changeAccount; + const changed = yield* h.phase("succeeded"); + assert.lengthOf( + changed.methods!.filter((method) => method.id.startsWith("chatgpt-profile:")), + 2, + ); + }), + ), +); + +it.effect( + "migrates a legacy registration and retains its verified identity when credentials are cleared", + () => + provision( + Effect.gen(function* () { + const bytes = new Map(); + const first = yield* makeHarnessFor(instanceId, bytes); + yield* first.signIn; + yield* first.phase("succeeded"); + const redirectUri = first.authorizationRequests[0]!.searchParams.get("redirect_uri"); + for (const [key, value] of bytes) { + const saved = JSON.parse(new TextDecoder().decode(value)); + if (saved.profiles) + bytes.set( + key, + new TextEncoder().encode(JSON.stringify({ clientId: "oaiapp_test", redirectUri })), + ); + } + yield* first.auth.revoke; + assert.deepEqual(first.storedRecords(), [ + { + profiles: [ + { + clientId: "oaiapp_test", + connectionLabel: "Connection 1", + redirectUri, + subject: "user-test", + email: "hidden@example.test", + }, + ], + lastClientId: "oaiapp_test", + }, + ]); + const restarted = yield* makeHarnessFor(instanceId, bytes); + restarted.setIdentity("another-user", "another@example.test"); + yield* restarted.signIn; + assert.include((yield* restarted.phase("failed")).message!, "different ChatGPT account"); + assert.isTrue(Option.isNone(yield* restarted.auth.read)); + assert.strictEqual( + restarted.authorizationRequests[0]!.searchParams.get("ext_agent_host_id"), + first.authorizationRequests[0]!.searchParams.get("ext_agent_host_id"), + ); + }), + ), +); + +it.effect("declined sharing on an older profile does not replace the active account", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + h.setIdentity("other-user", "other@example.test"); + h.setCallbackClientId("oaiapp_other_account"); + yield* h.changeAccount; + yield* h.phase("succeeded"); + const before = h.storedRecords(); + h.setIdentity("user-test", "hidden@example.test"); + h.setCallbackClientId("oaiapp_test"); + h.declineSharing(); + yield* h.reconnectProfile("oaiapp_test"); + assert.include( + (yield* h.phase("failed")).message!, + "existing ChatGPT connection is unchanged", + ); + assert.deepEqual( + Option.getOrThrow(yield* h.auth.read), + before.find((record) => record.clientId === "oaiapp_other_account"), + ); + }), + ), +); + +it.effect( + "Disconnect preserves a remote profile's callback URI for client-delivered reconnect", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + const first = yield* h.startRemote(); + yield* h.auth.controller.complete("owner", { + flowId: first.waiting.flowId!, + callbackUrl: first.callbackUrl, + }); + yield* h.phase("succeeded"); + yield* h.auth.controller.logout(Effect.void); + const second = yield* h.startRemote(); + assert.strictEqual( + new URL(second.waiting.authorizationUrl!).searchParams.get("client_id"), + "oaiapp_test", + ); + assertSameCallback( + new URL(second.waiting.authorizationUrl!).searchParams.get("redirect_uri"), + new URL(first.waiting.authorizationUrl!).searchParams.get("redirect_uri"), + ); + yield* h.auth.controller.complete("owner", { + flowId: second.waiting.flowId!, + callbackUrl: second.callbackUrl, + }); + yield* h.phase("succeeded"); + assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "user-test"); + }), + ), +); + +for (const code of [ + "invalid_grant", + "invalid_refresh_token", + "token_expired", + "refresh_token_expired", + "refresh_token_invalidated", + "refresh_token_reused", + "invalid_client", + "invalid_token", +]) { + it.effect(`refresh recovery follows the machine-readable code: ${code}`, () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + yield* h.seedExpired; + h.setRefreshError(code); + yield* Effect.flip(h.auth.access); + assert.strictEqual( + Option.isNone(yield* h.auth.read), + !["invalid_client", "invalid_token"].includes(code), + ); + assert.isTrue( + h + .storedRecords() + .some((record) => + record.profiles?.some( + (profile: { clientId: string }) => profile.clientId === "oaiapp_test", + ), + ), + ); + }), + ), + ); +} + +it.effect( + "logout revokes the latest refresh token with the selected client and clears its ID hint", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + const host = h.authorizationRequests[0]!.searchParams.get("ext_agent_host_id"); + assert.match(host!, /^urn:uuid:[0-9a-f-]{36}$/u); + yield* h.seedExpired; + yield* h.auth.access; + const state = yield* h.auth.controller.logout(Effect.void); + assert.strictEqual(state.message, "Signed out."); + assert.deepEqual(Array.from(h.revocations[0]!), [ + ["token", "refresh-1"], + ["token_type_hint", "refresh_token"], + ["client_id", "oaiapp_test"], + ]); + assert.isTrue(Option.isNone(yield* h.auth.read)); + yield* h.signIn; + yield* h.phase("succeeded"); + assert.isFalse(h.authorizationRequests[1]!.searchParams.has("id_token_hint")); + assert.strictEqual(h.authorizationRequests[1]!.searchParams.get("ext_agent_host_id"), host); + }), + ), +); + +it.live("logout retries temporary revocation failures and reports local-only sign-out", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + h.setRevocationStatus(503); + const state = yield* h.auth.controller.logout(Effect.void); + assert.lengthOf(h.revocations, 3); + assert.strictEqual(state.phase, "idle"); + assert.include(state.message!, "Remote revocation could not be confirmed"); + assert.isTrue(Option.isNone(yield* h.auth.read)); + assert.isFalse(h.storedRecords().some((record) => record.idToken)); + }), + ), +); + +it.effect( + "reauth hints come from the selected profile, including after another profile logs out", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + const personal = Option.getOrThrow(yield* h.auth.read); + h.setCallbackClientId("oaiapp_work"); + h.setIdentity("work-user", "work@example.test"); + yield* h.changeAccount; + yield* h.phase("succeeded"); + assert.isFalse(h.authorizationRequests[1]!.searchParams.has("id_token_hint")); + assert.isFalse(h.authorizationRequests[1]!.searchParams.has("login_hint")); + yield* h.auth.controller.logout(Effect.void); + h.setCallbackClientId("oaiapp_test"); + h.setIdentity("user-test", "hidden@example.test"); + yield* h.reconnectProfile("oaiapp_test"); + yield* h.phase("succeeded"); + assert.strictEqual( + h.authorizationRequests[2]!.searchParams.get("id_token_hint"), + personal.idToken, + ); + assert.strictEqual( + h.authorizationRequests[2]!.searchParams.get("login_hint"), + personal.email, + ); + }), + ), +); + +it.effect("an expired initial code retains the issued ID for a fresh authorization", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.setCodeError("invalid_grant"); + yield* h.signIn; + const failed = yield* h.phase("failed"); + assert.isTrue(Option.isNone(yield* h.auth.read)); + assert.isTrue(failed.methods!.some((method) => method.id === "chatgpt-profile:oaiapp_test")); + h.setCodeError(undefined); + yield* h.reconnectProfile("oaiapp_test"); + yield* h.phase("succeeded"); + assert.strictEqual(h.authorizationRequests[1]!.searchParams.get("client_id"), "oaiapp_test"); + assert.notStrictEqual( + h.authorizationRequests[0]!.searchParams.get("state"), + h.authorizationRequests[1]!.searchParams.get("state"), + ); + }), + ), +); + +it.effect("identity-only sign-in requests consent on an explicit retry, then rechecks scopes", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.declineSharing(); + yield* h.signIn; + yield* h.phase("failed"); + yield* h.reconnectProfile("oaiapp_test"); + yield* h.phase("failed"); + assert.strictEqual(h.authorizationRequests[1]!.searchParams.get("prompt"), "consent"); + assert.isFalse(h.authorizationRequests[1]!.searchParams.has("force_reconsent")); + assert.include( + h.authorizationRequests[1]!.searchParams.get("scope")!, + "chatgpt.tokens.use.direct", + ); + yield* Effect.flip(h.auth.access); + }), + ), +); + +it.effect( + "controller replacement shares refresh serialization for the same environment session", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + const replacement = yield* h.recreateAuth; + yield* h.seedExpired; + const records = yield* Effect.all([h.auth.access, replacement.access], { + concurrency: "unbounded", + }); + assert.strictEqual(h.refreshes(), 1); + assert.strictEqual(records[0].refreshToken, "refresh-1"); + assert.deepEqual(records[0], records[1]); + }), + ), +); + +it.effect( + "old localhost registrations retain their profile while a new client gets its own identity", + () => + provision( + Effect.gen(function* () { + const bytes = new Map(); + const h = yield* makeHarnessFor(instanceId, bytes); + yield* h.signIn; + yield* h.phase("succeeded"); + for (const [key, value] of bytes) { + const record = JSON.parse(new TextDecoder().decode(value)); + if (record.profiles) + bytes.set( + key, + new TextEncoder().encode( + JSON.stringify({ + ...record, + profiles: record.profiles.map((profile: { redirectUri: string }) => ({ + ...profile, + redirectUri: profile.redirectUri.replace("127.0.0.1", "localhost"), + })), + }), + ), + ); + } + h.setCallbackClientId("oaiapp_migrated"); + h.setIdentity("new-client-subject", "hidden@example.test"); + yield* h.signIn; + yield* h.phase("succeeded"); + assert.strictEqual( + h.authorizationRequests[1]!.searchParams.get("client_id"), + "dynamic_agent_client", + ); + assert.strictEqual( + new URL(h.authorizationRequests[1]!.searchParams.get("redirect_uri")!).hostname, + "127.0.0.1", + ); + assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "new-client-subject"); + const profiles = (yield* h.phase("succeeded")).methods!; + assert.isTrue(profiles.some((profile) => profile.id === "chatgpt-profile:oaiapp_test")); + assert.isTrue(profiles.some((profile) => profile.id === "chatgpt-profile:oaiapp_migrated")); + yield* h.signIn; + yield* h.phase("succeeded"); + assert.strictEqual( + h.authorizationRequests[2]!.searchParams.get("client_id"), + "oaiapp_migrated", + ); + assert.strictEqual( + h.authorizationRequests[2]!.searchParams.get("login_hint"), + "hidden@example.test", + ); + assert.isTrue(h.authorizationRequests[2]!.searchParams.has("id_token_hint")); + }), + ), +); + +it.effect("keeps the active connection first when another profile declines sharing", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + h.setCallbackClientId("oaiapp_identity_only"); + h.setIdentity("other-user", "other@example.test"); + h.declineSharing(); + yield* h.changeAccount; + const state = yield* h.phase("failed"); + assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_test"); + const profiles = state.methods!.filter((method) => method.id.startsWith("chatgpt-profile:")); + assert.strictEqual(profiles[0]!.id, "chatgpt-profile:oaiapp_test"); + assert.include(profiles[0]!.name, "Connection 1"); + assert.include(profiles[1]!.name, "Connection 2"); + }), + ), +); + +it.effect("primary completes OAuth and destination imports and owns the refresh session", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + const destination = yield* h.destination; + const before = h.bytes.size; + const waiting = yield* Deferred.make<{ authorizationUrl: string | null }>(); + const transferred = yield* h.handoff(null).pipe( + Stream.tap((state) => + state.phase === "auth" && state.state.phase === "waiting" + ? Deferred.succeed(waiting, state.state) + : Effect.void, + ), + Stream.filter((state) => state.phase === "finished"), + Stream.runHead, + Effect.map(Option.getOrThrow), + Effect.forkChild, + ); + yield* h.finishCallback(yield* Deferred.await(waiting)); + const result = yield* Fiber.join(transferred); + assert.strictEqual(result.phase, "finished"); + if (result.phase !== "finished") return; + assert.strictEqual(h.bytes.size, before); + assert.isTrue(Option.isNone(yield* h.auth.read)); + let stopped = false; + const imported = yield* destination.auth.controller.importProfile!( + result.profile, + Effect.sync(() => { + stopped = true; + }), + ); + assert.isTrue(stopped); + assert.strictEqual(imported.phase, "succeeded"); + assert.deepStrictEqual( + h.analyticsEvents.map(({ event }) => event), + [ + "chatgpt.auth.started", + "chatgpt.auth.completed", + "chatgpt.transfer.started", + "chatgpt.transfer.completed", + ], + ); + assert.isTrue( + h.analyticsEvents.every(({ properties }) => properties?.flow === "primary_handoff"), + ); + assert.strictEqual(h.analyticsEvents[1]?.properties?.outcome, "succeeded"); + assert.strictEqual(h.analyticsEvents[3]?.properties?.outcome, "succeeded"); + assert.notProperty(h.analyticsEvents[1]?.properties ?? {}, "connectedAccountCount"); + assert.strictEqual(h.analyticsEvents[1]?.properties?.intent, "different_account"); + assert.strictEqual(h.analyticsEvents[3]?.properties?.connectedAccountCount, 1); + assert.strictEqual(h.analyticsEvents[3]?.properties?.savedConnectionCount, 1); + const saved = Option.getOrThrow(yield* destination.auth.read); + assert.strictEqual(saved.clientId, result.profile.registration.clientId); + assert.strictEqual(saved.refreshToken, "initial-refresh"); + const reconnect = yield* destination.auth.controller.reconnectProfile!("chatgpt"); + assert.strictEqual(reconnect?.idTokenHint, result.profile.credentials.idToken); + assert.isFalse("refreshToken" in reconnect!); + const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId).pipe( + Effect.provideService( + ServerSecretStore, + ServerSecretStore.of({ + get: (name) => Effect.sync(() => Option.fromUndefinedOr(destination.bytes.get(name))), + set: (name, value) => + Effect.sync(() => { + destination.bytes.set(name, value); + }), + remove: () => Effect.void, + create: () => Effect.die("unused"), + getOrCreateRandom: () => Effect.die("unused"), + }), + ), + ); + yield* store.set(new TextEncoder().encode(JSON.stringify({ ...saved, expiresAt: 0 }))); + yield* destination.auth.access; + assert.strictEqual( + h.exchanges.filter((entry) => entry.get("grant_type") === "authorization_code").length, + 1, + ); + assert.strictEqual( + h.exchanges.filter((entry) => entry.get("grant_type") === "refresh_token").length, + 1, + ); + }), + ), +); + +it.effect("transferred profiles reject mismatched identities without overwriting credentials", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + const profile = yield* h.auth.exportProfile; + const destination = yield* h.destination; + yield* destination.auth.controller.importProfile!(profile, Effect.void); + const original = Option.getOrThrow(yield* destination.auth.read); + for (const credentials of [ + { ...profile.credentials, subject: "wrong-user" }, + { ...profile.credentials, clientId: "oaiapp_wrong" }, + { ...profile.credentials, idToken: "not-a-jwt" }, + { ...profile.credentials, scopes: ["openid"] }, + { ...profile.credentials, expiresAt: 0 }, + ]) { + const result = yield* destination.auth.controller.importProfile!( + { ...profile, credentials }, + Effect.void, + ).pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + assert.deepEqual(Option.getOrThrow(yield* destination.auth.read), original); + } + }), + ), +); + +it.effect("primary handoff reuses the selected registration and ID token hint", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + const profile = yield* h.auth.controller.reconnectProfile!("chatgpt"); + const waiting = yield* Deferred.make<{ authorizationUrl: string | null }>(); + const resultFiber = yield* h.handoff(profile).pipe( + Stream.tap((state) => + state.phase === "auth" && state.state.phase === "waiting" + ? Deferred.succeed(waiting, state.state) + : Effect.void, + ), + Stream.runCollect, + Effect.forkChild, + ); + const state = yield* Deferred.await(waiting); + const url = new URL(state.authorizationUrl!); + assert.strictEqual(url.searchParams.get("client_id"), profile!.clientId); + assert.strictEqual(url.searchParams.get("id_token_hint"), profile!.idTokenHint); + assert.strictEqual(url.searchParams.get("login_hint"), profile!.email); + assert.strictEqual(url.searchParams.get("ext_agent_host_id"), `urn:uuid:${h.environmentId}`); + yield* h.finishCallback(state); + const results = yield* Fiber.join(resultFiber); + assert.strictEqual(results.at(-1)?.phase, "finished"); + }), + ), +); + +it.effect( + "cancelling primary handoff closes its callback listener without saving credentials", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + const waiting = yield* Deferred.make<{ authorizationUrl: string | null }>(); + const flow = yield* h.handoff(null).pipe( + Stream.tap((state) => + state.phase === "auth" && state.state.phase === "waiting" + ? Deferred.succeed(waiting, state.state) + : Effect.void, + ), + Stream.runDrain, + Effect.forkChild, + ); + const state = yield* Deferred.await(waiting); + yield* Fiber.interrupt(flow); + const callback = new URL( + new URL(state.authorizationUrl!).searchParams.get("redirect_uri")!, + ); + const request = yield* Effect.tryPromise(() => fetch(callback)).pipe(Effect.result); + assert.strictEqual(request._tag, "Failure"); + assert.isTrue(Option.isNone(yield* h.auth.read)); + }), + ), +); + +it.effect("records one anonymous auth outcome per success, failure, or cancellation", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + h.setInvalidNonce(); + yield* h.signIn; + yield* h.phase("failed"); + const { waiting } = yield* h.startRemote(); + yield* h.auth.controller.cancel("owner", waiting.flowId!); + yield* h.phase("cancelled"); + const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed"); + assert.deepStrictEqual( + completed.map(({ properties }) => properties?.outcome), + ["succeeded", "failed", "cancelled"], + ); + assert.strictEqual( + h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.started").length, + 3, + ); + assert.strictEqual(completed[1]?.properties?.failureStage, "verify"); + for (const { properties } of completed) { + assert.isNumber(properties?.durationMs); + assert.strictEqual(properties?.flow, "direct"); + assert.deepStrictEqual( + Object.keys(properties!).sort(), + [ + ...(properties?.outcome === "succeeded" + ? [ + "accountCountScope", + "connectedAccountCount", + "connectedConnectionCount", + "savedConnectionCount", + "unidentifiedConnectedConnectionCount", + ] + : []), + "callbackMode", + "durationMs", + ...(properties?.failureStage ? ["failureStage"] : []), + "flow", + "intent", + "outcome", + ].sort(), + ); + } + const serialized = JSON.stringify(h.analyticsEvents); + for (const secret of [ + "hidden@example.test", + "oaiapp_test", + "user-test", + "access_token", + "refresh_token", + ]) { + assert.notInclude(serialized, secret); + } + }), + ), +); + +it.effect("telemetry failures do not fail a verified ChatGPT sign-in", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarnessFor(instanceId, new Map(), true); + yield* h.signIn; + const state = yield* h.phase("succeeded"); + assert.strictEqual(state.phase, "succeeded"); + assert.isTrue(Option.isSome(yield* h.auth.read)); + }), + ), +); + +it.effect("counts accounts separately from saved connections across additions and reconnects", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + yield* h.signIn; + yield* h.phase("succeeded"); + h.setCallbackClientId("oaiapp_same_account"); + h.setIdentity("another-client-subject", "HIDDEN@example.test"); + yield* h.changeAccount; + yield* h.phase("succeeded"); + h.setCallbackClientId("oaiapp_other_account"); + h.setIdentity("other-user", "other@example.test"); + yield* h.changeAccount; + yield* h.phase("succeeded"); + yield* h.auth.controller.logout(Effect.void); + h.setCallbackClientId("oaiapp_test"); + h.setIdentity("user-test", "hidden@example.test"); + yield* h.reconnectProfile("oaiapp_test"); + yield* h.phase("succeeded"); + const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed"); + assert.deepStrictEqual( + completed.map(({ properties }) => [ + properties?.connectedAccountCount, + properties?.connectedConnectionCount, + properties?.savedConnectionCount, + ]), + [ + [1, 1, 1], + [1, 1, 1], + [1, 2, 2], + [2, 3, 3], + [1, 2, 3], + ], + ); + assert.isTrue( + completed.every(({ properties }) => properties?.unidentifiedConnectedConnectionCount === 0), + ); + assert.notInclude(JSON.stringify(h.analyticsEvents), "example.test"); + }), + ), +); + +it.effect("includes accounts from other Codex instances in the environment", () => + provision( + Effect.gen(function* () { + const bytes = new Map(); + const personal = yield* makeHarnessFor(instanceId, bytes); + const work = yield* makeHarnessFor(ProviderInstanceId.make("managed-work"), bytes); + yield* personal.signIn; + yield* personal.phase("succeeded"); + work.setCallbackClientId("oaiapp_work"); + work.setIdentity("work-user", "work@example.test"); + yield* work.signIn; + yield* work.phase("succeeded"); + const completed = work.analyticsEvents.find( + ({ event }) => event === "chatgpt.auth.completed", + ); + assert.strictEqual(completed?.properties?.accountCountScope, "environment"); + assert.strictEqual(completed?.properties?.connectedAccountCount, 2); + assert.strictEqual(completed?.properties?.connectedConnectionCount, 2); + assert.strictEqual(completed?.properties?.savedConnectionCount, 2); + }).pipe( + Effect.provide( + settingsLayerTest({ + providerInstances: { + [instanceId]: { driver: "codex", enabled: true }, + [ProviderInstanceId.make("managed-work")]: { driver: "codex", enabled: true }, + }, + }), + ), + ), + ), +); + +it.effect("an unreadable unrelated profile omits counts without failing sign-in", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + const unrelated = yield* ProviderCredentialStore.make("codex-chatgpt", "codex").pipe( + Effect.provideService(ServerSecretStore, h.secrets), + ); + // The harness owns its store; seed the corresponding binding in that store. + h.bytes.set(unrelated.binding.key, new TextEncoder().encode("invalid")); + yield* h.signIn; + yield* h.phase("succeeded"); + const completed = h.analyticsEvents.find(({ event }) => event === "chatgpt.auth.completed"); + assert.strictEqual(completed?.properties?.outcome, "succeeded"); + assert.notProperty(completed?.properties ?? {}, "connectedAccountCount"); + }).pipe(Effect.provide(settingsLayerTest())), + ), +); + +it.effect("reports connections without an email separately from identifiable accounts", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId).pipe( + Effect.provideService(ServerSecretStore, h.secrets), + ); + yield* store.set( + new TextEncoder().encode( + JSON.stringify({ + activeClientId: "oaiapp_test", + sessions: [{ ...Option.getOrThrow(yield* h.auth.read), email: null }], + }), + ), + ); + h.setCallbackClientId("oaiapp_other_account"); + h.setIdentity("other-user", "other@example.test"); + yield* h.changeAccount; + yield* h.phase("succeeded"); + const completed = h.analyticsEvents.findLast( + ({ event }) => event === "chatgpt.auth.completed", + ); + assert.strictEqual(completed?.properties?.connectedAccountCount, 1); + assert.strictEqual(completed?.properties?.connectedConnectionCount, 2); + assert.strictEqual(completed?.properties?.unidentifiedConnectedConnectionCount, 1); + }), + ), +); + +it.effect("records an expired auth outcome when sign-in reaches its deadline", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.startRemote(); + yield* TestClock.adjust(300_001); + assert.include((yield* h.phase("failed")).message ?? "", "expired"); + const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed"); + assert.strictEqual(completed.length, 1); + assert.strictEqual(completed[0]?.properties?.outcome, "expired"); + assert.isAtLeast(completed[0]?.properties?.durationMs as number, 300_000); + }), + ), +); diff --git a/apps/server/src/provider/CodexChatGptAuth.ts b/apps/server/src/provider/CodexChatGptAuth.ts new file mode 100644 index 000000000000..365025dbb09c --- /dev/null +++ b/apps/server/src/provider/CodexChatGptAuth.ts @@ -0,0 +1,985 @@ +// @effect-diagnostics nodeBuiltinImport:off - OAuth loopback listener and PKCE use Node APIs. +import * as NodeCrypto from "node:crypto"; +import * as NodeHttp from "node:http"; +import { createRemoteJWKSet, jwtVerify } from "jose"; +import { + ProviderSetupError, + type ChatGptReconnectProfile, + type ChatGptTransferredProfile, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import { codexCallbackUrl } from "@t3tools/shared/codexAuthHandoff"; +import * as Clock from "effect/Clock"; +import * as Cause from "effect/Cause"; +import { AnalyticsService } from "../telemetry/AnalyticsService.ts"; +import * as Exit from "effect/Exit"; +import { codexAuthCallbackPage, codexAuthReturnUrl } from "./CodexAuthCallbackPage.ts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as ProviderAuthFlow from "./ProviderAuthFlow.ts"; +import type { ProviderAuthFlowContext } from "./ProviderAuthFlow.ts"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; +import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts"; +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts"; + +const isSetupError = Schema.is(ProviderSetupError); +const RESOURCE = "https://api.openai.com/v1"; +const REQUIRED_SCOPE = "chatgpt.tokens.use.direct"; +const DISCOVERY = "https://auth.openai.com/.well-known/openid-configuration"; +const TokenResponse = Schema.Struct({ + access_token: Schema.NonEmptyString, + refresh_token: Schema.optionalKey(Schema.NonEmptyString), + id_token: Schema.optionalKey(Schema.String), + token_type: Schema.String, + expires_in: Schema.Int.check(Schema.isGreaterThan(0)), + scope: Schema.String, + earliest_refresh_at: Schema.optionalKey(Schema.Union([Schema.Finite, Schema.String])), +}); +const Record = Schema.Struct({ + clientId: Schema.String, + accessToken: Schema.String, + refreshToken: Schema.NullOr(Schema.String), + expiresAt: Schema.Finite, + earliestRefreshAt: Schema.NullOr(Schema.Finite), + scopes: Schema.Array(Schema.String), + subject: Schema.String, + email: Schema.NullOr(Schema.String), + idToken: Schema.optionalKey(Schema.String), + issuer: Schema.optionalKey(Schema.String), +}); +export type CodexChatGptCredentials = typeof Record.Type; +const Sessions = Schema.Struct({ + activeClientId: Schema.NullOr(Schema.String), + sessions: Schema.Array(Record), +}); +const sessionLocks = new WeakMap< + typeof ServerSecretStore.Service, + Map +>(); +const Registration = Schema.Struct({ + clientId: Schema.String.check(Schema.isPattern(/^oaiapp_/u)), + subject: Schema.optionalKey(Schema.String), + connectionLabel: Schema.optionalKey(Schema.String), + sharingEnabled: Schema.optionalKey(Schema.Boolean), + email: Schema.optionalKey(Schema.NullOr(Schema.String)), + redirectUri: Schema.optionalKey( + Schema.String.check( + Schema.isPattern(/^http:\/\/(?:127\.0\.0\.1|localhost):[1-9]\d{0,4}\/auth\/callback$/u), + ), + ), +}); +const RegistrationProfiles = Schema.Struct({ + profiles: Schema.Array(Registration), + lastClientId: Schema.NullOr(Schema.String), +}); +const decodeRegistration = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Union([RegistrationProfiles, Registration])), +); +const encodeRegistration = Schema.encodeEffect(Schema.fromJsonString(RegistrationProfiles)); +const profileMethodId = (clientId: string) => `chatgpt-profile:${clientId}`; +const Discovery = Schema.Struct({ + issuer: Schema.String, + authorization_endpoint: Schema.String, + token_endpoint: Schema.String, + jwks_uri: Schema.String, + revocation_endpoint: Schema.optionalKey(Schema.String), +}); +const OAuthError = Schema.Struct({ error: Schema.String }); +const decodeSessions = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Union([Sessions, Record])), +); +const encodeSessions = Schema.encodeEffect(Schema.fromJsonString(Sessions)); +const decodeTokens = Schema.decodeUnknownSync(TokenResponse); +const decodeDiscoveryEffect = Schema.decodeUnknownEffect(Discovery); +const decodeOAuthError = Schema.decodeUnknownSync(OAuthError); + +export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* (options: { + readonly instanceId: ProviderInstanceId; + readonly discoveryUrl?: string; + readonly resource?: string; + readonly defaultReturnUrl?: string; + readonly reconnectProfile?: ChatGptReconnectProfile | null; + readonly telemetryFlow?: "direct" | "primary_handoff"; +}) { + const analytics = yield* Effect.serviceOption(AnalyticsService); + const settings = yield* Effect.serviceOption(ServerSettingsService); + const track = ( + event: "auth" | "transfer", + properties: Readonly>, + task: Effect.Effect, + expiresAt?: number, + ) => + Effect.gen(function* () { + if (Option.isNone(analytics)) return yield* task; + const record = (name: string, properties: Readonly>) => + analytics.value.record(name, properties).pipe(Effect.ignoreCause); + const startedAt = yield* Clock.currentTimeMillis; + yield* record(`chatgpt.${event}.started`, properties); + return yield* task.pipe( + Effect.onExit((result) => + Effect.gen(function* () { + const endedAt = yield* Clock.currentTimeMillis; + const error = Exit.isFailure(result) + ? Cause.findErrorOption(result.cause) + : Option.none(); + const counts = + Exit.isSuccess(result) && + (event === "transfer" || options.telemetryFlow !== "primary_handoff") + ? yield* accountCounts.pipe(Effect.catchCause(() => Effect.succeed({}))) + : {}; + yield* record(`chatgpt.${event}.completed`, { + ...counts, + ...properties, + outcome: Exit.isSuccess(result) + ? "succeeded" + : Cause.hasInterruptsOnly(result.cause) + ? expiresAt !== undefined && endedAt >= expiresAt + ? "expired" + : "cancelled" + : "failed", + durationMs: Math.max(0, endedAt - startedAt), + ...(Option.isSome(error) && isSetupError(error.value) + ? { failureStage: error.value.operation } + : {}), + }); + }), + ), + ); + }); + const http = yield* HttpClient.HttpClient; + const store = yield* ProviderCredentialStore.make("codex-chatgpt", options.instanceId); + const registrationStore = yield* ProviderCredentialStore.make( + "codex-chatgpt-registration", + options.instanceId, + ); + const secrets = yield* ServerSecretStore; + const environmentLocks = sessionLocks.get(secrets) ?? new Map(); + sessionLocks.set(secrets, environmentLocks); + const lock = environmentLocks.get(store.binding.key) ?? (yield* Semaphore.make(1)); + environmentLocks.set(store.binding.key, lock); + const withSessionLock = (task: Effect.Effect) => + withChatGptSessionLock(secrets.directory, store.binding.key, options.instanceId, task); + const environment = yield* ServerEnvironmentIdentity; + const hostId = `urn:uuid:${yield* environment.getEnvironmentId}`; + const resource = options.resource ?? RESOURCE; + const failure = (operation: string, detail: string) => + new ProviderSetupError({ instanceId: options.instanceId, operation, detail }); + let metadata: typeof Discovery.Type | undefined; + let jwks: ReturnType | undefined; + const discover = Effect.gen(function* () { + if (metadata) return metadata; + const result = yield* http.get(options.discoveryUrl ?? DISCOVERY).pipe( + Effect.flatMap((response) => + Effect.gen(function* () { + if (response.status < 200 || response.status >= 300) + return yield* failure("discover", "Could not reach ChatGPT sign-in. Try again."); + return yield* response.json; + }), + ), + Effect.flatMap(decodeDiscoveryEffect), + Effect.mapError(() => failure("discover", "Could not reach ChatGPT sign-in. Try again.")), + ); + if ( + !options.discoveryUrl && + (result.issuer !== "https://auth.openai.com" || + [ + result.authorization_endpoint, + result.token_endpoint, + result.jwks_uri, + ...(result.revocation_endpoint ? [result.revocation_endpoint] : []), + ].some((url) => new URL(url).origin !== result.issuer)) + ) + return yield* failure("discover", "ChatGPT sign-in configuration could not be verified."); + metadata = result; + jwks = createRemoteJWKSet(new URL(result.jwks_uri)); + return result; + }); + const readSessions = store.get.pipe( + Effect.mapError(() => failure("read", "Could not read the saved ChatGPT connection.")), + Effect.flatMap((bytes) => + Option.isNone(bytes) + ? Effect.succeed({ activeClientId: null, sessions: [] }) + : decodeSessions(new TextDecoder().decode(bytes.value)).pipe( + Effect.map((saved) => + "sessions" in saved ? saved : { activeClientId: saved.clientId, sessions: [saved] }, + ), + Effect.mapError(() => + failure("read", "The saved ChatGPT connection is invalid. Sign in again."), + ), + ), + ), + ); + const read = readSessions.pipe( + Effect.map((saved) => + Option.fromUndefinedOr( + saved.sessions.find((session) => session.clientId === saved.activeClientId), + ), + ), + ); + const writeSessions = (saved: typeof Sessions.Type) => + encodeSessions(saved).pipe( + Effect.flatMap((json) => store.set(new TextEncoder().encode(json))), + Effect.mapError(() => failure("save", "Could not save the ChatGPT connection.")), + ); + // Registration profiles outlive tokens, but belong only to this environment/instance. + // Accept the original single-registration record until it is next saved. + const readRegistrations = registrationStore.get.pipe( + Effect.mapError(() => + failure("registration", "Could not read the ChatGPT sign-in registration. Try again."), + ), + Effect.flatMap((bytes) => + Option.isNone(bytes) + ? Effect.succeed({ profiles: [], lastClientId: null }) + : decodeRegistration(new TextDecoder().decode(bytes.value)).pipe( + Effect.map((record) => { + const saved = + "profiles" in record + ? record + : { profiles: [record], lastClientId: record.clientId }; + const last = saved.profiles.find( + (profile) => profile.clientId === saved.lastClientId, + ); + const ordered = last + ? [last, ...saved.profiles.filter((profile) => profile.clientId !== last.clientId)] + : saved.profiles; + return { + ...saved, + profiles: ordered.map((profile) => ({ + ...profile, + connectionLabel: + profile.connectionLabel ?? `Connection ${saved.profiles.indexOf(profile) + 1}`, + })), + }; + }), + Effect.mapError(() => + failure("registration", "The saved ChatGPT sign-in registration is invalid."), + ), + ), + ), + ); + const accountCounts = Effect.gen(function* () { + const instanceIds = new Set([options.instanceId]); + if (Option.isSome(settings)) { + const current = yield* settings.value.getSettings; + // Include the legacy default instance as well as explicitly configured ones. + instanceIds.add("codex"); + for (const [id, instance] of Object.entries(current.providerInstances)) { + if (instance.driver === "codex") instanceIds.add(id); + } + } + const accounts = new Set(); + const connections = new Set(); + const savedConnections = new Set(); + let unidentifiedConnectedConnectionCount = 0; + for (const id of instanceIds) { + const registrations = yield* ProviderCredentialStore.make("codex-chatgpt-registration", id); + const registrationBytes = yield* registrations.get; + if (Option.isSome(registrationBytes)) { + const saved = yield* decodeRegistration(new TextDecoder().decode(registrationBytes.value)); + for (const profile of "profiles" in saved ? saved.profiles : [saved]) { + savedConnections.add(profile.clientId); + } + } + const credentials = yield* ProviderCredentialStore.make("codex-chatgpt", id); + const credentialBytes = yield* credentials.get; + if (Option.isNone(credentialBytes)) continue; + const saved = yield* decodeSessions(new TextDecoder().decode(credentialBytes.value)); + for (const session of "sessions" in saved ? saved.sessions : [saved]) { + if (!session.scopes.includes(REQUIRED_SCOPE) || connections.has(session.clientId)) continue; + connections.add(session.clientId); + // Subjects are client-scoped. Use verified email only for counting locally, + // never export it or merge the underlying profiles. + const email = session.email?.trim().toLowerCase(); + if (email) accounts.add(email); + else unidentifiedConnectedConnectionCount++; + } + } + return { + accountCountScope: Option.isSome(settings) ? "environment" : "provider_instance", + connectedAccountCount: accounts.size, + connectedConnectionCount: connections.size, + savedConnectionCount: savedConnections.size, + unidentifiedConnectedConnectionCount, + }; + }).pipe(Effect.provideService(ServerSecretStore, secrets)); + const saveRegistration = Effect.fnUntraced(function* (profile: typeof Registration.Type) { + const saved = yield* readRegistrations; + profile = { + ...profile, + connectionLabel: + saved.profiles.find((entry) => entry.clientId === profile.clientId)?.connectionLabel ?? + profile.connectionLabel ?? + `Connection ${saved.profiles.length + 1}`, + }; + yield* encodeRegistration({ + profiles: [profile, ...saved.profiles.filter((entry) => entry.clientId !== profile.clientId)], + lastClientId: profile.clientId, + }).pipe( + Effect.flatMap((json) => registrationStore.set(new TextEncoder().encode(json))), + Effect.mapError(() => + failure("registration", "Could not save the ChatGPT sign-in registration. Try again."), + ), + ); + }); + // The active pointer and all profile token sets change in one protected atomic write. + const save = Effect.fnUntraced(function* (record: CodexChatGptCredentials, activate = true) { + const saved = yield* readSessions; + yield* writeSessions({ + activeClientId: activate ? record.clientId : saved.activeClientId, + sessions: [ + ...saved.sessions.filter((session) => session.clientId !== record.clientId), + record, + ], + }); + }); + const clearTokens = Effect.gen(function* () { + const saved = yield* readSessions; + const sessions = saved.sessions.filter((session) => session.clientId !== saved.activeClientId); + if (sessions.length) yield* writeSessions({ activeClientId: null, sessions }); + else + yield* store.remove.pipe( + Effect.mapError(() => + failure("disconnect", "Could not clear the ChatGPT connection. Try again."), + ), + ); + }); + const remove = Effect.gen(function* () { + // Promote legacy identity into the retained profile before deleting credentials. + const credentials = yield* read; + if (Option.isSome(credentials)) { + const { clientId, subject, email } = credentials.value; + const saved = yield* readRegistrations; + const profile = saved.profiles.find((entry) => entry.clientId === clientId); + if (profile && (profile.subject === undefined || profile.email === undefined)) + yield* saveRegistration({ ...profile, subject, email }); + } + yield* clearTokens; + }); + const exchange = Effect.fn("CodexChatGptAuth.exchange")(function* (body: URLSearchParams) { + const endpoints = yield* discover; + const response = yield* http + .execute( + HttpClientRequest.post(endpoints.token_endpoint).pipe( + HttpClientRequest.setHeader("accept", "application/json"), + HttpClientRequest.bodyText(body.toString(), "application/x-www-form-urlencoded"), + ), + ) + .pipe( + Effect.flatMap((result) => + result.json.pipe( + Effect.map((raw) => ({ + ok: result.status >= 200 && result.status < 300, + status: result.status, + raw, + })), + ), + ), + Effect.mapError(() => + failure("exchange", "ChatGPT sign-in is temporarily unavailable. Try again."), + ), + ); + if (!response.ok) { + const error = yield* Effect.try({ + try: () => decodeOAuthError(response.raw).error, + catch: () => failure("exchange", "ChatGPT did not accept this sign-in. Try again."), + }); + if ( + body.get("grant_type") === "refresh_token" && + [ + "invalid_grant", + "invalid_refresh_token", + "token_expired", + "refresh_token_expired", + "refresh_token_invalidated", + "refresh_token_reused", + ].includes(error) + ) { + yield* remove; + return yield* failure( + "refresh", + "Your ChatGPT connection expired or was disconnected. Sign in again.", + ); + } + if (error === "invalid_client") + return yield* failure( + "client", + "OpenAI rejected this app's client registration. Check the ChatGPT connection configuration.", + ); + if (body.get("grant_type") === "authorization_code" && error === "invalid_grant") + return yield* failure("code-expired", "This sign-in code expired. Start again."); + return yield* failure( + "exchange", + error === "access_denied" + ? "ChatGPT sign-in was declined. Sign in again when you are ready." + : "ChatGPT could not complete sign-in. Try again.", + ); + } + return yield* Effect.try({ + try: () => decodeTokens(response.raw), + catch: () => + failure("exchange", "ChatGPT returned an invalid token response. Sign in again."), + }); + }); + const earliest = (value: (typeof TokenResponse.Type)["earliest_refresh_at"]) => { + if (value === undefined) return null; + const time = typeof value === "number" ? value * 1000 : Date.parse(value); + return Number.isFinite(time) ? time : null; + }; + const authenticate = Effect.fn("CodexChatGptAuth.authenticate")(function* ( + method: string, + context: ProviderAuthFlowContext, + ) { + const endpoints = yield* discover; + const existing = yield* read; + const previous = Option.getOrUndefined(existing); + const registrations = yield* readRegistrations; + const changingAccount = method === "chatgpt-change-account"; + const selectedClientId = method.startsWith("chatgpt-profile:") + ? method.slice("chatgpt-profile:".length) + : (previous?.clientId ?? registrations.lastClientId); + const savedRegistration = changingAccount + ? undefined + : registrations.profiles.find((profile) => profile.clientId === selectedClientId); + // Older development registrations used localhost, which cannot be changed on reauth. + // Register a 127.0.0.1 connection instead, preserving the verified account. + const legacyCallback = savedRegistration?.redirectUri?.includes("//localhost:") === true; + const registeredClientId = legacyCallback ? undefined : savedRegistration?.clientId; + const selectedTokens = (yield* readSessions).sessions.find( + (session) => session.clientId === selectedClientId, + ); + const state = NodeCrypto.randomBytes(32).toString("base64url"); + const nonce = NodeCrypto.randomBytes(32).toString("base64url"); + const verifier = NodeCrypto.randomBytes(64).toString("base64url"); + const returnUrl = + codexAuthReturnUrl(context.returnUrl) ?? codexAuthReturnUrl(options.defaultReturnUrl); + const pageNonce = NodeCrypto.randomBytes(24).toString("base64url"); + const callback = Promise.withResolvers<{ url: URL; response?: NodeHttp.ServerResponse }>(); + const clientCallback = context.callbackMode === "client"; + let used = false; + const server = clientCallback + ? undefined + : yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + const server = NodeHttp.createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (request.method !== "GET" || url.pathname !== "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/auth/callback") { + response.writeHead(404).end(); + return; + } + if (used) { + response.writeHead(410).end("Sign-in is no longer active."); + return; + } + used = true; + callback.resolve({ url, response }); + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve(server)); + }), + catch: () => + failure("callback", "Could not start the local sign-in callback. Try again."), + }), + (server) => + Effect.promise( + () => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + ), + ); + const address = server?.address(); + if (!clientCallback && (!address || typeof address === "string")) + return yield* failure("callback", "Could not start the local sign-in callback."); + // Only the port may vary between attempts; token exchange uses this exact URI. + const port = + address && typeof address !== "string" ? address.port : NodeCrypto.randomInt(49_152, 65_536); + const redirectUri = `http://127.0.0.1:${port}/auth/callback`; + const idTokenHint = selectedTokens?.idToken ?? options.reconnectProfile?.idTokenHint; + const url = new URL(endpoints.authorization_endpoint); + url.search = new URLSearchParams({ + client_id: registeredClientId ?? "dynamic_agent_client", + ...(registeredClientId + ? { + ...(savedRegistration?.email ? { login_hint: savedRegistration.email } : {}), + ...(idTokenHint ? { id_token_hint: idTokenHint } : {}), + ...(savedRegistration?.sharingEnabled === false || + (selectedTokens && !selectedTokens.scopes.includes(REQUIRED_SCOPE)) + ? { prompt: "consent" } + : {}), + } + : { agent_name_hint: "T3 Code" }), + ext_agent_host_id: hostId, + response_type: "code", + redirect_uri: redirectUri, + scope: "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct", + resource, + state, + nonce, + code_challenge_method: "S256", + code_challenge: NodeCrypto.createHash("sha256").update(verifier).digest("base64url"), + }).toString(); + yield* context.setInteraction( + { + type: "browser", + id: context.flowId, + url: url.toString(), + requiresConsent: false, + acceptsCallback: true, + }, + undefined, + (callbackUrl) => + Effect.try({ + try: () => { + const returned = codexCallbackUrl(callbackUrl, redirectUri, state); + if (used) throw new Error("used"); + used = true; + callback.resolve({ url: returned }); + }, + catch: () => + failure("complete", "This redirect URL does not belong to the active ChatGPT sign-in."), + }), + ); + const received = yield* Effect.tryPromise({ + try: () => callback.promise, + catch: () => failure("callback", "ChatGPT sign-in could not be completed."), + }); + const returned = received.url; + yield* Effect.gen(function* () { + if (returned.searchParams.get("state") !== state) + return yield* failure("callback", "ChatGPT sign-in could not be verified. Start again."); + if (returned.searchParams.has("error")) + return yield* failure( + "callback", + returned.searchParams.get("error") === "access_denied" + ? "ChatGPT sign-in was declined. Sign in again when you are ready." + : "ChatGPT sign-in could not be completed. Start again.", + ); + const code = returned.searchParams.get("code"); + const clientId = registeredClientId ?? returned.searchParams.get("client_id"); + if ( + !code || + !clientId || + !clientId.startsWith("oaiapp_") || + (registeredClientId && + returned.searchParams.has("client_id") && + returned.searchParams.get("client_id") !== registeredClientId) + ) + return yield* failure( + "callback", + "ChatGPT registration is incomplete. Start sign-in again.", + ); + yield* context.verifying; + const tokens = yield* exchange( + new URLSearchParams({ + grant_type: "authorization_code", + client_id: clientId, + code, + code_verifier: verifier, + redirect_uri: redirectUri, + resource, + }), + ).pipe( + Effect.catch((error) => + Effect.gen(function* () { + if (error.operation === "code-expired") { + if (!registeredClientId) + yield* withSessionLock(saveRegistration({ clientId, redirectUri })); + return yield* failure( + "exchange", + "This sign-in code expired. Reconnect the saved ChatGPT profile to start a fresh sign-in.", + ); + } + return yield* error; + }), + ), + ); + if (!tokens.id_token) + return yield* failure( + "verify", + "ChatGPT did not return a verified identity. Sign in again.", + ); + const identity = yield* Effect.tryPromise({ + try: async () => { + const { payload } = await jwtVerify(tokens.id_token!, jwks!, { + issuer: endpoints.issuer, + audience: clientId, + algorithms: ["RS256", "ES256"], + clockTolerance: 5, + }); + if (payload.nonce !== nonce || !payload.sub || !payload.exp) throw new Error("identity"); + return { + subject: payload.sub, + email: typeof payload.email === "string" ? payload.email : null, + }; + }, + catch: () => failure("verify", "ChatGPT sign-in could not be verified. Start again."), + }); + const expectedSubject = + savedRegistration?.subject ?? + (previous?.clientId === registeredClientId ? previous?.subject : undefined); + // Subjects are checked within the same registration. Legacy callback migration + // registers a new client, whose subject cannot be compared with the old client. + if (registeredClientId && expectedSubject && identity.subject !== expectedSubject) + return yield* failure( + "verify", + "This sign-in returned a different ChatGPT account. Use the different-account sign-in option instead. Your saved connection is unchanged.", + ); + const knownProfile = registrations.profiles.find((profile) => profile.clientId === clientId); + if ( + knownProfile && + ((knownProfile.redirectUri && + (() => { + const original = new URL(knownProfile.redirectUri); + const current = new URL(redirectUri); + return ( + original.protocol !== current.protocol || + original.hostname !== current.hostname || + original.pathname !== current.pathname + ); + })()) || + (knownProfile.subject && knownProfile.subject !== identity.subject)) + ) + return yield* failure( + "verify", + "ChatGPT returned a conflicting account registration. Start again.", + ); + if (tokens.token_type.toLowerCase() !== "bearer") + return yield* failure( + "verify", + "ChatGPT returned an unsupported connection. Sign in again.", + ); + const scopes = tokens.scope.split(/\s+/).filter(Boolean); + yield* withSessionLock( + Effect.gen(function* () { + yield* saveRegistration({ + clientId, + redirectUri, + sharingEnabled: scopes.includes(REQUIRED_SCOPE), + ...identity, + }); + yield* save( + { + clientId, + accessToken: tokens.access_token, + idToken: tokens.id_token!, + issuer: endpoints.issuer, + refreshToken: tokens.refresh_token ?? null, + expiresAt: (yield* Clock.currentTimeMillis) + tokens.expires_in * 1000, + earliestRefreshAt: earliest(tokens.earliest_refresh_at), + scopes, + ...identity, + }, + scopes.includes(REQUIRED_SCOPE) || Option.isNone(yield* read), + ); + }), + ); + if (!scopes.includes(REQUIRED_SCOPE)) + return yield* failure( + "sharing", + previous && previous.clientId !== clientId + ? "Token sharing was not enabled for the new account. Your existing ChatGPT connection is unchanged." + : "Signed in with ChatGPT, but token sharing is disabled. Sign in again and enable token sharing, or use another provider.", + ); + }).pipe( + Effect.onExit((result) => + Effect.promise( + () => + new Promise((resolve) => { + const response = received.response; + if (!response || response.destroyed) { + resolve(); + return; + } + response.once("close", resolve); + response + .writeHead(200, { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + "referrer-policy": "no-referrer", + "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${pageNonce}'; base-uri 'none'; frame-ancestors 'none'`, + "x-content-type-options": "nosniff", + }) + .end(codexAuthCallbackPage(Exit.isSuccess(result), returnUrl, pageNonce), resolve); + }), + ), + ), + ); + }); + const access = lock.withPermit( + withSessionLock( + Effect.uninterruptible( + Effect.gen(function* () { + const saved = yield* read; + if (Option.isNone(saved)) + return yield* failure("access", "Sign in with ChatGPT to use managed Codex."); + let record = saved.value; + if (!record.scopes.includes(REQUIRED_SCOPE)) + return yield* failure( + "sharing", + "Token sharing is disabled. Sign in with ChatGPT and enable token sharing.", + ); + const now = yield* Clock.currentTimeMillis; + if (record.expiresAt - now > 60_000) return record; + if (record.earliestRefreshAt !== null && record.earliestRefreshAt > now) { + if (record.expiresAt > now) return record; + return yield* failure( + "refresh", + "ChatGPT cannot renew this connection yet. Try again shortly.", + ); + } + if (!record.refreshToken) { + yield* remove; + return yield* failure("refresh", "Your ChatGPT connection expired. Sign in again."); + } + const tokens = yield* exchange( + new URLSearchParams({ + grant_type: "refresh_token", + client_id: record.clientId, + refresh_token: record.refreshToken, + resource, + }), + ).pipe( + Effect.interruptible, + Effect.timeout("20 seconds"), + Effect.mapError((error) => + error._tag === "ProviderSetupError" && error.operation === "client" + ? error + : failure( + "refresh", + "Could not renew the ChatGPT connection. Retry, or sign in again.", + ), + ), + ); + if (!tokens.refresh_token || tokens.token_type.toLowerCase() !== "bearer") { + return yield* failure("refresh", "ChatGPT returned an invalid renewal. Sign in again."); + } + record = { + ...record, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: (yield* Clock.currentTimeMillis) + tokens.expires_in * 1000, + earliestRefreshAt: earliest(tokens.earliest_refresh_at), + scopes: tokens.scope.split(/\s+/).filter(Boolean), + }; + yield* save(record); + if (!record.scopes.includes(REQUIRED_SCOPE)) + return yield* failure( + "sharing", + "ChatGPT token sharing is no longer enabled. Sign in again.", + ); + return record; + }), + ), + ), + ); + const logout = Effect.gen(function* () { + const credentials = Option.getOrUndefined(yield* read); + let confirmed = !credentials?.refreshToken; + if (credentials?.refreshToken) { + for (let attempt = 0; attempt < 3; attempt++) { + const result = yield* Effect.gen(function* () { + const endpoints = yield* discover; + if (!endpoints.revocation_endpoint) return { confirmed: false, retry: false }; + const response = yield* http.execute( + HttpClientRequest.post(endpoints.revocation_endpoint).pipe( + HttpClientRequest.bodyText( + new URLSearchParams({ + token: credentials.refreshToken!, + token_type_hint: "refresh_token", + client_id: credentials.clientId, + }).toString(), + "application/x-www-form-urlencoded", + ), + ), + ); + return { confirmed: response.status === 200, retry: response.status >= 500 }; + }).pipe( + Effect.timeout("10 seconds"), + Effect.orElseSucceed(() => ({ confirmed: false, retry: true })), + ); + confirmed = result.confirmed; + if (confirmed || !result.retry || attempt === 2) break; + yield* Effect.sleep(attempt === 0 ? "250 millis" : "1 second"); + } + } + yield* remove; + return confirmed + ? undefined + : "Signed out locally. Remote revocation could not be confirmed. Disconnect the app in ChatGPT Settings."; + }); + if (options.reconnectProfile) { + const { idTokenHint: _hint, ...registration } = options.reconnectProfile; + yield* saveRegistration(registration).pipe(Effect.orDie); + } + const controller = yield* ProviderAuthFlow.make({ + instanceId: options.instanceId, + credentialBinding: store.binding, + refreshMethodsAfterAuth: true, + methods: Effect.gen(function* () { + const saved = yield* readRegistrations; + const active = Option.getOrUndefined(yield* read); + const current = saved.profiles.find( + (profile) => profile.clientId === (active?.clientId ?? saved.lastClientId), + ); + const profiles = current + ? [current, ...saved.profiles.filter((profile) => profile.clientId !== current.clientId)] + : saved.profiles; + return [ + { + id: "chatgpt", + name: "Sign in with ChatGPT", + description: current?.email ? `Reconnect ${current.email}.` : null, + ...(current?.email ? { accountEmail: current.email } : {}), + type: "agent" as const, + }, + { + id: "chatgpt-change-account", + name: "Use a different ChatGPT account", + description: "Register a connection for another ChatGPT account.", + type: "agent" as const, + }, + // The wire contract allows 32 methods; advertise the 30 most recent profiles. + ...profiles.slice(0, 30).map((profile) => ({ + id: profileMethodId(profile.clientId), + name: `${profile.email ?? "ChatGPT account"} ยท ${profile.connectionLabel}`, + ...(profile.email ? { accountEmail: profile.email } : {}), + description: "Reuse this account's original sign-in registration.", + type: "agent" as const, + })), + ]; + }), + authenticate: (method, context) => + lock.withPermit( + track( + "auth", + { + flow: options.telemetryFlow ?? "direct", + intent: + options.telemetryFlow === "primary_handoff" + ? options.reconnectProfile + ? "saved_profile" + : "different_account" + : method === "chatgpt-change-account" + ? "different_account" + : method.startsWith("chatgpt-profile:") + ? "saved_profile" + : "default", + callbackMode: context.callbackMode ?? "server", + }, + authenticate(method, context), + context.expiresAt, + ), + ), + logout: lock.withPermit(withSessionLock(logout)), + }); + const reconnectProfile = Effect.fnUntraced(function* (methodId: string) { + if (methodId === "chatgpt-change-account") return null; + const registrations = yield* readRegistrations; + const active = Option.getOrUndefined(yield* read); + const clientId = methodId.startsWith("chatgpt-profile:") + ? methodId.slice("chatgpt-profile:".length) + : (active?.clientId ?? registrations.lastClientId); + const registration = registrations.profiles.find((profile) => profile.clientId === clientId); + if (!registration) { + if (methodId.startsWith("chatgpt-profile:")) + return yield* failure("export", "This saved connection is no longer available."); + return null; + } + const session = (yield* readSessions).sessions.find((session) => session.clientId === clientId); + return { ...registration, ...(session?.idToken ? { idTokenHint: session.idToken } : {}) }; + }); + const exportProfile = Effect.gen(function* () { + const credentials = Option.getOrUndefined(yield* read); + const registration = + credentials && + (yield* readRegistrations).profiles.find( + (profile) => profile.clientId === credentials.clientId, + ); + if (!credentials?.idToken || !credentials.issuer || !registration) + return yield* failure( + "export", + "Complete ChatGPT sign-in before transferring this connection.", + ); + return { + registration, + credentials: { ...credentials, idToken: credentials.idToken, issuer: credentials.issuer }, + } satisfies ChatGptTransferredProfile; + }); + const importProfile = ( + profile: ChatGptTransferredProfile, + stopSessions: Effect.Effect, + ) => { + const validate = Effect.gen(function* () { + const endpoints = yield* discover; + const credentials = profile.credentials; + if ( + credentials.clientId !== profile.registration.clientId || + credentials.issuer !== endpoints.issuer || + !credentials.scopes.includes(REQUIRED_SCOPE) || + credentials.expiresAt <= (yield* Clock.currentTimeMillis) + ) + return yield* failure( + "import", + "The transferred ChatGPT connection is invalid or expired.", + ); + const identity = yield* Effect.tryPromise({ + try: () => + jwtVerify(credentials.idToken, jwks!, { + issuer: endpoints.issuer, + audience: credentials.clientId, + algorithms: ["RS256", "ES256"], + clockTolerance: 5, + }), + catch: () => failure("import", "The transferred ChatGPT identity could not be verified."), + }); + if ( + identity.payload.sub !== credentials.subject || + profile.registration.subject !== credentials.subject || + (identity.payload.email ?? null) !== credentials.email || + (profile.registration.email !== undefined && + profile.registration.email !== credentials.email) + ) + return yield* failure( + "import", + "The transferred ChatGPT identity does not match its profile.", + ); + }); + const saveImported = Effect.gen(function* () { + const { idTokenHint: _hint, ...registration } = profile.registration; + const existing = (yield* readRegistrations).profiles.find( + (saved) => saved.clientId === registration.clientId, + ); + if (existing?.subject && existing.subject !== profile.credentials.subject) + return yield* failure( + "import", + "The transferred identity conflicts with the saved ChatGPT connection.", + ); + yield* saveRegistration(registration); + yield* save(profile.credentials, true); + }); + return lock.withPermit( + track( + "transfer", + { flow: "primary_handoff" }, + validate.pipe( + Effect.andThen(controller.adoptCredentials!(withSessionLock(saveImported), stopSessions)), + ), + ), + ); + }; + return { + controller: { ...controller, reconnectProfile, importProfile }, + read, + access, + exportProfile, + revoke: lock.withPermit(withSessionLock(remove)), + }; +}); diff --git a/apps/server/src/provider/CodexChatGptHandoff.ts b/apps/server/src/provider/CodexChatGptHandoff.ts new file mode 100644 index 000000000000..2a1dce4a6a96 --- /dev/null +++ b/apps/server/src/provider/CodexChatGptHandoff.ts @@ -0,0 +1,63 @@ +import type { ChatGptHandoffInput, ChatGptHandoffState } from "@t3tools/contracts"; +import { ProviderSetupError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { FetchHttpClient } from "effect/unstable/http"; +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts"; +import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; + +// The primary owns OAuth for this stream; the destination owns the refresh session. +export function subscribeChatGptHandoff( + input: ChatGptHandoffInput, + owner: string, + endpoints: { discoveryUrl: string; resource: string } | undefined = undefined, +) { + return Stream.unwrap( + Effect.gen(function* () { + const bytes = new Map(); + yield* Effect.addFinalizer(() => Effect.sync(() => bytes.clear())); + const store = ServerSecretStore.of({ + get: (key) => Effect.sync(() => Option.fromUndefinedOr(bytes.get(key))), + set: (key, value) => + Effect.sync(() => { + bytes.set(key, value); + }), + remove: (key) => + Effect.sync(() => { + bytes.delete(key); + }), + create: () => Effect.die("Handoff does not create persistent secrets."), + getOrCreateRandom: () => Effect.die("Handoff uses the destination environment identity."), + }); + const auth = yield* makeCodexChatGptAuth({ + ...endpoints, + instanceId: input.instanceId, + reconnectProfile: input.profile, + telemetryFlow: "primary_handoff", + defaultReturnUrl: input.returnUrl, + }).pipe( + Effect.provideService(ServerSecretStore, store), + Effect.provideService( + ServerEnvironmentIdentity, + ServerEnvironmentIdentity.of({ + getEnvironmentId: Effect.succeed(input.environmentId), + }), + ), + Effect.provide(FetchHttpClient.layer), + ); + yield* auth.controller.start(owner, Effect.void, "chatgpt", input.returnUrl, "server"); + return auth.controller.subscribe(owner).pipe( + Stream.takeUntil((state) => ["succeeded", "failed", "cancelled"].includes(state.phase)), + Stream.mapEffect((state): Effect.Effect => + state.phase === "succeeded" + ? auth.exportProfile.pipe( + Effect.map((profile) => ({ phase: "finished" as const, profile })), + ) + : Effect.succeed({ phase: "auth" as const, state }), + ), + ); + }), + ); +} diff --git a/apps/server/src/provider/CodexChatGptModels.test.ts b/apps/server/src/provider/CodexChatGptModels.test.ts new file mode 100644 index 000000000000..9e7ec690d1df --- /dev/null +++ b/apps/server/src/provider/CodexChatGptModels.test.ts @@ -0,0 +1,75 @@ +// @effect-diagnostics preferSchemaOverJson:off - Mock HTTP responses use JSON fixtures. +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { chatGptModels } from "./CodexChatGptModels.ts"; + +it.effect( + "uses each selected profile's token and the server's visible catalog order and names", + () => + Effect.gen(function* () { + const requests: string[] = []; + const http = HttpClient.make((request) => + Effect.sync(() => { + assert.strictEqual(request.url, "https://api.openai.com/v1/models"); + requests.push(request.headers.authorization!); + return HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ + models: + request.headers.authorization === "Bearer account-a" + ? [ + { slug: "second", display_name: "Second from OpenAI", visibility: "list" }, + { slug: "hidden", display_name: "Hidden", visibility: "hidden" }, + { slug: "first", display_name: "First from OpenAI", visibility: "list" }, + ] + : [{ slug: "account-b-only", display_name: "B", visibility: "list" }], + }), + ), + ); + }), + ); + const native = [ + { + slug: "first", + name: "Cached first", + isCustom: false, + capabilities: { optionDescriptors: [] }, + }, + { slug: "not-entitled", name: "Cached other", isCustom: false, capabilities: null }, + ]; + const a = yield* chatGptModels("account-a", native).pipe( + Effect.provideService(HttpClient.HttpClient, http), + ); + assert.deepEqual( + a.map((model) => [model.slug, model.name]), + [ + ["second", "Second from OpenAI"], + ["first", "First from OpenAI"], + ], + ); + assert.deepEqual(a[1]!.capabilities, native[0]!.capabilities); + assert.isNull(a[0]!.capabilities); + const b = yield* chatGptModels("account-b", native).pipe( + Effect.provideService(HttpClient.HttpClient, http), + ); + assert.deepEqual( + b.map((model) => model.slug), + ["account-b-only"], + ); + assert.deepEqual(requests, ["Bearer account-a", "Bearer account-b"]); + }), +); + +it.effect("does not present a cached catalog as account entitlements when discovery fails", () => + Effect.gen(function* () { + const http = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 503 }))), + ); + const error = yield* Effect.flip( + chatGptModels("account-a", []).pipe(Effect.provideService(HttpClient.HttpClient, http)), + ); + assert.strictEqual(error._tag, "ChatGptCatalogError"); + }), +); diff --git a/apps/server/src/provider/CodexChatGptModels.ts b/apps/server/src/provider/CodexChatGptModels.ts new file mode 100644 index 000000000000..5b00d1d82886 --- /dev/null +++ b/apps/server/src/provider/CodexChatGptModels.ts @@ -0,0 +1,47 @@ +import type { ServerProviderModel } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +export class ChatGptCatalogError extends Schema.TaggedError()( + "ChatGptCatalogError", + { status: Schema.Int }, +) {} + +const Catalog = Schema.Struct({ + models: Schema.Array( + Schema.Struct({ + slug: Schema.NonEmptyString, + display_name: Schema.NonEmptyString, + visibility: Schema.String, + }), + ), +}); + +/** Account choices come from OpenAI; native model/list contributes capability metadata only. */ +export const chatGptModels = Effect.fn("chatGptModels")(function* ( + accessToken: string, + nativeModels: ReadonlyArray, +) { + const http = yield* HttpClient.HttpClient; + const response = yield* http.execute( + HttpClientRequest.get("https://api.openai.com/v1/models").pipe( + HttpClientRequest.bearerToken(accessToken), + ), + ); + if (response.status !== 200) return yield* new ChatGptCatalogError({ status: response.status }); + const catalog = yield* response.json.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Catalog))); + return catalog.models + .filter((model) => model.visibility === "list") + .map( + (model) => + ({ + ...nativeModels.find((native) => native.slug === model.slug), + capabilities: + nativeModels.find((native) => native.slug === model.slug)?.capabilities ?? null, + slug: model.slug, + name: model.display_name, + isCustom: false, + }) satisfies ServerProviderModel, + ); +}, Effect.timeout("15 seconds")); diff --git a/apps/server/src/provider/CodexChatGptSessionLock.test.ts b/apps/server/src/provider/CodexChatGptSessionLock.test.ts new file mode 100644 index 000000000000..b8bdd3a0e976 --- /dev/null +++ b/apps/server/src/provider/CodexChatGptSessionLock.test.ts @@ -0,0 +1,75 @@ +// @effect-diagnostics nodeBuiltinImport:off - A separate Node process proves filesystem exclusion. +import * as NodeChildProcess from "node:child_process"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts"; + +const execFile = NodeUtil.promisify(NodeChildProcess.execFile); +const lockModule = NodeModule.createRequire(import.meta.url).resolve("proper-lockfile"); +const instanceId = ProviderInstanceId.make("cross-process-test"); +const probe = (directory: string) => + Effect.promise(async () => { + const { stdout } = await execFile(process.execPath, [ + "-e", + ` + const { lock } = require(process.argv[1]); + lock(process.argv[2], { realpath: false }).then(async release => { + await release(); process.stdout.write('acquired'); + }, error => { + if (error.code === 'ELOCKED') process.stdout.write('blocked'); + else { console.error(error); process.exitCode = 1; } + }); + `, + lockModule, + NodePath.join(directory, "session.bin"), + ]); + return stdout; + }); + +it.live("excludes another process and releases after the credential update", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped(); + const blocked = yield* withChatGptSessionLock( + directory, + "session", + instanceId, + probe(directory), + ); + assert.strictEqual(blocked, "blocked"); + assert.strictEqual(yield* probe(directory), "acquired"); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("releases the cross-process lease when the operation is interrupted", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped(); + const entered = yield* Deferred.make(); + const operation = yield* withChatGptSessionLock( + directory, + "session", + instanceId, + Effect.gen(function* () { + yield* Deferred.succeed(entered, undefined); + return yield* Effect.never; + }), + ).pipe(Effect.forkScoped); + yield* Deferred.await(entered); + assert.strictEqual(yield* probe(directory), "blocked"); + yield* Fiber.interrupt(operation); + assert.strictEqual(yield* probe(directory), "acquired"); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/CodexChatGptSessionLock.ts b/apps/server/src/provider/CodexChatGptSessionLock.ts new file mode 100644 index 000000000000..e580f2a78eb2 --- /dev/null +++ b/apps/server/src/provider/CodexChatGptSessionLock.ts @@ -0,0 +1,45 @@ +// @effect-diagnostics nodeBuiltinImport:off - Credential leases coordinate Node server processes. +import * as NodePath from "node:path"; +import { lock } from "proper-lockfile"; +import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; + +/** Keep rotating tokens and their active profile atomic across servers sharing a secret store. */ +export const withChatGptSessionLock = ( + directory: string | undefined, + key: string, + instanceId: ProviderInstanceId, + task: Effect.Effect, +): Effect.Effect => { + // In-memory stores have no shared filesystem; the auth controller still serializes its callers. + if (!directory) return task; + const failure = () => + new ProviderSetupError({ + instanceId, + operation: "credential-lock", + detail: "Could not lock the ChatGPT connection for an update. Try again.", + }); + return Effect.scoped( + Effect.gen(function* () { + const compromised = yield* Deferred.make(); + const services = yield* Effect.context(); + yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => + lock(NodePath.join(directory, `${key}.bin`), { + realpath: false, + stale: 120_000, + update: 10_000, + retries: { retries: 80, factor: 1, minTimeout: 500, maxTimeout: 500 }, + onCompromised: () => + Effect.runSyncWith(services)(Deferred.fail(compromised, failure())), + }), + catch: failure, + }), + (release) => Effect.promise(() => release()).pipe(Effect.ignore), + ); + return yield* Effect.raceFirst(task, Deferred.await(compromised)); + }), + ); +}; diff --git a/apps/server/src/provider/CodexInstallation.test.ts b/apps/server/src/provider/CodexInstallation.test.ts new file mode 100644 index 000000000000..40199c676839 --- /dev/null +++ b/apps/server/src/provider/CodexInstallation.test.ts @@ -0,0 +1,364 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { BUNDLED_MODEL_MANIFEST, ModelManifest, type ModelManifestData } from "./ModelManifest.ts"; +import { expect, it } from "@effect/vitest"; +import { + HostProcessArchitecture, + HostProcessEnvironment, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Exit from "effect/Exit"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import * as NodeCrypto from "node:crypto"; +import { + makeCodexInstallation, + type CodexInstallation, + type CodexInstallationOptions, + resolveCodexReleaseAsset, +} from "./CodexInstallation.ts"; + +const archive = Buffer.from( + "H4sIAAAAAAAC/+3W0W6DIBQGYB/FcD0sKNSkD7J7aom6tmAQtzXL3n3QZM3qdWVt+n8XoCckJp78wLY3q8bu9Ge2HBbUUp7nYD4H8s9zrNdSsCxnWQLT6JULn8ye09K9h/u2/c0/jSM9xqGzo0+bf3Gdf15WQiD/Kdy61/CA+z8dlO9Wrv2387+c5Z9VZY38p7BY0+Gh8t/sVauLt9Ga9Pnnop7ln1e4/6fxRQ7qZCf/qt3YW0M2OX/JyfvljbCCy3XBSaiGH9VqH4tKuaZbC6qG4aDpTrmP3sQV2nh3Gmxvzqsud0vyjaABAAAAAAAAAAAAAAAk8gOq19rvACgAAA==", + "base64", +); +const asset = { + version: "0.156.1", + target: "aarch64-apple-darwin", + url: "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/openai/codex/releases/download/test/package.tar.gz", + sha256: NodeCrypto.createHash("sha256").update(archive).digest("hex"), + archiveBytes: archive.length, +}; +const makeHarness = Effect.fn("test.makeCodexInstallation")(function* ( + input: { + options?: Partial; + manifestCurrent?: Effect.Effect; + body?: Stream.Stream; + baseDir?: string; + local?: { version: string; appServerFails?: boolean; versionFails?: boolean }; + } = {}, +) { + const fs = yield* FileSystem.FileSystem; + const baseDir = + input.baseDir ?? (yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-install-test-" })); + const localDirectory = `${baseDir}/local`; + const localBinaryPath = `${localDirectory}/codex`; + const probeLog = `${baseDir}/local-probes.txt`; + if (input.local) { + yield* fs.makeDirectory(localDirectory, { recursive: true }); + yield* fs.writeFileString( + localBinaryPath, + `#!/bin/sh\nprintf '%s\\n' "$*" >> '${probeLog}'\ncase "$1" in\n--version) ${input.local.versionFails ? "exit 1" : `printf '%s\\n' 'codex-cli ${input.local.version}'`};;\napp-server) exit ${input.local.appServerFails ? "1" : "0"};;\nesac\n`, + { mode: 0o755 }, + ); + } + let downloads = 0; + const installation = yield* makeCodexInstallation({ + baseDir, + releaseAsset: asset, + validate: () => Effect.void, + ...input.options, + }).pipe( + Effect.provideService(ModelManifest, { + current: input.manifestCurrent ?? Effect.succeed(BUNDLED_MODEL_MANIFEST), + refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), + forceRefresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), + refreshInBackground: Effect.void, + }), + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HostProcessArchitecture, "arm64"), + Effect.provideService(HostProcessEnvironment, { PATH: input.local ? localDirectory : "" }), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + downloads++; + return Object.defineProperty( + HttpClientResponse.fromWeb(request, new Response(null)), + "stream", + { + value: input.body ?? Stream.succeed(archive), + }, + ); + }), + ), + ), + ); + return { installation, fs, baseDir, localBinaryPath, probeLog, downloads: () => downloads }; +}); +const terminalState = (installation: CodexInstallation["Service"]) => + installation.changes.pipe( + Stream.filter((state) => ["succeeded", "failed", "cancelled"].includes(state.phase)), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + +for (const version of ["0.156.0", "0.156.1", "0.156.2", "0.157.0"]) { + it.effect(`reuses installed Codex ${version} without downloading or taking ownership of it`, () => + Effect.gen(function* () { + const h = yield* makeHarness({ local: { version } }); + expect(yield* h.installation.start).toMatchObject({ + source: "local", + phase: "succeeded", + installedVersion: version, + executablePath: h.localBinaryPath, + canRemove: false, + }); + expect(yield* h.installation.resolve()).toMatchObject({ + source: "local", + executablePath: h.localBinaryPath, + managedVersionDirectory: null, + version, + }); + yield* h.installation.acquire().pipe(Effect.scoped); + expect(h.downloads()).toBe(0); + expect(yield* h.fs.exists(h.installation.managedDirectory)).toBe(false); + expect((yield* h.fs.readFileString(h.probeLog)).trim().split("\n")).toEqual([ + "--version", + "app-server --help", + ]); + yield* h.installation.remove(); + expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true); + expect((yield* h.installation.state).source).toBe("local"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +} +for (const local of [ + { version: "0.128.9" }, + { version: "0.145.0" }, + { version: "0.155.1" }, + { version: "0.155.9" }, + { version: "0.156.1-alpha.1" }, + { version: "unknown" }, + { version: "0.156.1", appServerFails: true }, + { version: "0.156.1", versionFails: true }, +]) { + it.effect( + `downloads the pinned release when the local CLI is unsupported or broken: ${JSON.stringify(local)}`, + () => + Effect.gen(function* () { + const h = yield* makeHarness({ local }); + expect((yield* h.installation.state).installedVersion).toBeNull(); + yield* h.installation.start; + const installed = yield* terminalState(h.installation); + expect(installed.phase).toBe("succeeded"); + const executable = yield* h.installation.resolve(); + expect(executable.source).toBe("managed"); + expect(installed.executablePath).toBe(executable.executablePath); + expect(h.downloads()).toBe(1); + expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +} +it.effect("falls back to a managed download when the reused local executable disappears", () => + Effect.gen(function* () { + const h = yield* makeHarness({ local: { version: "0.156.1" } }); + expect((yield* h.installation.resolve()).source).toBe("local"); + yield* h.fs.remove(h.localBinaryPath); + yield* h.installation.start; + expect((yield* terminalState(h.installation)).phase).toBe("succeeded"); + expect((yield* h.installation.resolve()).source).toBe("managed"); + expect(h.downloads()).toBe(1); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("rechecks compatibility after the local executable is replaced", () => + Effect.gen(function* () { + const h = yield* makeHarness({ local: { version: "0.156.1" } }); + expect((yield* h.installation.resolve()).source).toBe("local"); + yield* h.fs.remove(h.localBinaryPath); + yield* h.fs.writeFileString(h.localBinaryPath, "#!/bin/sh\nprintf 'codex-cli 0.128.9\\n'\n", { + mode: 0o755, + }); + yield* h.installation.start; + expect((yield* terminalState(h.installation)).phase).toBe("succeeded"); + expect((yield* h.installation.resolve()).source).toBe("managed"); + expect(h.downloads()).toBe(1); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("rechecks a cached local executable when the shared manifest policy changes", () => + Effect.gen(function* () { + let manifest = BUNDLED_MODEL_MANIFEST; + const h = yield* makeHarness({ + local: { version: "0.156.0" }, + manifestCurrent: Effect.sync(() => manifest), + }); + expect((yield* h.installation.resolve()).source).toBe("local"); + manifest = { + ...manifest, + compatibility: [ + { + driver: "codex", + t3CodeRange: ">=0.0.42", + ranges: [ + { range: ">=0.156.1", status: "supported" }, + { range: "<0.156.1", status: "broken" }, + ], + }, + ], + }; + yield* h.installation.start; + expect((yield* terminalState(h.installation)).phase).toBe("succeeded"); + expect((yield* h.installation.resolve()).source).toBe("managed"); + expect(h.downloads()).toBe(1); + expect((yield* h.fs.readFileString(h.probeLog)).trim().split("\n")).toEqual([ + "--version", + "app-server --help", + ]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("uses bundled Codex compatibility when the remote manifest omits its policy", () => + Effect.gen(function* () { + const h = yield* makeHarness({ + local: { version: "0.156.0" }, + manifestCurrent: Effect.succeed({ ...BUNDLED_MODEL_MANIFEST, compatibility: [] }), + }); + expect((yield* h.installation.start).source).toBe("local"); + expect(h.downloads()).toBe(0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("preserves the invoked name of version-manager launcher symlinks", () => + Effect.gen(function* () { + const h = yield* makeHarness({ local: { version: "0.156.1" } }); + const launcher = `${h.baseDir}/launcher`; + yield* h.fs.writeFileString( + launcher, + '#!/bin/sh\ncase "$0" in */codex) ;; *) exit 1;; esac\ncase "$1" in --version) printf "codex-cli 0.156.1\\n";; app-server) exit 0;; esac\n', + { mode: 0o755 }, + ); + yield* h.fs.remove(h.localBinaryPath); + yield* h.fs.symlink(launcher, h.localBinaryPath); + expect(yield* h.installation.start).toMatchObject({ source: "local", phase: "succeeded" }); + expect((yield* h.installation.resolve()).executablePath).toBe(h.localBinaryPath); + expect(h.downloads()).toBe(0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect( + "installs the complete verified package in tools/codex/version and survives a restart", + () => + Effect.gen(function* () { + const { installation, fs, baseDir } = yield* makeHarness(); + yield* installation.start; + expect((yield* terminalState(installation)).phase).toBe("succeeded"); + const executable = yield* installation.resolve(); + expect(executable.executablePath).toBe(`${baseDir}/tools/codex/0.156.1/bin/codex`); + expect( + yield* fs.readFileString(`${executable.managedVersionDirectory}/bin/codex-code-mode-host`), + ).toBe("host"); + expect(yield* fs.readFileString(`${executable.managedVersionDirectory}/codex-path/rg`)).toBe( + "rg", + ); + const restarted = yield* makeHarness({ baseDir }); + expect((yield* restarted.installation.resolve()).version).toBe("0.156.1"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("requires an update before running an older activated managed installation", () => + Effect.gen(function* () { + const first = yield* makeHarness(); + yield* first.installation.start; + yield* terminalState(first.installation); + const executable = yield* first.installation.resolve(); + const oldDirectory = `${first.installation.managedDirectory}/0.155.1`; + yield* first.fs.rename(executable.managedVersionDirectory!, oldDirectory); + for (const name of ["codex-package.json", ".install-complete.json"]) { + const file = `${oldDirectory}/${name}`; + yield* first.fs.writeFileString( + file, + (yield* first.fs.readFileString(file)).replaceAll("0.156.1", "0.155.1"), + ); + } + yield* first.fs.writeFileString( + `${first.installation.managedDirectory}/active.json`, + '{"version":"0.155.1"}', + ); + const restarted = yield* makeHarness({ baseDir: first.baseDir }); + expect((yield* Effect.flip(restarted.installation.resolve())).detail).toContain("0.156.0"); + yield* restarted.installation.start; + expect((yield* terminalState(restarted.installation)).phase).toBe("succeeded"); + expect((yield* restarted.installation.resolve()).version).toBe("0.156.1"); + expect(restarted.downloads()).toBe(1); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("rejects corrupted downloads without publishing a runtime", () => + Effect.gen(function* () { + const { installation, fs } = yield* makeHarness({ + options: { releaseAsset: { ...asset, sha256: "0".repeat(64) } }, + }); + yield* installation.start; + const state = yield* terminalState(installation); + expect(state.phase).toBe("failed"); + expect(state.message).toContain("SHA-256"); + expect(yield* fs.exists(`${installation.managedDirectory}/active.json`)).toBe(false); + expect(yield* fs.exists(`${installation.managedDirectory}/0.156.1`)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("cancels an in-flight download and cleans the staging directory", () => + Effect.gen(function* () { + const downloading = yield* Deferred.make(); + const body = Stream.fromEffect( + Deferred.succeed(downloading, undefined).pipe(Effect.andThen(Effect.never)), + ); + const { installation, fs } = yield* makeHarness({ body }); + const started = yield* installation.start; + yield* Deferred.await(downloading); + expect((yield* installation.cancel(started.operationId!)).phase).toBe("cancelled"); + expect( + (yield* fs.readDirectory(installation.managedDirectory)).filter((name) => + name.startsWith(".install-"), + ), + ).toEqual([]); + expect(yield* fs.exists(`${installation.managedDirectory}/active.json`)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("protects active process leases and removes the runtime after release", () => + Effect.gen(function* () { + const { installation, fs } = yield* makeHarness(); + yield* installation.start; + yield* terminalState(installation); + const leaseScope = yield* Scope.make(); + yield* installation.acquire().pipe(Effect.provideService(Scope.Scope, leaseScope)); + expect((yield* Effect.flip(installation.remove())).detail).toContain("Stop Codex sessions"); + yield* Scope.close(leaseScope, Exit.void); + yield* installation.remove(); + expect(yield* fs.exists(installation.managedDirectory)).toBe(false); + expect((yield* installation.state).installedVersion).toBeNull(); + expect((yield* installation.state).executablePath).toBeNull(); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("keeps an activated runtime when a later update fails verification", () => + Effect.gen(function* () { + const first = yield* makeHarness(); + yield* first.installation.start; + yield* terminalState(first.installation); + const update = yield* makeHarness({ + baseDir: first.baseDir, + options: { releaseAsset: { ...asset, version: "0.156.2", sha256: "0".repeat(64) } }, + }); + expect((yield* update.installation.state).executablePath).toBe( + (yield* first.installation.resolve()).executablePath, + ); + yield* update.installation.start; + expect((yield* terminalState(update.installation)).phase).toBe("failed"); + expect((yield* update.installation.resolve()).version).toBe("0.156.1"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it("publishes complete packages for all supported platforms", () => { + for (const platform of ["darwin", "linux", "win32"] as const) + for (const arch of ["x64", "arm64"]) + expect(resolveCodexReleaseAsset(platform, arch)?.url).toContain("codex-package-"); + expect(resolveCodexReleaseAsset("linux", "riscv64")).toBeNull(); +}); diff --git a/apps/server/src/provider/CodexInstallation.ts b/apps/server/src/provider/CodexInstallation.ts new file mode 100644 index 000000000000..4beec81770a6 --- /dev/null +++ b/apps/server/src/provider/CodexInstallation.ts @@ -0,0 +1,734 @@ +// @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest. +import { ProviderDriverKind, type ProviderInstallState } from "@t3tools/contracts"; +import { + HostProcessArchitecture, + HostProcessEnvironment, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Clock from "effect/Clock"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as NodeCrypto from "node:crypto"; +import { ServerConfig } from "../config.ts"; +import { BUNDLED_MODEL_MANIFEST, ModelManifest } from "./ModelManifest.ts"; +import { resolveProviderCompatibility } from "./providerCompatibility.ts"; + +const DRIVER = ProviderDriverKind.make("codex"); +const Version = Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/u)); +const ActiveRelease = Schema.Struct({ version: Version }); +const InstalledRelease = Schema.Struct({ + version: Version, + target: Schema.String, + sha256: Schema.String, +}); +const PackageManifest = Schema.Struct({ + layoutVersion: Schema.Literal(1), + version: Version, + target: Schema.String, + entrypoint: Schema.String, +}); +const decodeVersion = Schema.decodeUnknownEffect(Version); +const encodeRecord = Schema.encodeEffect(Schema.fromJsonString(InstalledRelease)); +const encodeActive = Schema.encodeEffect(Schema.fromJsonString(ActiveRelease)); +export interface CodexReleaseAsset { + readonly version: string; + readonly target: string; + readonly url: string; + readonly sha256: string; + readonly archiveBytes: number; +} +// Official complete packages retain the code-mode, search, and resource companions. +const RELEASES: Readonly> = { + "darwin-arm64": { + version: "0.156.1", + target: "aarch64-apple-darwin", + url: "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/openai/codex/releases/download/rust-v0.156.1/codex-package-aarch64-apple-darwin.tar.gz", + sha256: "fea42f9625091f011e38f059da974d52e57ba31831648bb1c7f0b1a385fde547", + archiveBytes: 127394863, + }, + "darwin-x64": { + version: "0.156.1", + target: "x86_64-apple-darwin", + url: "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/openai/codex/releases/download/rust-v0.156.1/codex-package-x86_64-apple-darwin.tar.gz", + sha256: "618dbcd55419fa041871f777a14b107ceb3fe2d339ef81e21e6ab5374420dc71", + archiveBytes: 138670455, + }, + "linux-arm64": { + version: "0.156.1", + target: "aarch64-unknown-linux-musl", + url: "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/openai/codex/releases/download/rust-v0.156.1/codex-package-aarch64-unknown-linux-musl.tar.gz", + sha256: "fdd47ed6aade0360796fd3f6f95a45096f327c15e19e8c7339f9dc5633041786", + archiveBytes: 136933361, + }, + "linux-x64": { + version: "0.156.1", + target: "x86_64-unknown-linux-musl", + url: "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/openai/codex/releases/download/rust-v0.156.1/codex-package-x86_64-unknown-linux-musl.tar.gz", + sha256: "8b711520beddf385467b8da4d2c93736637c6ba1e46811cf0d8606b7c490b6f6", + archiveBytes: 145976992, + }, + "win32-arm64": { + version: "0.156.1", + target: "aarch64-pc-windows-msvc", + url: "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/openai/codex/releases/download/rust-v0.156.1/codex-package-aarch64-pc-windows-msvc.tar.gz", + sha256: "85994caecdc7609c49fd585c1cdb5677fa9d0acbf789650cff23a13afc9505db", + archiveBytes: 142037952, + }, + "win32-x64": { + version: "0.156.1", + target: "x86_64-pc-windows-msvc", + url: "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/openai/codex/releases/download/rust-v0.156.1/codex-package-x86_64-pc-windows-msvc.tar.gz", + sha256: "a2e017db9807e6a2269a26fea0e1d9546469cef4d472a33016bc9f3ad7d3b733", + archiveBytes: 153839991, + }, +}; +export const resolveCodexReleaseAsset = (platform: NodeJS.Platform, arch: string) => + RELEASES[`${platform}-${arch}`] ?? null; +export class CodexInstallationError extends Schema.TaggedError()( + "CodexInstallationError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message() { + return this.detail; + } +} +const isInstallationError = Schema.is(CodexInstallationError); +const installationError = (operation: string, detail: string, cause?: unknown) => + new CodexInstallationError({ operation, detail, ...(cause === undefined ? {} : { cause }) }); +const wrapFailure = (operation: string, detail: string) => (cause: unknown) => + isInstallationError(cause) ? cause : installationError(operation, detail, cause); +export interface CodexExecutable { + readonly executablePath: string; + readonly source: "managed" | "local"; + readonly version: string; + readonly managedVersionDirectory: string | null; +} +interface CodexInstallationService { + readonly managedDirectory: string; + readonly resolve: () => Effect.Effect; + readonly acquire: () => Effect.Effect; + readonly start: Effect.Effect; + readonly cancel: ( + operationId: string, + ) => Effect.Effect; + readonly state: Effect.Effect; + readonly changes: Stream.Stream; + readonly remove: ( + protectedBinaryPaths?: ReadonlyArray, + ) => Effect.Effect; +} +export class CodexInstallation extends Context.Service< + CodexInstallation, + CodexInstallationService +>()("t3/provider/CodexInstallation") { + static readonly layer = Layer.effect( + CodexInstallation, + Effect.gen(function* () { + const config = yield* ServerConfig; + return yield* makeCodexInstallation({ baseDir: config.baseDir }); + }), + ); +} +export interface CodexInstallationOptions { + readonly baseDir: string; + readonly releaseAsset?: CodexReleaseAsset | null; + readonly validate?: ( + executable: CodexExecutable, + expectedVersion: string, + ) => Effect.Effect; +} +const isRunning = (state: ProviderInstallState) => + ["downloading", "extracting", "verifying"].includes(state.phase); +export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function* ( + options: CodexInstallationOptions, +) { + const manifestService = yield* ModelManifest; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const http = yield* HttpClient.HttpClient; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serviceScope = yield* Effect.scope; + const platform = yield* HostProcessPlatform; + const environment = yield* HostProcessEnvironment; + const arch = yield* HostProcessArchitecture; + const asset = + options.releaseAsset === undefined + ? resolveCodexReleaseAsset(platform, arch) + : options.releaseAsset; + const managedDirectory = path.join(options.baseDir, "tools", "codex"); + const activePath = path.join(managedDirectory, "active.json"); + const executableName = platform === "win32" ? "codex.exe" : "codex"; + const gate = yield* Semaphore.make(1); + let leases = 0; + let running: { readonly operationId: string; readonly fiber: Fiber.Fiber } | undefined; + const state = yield* SubscriptionRef.make({ + driver: DRIVER, + operationId: null, + phase: "idle", + downloadedBytes: 0, + totalBytes: asset?.archiveBytes ?? null, + version: asset?.version ?? null, + installedVersion: null, + executablePath: null, + canRemove: false, + message: null, + }); + const readRecord = Effect.fn("CodexInstallation.readRecord")(function* ( + file: string, + schema: Schema.Codec, + ) { + const info = yield* fs.stat(file); + if (info.type !== "File" || Number(info.size) > 8192) + return yield* installationError( + "resolve", + "The managed Codex installation record is invalid. Reinstall Codex.", + ); + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))( + yield* fs.readFileString(file), + ); + }); + const fromDirectory = Effect.fn("CodexInstallation.fromDirectory")(function* ( + directory: string, + version: string, + target: string, + ) { + const manifest = yield* readRecord(path.join(directory, "codex-package.json"), PackageManifest); + if ( + manifest.version !== version || + manifest.target !== target || + manifest.entrypoint !== `bin/${executableName}` + ) + return yield* installationError( + "verify", + "The downloaded Codex package does not match the expected release.", + ); + for (const name of [ + `bin/${executableName}`, + `bin/codex-code-mode-host${platform === "win32" ? ".exe" : ""}`, + `codex-path/rg${platform === "win32" ? ".exe" : ""}`, + ]) { + const info = yield* fs.stat(path.join(directory, name)); + if ( + info.type !== "File" || + Number(info.size) === 0 || + (platform !== "win32" && (info.mode & 0o111) === 0) + ) + return yield* installationError( + "verify", + "The managed Codex package is incomplete. Reinstall Codex.", + ); + } + return { + executablePath: path.join(directory, "bin", executableName), + source: "managed", + version, + managedVersionDirectory: directory, + } satisfies CodexExecutable; + }); + const completedRelease = Effect.fn("CodexInstallation.completedRelease")(function* ( + version: string, + ) { + yield* decodeVersion(version); + const directory = path.join(managedDirectory, version); + const record = yield* readRecord( + path.join(directory, ".install-complete.json"), + InstalledRelease, + ); + if (record.version !== version) + return yield* installationError( + "resolve", + "The managed Codex installation record has the wrong version.", + ); + return yield* fromDirectory(directory, version, record.target); + }); + const compatibility = Effect.fn("CodexInstallation.compatibility")(function* (version: string) { + const manifest = yield* manifestService.current; + return ( + resolveProviderCompatibility(manifest.compatibility, DRIVER, version) ?? + resolveProviderCompatibility(BUNDLED_MODEL_MANIFEST.compatibility, DRIVER, version) + ); + }); + const resolveManaged = Effect.fn("CodexInstallation.resolveManaged")( + function* () { + const active = yield* readRecord(activePath, ActiveRelease); + const executable = yield* completedRelease(active.version); + const advisory = yield* compatibility(executable.version); + if (advisory?.status !== "supported") + return yield* installationError( + "resolve", + advisory?.message ?? "Update the managed Codex installation to continue.", + ); + return executable; + }, + Effect.mapError( + wrapFailure("resolve", "Codex is not installed in T3 Code. Install it to continue."), + ), + ); + const acquire = Effect.fn("CodexInstallation.acquire")(function* () { + return yield* Effect.acquireRelease( + gate.withPermit( + resolve().pipe( + Effect.tap(() => + Effect.sync(() => { + leases += 1; + }), + ), + ), + ), + () => + Effect.sync(() => { + leases -= 1; + }), + ); + }); + const runCommand = Effect.fn("CodexInstallation.runCommand")(function* ( + command: string, + args: ReadonlyArray, + ) { + const resolved = yield* resolveSpawnCommand(command, args).pipe( + Effect.provideService(HostProcessPlatform, platform), + ); + const child = yield* spawner.spawn( + ChildProcess.make(resolved.command, resolved.args, { shell: resolved.shell }), + ); + const [output, , exitCode] = yield* Effect.all( + [ + child.stdout.pipe(Stream.decodeText(), Stream.mkString), + child.stderr.pipe(Stream.runDrain), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0) + return yield* installationError( + "verify", + "Could not unpack or start the downloaded Codex package.", + ); + return output; + }, Effect.scoped); + const localCache = new Map(); + const resolveLocal = Effect.fn("CodexInstallation.resolveLocal")( + function* () { + const executablePath = yield* resolveCommandPath("codex", { env: environment }).pipe( + Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + // Keep launcher symlinks intact: version-manager shims dispatch by their invoked name. + const realExecutablePath = yield* fs.realPath(executablePath); + // A PATH entry pointing into T3's download remains a managed installation. + const realManaged = yield* fs.realPath(managedDirectory).pipe(Effect.option); + if ( + realExecutablePath.startsWith( + `${Option.getOrElse(realManaged, () => managedDirectory)}${path.sep}`, + ) + ) + return null; + const info = yield* fs.stat(executablePath); + const fingerprint = `${realExecutablePath}:${info.size}:${Option.getOrUndefined(info.mtime)?.getTime()}:${Option.getOrUndefined(info.ino)}`; + const cached = localCache.get(executablePath); + const executable = + cached?.fingerprint === fingerprint + ? cached.executable + : yield* Effect.gen(function* () { + const output = yield* runCommand(executablePath, ["--version"]); + const version = /^codex-cli (\d+\.\d+\.\d+)$/u.exec(output.trim())?.[1]; + if (!version) return null; + yield* runCommand(executablePath, ["app-server", "--help"]); + return { + executablePath, + source: "local", + version, + managedVersionDirectory: null, + } satisfies CodexExecutable; + }).pipe( + Effect.timeout("5 seconds"), + Effect.orElseSucceed(() => null), + ); + localCache.set(executablePath, { fingerprint, executable }); + if (!executable) return null; + // Cache executable probes, but reclassify against the current manifest on every resolve. + const advisory = yield* compatibility(executable.version); + return advisory?.status === "supported" ? executable : null; + }, + Effect.orElseSucceed(() => null), + ); + const resolve = Effect.fn("CodexInstallation.resolve")(function* () { + const local = yield* resolveLocal(); + return local ?? (yield* resolveManaged()); + }); + const reuseLocal = Effect.fn("CodexInstallation.reuseLocal")(function* () { + const local = yield* resolveLocal(); + if (!local) return false; + yield* SubscriptionRef.update( + state, + (current) => + ({ + ...current, + phase: "succeeded", + source: "local", + operationId: null, + installedVersion: local.version, + executablePath: local.executablePath, + downloadedBytes: 0, + totalBytes: null, + message: null, + }) satisfies ProviderInstallState, + ); + return true; + }); + const validate = + options.validate ?? + Effect.fn("CodexInstallation.validate")( + function* (executable: CodexExecutable, version: string) { + const output = yield* runCommand(executable.executablePath, ["--version"]); + if (output.trim() !== `codex-cli ${version}`) + return yield* installationError( + "verify", + "The downloaded Codex executable has the wrong version.", + ); + }, + Effect.mapError(wrapFailure("verify", "The downloaded Codex runtime could not start.")), + ); + const install = Effect.fn("CodexInstallation.install")( + function* (release: CodexReleaseAsset) { + yield* decodeVersion(release.version); + yield* fs.makeDirectory(managedDirectory, { recursive: true }); + yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove: true })); + const destination = path.join(managedDirectory, release.version); + const activate = Effect.fn("CodexInstallation.activate")( + function* () { + const executable = yield* completedRelease(release.version); + const temporary = yield* fs.makeTempDirectoryScoped({ + directory: managedDirectory, + prefix: ".active-", + }); + const pointer = path.join(temporary, "active.json"); + yield* fs.writeFileString(pointer, yield* encodeActive({ version: release.version }), { + mode: 0o600, + flag: "wx", + }); + yield* fs.rename(pointer, activePath); + yield* SubscriptionRef.update( + state, + (current) => + ({ + ...current, + phase: "succeeded", + source: "managed", + installedVersion: release.version, + executablePath: executable.executablePath, + message: null, + }) satisfies ProviderInstallState, + ); + }, + Effect.scoped, + Effect.uninterruptible, + ); + if (yield* fs.exists(destination)) { + const existing = yield* completedRelease(release.version); + const record = yield* readRecord( + path.join(destination, ".install-complete.json"), + InstalledRelease, + ); + if (record.sha256 !== release.sha256 || record.target !== release.target) + return yield* installationError( + "verify", + "The existing managed Codex release differs from the official package. Remove it and reinstall.", + ); + yield* validate(existing, release.version).pipe( + Effect.scoped, + Effect.timeout("90 seconds"), + ); + yield* activate(); + return; + } + const staging = yield* fs.makeTempDirectoryScoped({ + directory: managedDirectory, + prefix: ".install-", + }); + const archivePath = path.join(staging, "download.tar.gz"); + const runtime = path.join(staging, "runtime"); + yield* fs.makeDirectory(runtime); + const hash = NodeCrypto.createHash("sha256"); + let downloadedBytes = 0; + let lastProgressAt = yield* Clock.currentTimeMillis; + const response = yield* http + .execute(HttpClientRequest.get(release.url)) + .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); + yield* response.stream.pipe( + Stream.tap((chunk) => + Effect.gen(function* () { + downloadedBytes += chunk.byteLength; + if (downloadedBytes > release.archiveBytes) + return yield* installationError( + "download", + "The Codex download exceeded the expected release size.", + ); + hash.update(chunk); + const now = yield* Clock.currentTimeMillis; + if (now - lastProgressAt >= 250 || downloadedBytes === release.archiveBytes) { + lastProgressAt = now; + yield* SubscriptionRef.update(state, (current) => ({ ...current, downloadedBytes })); + } + }), + ), + Stream.run(fs.sink(archivePath, { flag: "wx", mode: 0o600 })), + Effect.timeout("45 minutes"), + ); + if (downloadedBytes !== release.archiveBytes || hash.digest("hex") !== release.sha256) + return yield* installationError( + "download", + "The Codex download failed its size or SHA-256 check. Nothing was installed.", + ); + yield* SubscriptionRef.update( + state, + (current) => + ({ + ...current, + phase: "extracting", + message: "Extracting Codex.", + }) satisfies ProviderInstallState, + ); + const entries = (yield* runCommand("tar", ["-tzf", archivePath])).trim().split("\n"); + const types = (yield* runCommand("tar", ["-tvzf", archivePath])).trim().split("\n"); + if ( + entries.length > 10000 || + entries.length !== types.length || + types.some((line) => !["-", "d"].includes(line[0] ?? "")) || + entries.some((entry) => { + const parts = entry.replace(/\/$/u, "").split("/"); + return ( + !entry || + entry.includes("\\") || + entry.startsWith("/") || + parts.some((part) => part === ".." || part === "." || part === "" || part.includes(":")) + ); + }) + ) + return yield* installationError("extract", "The Codex archive contains unsafe entries."); + yield* runCommand("tar", ["-xzf", archivePath, "-C", runtime]); + yield* SubscriptionRef.update( + state, + (current) => + ({ + ...current, + phase: "verifying", + message: "Checking Codex.", + }) satisfies ProviderInstallState, + ); + const executable = yield* fromDirectory(runtime, release.version, release.target); + yield* validate(executable, release.version).pipe( + Effect.scoped, + Effect.timeout("90 seconds"), + ); + yield* fs.writeFileString( + path.join(runtime, ".install-complete.json"), + yield* encodeRecord({ + version: release.version, + target: release.target, + sha256: release.sha256, + }), + { flag: "wx", mode: 0o600 }, + ); + yield* fs.rename(runtime, destination); + yield* activate(); + }, + Effect.scoped, + Effect.mapError( + wrapFailure( + "install", + "Could not install Codex. Check disk space and directory access, then try again.", + ), + ), + ); + const start = gate + .withPermit( + Effect.gen(function* () { + const current = yield* SubscriptionRef.get(state); + if (isRunning(current)) return current; + if (yield* reuseLocal()) return yield* SubscriptionRef.get(state); + if (!asset) { + return yield* installationError( + "start", + `OpenAI does not publish a Codex runtime for ${platform}-${arch}. Use a supported remote environment or a custom executable.`, + ); + } + const operationId = yield* crypto.randomUUIDv4; + const next: ProviderInstallState = { + driver: DRIVER, + operationId, + phase: "downloading", + downloadedBytes: 0, + totalBytes: asset.archiveBytes, + version: asset.version, + installedVersion: current.installedVersion, + canRemove: current.canRemove, + message: "Downloading Codex.", + }; + yield* SubscriptionRef.set(state, next); + const work = install(asset).pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) + ? SubscriptionRef.update(state, (value) => { + if (value.operationId !== operationId || value.phase === "succeeded") + return value; + const error = Cause.findErrorOption(exit.cause); + const cancelled = Cause.hasInterruptsOnly(exit.cause); + return { + ...value, + phase: cancelled ? "cancelled" : "failed", + message: cancelled + ? "Installation cancelled. The previous runtime is unchanged." + : Option.isSome(error) + ? error.value.detail + : "Could not finish the Codex installation. Check disk space and directory access.", + } satisfies ProviderInstallState; + }) + : Effect.void, + ), + Effect.ignoreCause, + Effect.ensuring( + Effect.sync(() => { + if (running?.operationId === operationId) running = undefined; + }), + ), + ); + const fiber = yield* Effect.forkIn(Effect.interruptible(work), serviceScope); + running = { operationId, fiber }; + return next; + }).pipe(Effect.uninterruptible), + ) + .pipe(Effect.mapError(wrapFailure("start", "Could not start the Codex installation."))); + + const cancel = Effect.fn("CodexInstallation.cancel")(function* (operationId: string) { + return yield* gate.withPermit( + Effect.gen(function* () { + const current = yield* SubscriptionRef.get(state); + if (current.operationId !== operationId) { + return yield* installationError( + "cancel", + "This installation is no longer current. Refresh its status before cancelling.", + ); + } + if (running?.operationId === operationId && isRunning(current)) { + yield* Fiber.interrupt(running.fiber); + } + return yield* SubscriptionRef.get(state); + }), + ); + }); + + const remove = Effect.fn("CodexInstallation.remove")( + function* (protectedBinaryPaths: ReadonlyArray = []) { + yield* gate.withPermit( + Effect.gen(function* () { + if (isRunning(yield* SubscriptionRef.get(state)) || leases > 0) { + return yield* installationError( + "remove", + "Stop Codex sessions and sign-in flows before removing its managed runtime.", + ); + } + const realManaged = yield* fs.realPath(managedDirectory).pipe(Effect.option); + if (Option.isSome(realManaged)) { + for (const binary of protectedBinaryPaths) { + const resolved = yield* fs.realPath(binary).pipe(Effect.option); + const candidate = Option.getOrElse(resolved, () => path.resolve(binary)); + if (candidate.startsWith(`${realManaged.value}${path.sep}`)) + return yield* installationError( + "remove", + "A provider instance uses a custom path inside managed Codex. Clear that path before removing it.", + ); + } + } + yield* fs.remove(managedDirectory, { recursive: true, force: true }); + yield* SubscriptionRef.update( + state, + (current) => + ({ + ...current, + operationId: null, + phase: "idle", + downloadedBytes: 0, + installedVersion: null, + executablePath: null, + source: null, + canRemove: false, + message: null, + }) satisfies ProviderInstallState, + ); + yield* reuseLocal(); + }).pipe(Effect.uninterruptible), + ); + }, + Effect.mapError( + wrapFailure( + "remove", + "Could not remove the managed Codex runtime. Check for open processes and try again.", + ), + ), + ); + + yield* Effect.gen(function* () { + const canRemove = yield* fs.exists(managedDirectory); + yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove })); + if (yield* reuseLocal()) return; + if (!(yield* fs.exists(activePath))) return; + const active = yield* readRecord(activePath, ActiveRelease); + const installed = yield* completedRelease(active.version); + yield* SubscriptionRef.update( + state, + (current) => + ({ + ...current, + installedVersion: installed.version, + executablePath: installed.executablePath, + source: "managed", + }) satisfies ProviderInstallState, + ); + }).pipe( + Effect.catch(() => + SubscriptionRef.update( + state, + (current) => + ({ + ...current, + phase: "failed", + message: "The managed Codex runtime is incomplete. Remove it and reinstall.", + }) satisfies ProviderInstallState, + ), + ), + ); + + return CodexInstallation.of({ + managedDirectory, + resolve, + acquire, + start, + cancel, + state: SubscriptionRef.get(state), + changes: SubscriptionRef.changes(state), + remove, + }); +}); diff --git a/apps/server/src/provider/CodexManagedErrors.test.ts b/apps/server/src/provider/CodexManagedErrors.test.ts new file mode 100644 index 000000000000..a1f253c922ed --- /dev/null +++ b/apps/server/src/provider/CodexManagedErrors.test.ts @@ -0,0 +1,64 @@ +import { assert, it } from "@effect/vitest"; +import { classifyCodexManagedError } from "./CodexManagedErrors.ts"; +it("maps streamed failures to safe actionable messages and reconnect decisions", () => { + assert.deepEqual( + classifyCodexManagedError({ + error: { + code: "subscription_sharing_v2_invalid_user", + message: "opaque token dummy-sensitive", + }, + }), + { + message: + "ChatGPT could not validate this connection. Check the selected account and sharing permissions.", + revoke: false, + code: "subscription_sharing_v2_invalid_user", + }, + ); + assert.include( + classifyCodexManagedError("subscription_sharing_usage_limit_exceeded")!.message, + "Usage settings", + ); + assert.equal( + classifyCodexManagedError("subscription_sharing_usage_limit_exceeded")!.code, + "subscription_sharing_usage_limit_exceeded", + ); + assert.isFalse(classifyCodexManagedError("subscription_sharing_usage_unavailable")!.revoke); + assert.include( + classifyCodexManagedError("subscription_sharing_unsupported_capability")!.message, + "feature", + ); + assert.isUndefined(classifyCodexManagedError(undefined)); + assert.isUndefined(classifyCodexManagedError({ error: "unknown" })); +}); + +it("distinguishes unsupported tools from input items without exposing the raw response", () => { + const code = "subscription_sharing_unsupported_capability"; + for (const [detail, expected] of [ + ["tool 'namespace' is not supported", "tool namespace"], + ["input item 'additional_tools' is not supported", "input item"], + ]) { + const response = { error: { code, message: `${detail}; dummy-sensitive` } }; + for (const value of [response, JSON.stringify(response)]) { + const failure = classifyCodexManagedError(value); + assert.isDefined(failure); + assert.include(failure!.message, expected!); + assert.notInclude(failure!.message, "dummy-sensitive"); + assert.isFalse(failure!.revoke); + } + } +}); + +it("preserves credentials for the current subscriber and permission error codes", () => { + for (const code of [ + "subscription_sharing_invalid_user", + "subscription_sharing_user_not_eligible", + "subscription_sharing_route_not_supported", + "subscription_sharing_user_unavailable", + "chatpass_v2_scope_not_authorized", + "chatpass_v2_invalid_authorization_context", + ]) { + assert.strictEqual(classifyCodexManagedError({ error: { code } })?.code, code); + assert.isFalse(classifyCodexManagedError({ error: { code } })!.revoke); + } +}); diff --git a/apps/server/src/provider/CodexManagedErrors.ts b/apps/server/src/provider/CodexManagedErrors.ts new file mode 100644 index 000000000000..9c8fe26bbb6e --- /dev/null +++ b/apps/server/src/provider/CodexManagedErrors.ts @@ -0,0 +1,87 @@ +const legacyFailures = { + subscription_sharing_v2_user_not_eligible: { + message: + "ChatGPT sharing is unavailable for this account or workspace. Use another provider or check its sharing policy.", + revoke: false, + }, + subscription_sharing_usage_limit_exceeded: { + message: + "Your ChatGPT usage limit was reached. Check ChatGPT Usage settings for your available allowance.", + revoke: false, + }, + subscription_sharing_usage_unavailable: { + message: "ChatGPT usage is temporarily unavailable. Try again shortly.", + revoke: false, + }, + subscription_sharing_unsupported_capability: { + message: + "Codex used a feature that ChatGPT sharing does not support. Use another provider for this request.", + revoke: false, + }, + subscription_sharing_v2_client_not_enabled: { + message: + "This app is not enabled for this ChatGPT connection. Use your existing CLI or another provider.", + revoke: false, + }, + subscription_sharing_v2_route_not_supported: { + message: "ChatGPT does not support this request route.", + revoke: false, + }, + subscription_sharing_v2_invalid_user: { + message: + "ChatGPT could not validate this connection. Check the selected account and sharing permissions.", + revoke: false, + }, + subscription_sharing_v2_user_unavailable: { + message: "ChatGPT is temporarily unavailable. Try again shortly.", + revoke: false, + }, +} as const; +const failures = { + ...legacyFailures, + subscription_sharing_user_not_eligible: legacyFailures.subscription_sharing_v2_user_not_eligible, + subscription_sharing_route_not_supported: + legacyFailures.subscription_sharing_v2_route_not_supported, + subscription_sharing_invalid_user: legacyFailures.subscription_sharing_v2_invalid_user, + subscription_sharing_user_unavailable: legacyFailures.subscription_sharing_v2_user_unavailable, + chatpass_v2_scope_not_authorized: { + message: + "This ChatGPT grant does not authorize the request. Check the connection's sharing permissions.", + revoke: false, + }, + chatpass_v2_invalid_authorization_context: { + message: + "ChatGPT could not authorize this connection. Check the client and sharing permissions.", + revoke: false, + }, +}; +export function classifyCodexManagedError(value: unknown) { + let text: string; + try { + text = typeof value === "string" ? value : JSON.stringify(value); + } catch { + return undefined; + } + if (typeof text !== "string") return undefined; + for (const [code, failure] of Object.entries(failures)) { + if (!text.includes(code)) continue; + if (code === "subscription_sharing_unsupported_capability") { + if (text.includes("tool 'namespace'")) + return { + ...failure, + code, + message: + "Codex sent a tool namespace that ChatGPT sharing does not support. Use another provider for this request.", + }; + if (text.includes("additional_tools")) + return { + ...failure, + code, + message: + "Codex sent an input item that ChatGPT sharing does not support. Use another provider for this request.", + }; + } + return { ...failure, code }; + } + return undefined; +} diff --git a/apps/server/src/provider/CodexManagedHome.ts b/apps/server/src/provider/CodexManagedHome.ts new file mode 100644 index 000000000000..7f6d6313e11e --- /dev/null +++ b/apps/server/src/provider/CodexManagedHome.ts @@ -0,0 +1,20 @@ +import type { CodexSettings, ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import { resolveCodexHomeLayout } from "./Drivers/CodexHomeLayout.ts"; + +export const resolveManagedCodexHomeLayout = Effect.fn("resolveManagedCodexHomeLayout")(function* ( + stateDir: string, + instanceId: ProviderInstanceId, + config: CodexSettings, +) { + const path = yield* Path.Path; + return yield* resolveCodexHomeLayout({ + ...config, + shadowHomePath: + config.shadowHomePath.trim() || + (instanceId === "codex" + ? "" + : path.join(stateDir, "providers", "codex", instanceId, "shadow")), + }); +}); diff --git a/apps/server/src/provider/CodexManagedRuntime.test.ts b/apps/server/src/provider/CodexManagedRuntime.test.ts new file mode 100644 index 000000000000..aeba7c5bfe3d --- /dev/null +++ b/apps/server/src/provider/CodexManagedRuntime.test.ts @@ -0,0 +1,225 @@ +// @effect-diagnostics nodeBuiltinImport:off - checks the host's default Codex directory without writing to it. +import * as NodeOS from "node:os"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { CodexSettings, EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { ServerConfig } from "../config.ts"; +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts"; +import { CodexInstallation } from "./CodexInstallation.ts"; +import { makeCodexManagedRuntime } from "./CodexManagedRuntime.ts"; +import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; +import { codexAppServerArgs } from "./Layers/codexLaunchArgs.ts"; +import { resolveManagedCodexHomeLayout } from "./CodexManagedHome.ts"; + +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeSettings = Schema.decodeSync(CodexSettings); +it.effect("managed home defaults to the global Codex home and honors configured home paths", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const primary = yield* resolveManagedCodexHomeLayout( + "/t3-state", + ProviderInstanceId.make("codex"), + decodeSettings({}), + ); + assert.equal(primary.sharedHomePath, path.join(NodeOS.homedir(), ".codex")); + assert.equal(primary.mode, "direct"); + const additional = yield* resolveManagedCodexHomeLayout( + "/t3-state", + ProviderInstanceId.make("codex-work"), + decodeSettings({}), + ); + assert.equal(additional.sharedHomePath, primary.sharedHomePath); + assert.equal(additional.mode, "authOverlay"); + const configured = yield* resolveManagedCodexHomeLayout( + "/t3-state", + ProviderInstanceId.make("codex-work"), + decodeSettings({ homePath: "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/custom/shared", shadowHomePath: "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/custom/shadow" }), + ); + assert.equal(configured.sharedHomePath, "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/custom/shared"); + assert.equal(configured.effectiveHomePath, "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/custom/shadow"); + }).pipe(Effect.provide(NodeServices.layer)), +); +for (const source of ["managed", "local"] as const) + for (const account of ["primary", "additional"] as const) + it.effect( + `${source} Codex ${account} account shares home state without routing owned tokens through ambient CLI overrides`, + () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make( + account === "primary" ? "codex" : "codex-personal", + ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* fs.makeTempDirectoryScoped({ prefix: "codex-shared-home-" }); + yield* fs.writeFileString(path.join(sharedHome, "auth.json"), "native-auth-unchanged"); + yield* fs.writeFileString(path.join(sharedHome, "config.toml"), "# shared config\n"); + const data = new Map(); + const secrets = ServerSecretStore.of({ + get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), + set: (key, value) => + Effect.sync(() => { + data.set(key, value); + }), + remove: (key) => + Effect.sync(() => { + data.delete(key); + }), + create: () => Effect.die("unused"), + getOrCreateRandom: () => Effect.die("unused"), + }); + let leases = 0; + const executable = { + executablePath: + source === "managed" ? "/isolated/tools/codex/0.156.1/bin/codex" : "/user/bin/codex", + managedVersionDirectory: source === "managed" ? "/isolated/tools/codex/0.156.1" : null, + source, + version: "0.156.1", + }; + const installerLayer = Layer.mock(CodexInstallation)({ + managedDirectory: "/isolated/tools/codex", + resolve: () => Effect.succeed(executable), + acquire: () => + Effect.gen(function* () { + leases++; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + leases--; + }), + ); + return executable; + }), + }); + yield* Effect.gen(function* () { + const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId); + const json = yield* encodeJson({ + clientId: "oaiapp_test", + accessToken: "dummy-owned-access", + refreshToken: "dummy-refresh", + expiresAt: (yield* Clock.currentTimeMillis) + 3_600_000, + earliestRefreshAt: null, + scopes: ["chatgpt.tokens.use.direct"], + subject: "test-user", + email: null, + }); + yield* store.set(new TextEncoder().encode(json)); + const ambient = { + CODEX_HOME: "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/user/.codex", + OPENAI_API_KEY: "dummy-global-key", + OPENAI_BASE_URL: "https://user-proxy.test", + T3CODE_CODEX_LAUNCH_ARGS: "--config model_provider=global-proxy", + PATH: "/usr/bin", + }; + const runtime = yield* makeCodexManagedRuntime({ + instanceId, + enabled: true, + config: decodeSettings({ setupMode: "managed", homePath: sharedHome }), + environment: ambient, + }); + yield* Effect.gen(function* () { + const effective = yield* runtime.resolve; + assert.strictEqual(leases, 1); + assert.strictEqual(effective.config.binaryPath, executable.executablePath); + assert.notStrictEqual(effective.config.homePath, ambient.CODEX_HOME); + assert.equal(runtime.homeLayout.sharedHomePath, sharedHome); + if (account === "primary") { + assert.equal(effective.config.homePath, sharedHome); + assert.equal(runtime.homeLayout.mode, "direct"); + } else { + assert.include(effective.config.homePath, instanceId); + assert.include(effective.config.homePath, "userdata/providers/codex"); + assert.equal(runtime.homeLayout.mode, "authOverlay"); + assert.equal( + yield* fs.readLink(path.join(effective.config.homePath, "sessions")), + path.join(sharedHome, "sessions"), + ); + assert.equal( + yield* fs.readLink(path.join(effective.config.homePath, "config.toml")), + path.join(sharedHome, "config.toml"), + ); + assert.isFalse(yield* fs.exists(path.join(effective.config.homePath, "auth.json"))); + } + assert.strictEqual(effective.environment.ACCESS_TOKEN, "dummy-owned-access"); + assert.isUndefined(effective.environment.OPENAI_API_KEY); + assert.isUndefined(effective.environment.OPENAI_BASE_URL); + assert.isUndefined(effective.environment.T3CODE_CODEX_LAUNCH_ARGS); + const args = codexAppServerArgs(effective.config.launchArgs); + assert.include( + args, + 'model_providers.openai_token_sharing.base_url="https://api.openai.com/v1"', + ); + assert.include( + args, + 'model_providers.openai_token_sharing.model_catalog_url="https://api.openai.com/v1/models"', + ); + assert.include(args, "features.api_key_model_discovery=true"); + assert.notInclude(effective.config.launchArgs, "model_catalog_json"); + assert.notInclude(effective.config.launchArgs, "x-openai-chatpass-test"); + assert.include( + args, + "model_providers.openai_token_sharing.supports_websockets=false", + ); + assert.include( + args, + "model_providers.openai_token_sharing.requires_openai_auth=false", + ); + assert.notInclude(effective.config.launchArgs, "dummy-owned-access"); + assert.strictEqual(ambient.CODEX_HOME, "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/user/.codex"); + }).pipe(Effect.scoped); + assert.strictEqual(leases, 0); + yield* runtime.auth.controller.logout(Effect.void); + assert.equal( + yield* fs.readFileString(path.join(sharedHome, "auth.json")), + "native-auth-unchanged", + ); + }).pipe( + Effect.provideService(ServerSecretStore, secrets), + Effect.provideService(ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed( + EnvironmentId.make("00000000-0000-4000-8000-000000000001"), + ), + }), + Effect.provide(installerLayer), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + assert.isTrue( + [ + "https://auth.openai.com/.well-known/openid-configuration", + "https://auth.openai.com/revoke", + ].includes(request.url), + ); + return HttpClientResponse.fromWeb( + request, + request.url.endsWith("/revoke") + ? new Response(null, { status: 200 }) + : Response.json({ + issuer: "https://auth.openai.com", + authorization_endpoint: "https://auth.openai.com/api/accounts/authorize", + token_endpoint: "https://auth.openai.com/api/accounts/oauth/token", + jwks_uri: "https://auth.openai.com/jwks", + revocation_endpoint: "https://auth.openai.com/revoke", + }), + ); + }), + ), + ), + ); + }).pipe( + Effect.scoped, + Effect.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-managed-runtime-" }).pipe( + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); diff --git a/apps/server/src/provider/CodexManagedRuntime.ts b/apps/server/src/provider/CodexManagedRuntime.ts new file mode 100644 index 000000000000..f9c653142350 --- /dev/null +++ b/apps/server/src/provider/CodexManagedRuntime.ts @@ -0,0 +1,112 @@ +import { resolveManagedCodexHomeLayout } from "./CodexManagedHome.ts"; +import { CodexSettings, ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { ServerConfig } from "../config.ts"; +import { CodexInstallation } from "./CodexInstallation.ts"; +import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; +import { materializeCodexShadowHome } from "./Drivers/CodexHomeLayout.ts"; + +export interface CodexEffectiveRuntime { + readonly config: CodexSettings; + readonly environment: NodeJS.ProcessEnv; + readonly revision: string; +} +const decodeSettings = Schema.decodeSync(CodexSettings); +// Managed sign-in stores tokens in T3's credential store and never writes native auth.json. +export const managedCodexLaunchArgs = [ + 'model_provider="openai_token_sharing"', + 'model_providers.openai_token_sharing.name="OpenAI Token Sharing"', + 'model_providers.openai_token_sharing.base_url="https://api.openai.com/v1"', + 'model_providers.openai_token_sharing.model_catalog_url="https://api.openai.com/v1/models"', + "features.api_key_model_discovery=true", + 'model_providers.openai_token_sharing.env_key="ACCESS_TOKEN"', + 'model_providers.openai_token_sharing.wire_api="responses"', + "model_providers.openai_token_sharing.requires_openai_auth=false", + "model_providers.openai_token_sharing.supports_websockets=false", +] + .map((value) => `-c '${value}'`) + .join(" "); + +export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(function* (options: { + readonly instanceId: ProviderInstanceId; + readonly enabled: boolean; + readonly environment: NodeJS.ProcessEnv; + readonly config: CodexSettings; +}) { + const installation = yield* CodexInstallation; + const config = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const auth = yield* makeCodexChatGptAuth({ + instanceId: options.instanceId, + defaultReturnUrl: new URL( + "/welcome", + config.devUrl ?? `http://localhost:${config.port}`, + ).toString(), + }); + const homeLayout = yield* resolveManagedCodexHomeLayout( + config.stateDir, + options.instanceId, + options.config, + ); + const homePath = homeLayout.effectiveHomePath ?? homeLayout.sharedHomePath; + const resolve = Effect.gen(function* () { + const executable = yield* installation.acquire().pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "install", + detail: "Set up managed Codex before starting a session.", + }), + ), + ); + const credentials = yield* auth.access; + yield* materializeCodexShadowHome(homeLayout).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "runtime", + detail: cause.message, + }), + ), + ); + yield* fs.makeDirectory(homePath, { recursive: true }).pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "runtime", + detail: "Could not prepare the managed Codex runtime.", + }), + ), + ); + // Ambient CLI overrides cannot redirect a T3-owned token to a different provider. + const environment: NodeJS.ProcessEnv = { + ...options.environment, + ACCESS_TOKEN: credentials.accessToken, + CODEX_HOME: homePath, + }; + delete environment.T3CODE_CODEX_LAUNCH_ARGS; + delete environment.OPENAI_API_KEY; + delete environment.OPENAI_BASE_URL; + return { + config: decodeSettings({ + enabled: options.enabled, + setupMode: "managed", + binaryPath: executable.executablePath, + homePath, + launchArgs: managedCodexLaunchArgs, + }), + environment, + revision: credentials.accessToken, + } satisfies CodexEffectiveRuntime; + }); + return { auth, resolve, installation, homePath, homeLayout }; +}); diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 246ef79515d3..c9b11ffabd8f 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -1,16 +1,24 @@ +import { CodexInstallation } from "../CodexInstallation.ts"; +import { ServerSecretStore } from "../../auth/ServerSecretStore.ts"; +import { ServerEnvironmentIdentity } from "../../environment/ServerEnvironment.ts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { expect, it } from "@effect/vitest"; -import { ProviderInstanceId } from "@t3tools/contracts"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { HttpClient } from "effect/unstable/http"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -26,11 +34,21 @@ import { resolveLatestProviderVersion, } from "../providerMaintenance.ts"; import { CodexDriver } from "./CodexDriver.ts"; +import * as ProviderCredentialStore from "../ProviderCredentialStore.ts"; const testLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-codex-driver-maintenance-", }).pipe( Layer.provideMerge(NodeServices.layer), + Layer.provideMerge( + Layer.mock(CodexInstallation)({ managedDirectory: "unused-managed-installation" }), + ), + Layer.provideMerge(Layer.mock(ServerSecretStore)({})), + Layer.provideMerge( + Layer.succeed(ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed(EnvironmentId.make("00000000-0000-4000-8000-000000000001")), + }), + ), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), @@ -54,14 +72,144 @@ const windowsHost = HostProcessPlatform.defaultValue() === "win32"; const noSpawn = ChildProcessSpawner.make(() => Effect.die("Disabled Codex must not spawn a process"), ); +const encodeCredentials = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); it.layer(testLayer)("CodexDriver", (it) => { + it.effect("disconnect refreshes a restored managed account while its auth flow is idle", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("restored-managed-account"); + const credentials = new Map(); + const secrets = ServerSecretStore.of({ + get: (key) => Effect.sync(() => Option.fromUndefinedOr(credentials.get(key))), + set: (key, value) => + Effect.sync(() => { + credentials.set(key, value); + }), + remove: (key) => + Effect.sync(() => { + credentials.delete(key); + }), + create: () => Effect.die("unused"), + getOrCreateRandom: () => Effect.die("unused"), + }); + yield* Effect.gen(function* () { + const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId); + const json = yield* encodeCredentials({ + clientId: "oaiapp_test", + accessToken: "dummy-owned-access", + refreshToken: "dummy-refresh", + expiresAt: Number.MAX_SAFE_INTEGER, + earliestRefreshAt: null, + scopes: ["chatgpt.tokens.use.direct"], + subject: "test-user", + email: "account@example.test", + }); + yield* store.set(new TextEncoder().encode(json)); + const executable = { + executablePath: "/user/bin/codex", + managedVersionDirectory: null, + source: "local" as const, + version: "0.156.1", + }; + const installation = yield* CodexInstallation; + const serverConfig = yield* ServerConfig; + const sharedHome = NodePath.join(serverConfig.stateDir, "shared-codex-home"); + const instance = yield* CodexDriver.create({ + instanceId, + displayName: "Restored account", + enabled: true, + environment: [], + config: { ...CodexDriver.defaultConfig(), setupMode: "managed", homePath: sharedHome }, + }).pipe( + Effect.provideService( + CodexInstallation, + CodexInstallation.of({ + ...installation, + managedDirectory: "unused-managed-installation", + resolve: () => Effect.succeed(executable), + acquire: () => Effect.succeed(executable), + }), + ), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.badArgument({ + module: "ChildProcessSpawner", + method: "spawn", + description: "The fixture app-server is unavailable", + }), + ), + ), + ), + ); + const observedAccount = yield* Deferred.make(); + const disconnected = yield* instance.snapshot.streamChanges.pipe( + Stream.tap((provider) => + provider.auth.status === "authenticated" + ? Deferred.succeed(observedAccount, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + Stream.filter((provider) => provider.auth.status === "unauthenticated"), + Stream.runHead, + Effect.forkScoped, + ); + const restored = yield* instance.snapshot.refresh; + expect(restored.auth.email).toBe("account@example.test"); + expect(restored.runtimePaths?.homePath).toBe(sharedHome); + expect(restored.runtimePaths?.shadowHomePath).toContain( + `providers/codex/${instanceId}/shadow`, + ); + yield* Deferred.await(observedAccount); + const before = yield* instance.auth!.subscribe("test-owner").pipe(Stream.runHead); + expect(Option.getOrThrow(before).phase).toBe("idle"); + yield* instance.auth!.logout(Effect.void); + const after = Option.getOrThrow(yield* Fiber.join(disconnected)); + expect(after.auth.status).toBe("unauthenticated"); + expect(after.auth.email).toBeUndefined(); + expect(after.installed).toBe(true); + expect(after.models).toEqual([]); + expect(Option.isNone(yield* store.get)).toBe(true); + }).pipe( + Effect.provideService(ServerSecretStore, secrets), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + expect([ + "https://auth.openai.com/.well-known/openid-configuration", + "https://auth.openai.com/revoke", + "https://api.openai.com/v1/models", + ]).toContain(request.url); + if (request.url.endsWith("/models")) + return HttpClientResponse.fromWeb(request, Response.json({ models: [] })); + return HttpClientResponse.fromWeb( + request, + request.url.endsWith("/revoke") + ? new Response(null, { status: 200 }) + : Response.json({ + issuer: "https://auth.openai.com", + authorization_endpoint: "https://auth.openai.com/api/accounts/authorize", + token_endpoint: "https://auth.openai.com/api/accounts/oauth/token", + jwks_uri: "https://auth.openai.com/jwks", + revocation_endpoint: "https://auth.openai.com/revoke", + }), + ); + }), + ), + ), + ); + }).pipe(Effect.scoped), + ); + it.effect.skipIf(windowsHost)( "runs the standalone updater against the shared home, not the shadow home", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-driver-" }); + const tempDir = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-codex-driver-" }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); const sharedHome = NodePath.join(tempDir, "codex-home"); const shadowHome = NodePath.join(tempDir, "codex-shadow"); const binaryPath = NodePath.join(sharedHome, "packages", "standalone", "bin", "codex"); @@ -136,7 +284,9 @@ it.layer(testLayer)("CodexDriver", (it) => { it.effect.skipIf(windowsHost)(fixture.name, () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-installer-" }); + const tempDir = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-codex-installer-" }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); const installPath = NodePath.join(tempDir, ...fixture.installSegments); const realBinaryPath = NodePath.join( installPath, @@ -193,7 +343,9 @@ it.layer(testLayer)("CodexDriver", (it) => { it.effect.skipIf(windowsHost)(`leaves a mise ${layout} installation manual-only`, () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: `t3-codex-mise-${layout}-` }); + const tempDir = yield* fs + .makeTempDirectoryScoped({ prefix: `t3-codex-mise-${layout}-` }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); const binaryPath = layout === "direct" ? NodePath.join(tempDir, "mise", "installs", "codex", "0.110.0", "codex") @@ -268,7 +420,9 @@ it.layer(testLayer)("CodexDriver", (it) => { (fixture) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-mise-shim-" }); + const tempDir = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-codex-mise-shim-" }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); const brewPrefix = NodePath.join(tempDir, "homebrew"); const brewPath = NodePath.join(brewPrefix, "bin", "brew"); const misePath = NodePath.join(brewPrefix, "Cellar", "mise", "2026.9.1", "bin", "mise"); @@ -361,7 +515,11 @@ it.layer(testLayer)("CodexDriver", (it) => { if (fixture.nodeFirst) { expect(capabilities.update).toMatchObject({ executable: "npm", - args: expect.arrayContaining(["--prefix", npmPrefix, "@openai/codex@latest"]), + args: expect.arrayContaining([ + "--prefix", + yield* fs.realPath(npmPrefix), + "@openai/codex@latest", + ]), }); } else { expect(capabilities.update).toBeNull(); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 71b4a3a59bc2..15ee69264c13 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -68,6 +68,10 @@ import { materializeCodexShadowHome, resolveCodexHomeLayout, } from "./CodexHomeLayout.ts"; +import { makeManagedCodexProvider } from "./CodexManagedProvider.ts"; +import { CodexInstallation } from "../CodexInstallation.ts"; +import { ServerSecretStore } from "../../auth/ServerSecretStore.ts"; +import { ServerEnvironmentIdentity } from "../../environment/ServerEnvironment.ts"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DRIVER_KIND = ProviderDriverKind.make("codex"); @@ -111,7 +115,10 @@ export type CodexDriverEnv = | Path.Path | ProviderEventLoggers | ServerConfig - | ServerSettingsService; + | ServerSettingsService + | ServerSecretStore + | ServerEnvironmentIdentity + | CodexInstallation; export const CodexDriver: ProviderDriver = { driverKind: DRIVER_KIND, @@ -123,6 +130,15 @@ export const CodexDriver: ProviderDriver = { defaultConfig: (): CodexSettings => decodeCodexSettings({}), create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { + if (config.setupMode === "managed") + return yield* makeManagedCodexProvider({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts new file mode 100644 index 000000000000..cc6b185d4fd1 --- /dev/null +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -0,0 +1,280 @@ +import { ProviderDriverKind, TextGenerationError, type CodexSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; +import * as Option from "effect/Option"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { chatGptModels } from "../CodexChatGptModels.ts"; +import { makeCodexManagedRuntime } from "../CodexManagedRuntime.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; +import { + checkCodexProviderStatus, + makePendingCodexProvider, + probeCodexSkillsForCwd, +} from "../Layers/CodexProvider.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { type ProviderDriverCreateInput, type ProviderInstance } from "../ProviderDriver.ts"; +import { codexContinuationIdentity } from "./CodexHomeLayout.ts"; +import { withInstanceIdentity } from "./instanceIdentity.ts"; +import { HttpClient } from "effect/unstable/http"; +const DRIVER = ProviderDriverKind.make("codex"); + +export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(function* ( + input: ProviderDriverCreateInput, +) { + const { instanceId, enabled, displayName, accentColor, config } = input; + const http = yield* HttpClient.HttpClient; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const settings = yield* ServerSettingsService; + const runtime = yield* makeCodexManagedRuntime({ + instanceId, + enabled, + config, + environment: mergeProviderInstanceEnvironment(input.environment), + }); + const continuationIdentity = codexContinuationIdentity(runtime.homeLayout); + const stamp = withInstanceIdentity({ + instanceId, + driverKind: DRIVER, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const setup = { canAuthenticate: true, canInstall: true }; + const runtimePaths = { + homePath: runtime.homeLayout.sharedHomePath, + shadowHomePath: runtime.homeLayout.mode === "authOverlay" ? runtime.homePath : null, + }; + const pending = makePendingCodexProvider({ ...config, customModels: [] }).pipe( + Effect.map((draft) => + stamp({ + ...draft, + models: [], + setup, + runtimePaths, + }), + ), + ); + const check = Effect.gen(function* () { + const base = yield* pending; + if (!enabled) return base; + const executable = yield* runtime.installation.resolve().pipe(Effect.option); + if (Option.isNone(executable)) + return { + ...base, + installed: false, + models: [], + message: "Set up Codex to get started.", + auth: { status: "unauthenticated" as const }, + }; + const saved = yield* runtime.auth.read.pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isSome(saved) && !saved.value.scopes.includes("chatgpt.tokens.use.direct")) + return { + ...base, + installed: true, + version: executable.value.version, + models: [], + message: + "Signed in with ChatGPT, but token sharing is disabled. Sign in again and enable token sharing, or use another provider.", + auth: { + status: "unauthenticated" as const, + label: "ChatGPT", + ...(saved.value.email?.trim() ? { email: saved.value.email.trim() } : {}), + }, + }; + if (Option.isNone(saved)) + return { + ...base, + installed: true, + version: executable.value.version, + models: [], + message: "Sign in with ChatGPT to use Codex.", + auth: { status: "unauthenticated" as const }, + }; + const usageLimits = { + checkedAt: base.checkedAt, + windows: [], + unavailable: { + reason: "unsupported" as const, + message: + "ChatGPT tracks subscription usage across connected apps. Open Usage settings with the account you connected to Codex.", + }, + externalUsage: { label: "ChatGPT usage", url: "https://chatgpt.com/#settings/Usage" }, + }; + const managedAuth = { + subscriptionSharing: true, + profileId: saved.value.clientId, + status: "authenticated" as const, + type: "chatgpt", + label: "ChatGPT", + ...(saved.value.email?.trim() ? { email: saved.value.email.trim() } : {}), + }; + return yield* runtime.auth.controller.withAccess!(runtime.resolve).pipe( + Effect.flatMap((effective) => + Effect.gen(function* () { + const draft = yield* checkCodexProviderStatus( + effective.config, + undefined, + effective.environment, + managedAuth, + ); + const models = yield* chatGptModels( + effective.environment.ACCESS_TOKEN!, + draft.models, + ).pipe(Effect.provideService(HttpClient.HttpClient, http)); + return { ...draft, models }; + }), + ), + Effect.map((draft) => + stamp({ + ...draft, + auth: managedAuth, + version: draft.version ?? executable.value.version, + usageLimits, + models: draft.models.map((model) => ({ + ...model, + ...(model.capabilities + ? { + capabilities: { + ...model.capabilities, + optionDescriptors: (model.capabilities.optionDescriptors ?? []).filter( + (option) => option.id !== "serviceTier", + ), + }, + } + : {}), + })), + setup, + runtimePaths, + ...(draft.slashCommands + ? { + slashCommands: draft.slashCommands.filter((command) => command.name !== "feedback"), + } + : {}), + }), + ), + Effect.catch(() => + runtime.auth.read.pipe( + Effect.orElseSucceed(() => Option.none()), + Effect.map((current) => ({ + ...base, + installed: true, + version: executable.value.version, + models: [], + auth: { + status: + Option.isSome(current) && current.value.scopes.includes("chatgpt.tokens.use.direct") + ? ("authenticated" as const) + : ("unauthenticated" as const), + label: "ChatGPT", + ...(Option.isSome(current) && + current.value.scopes.includes("chatgpt.tokens.use.direct") + ? { subscriptionSharing: true, profileId: current.value.clientId } + : {}), + ...(Option.isSome(current) && current.value.email?.trim() + ? { email: current.value.email.trim() } + : {}), + }, + ...(Option.isSome(current) && current.value.scopes.includes("chatgpt.tokens.use.direct") + ? { usageLimits } + : {}), + message: "Could not check Codex right now. Retry, or reconnect in provider settings.", + })), + ), + ), + Effect.scoped, + ); + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + const snapshot = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed({ provider: DRIVER, packageName: null, update: null }), + getSettings: settings.getSettings, + streamSettings: settings.streamChanges, + haveSettingsChanged: () => false, + initialSnapshot: () => pending, + checkProvider: check, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Could not prepare managed Codex.", + cause, + }), + ), + ); + yield* runtime.auth.controller.subscribe("managed-codex-snapshot").pipe( + // Disconnect also publishes idle when a saved account has no active sign-in flow. + Stream.filter((state) => ["idle", "succeeded", "failed", "cancelled"].includes(state.phase)), + Stream.runForEach(() => snapshot.refresh.pipe(Effect.asVoid)), + Effect.forkScoped, + ); + const resolveRuntime = runtime.auth.controller.withAccess!(runtime.resolve); + const adapter = yield* makeCodexAdapter(config, { + instanceId, + resolveRuntime, + onManagedConnectionRevoked: runtime.auth.revoke.pipe(Effect.ignore), + }); + const nativeGeneration = yield* makeCodexTextGeneration( + config, + undefined, + snapshot.getSnapshot.pipe(Effect.map((value) => value.models)), + resolveRuntime, + ); + const protect = (operation: string, effect: Effect.Effect) => + runtime.auth.controller.withAccess!(effect).pipe( + Effect.scoped, + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: "detail" in cause ? cause.detail : "Codex text generation failed.", + }), + ), + ); + const textGeneration: ProviderInstance["textGeneration"] = { + generateCommitMessage: (value) => + protect("generateCommitMessage", nativeGeneration.generateCommitMessage(value)), + generatePrContent: (value) => + protect("generatePrContent", nativeGeneration.generatePrContent(value)), + generateBranchName: (value) => + protect("generateBranchName", nativeGeneration.generateBranchName(value)), + generateThreadTitle: (value) => + protect("generateThreadTitle", nativeGeneration.generateThreadTitle(value)), + }; + return { + instanceId, + driverKind: DRIVER, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + auth: runtime.auth.controller, + snapshotForCwd: (cwd: string) => + enabled + ? resolveRuntime.pipe( + Effect.flatMap((effective) => + probeCodexSkillsForCwd({ + binaryPath: effective.config.binaryPath, + homePath: effective.config.homePath, + launchArgs: effective.config.launchArgs, + cwd, + environment: effective.environment, + }), + ), + Effect.flatMap((skills) => + snapshot.getSnapshot.pipe(Effect.map((draft) => ({ ...draft, skills }))), + ), + Effect.scoped, + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.catch(() => snapshot.getSnapshot), + ) + : snapshot.getSnapshot, + } satisfies ProviderInstance; +}); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 8380c2dc1fdd..9aff859521fc 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -3084,3 +3084,112 @@ usageLimitLayer("CodexAdapterLive usage limits", (it) => { }), ); }); + +it.effect("managed runtime rotation restarts app-server and resumes the same native thread", () => { + const runtimes: FakeCodexRuntime[] = []; + let revision = "first"; + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + return yield* makeCodexAdapter(decodeCodexSettings({}), { + resolveRuntime: Effect.sync(() => ({ + config: decodeCodexSettings({ + binaryPath: "/t3/tools/codex/0.155.1/bin/codex", + homePath: "/t3/caches/codex/home", + launchArgs: "-c 'model_provider=managed'", + }), + environment: { ACCESS_TOKEN: `dummy-${revision}` }, + revision, + })), + makeRuntime: (options) => { + const runtime = new FakeCodexRuntime(options); + runtime.startImpl.mockImplementation(() => + Promise.resolve({ + provider: ProviderDriverKind.make("codex"), + threadId: options.threadId, + runtimeMode: options.runtimeMode, + cwd: options.cwd, + status: "ready", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + resumeCursor: { threadId: "native-managed-thread" }, + }), + ); + runtimes.push(runtime); + return Effect.succeed(runtime); + }, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("managed-token-rotation"); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ threadId, input: "first" }); + NodeAssert.equal(runtimes.length, 1); + revision = "rotated"; + yield* adapter.sendTurn({ threadId, input: "second" }); + NodeAssert.equal(runtimes.length, 2); + NodeAssert.equal(runtimes[0]?.closeImpl.mock.calls.length, 1); + NodeAssert.deepEqual(runtimes[1]?.options.resumeCursor, { threadId: "native-managed-thread" }); + NodeAssert.equal(runtimes[1]?.options.environment?.ACCESS_TOKEN, "dummy-rotated"); + NodeAssert.equal(runtimes[1]?.options.binaryPath, "/t3/tools/codex/0.155.1/bin/codex"); + }).pipe(Effect.provide(layer)); +}); + +it.effect("managed turn failures preserve the sharing-limit code for client notices", () => { + const factory = makeRuntimeFactory(); + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + return yield* makeCodexAdapter(decodeCodexSettings({}), { + makeRuntime: factory.factory, + resolveRuntime: Effect.succeed({ + config: decodeCodexSettings({}), + environment: {}, + revision: "managed", + }), + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ threadId: asThreadId("thread-1"), runtimeMode: "full-access" }); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + const notification = codexUsageLimitTurnFailed("managed-sharing-limit"); + yield* factory.lastRuntime!.emit({ + ...notification, + payload: { + threadId: "thread-1", + turn: { + id: "turn-limit", + items: [], + status: "failed", + error: { message: "subscription_sharing_usage_limit_exceeded", codexErrorInfo: "other" }, + }, + }, + }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + NodeAssert.equal(events[0]?.type, "runtime.error"); + if (events[0]?.type === "runtime.error") { + NodeAssert.equal(events[0].payload.code, "subscription_sharing_usage_limit_exceeded"); + NodeAssert.match(events[0].payload.message, /ChatGPT usage limit/); + } + NodeAssert.equal(events[1]?.type, "turn.completed"); + if (events[1]?.type === "turn.completed") NodeAssert.equal(events[1].payload.state, "failed"); + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index baa8d846f7c6..64c7012c4a92 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -67,7 +67,6 @@ import { makeCodexSessionRuntime, type CodexSessionRuntimeError, type CodexSessionRuntimeOptions, - type CodexSessionRuntimeSendTurnInput, type CodexSessionRuntimeShape, } from "./CodexSessionRuntime.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -85,6 +84,7 @@ const isCodexSessionRuntimeThreadIdMissingError = Schema.is( ); const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema); +import { classifyCodexManagedError } from "../CodexManagedErrors.ts"; const PROVIDER = ProviderDriverKind.make("codex"); export interface CodexAdapterLiveOptions { @@ -99,6 +99,12 @@ export interface CodexAdapterLiveOptions { CodexSessionRuntimeError, ChildProcessSpawner.ChildProcessSpawner | Scope.Scope >; + readonly resolveRuntime?: Effect.Effect< + import("../CodexManagedRuntime.ts").CodexEffectiveRuntime, + import("@t3tools/contracts").ProviderSetupError, + Scope.Scope + >; + readonly onManagedConnectionRevoked?: Effect.Effect; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; } @@ -109,6 +115,8 @@ interface CodexAdapterSessionContext { readonly runtime: CodexSessionRuntimeShape; readonly eventFiber: Fiber.Fiber; readonly turnTokenUsage: CodexTurnTokenUsageState; + readonly startInput: Parameters[0]; + readonly runtimeRevision?: string; stopped: boolean; } @@ -2270,8 +2278,28 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( yield* Effect.suspend(() => stopSessionInternal(existing)); } + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + const resolved = options?.resolveRuntime + ? yield* options.resolveRuntime.pipe( + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: cause.detail, + }), + ), + ) + : undefined; + const effectiveConfig = resolved?.config ?? codexConfig; + const effectiveEnvironment = resolved?.environment ?? options?.environment; const serviceTier = - input.modelSelection?.instanceId === boundInstanceId + !resolved && input.modelSelection?.instanceId === boundInstanceId ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); @@ -2279,11 +2307,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( threadId: input.threadId, providerInstanceId: boundInstanceId, cwd: input.cwd ?? process.cwd(), - binaryPath: codexConfig.binaryPath, ...(options?.models ? { models: options.models } : {}), - launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), - ...(options?.environment ? { environment: options.environment } : {}), - ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), + binaryPath: effectiveConfig.binaryPath, + launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, effectiveEnvironment), + ...(effectiveEnvironment ? { environment: effectiveEnvironment } : {}), + ...(effectiveConfig.homePath ? { homePath: effectiveConfig.homePath } : {}), ...(isCodexResumeCursorSchema(input.resumeCursor) ? { resumeCursor: input.resumeCursor } : {}), @@ -2296,7 +2324,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? { environment: { ...McpProviderSession.withAgentDeviceEnvironment( - options?.environment ?? process.env, + effectiveEnvironment ?? process.env, mcpSession, ), T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""), @@ -2318,11 +2346,6 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( // after the stop and often sparse, so keep the session's merged view of // it and read it when a turn fails on the limit. let rateLimits: CodexRateLimitSnapshot | undefined; - const sessionScope = yield* Scope.make("sequential"); - let sessionScopeTransferred = false; - yield* Effect.addFinalizer(() => - sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), - ); const createRuntime = options?.makeRuntime ?? makeCodexSessionRuntime; const runtime = yield* createRuntime(runtimeInput).pipe( Effect.provideService(Scope.Scope, sessionScope), @@ -2393,6 +2416,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( if (errorPayload?.error.codexErrorInfo === "usageLimitExceeded") return; } + const managedError = options?.resolveRuntime + ? classifyCodexManagedError(event.payload) + : undefined; + if (managedError?.revoke && options?.onManagedConnectionRevoked) + yield* options.onManagedConnectionRevoked; let usageLimitError: ProviderRuntimeEvent | undefined; let usageLimitMessage: string | undefined; if (event.method === "turn/completed") { @@ -2404,7 +2432,18 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( completedPayload?.turn.status === "failed" ? completedPayload.turn.error : undefined; - if (turnError?.codexErrorInfo === "usageLimitExceeded") { + if (turnError && managedError) { + usageLimitMessage = managedError.message; + usageLimitError = { + ...runtimeEventBase(event, event.threadId), + type: "runtime.error", + payload: { + message: managedError.message, + code: managedError.code, + class: "provider_error", + }, + }; + } else if (turnError?.codexErrorInfo === "usageLimitExceeded") { usageLimitMessage = codexUsageLimitMessage(rateLimits, event.createdAt); usageLimitError = { ...runtimeEventBase(event, event.threadId), @@ -2419,12 +2458,27 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( } const mappedEvents = mapToRuntimeEvents(event, event.threadId).map((runtimeEvent) => { + if (managedError && runtimeEvent.type === "runtime.error") + return { + ...runtimeEvent, + payload: { + ...runtimeEvent.payload, + message: managedError.message, + detail: managedError.message, + code: managedError.code, + }, + } satisfies ProviderRuntimeEvent; + if (runtimeEvent.type === "turn.completed" && runtimeEvent.turnId) { return { ...runtimeEvent, payload: { ...runtimeEvent.payload, - ...(usageLimitMessage ? { errorMessage: usageLimitMessage } : {}), + ...(managedError + ? { errorMessage: managedError.message } + : usageLimitMessage + ? { errorMessage: usageLimitMessage } + : {}), tokenUsage: completeCodexTurnTokenUsage( turnTokenUsage, String(runtimeEvent.turnId), @@ -2489,6 +2543,8 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( runtime, eventFiber, turnTokenUsage, + startInput: input, + ...(resolved ? { runtimeRevision: resolved.revision } : {}), stopped: false, }); sessionScopeTransferred = true; @@ -2530,13 +2586,34 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( { concurrency: 1 }, ); - const session = yield* requireSession(input.threadId); + let session = yield* requireSession(input.threadId); + if (options?.resolveRuntime) { + const next = yield* options.resolveRuntime.pipe( + Effect.scoped, + Effect.mapError( + (cause) => + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: cause.detail, + }), + ), + ); + if (next.revision !== session.runtimeRevision) { + const previous = yield* session.runtime.getSession; + yield* startSession({ + ...session.startInput, + ...(previous.resumeCursor ? { resumeCursor: previous.resumeCursor } : {}), + }); + session = yield* requireSession(input.threadId); + } + } const reasoningEffort = input.modelSelection?.instanceId === boundInstanceId ? getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort") : undefined; const serviceTier = - input.modelSelection?.instanceId === boundInstanceId + !options?.resolveRuntime && input.modelSelection?.instanceId === boundInstanceId ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; return yield* session.runtime diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index d99b97150c5c..57b0cab334bb 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -343,8 +343,8 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { return { clientInfo: { - name: "t3code_desktop", - title: "T3 Code Desktop", + name: "T3 Code", + title: "T3 Code", version: packageJson.version, }, capabilities: { @@ -416,6 +416,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun readonly cwd: string; readonly customModels?: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; + readonly skipNativeUsage?: boolean; }) { const { client, initialize } = yield* withCodexAppServerClient(input); @@ -441,31 +442,33 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun requestAllCodexModels(client), // Usage is an enrichment: a failure or a slow answer degrades to "no // usage this probe" rather than costing the account and models. - client.request("account/rateLimits/read", null).pipe( - Effect.map((response): CodexRateLimitsProbe => ({ - snapshot: response.rateLimits, - rateLimitsByLimitId: response.rateLimitsByLimitId, - resetCredits: response.rateLimitResetCredits, - })), - Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)), - Effect.map( - Option.getOrElse((): CodexRateLimitsProbe => ({ - failure: "Codex did not answer the usage request.", - })), - ), - Effect.catch((error) => - Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe( - Effect.as({ failure: codexRateLimitsFailureMessage(error) }), + input.skipNativeUsage + ? Effect.succeed(undefined) + : client.request("account/rateLimits/read", null).pipe( + Effect.map((response): CodexRateLimitsProbe => ({ + snapshot: response.rateLimits, + rateLimitsByLimitId: response.rateLimitsByLimitId, + resetCredits: response.rateLimitResetCredits, + })), + Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)), + Effect.map( + Option.getOrElse((): CodexRateLimitsProbe => ({ + failure: "Codex did not answer the usage request.", + })), + ), + Effect.catch((error) => + Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe( + Effect.as({ failure: codexRateLimitsFailureMessage(error) }), + ), + ), ), - ), - ), ], { concurrency: "unbounded" }, ); return { account: accountResponse, - rateLimits, + ...(rateLimits ? { rateLimits } : {}), version, models: applyPreferredCodexDefaultModel( appendCustomCodexModels(models, input.customModels ?? []), @@ -567,12 +570,14 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu readonly cwd: string; readonly customModels: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; + readonly skipNativeUsage?: boolean; }) => Effect.Effect< CodexAppServerProviderSnapshot, CodexErrors.CodexAppServerError, ChildProcessSpawner.ChildProcessSpawner | Scope.Scope > = probeCodexAppServerProvider, environment?: NodeJS.ProcessEnv, + managedAuth?: ServerProvider["auth"], ): Effect.fn.Return< ServerProviderDraft, ServerSettingsError, @@ -606,6 +611,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu cwd: process.cwd(), customModels: codexSettings.customModels, environment: resolvedEnvironment, + ...(managedAuth ? { skipNativeUsage: true } : {}), }).pipe( Effect.scoped, Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)), @@ -654,7 +660,9 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu } const snapshot = probeResult.success.value; - const accountStatus = accountProbeStatus(snapshot.account); + const accountStatus = managedAuth + ? { status: "ready" as const, auth: managedAuth, message: undefined } + : accountProbeStatus(snapshot.account); const usageLimits = snapshot.account.account?.type === "apiKey" ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) @@ -691,7 +699,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu status: accountStatus.status, auth: accountStatus.auth, ...(accountStatus.message ? { message: accountStatus.message } : {}), - usageLimits, + ...(managedAuth ? {} : { usageLimits }), }, }); }); diff --git a/apps/server/src/provider/Layers/ProviderAuthService.ts b/apps/server/src/provider/Layers/ProviderAuthService.ts index cec2a5878880..f9213268a33a 100644 --- a/apps/server/src/provider/Layers/ProviderAuthService.ts +++ b/apps/server/src/provider/Layers/ProviderAuthService.ts @@ -144,6 +144,33 @@ export const makeProviderAuthService = Effect.gen(function* () { }); return ProviderAuthService.ProviderAuthService.of({ + reconnectProfile: Effect.fnUntraced(function* (input) { + const auth = yield* getController(input.instanceId, "export"); + if (!auth.reconnectProfile) + return yield* new ProviderSetupError({ + instanceId: input.instanceId, + operation: "export", + detail: "This provider does not support ChatGPT profile transfer.", + }); + return yield* auth.reconnectProfile(input.methodId); + }), + importProfile: (input) => + credentialChanges.withPermit( + Effect.gen(function* () { + const auth = yield* getController(input.instanceId, "import"); + yield* checkSharedBinding(input.instanceId, "start", auth); + if (!auth.importProfile) + return yield* new ProviderSetupError({ + instanceId: input.instanceId, + operation: "import", + detail: "This provider does not support ChatGPT profile transfer.", + }); + return yield* auth.importProfile( + input.profile, + stopSessions(input.instanceId, auth.credentialBinding), + ); + }), + ), start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) { return yield* credentialChanges.withPermit( Effect.gen(function* () { @@ -153,6 +180,8 @@ export const makeProviderAuthService = Effect.gen(function* () { ownerSessionId, stopSessions(input.instanceId, auth.credentialBinding), input.methodId, + input.returnUrl, + input.callbackMode, ); }), ); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index c33b1dfc690a..0186d2dc4503 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -1,3 +1,6 @@ +import { CodexInstallation } from "../CodexInstallation.ts"; +import { ServerSecretStore } from "../../auth/ServerSecretStore.ts"; +import { ServerEnvironmentIdentity } from "../../environment/ServerEnvironment.ts"; /** * Multi-instance validation slices for `ProviderInstanceRegistryLive`. * @@ -25,6 +28,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { + EnvironmentId, type ClaudeSettings, type CodexSettings, type CursorSettings, @@ -246,6 +250,17 @@ describe("ProviderInstanceRegistryLive โ€” multi-instance codex slice", () => { prefix: "provider-instance-registry-test", }).pipe( Layer.provideMerge(NodeServices.layer), + Layer.provideMerge( + Layer.mock(CodexInstallation)({ managedDirectory: "unused-managed-installation" }), + ), + Layer.provideMerge(Layer.mock(ServerSecretStore)({})), + Layer.provideMerge( + Layer.succeed(ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed( + EnvironmentId.make("00000000-0000-4000-8000-000000000001"), + ), + }), + ), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(TestHttpClientLive), @@ -594,7 +609,20 @@ describe("ProviderInstanceRegistryLive โ€” all drivers slice", () => { // provides `OpenCodeRuntimeLive`'s deps while keeping its own outputs // surfaced; that merged layer then provides `ServerConfig.layerTest`'s // `FileSystem` dep while keeping everything else surfaced to the test. - const infraLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); + const infraLayer = OpenCodeRuntimeLive.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge( + Layer.mock(CodexInstallation)({ managedDirectory: "unused-managed-installation" }), + ), + Layer.provideMerge(Layer.mock(ServerSecretStore)({})), + Layer.provideMerge( + Layer.succeed(ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed( + EnvironmentId.make("00000000-0000-4000-8000-000000000001"), + ), + }), + ), + ); const testLayer = AntigravityInstallation.layer.pipe( Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index ee1a23573277..9022890b2e3d 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1,3 +1,6 @@ +import { CodexInstallation } from "../CodexInstallation.ts"; +import { ServerSecretStore } from "../../auth/ServerSecretStore.ts"; +import { ServerEnvironmentIdentity } from "../../environment/ServerEnvironment.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it, assert } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; @@ -16,6 +19,7 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as CodexErrors from "effect-codex-app-server/errors"; import { + EnvironmentId, ClaudeSettings, CodexSettings, DEFAULT_SERVER_SETTINGS, @@ -369,7 +373,16 @@ const awaitPersistedProvider = ( Effect.forkScoped, ); -it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), TestHttpClientLive))( +const TestNodeServices = Layer.mergeAll( + NodeServices.layer, + Layer.mock(CodexInstallation)({ managedDirectory: "unused-managed-installation" }), + Layer.mock(ServerSecretStore)({}), + Layer.succeed(ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed(EnvironmentId.make("00000000-0000-4000-8000-000000000001")), + }), +); + +it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), TestHttpClientLive))( "ProviderRegistry", (it) => { describe("checkCodexProviderStatus", () => { diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 7f0465b9c401..7acf3fcb7b01 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -418,6 +418,7 @@ function makeProviderServiceLayer( readonly directory?: ProviderSessionDirectory.ProviderSessionDirectory["Service"]; readonly supportsConversationRollback?: boolean; readonly analyticsLayer?: Layer.Layer; + readonly settingsLayer?: typeof defaultServerSettingsLayer; readonly registry?: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"]; } = {}, ) { @@ -450,7 +451,7 @@ function makeProviderServiceLayer( Layer.provide(NodeServices.layer), Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), - Layer.provide(defaultServerSettingsLayer), + Layer.provide(input.settingsLayer ?? defaultServerSettingsLayer), Layer.provide(serverConfigTestLayer), Layer.provideMerge(input.analyticsLayer ?? AnalyticsService.layerTest), Layer.provide( @@ -5272,3 +5273,89 @@ describe("agent browser access", () => { }).pipe(Effect.provide(NodeServices.layer)), ); }); + +const chatGptAnalytics = makeRecordingAnalytics(); +const chatGptAdapter = makeFakeCodexAdapter(); +const chatGptTelemetry = makeProviderServiceLayer({ + analyticsLayer: chatGptAnalytics.layer, + settingsLayer: ServerSettings.ServerSettingsService.layerTest({ + providerInstances: { + [secondaryCodexInstanceId]: { driver: CODEX_DRIVER, config: { setupMode: "managed" } }, + }, + }), + registry: makeStaticInstanceRegistry([[secondaryCodexInstanceId, chatGptAdapter.adapter]]), +}); +chatGptTelemetry.layer("ChatGPT connector turn analytics", (it) => { + it.effect("tags attempts, sends, and one terminal outcome without recording the prompt", () => + Effect.gen(function* () { + chatGptAnalytics.reset(); + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("chatgpt-analytics-success"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: secondaryCodexInstanceId, + threadId, + runtimeMode: "full-access", + }); + const turn = yield* provider.sendTurn({ threadId, input: "private test prompt" }); + const drain = yield* Stream.take(provider.streamEvents, 2).pipe( + Stream.runDrain, + Effect.forkChild, + ); + yield* Effect.yieldNow; + const completion: LegacyProviderRuntimeEvent = { + type: "turn.completed", + eventId: asEventId("chatgpt-completed"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: turn.turnId, + payload: { state: "completed" }, + }; + chatGptAdapter.emit(completion); + chatGptAdapter.emit({ ...completion, eventId: asEventId("chatgpt-completed-duplicate") }); + yield* Fiber.join(drain); + for (const event of [ + "provider.turn.attempted", + "provider.turn.sent", + "provider.turn.completed", + ]) { + const events = chatGptAnalytics.eventsByName(event); + assert.equal(events.length, 1); + assert.equal(events[0]?.properties?.subscriptionSharing, true); + assert.notProperty(events[0]?.properties ?? {}, "input"); + assert.notProperty(events[0]?.properties ?? {}, "threadId"); + assert.notProperty(events[0]?.properties ?? {}, "providerInstanceId"); + } + }), + ); + it.effect("records rejected sends as failures without an accepted-turn event", () => + Effect.gen(function* () { + chatGptAnalytics.reset(); + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("chatgpt-analytics-rejection"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: secondaryCodexInstanceId, + threadId, + runtimeMode: "full-access", + }); + chatGptAdapter.sendTurn.mockImplementationOnce(() => + Effect.fail(new ProviderAdapterSessionNotFoundError({ provider: CODEX_DRIVER, threadId })), + ); + const result = yield* provider + .sendTurn({ threadId, input: "private rejected prompt" }) + .pipe(Effect.result); + assert.equal(result._tag, "Failure"); + assert.equal(chatGptAnalytics.eventsByName("provider.turn.attempted").length, 1); + assert.equal(chatGptAnalytics.eventsByName("provider.turn.sent").length, 0); + const rejected = chatGptAnalytics.eventsByName("provider.turn.rejected"); + assert.equal(rejected.length, 1); + assert.deepStrictEqual(rejected[0]?.properties, { + provider: CODEX_DRIVER, + subscriptionSharing: true, + errorType: "ProviderAdapterSessionNotFoundError", + }); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index f4e7b0b39bdb..a90a77ab86f4 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -263,6 +263,7 @@ interface TurnAnalyticsMetadata { readonly provider: ProviderDriverKind; readonly startedAtMs: number; readonly mixedModels: boolean; + readonly subscriptionSharing?: boolean; readonly model?: string; readonly effort?: string; readonly interactionMode?: string; @@ -543,6 +544,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( return { ...input.completion.terminalProperties, + ...(metadata?.subscriptionSharing ? { subscriptionSharing: true } : {}), ...(metadata?.model ? { model: metadata.model } : {}), ...(metadata?.effort ? { effort: metadata.effort } : {}), ...(metadata?.interactionMode ? { interactionMode: metadata.interactionMode } : {}), @@ -588,6 +590,21 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( readonly runtimeMode: string | undefined; }) { const startedAtMs = DateTime.toEpochMillis(yield* DateTime.now); + const settings = yield* serverSettings.getSettings.pipe(Effect.option); + const instance = Option.isSome(settings) + ? settings.value.providerInstances[input.providerInstanceId] + : undefined; + const subscriptionSharing = + input.provider === "codex" && + (instance + ? instance.driver === "codex" && + typeof instance.config === "object" && + instance.config !== null && + "setupMode" in instance.config && + instance.config.setupMode === "managed" + : input.providerInstanceId === "codex" && + Option.isSome(settings) && + settings.value.providers.codex.setupMode === "managed"); turnAnalyticsRequestId += 1; const requestId = turnAnalyticsRequestId; const effort = turnEffort(input.modelSelection); @@ -600,6 +617,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }; const metadata: TurnAnalyticsMetadata = { provider: input.provider, + ...(subscriptionSharing ? { subscriptionSharing: true } : {}), startedAtMs, mixedModels: false, requestId, @@ -1731,6 +1749,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* McpSessionRegistry.touchActiveMcpThread(input.threadId); const analyticsModelSelection = input.modelSelection?.instanceId === routed.instanceId ? input.modelSelection : undefined; + let subscriptionSharing = false; const turn = yield* Effect.acquireUseRelease( beginTurnAnalytics({ providerInstanceId: routed.instanceId, @@ -1742,7 +1761,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }), (turnMetadata) => Effect.gen(function* () { - const turn = yield* routed.adapter.sendTurn(input); + subscriptionSharing = turnMetadata.subscriptionSharing === true; + yield* analytics.record("provider.turn.attempted", { + provider: routed.adapter.provider, + ...(turnMetadata.subscriptionSharing ? { subscriptionSharing: true } : {}), + model: input.modelSelection?.model, + runtimeMode: routed.runtimeMode, + }); + const turn = yield* routed.adapter.sendTurn(input).pipe( + Effect.tapError((error) => + analytics.record("provider.turn.rejected", { + provider: routed.adapter.provider, + ...(turnMetadata.subscriptionSharing ? { subscriptionSharing: true } : {}), + errorType: error._tag, + }), + ), + ); yield* associateTurnAnalytics({ providerInstanceId: routed.instanceId, threadId: input.threadId, @@ -1776,6 +1810,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); yield* analytics.record("provider.turn.sent", { provider: routed.adapter.provider, + ...(subscriptionSharing ? { subscriptionSharing: true } : {}), model: input.modelSelection?.model, interactionMode: input.interactionMode, // Session-start events alone skew runtime mode toward users who toggle diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 77e06530f2e5..7933f8e2e962 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -441,6 +441,30 @@ describe("ModelManifest service", () => { ), ); + it.live("ignores older remote edits without replacing the current manifest or disk cache", () => { + let remote = { ...REMOTE_MANIFEST, updatedAt: "2000-01-01T00:00:00Z" }; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.deepStrictEqual(yield* service.current, BUNDLED_MODEL_MANIFEST); + + remote = { ...REMOTE_MANIFEST, updatedAt: REMOTE_UPDATED_AT }; + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + remote = { ...REMOTE_MANIFEST, updatedAt: BUNDLED_MODEL_MANIFEST.updatedAt! }; + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + const rebooted = yield* make; + assert.deepStrictEqual(yield* rebooted.current, REMOTE_MANIFEST); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-stale-fetch-test", + response: () => Response.json(remote), + }), + ), + ); + }); + it.live("keeps the bundled manifest when the remote payload is malformed", () => Effect.gen(function* () { const service = yield* make; diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index 92a01336bbbb..d1af1086e750 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -402,7 +402,11 @@ export const make = Effect.gen(function* () { Effect.timeout(FETCH_TIMEOUT_MS), Effect.catchCause(() => Effect.succeed(null)), ); - if (fetched === null) return manifest; + // A CDN can still serve an earlier edit after a release. Apply the same + // freshness rule as the disk cache so it cannot undo bundled version gates. + if (fetched === null || manifestUpdatedAtMs(fetched) < manifestUpdatedAtMs(manifest)) { + return manifest; + } manifest = fetched; fetchedAtMs = now; diff --git a/apps/server/src/provider/ProviderAuthFlow.test.ts b/apps/server/src/provider/ProviderAuthFlow.test.ts index d12b6967c82e..5ea9a0fc2f3b 100644 --- a/apps/server/src/provider/ProviderAuthFlow.test.ts +++ b/apps/server/src/provider/ProviderAuthFlow.test.ts @@ -518,3 +518,42 @@ it.effect("rebuilding a controller leaves sessions it admitted to their owners", assert.isFalse(closed); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect("profile import stops owned sessions and gates access until credentials are saved", () => + Effect.gen(function* () { + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "t3", key: "handoff-binding" }, + methods: Effect.succeed([method]), + authenticate: () => Effect.void, + logout: Effect.void, + }); + let closed = false; + yield* controller.withAccess!( + Effect.addFinalizer(() => + Effect.sync(() => { + closed = true; + }), + ), + ); + const writing = yield* Deferred.make(); + const release = yield* Deferred.make(); + const imported = yield* controller.adoptCredentials!( + Deferred.succeed(writing, undefined).pipe(Effect.andThen(Deferred.await(release))), + Effect.sync(() => { + assert.isTrue(closed); + }), + ).pipe(Effect.forkChild); + yield* Deferred.await(writing); + const denied = yield* controller.withAccess!(Effect.succeed("old credential process")).pipe( + Effect.result, + ); + assert.strictEqual(denied._tag, "Failure"); + yield* Deferred.succeed(release, undefined); + assert.strictEqual((yield* Fiber.join(imported)).phase, "succeeded"); + assert.strictEqual( + yield* controller.withAccess!(Effect.succeed("new credential process")), + "new credential process", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/ProviderAuthFlow.ts b/apps/server/src/provider/ProviderAuthFlow.ts index f01ce92e3fb5..f69de4b6bc37 100644 --- a/apps/server/src/provider/ProviderAuthFlow.ts +++ b/apps/server/src/provider/ProviderAuthFlow.ts @@ -25,9 +25,14 @@ import type * as ProviderAuthService from "./Services/ProviderAuthService.ts"; export interface ProviderAuthFlowContext { readonly flowId: string; + /** The lifetime timeout interrupts authenticate before publishing its expired result. */ + readonly expiresAt: number; + readonly returnUrl?: string; + readonly callbackMode?: "server" | "client"; readonly setInteraction: ( interaction: ProviderAuthInteraction, respond?: (response: ProviderAuthResponse) => Effect.Effect, + complete?: (callbackUrl: string) => Effect.Effect, ) => Effect.Effect; readonly verifying: Effect.Effect; } @@ -45,6 +50,7 @@ interface Flow { readonly id: string; readonly owner: string; readonly expiresAt: number; + complete?: ((callbackUrl: string) => Effect.Effect) | undefined; fiber?: Fiber.Fiber; responseFiber?: Fiber.Fiber; respond: @@ -60,12 +66,14 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { >; readonly methods: Effect.Effect, ProviderSetupError>; readonly defaultMethodId?: string; + /** Stored account profiles can change the advertised methods after auth/logout. */ + readonly refreshMethodsAfterAuth?: boolean; /** Fail with ProviderSetupError containing safe text for the user, never native token data. */ readonly authenticate: ( methodId: string, context: ProviderAuthFlowContext, ) => Effect.Effect; - readonly logout: Effect.Effect; + readonly logout: Effect.Effect; readonly timeoutMs?: number; }) { const scope = yield* Scope.Scope; @@ -139,6 +147,40 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { const controller: ProviderAuthService.ProviderAuthController = { credentialBinding: options.credentialBinding, + adoptCredentials: (update, stopSessions) => + Effect.gen(function* () { + yield* lock.withPermit( + Effect.gen(function* () { + if (operation !== "idle") + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "import", + detail: "Finish or cancel the existing sign-in first.", + }); + operation = "stopping"; + }), + ); + const result = yield* Effect.gen(function* () { + yield* stopOwnedSessions; + yield* stopSessions; + yield* update; + yield* refreshMethods; + }).pipe(Effect.exit); + const state: ProviderAuthState = { + ...empty, + methods: snapshot.value.state.methods ?? [], + phase: Exit.isSuccess(result) ? "succeeded" : "failed", + message: Exit.isSuccess(result) ? null : failureMessage(result.cause), + }; + yield* lock.withPermit( + Effect.gen(function* () { + yield* SubscriptionRef.set(snapshot, { owner: null, state }); + operation = "idle"; + }), + ); + if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); + return state; + }).pipe(Effect.uninterruptible), refreshMethods, invalidate: lock.withPermit( Effect.gen(function* () { @@ -191,7 +233,7 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { ); }), ), - start: (owner, stopSessions = Effect.void, selectedMethodId) => + start: (owner, stopSessions = Effect.void, selectedMethodId, returnUrl, callbackMode) => lock.withPermit( Effect.gen(function* () { if (operation === "auth" && active?.owner === owner) return snapshot.value.state; @@ -241,10 +283,14 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions)); yield* options.authenticate(methodId, { flowId: id, - setInteraction: (interaction, respond) => + expiresAt: flow.expiresAt, + ...(returnUrl ? { returnUrl } : {}), + ...(callbackMode ? { callbackMode } : {}), + setInteraction: (interaction, respond, complete) => Effect.gen(function* () { if (active !== flow) return; flow.respond = respond; + flow.complete = complete; yield* publish(flow, { phase: "waiting", interaction, @@ -257,6 +303,7 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { }), verifying: Effect.gen(function* () { flow.respond = undefined; + flow.complete = undefined; yield* publish(flow, { phase: "verifying", interaction: null, @@ -293,6 +340,7 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { yield* lock.withPermit( Effect.gen(function* () { if (active !== flow) return; + if (options.refreshMethodsAfterAuth) yield* refreshMethods; yield* publish(flow, { phase: Exit.isSuccess(result) ? "succeeded" : "failed", interaction: null, @@ -363,12 +411,24 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { return snapshot.value.state; }), ), - complete: () => - Effect.fail( - new ProviderSetupError({ - instanceId: options.instanceId, - operation: "complete", - detail: "This provider does not accept a pasted redirect URL.", + complete: (owner, input) => + lock.withPermit( + Effect.gen(function* () { + const flow = yield* requireFlow(owner, input.flowId); + const interaction = snapshot.value.state.interaction; + if ( + snapshot.value.state.phase !== "waiting" || + interaction?.type !== "browser" || + !interaction.acceptsCallback || + !flow.complete + ) + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "complete", + detail: "This sign-in does not accept a redirect URL.", + }); + yield* flow.complete(input.callbackUrl); + return snapshot.value.state; }), ), cancel: (owner, id) => @@ -413,13 +473,17 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber); if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions)); - yield* options.logout; + const message = yield* options.logout; + if (options.refreshMethodsAfterAuth) yield* refreshMethods; + return message; }).pipe(Effect.exit); const state: ProviderAuthState = { ...empty, methods: snapshot.value.state.methods ?? [], phase: Exit.isSuccess(result) ? "idle" : "failed", - message: Exit.isSuccess(result) ? "Signed out." : "Could not sign out. Try again.", + message: Exit.isSuccess(result) + ? (result.value ?? "Signed out.") + : "Could not sign out. Try again.", }; yield* lock.withPermit( Effect.gen(function* () { diff --git a/apps/server/src/provider/Services/ProviderAuthService.ts b/apps/server/src/provider/Services/ProviderAuthService.ts index 93fd39fb3e3f..273f1b5337ee 100644 --- a/apps/server/src/provider/Services/ProviderAuthService.ts +++ b/apps/server/src/provider/Services/ProviderAuthService.ts @@ -1,4 +1,6 @@ import type { + ChatGptReconnectProfile, + ChatGptTransferredProfile, ProviderAuthRespondInput, ProviderAuthStartInput, ProviderAuthState, @@ -13,6 +15,17 @@ import type * as Scope from "effect/Scope"; export interface ProviderAuthController { /** Equal keys mean these instances share credentials on this environment. */ readonly credentialBinding?: { readonly owner: "provider" | "t3"; readonly key: string }; + readonly reconnectProfile?: ( + methodId: string, + ) => Effect.Effect; + readonly importProfile?: ( + profile: ChatGptTransferredProfile, + stopSessions: Effect.Effect, + ) => Effect.Effect; + readonly adoptCredentials?: ( + update: Effect.Effect, + stopSessions: Effect.Effect, + ) => Effect.Effect; readonly isChangingCredentials?: Effect.Effect; readonly invalidate?: Effect.Effect; readonly refreshMethods?: Effect.Effect; @@ -23,6 +36,8 @@ export interface ProviderAuthController { ownerSessionId: string, stopSessions?: Effect.Effect, methodId?: string, + returnUrl?: string, + callbackMode?: "server" | "client", ) => Effect.Effect; readonly complete: ( ownerSessionId: string, @@ -49,6 +64,12 @@ interface ProviderAuthTarget { } export interface ProviderAuthServiceShape { + readonly reconnectProfile: ( + input: ProviderAuthTarget & { methodId: string }, + ) => Effect.Effect; + readonly importProfile: ( + input: ProviderAuthTarget & { profile: ChatGptTransferredProfile }, + ) => Effect.Effect; readonly start: ( input: ProviderAuthStartInput, ownerSessionId: string, diff --git a/apps/server/src/provider/providerInstallation.test.ts b/apps/server/src/provider/providerInstallation.test.ts index dba0bafecad7..b1bea19c2df1 100644 --- a/apps/server/src/provider/providerInstallation.test.ts +++ b/apps/server/src/provider/providerInstallation.test.ts @@ -13,6 +13,7 @@ import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import { layerTest as settingsLayerTest } from "../serverSettings.ts"; +import { CodexInstallation } from "./CodexInstallation.ts"; import { AntigravityInstallation } from "./AntigravityInstallation.ts"; import type { ProviderInstance } from "./ProviderDriver.ts"; import { makeProviderInstallation } from "./providerInstallation.ts"; @@ -33,9 +34,9 @@ const state: ProviderInstallState = { message: null, }; -function instance(kind = driver): ProviderInstance { +function instance(kind = driver, id = instanceId): ProviderInstance { return { - instanceId, + instanceId: id, driverKind: kind, enabled: false, displayName: undefined, @@ -77,6 +78,25 @@ const makeHarness = Effect.fn("providerInstallation.test.makeHarness")(function* return []; }), }), + Layer.mock(CodexInstallation)({ + managedDirectory: "/unused-managed-codex", + start: Effect.sync(() => { + calls.push("codex-start"); + return { ...state, driver: ProviderDriverKind.make("codex") }; + }), + cancel: () => + Effect.sync(() => { + calls.push("codex-cancel"); + return { ...state, driver: ProviderDriverKind.make("codex") }; + }), + state: Effect.succeed({ ...state, driver: ProviderDriverKind.make("codex") }), + changes: Stream.succeed({ ...state, driver: ProviderDriverKind.make("codex") }), + remove: (paths) => + Effect.sync(() => { + protectedPaths = paths ?? []; + calls.push("codex-remove"); + }), + }), Layer.mock(AntigravityInstallation)({ managedDirectory: "/unused-managed-runtime", start: Effect.sync(() => { @@ -139,6 +159,37 @@ describe("provider installation routing", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("routes a managed Codex instance through its own installer and refreshes removal", () => + Effect.gen(function* () { + const codexId = ProviderInstanceId.make("codex"); + const harness = yield* makeHarness({ + instance: instance(ProviderDriverKind.make("codex"), codexId), + settings: { providers: { codex: { setupMode: "managed" } } }, + }); + assert.equal((yield* harness.router.start({ instanceId: codexId })).driver, "codex"); + yield* harness.router.cancel({ instanceId: codexId, operationId: "operation" }); + const observed = yield* Stream.runCollect(harness.router.subscribe({ instanceId: codexId })); + assert.equal(Array.from(observed)[0]?.driver, "codex"); + yield* harness.router.remove({ instanceId: codexId }); + assert.deepEqual(harness.calls, ["codex-start", "codex-cancel", "codex-remove", "refresh"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("keeps native Codex installation outside managed setup", () => + Effect.gen(function* () { + const codexId = ProviderInstanceId.make("codex"); + const harness = yield* makeHarness({ + instance: instance(ProviderDriverKind.make("codex"), codexId), + settings: { providers: { codex: { setupMode: "existing" } } }, + }); + assert.include( + (yield* Effect.flip(harness.router.start({ instanceId: codexId }))).detail, + "Choose managed setup", + ); + assert.deepEqual(harness.calls, []); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("protects another instance's binary found through its own PATH", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/providerInstallation.ts b/apps/server/src/provider/providerInstallation.ts index 42c5c3b54738..5cf99ce27b95 100644 --- a/apps/server/src/provider/providerInstallation.ts +++ b/apps/server/src/provider/providerInstallation.ts @@ -1,5 +1,6 @@ import { AntigravitySettings, + CodexSettings, ProviderDriverKind, type ProviderInstallCancelInput, type ProviderInstanceId, @@ -11,6 +12,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import { CodexInstallation, type CodexInstallationError } from "./CodexInstallation.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import { AntigravityInstallation, @@ -23,11 +25,13 @@ import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment. const ANTIGRAVITY = ProviderDriverKind.make("antigravity"); const hasBinaryPath = Schema.is(Schema.Struct({ binaryPath: Schema.String })); +const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); const decodeAntigravitySettings = Schema.decodeUnknownEffect(AntigravitySettings); /** Route instance setup to the environment-owned installer without owning the download. */ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(function* () { - const installation = yield* AntigravityInstallation; + const antigravityInstallation = yield* AntigravityInstallation; + const codexInstallation = yield* CodexInstallation; const instances = yield* ProviderInstanceRegistry; const providers = yield* ProviderRegistry; const settings = yield* ServerSettingsService; @@ -55,26 +59,37 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu managedOnly = false, ) { const instance = yield* instances.getInstance(instanceId); - if (instance?.driverKind !== ANTIGRAVITY) { + const isCodex = instance?.driverKind === ProviderDriverKind.make("codex"); + if (instance?.driverKind !== ANTIGRAVITY && !isCodex) { return yield* new ProviderSetupError({ instanceId, operation, detail: "Managed installation is not available for this provider instance.", }); } - if (!managedOnly) return; + const installation = isCodex ? codexInstallation : antigravityInstallation; const entries = yield* readEntries(instanceId, operation); - const config = yield* decodeAntigravitySettings(entries[instanceId]?.config ?? {}).pipe( - Effect.mapError( - () => - new ProviderSetupError({ - instanceId, - operation, - detail: "The Antigravity instance configuration is invalid.", - }), - ), - ); - if (config.binaryPath) { + const invalidConfig = () => + new ProviderSetupError({ + instanceId, + operation, + detail: "The provider instance configuration is invalid.", + }); + const config = isCodex + ? yield* decodeCodexSettings(entries[instanceId]?.config ?? {}).pipe( + Effect.mapError(invalidConfig), + ) + : yield* decodeAntigravitySettings(entries[instanceId]?.config ?? {}).pipe( + Effect.mapError(invalidConfig), + ); + if (isCodex && (!("setupMode" in config) || config.setupMode !== "managed")) { + return yield* new ProviderSetupError({ + instanceId, + operation, + detail: "Choose managed setup to install Codex in T3 Code.", + }); + } + if (managedOnly && config.binaryPath && (!isCodex || config.binaryPath !== "codex")) { return yield* new ProviderSetupError({ instanceId, operation, @@ -82,20 +97,23 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu "This instance uses a custom executable. Clear its binary path to manage installation in T3 Code.", }); } + return { installation, driver: instance.driverKind }; }); - const failure = (instanceId: ProviderInstanceId) => (error: AntigravityInstallationError) => - new ProviderSetupError({ instanceId, operation: error.operation, detail: error.detail }); + const failure = + (instanceId: ProviderInstanceId) => + (error: AntigravityInstallationError | CodexInstallationError) => + new ProviderSetupError({ instanceId, operation: error.operation, detail: error.detail }); const start = Effect.fn("ProviderInstallation.start")(function* (input: ProviderSetupInput) { - yield* requireInstance(input.instanceId, "install", true); + const { installation } = yield* requireInstance(input.instanceId, "install", true); return yield* installation.start.pipe(Effect.mapError(failure(input.instanceId))); }); const cancel = Effect.fn("ProviderInstallation.cancel")(function* ( input: ProviderInstallCancelInput, ) { - yield* requireInstance(input.instanceId, "cancel-install"); + const { installation } = yield* requireInstance(input.instanceId, "cancel-install"); return yield* installation .cancel(input.operationId) .pipe(Effect.mapError(failure(input.instanceId))); @@ -103,11 +121,17 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu const subscribe = (input: ProviderSetupInput) => Stream.unwrap( - requireInstance(input.instanceId, "observe-install").pipe(Effect.as(installation.changes)), + requireInstance(input.instanceId, "observe-install").pipe( + Effect.map(({ installation }) => installation.changes), + ), ); const remove = Effect.fn("ProviderInstallation.remove")(function* (input: ProviderSetupInput) { - yield* requireInstance(input.instanceId, "remove-install", true); + const { installation, driver } = yield* requireInstance( + input.instanceId, + "remove-install", + true, + ); const entries = yield* readEntries(input.instanceId, "remove-install"); const protectedPaths = yield* Effect.forEach(Object.values(entries), (entry) => { if (!hasBinaryPath(entry.config) || !entry.config.binaryPath.trim()) { @@ -126,7 +150,7 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu .pipe(Effect.mapError(failure(input.instanceId))); const allInstances = yield* instances.listInstances; yield* Effect.forEach( - allInstances.filter((instance) => instance.driverKind === ANTIGRAVITY), + allInstances.filter((instance) => instance.driverKind === driver), (instance) => providers.refreshInstance(instance.instanceId), { discard: true }, ); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 43595dbafce1..cb1ed672312e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -140,6 +140,7 @@ import { AntigravityInstallation, AntigravityInstallationError, } from "./provider/AntigravityInstallation.ts"; +import { CodexInstallation } from "./provider/CodexInstallation.ts"; import type { ProviderInstance } from "./provider/ProviderDriver.ts"; import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import { ProviderAdapterRequestError } from "./provider/Errors.ts"; @@ -529,6 +530,7 @@ const buildAppUnderTest = (options?: { providerAuth?: Partial; providerInstanceRegistry?: Partial; antigravityInstallation?: Partial; + codexInstallation?: Partial; serverSettings?: Partial; externalLauncher?: Partial; vcsDriver?: Partial; @@ -828,6 +830,10 @@ const buildAppUnderTest = (options?: { listInstances: Effect.succeed([]), ...options?.layers?.providerInstanceRegistry, }), + Layer.mock(CodexInstallation)({ + managedDirectory: "unused-test-codex-runtime", + ...options?.layers?.codexInstallation, + }), Layer.mock(AntigravityInstallation)({ managedDirectory: "unused-test-antigravity-runtime", ...options?.layers?.antigravityInstallation, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 88c08fd3bdee..43bba34bd8c5 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -55,6 +55,7 @@ import * as ResetCreditCoordinator from "./provider/Layers/resetCreditCoordinato import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts"; +import { CodexInstallation } from "./provider/CodexInstallation.ts"; import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; @@ -475,22 +476,27 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(OrchestrationLayerLive), ); -const AntigravityInstallationRefreshLive = Layer.effectDiscard( +const ProviderInstallationRefreshLive = Layer.effectDiscard( Effect.gen(function* () { - const installation = yield* AntigravityInstallation; + const antigravity = yield* AntigravityInstallation; + const codex = yield* CodexInstallation; const instances = yield* ProviderInstanceRegistry; const providers = yield* ProviderRegistry; - yield* installation.changes.pipe( - Stream.map((state) => state.installedVersion), - Stream.changes, - Stream.drop(1), - Stream.runForEach(() => + yield* Stream.merge( + antigravity.changes.pipe( + Stream.changesWith((a, b) => a.installedVersion === b.installedVersion), + Stream.drop(1), + ), + codex.changes.pipe( + Stream.changesWith((a, b) => a.installedVersion === b.installedVersion), + Stream.drop(1), + ), + ).pipe( + Stream.runForEach((state) => instances.listInstances.pipe( Effect.flatMap((entries) => Effect.forEach( - entries.filter( - (instance) => instance.driverKind === ProviderDriverKind.make("antigravity"), - ), + entries.filter((instance) => instance.driverKind === state.driver), (instance) => providers.refreshInstance(instance.instanceId), { discard: true }, ), @@ -503,7 +509,7 @@ const AntigravityInstallationRefreshLive = Layer.effectDiscard( ); const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( - Layer.provideMerge(AntigravityInstallationRefreshLive), + Layer.provideMerge(ProviderInstallationRefreshLive), Layer.provideMerge(ReplayMarkers.layer), Layer.provideMerge(ProviderAuthServiceLive), // Core Services @@ -532,7 +538,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // with explicit `providerInstances` entries on boot. Layer.provideMerge(ProviderInstanceRegistryHydrationLive), ).pipe( - Layer.provideMerge(AntigravityInstallation.layer), + Layer.provideMerge(Layer.mergeAll(AntigravityInstallation.layer, CodexInstallation.layer)), // Shared native/canonical NDJSON writers used by both the per-instance // drivers (native stream, written from inside each `Adapter`) and // `ProviderService` (canonical stream, written after event normalization). diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 2410ddbf9be7..b43df46da90b 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -48,6 +48,11 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func codexConfig: CodexSettings, environment?: NodeJS.ProcessEnv, getModels: Effect.Effect> = Effect.succeed([]), + resolveRuntime?: Effect.Effect< + import("../provider/CodexManagedRuntime.ts").CodexEffectiveRuntime, + import("@t3tools/contracts").ProviderSetupError, + Scope.Scope + >, ) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -180,6 +185,15 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const outputPath = yield* writeTempFile(operation, "codex-output", ""); const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { + const resolved = resolveRuntime + ? yield* resolveRuntime.pipe( + Effect.mapError( + (cause) => new TextGenerationError({ operation, detail: cause.detail }), + ), + ) + : undefined; + const effectiveConfig = resolved?.config ?? codexConfig; + const effectiveEnvironment = resolved?.environment ?? resolvedEnvironment; const models = yield* getModels; const requestedModel = modelSelection.model; const model = @@ -188,13 +202,13 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func (candidate) => !candidate.isCustom && codexModelFamily(candidate.slug) === requestedModel, )?.slug ?? requestedModel; - const launchArgs = resolveCodexLaunchArgs(codexConfig.launchArgs, resolvedEnvironment); + const launchArgs = resolveCodexLaunchArgs(effectiveConfig.launchArgs, effectiveEnvironment); const reasoningEffort = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? DEFAULT_TEXT_GENERATION_REASONING_EFFORT; const serviceTier = getCodexServiceTierOptionValue(modelSelection); const spawnCommand = yield* resolveSpawnCommand( - codexConfig.binaryPath || "codex", + effectiveConfig.binaryPath || "codex", [ "exec", ...codexExecLaunchArgs(launchArgs), @@ -214,12 +228,14 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), "-", ], - { env: resolvedEnvironment }, + { env: effectiveEnvironment }, ); const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { env: { - ...resolvedEnvironment, - ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), + ...effectiveEnvironment, + ...(effectiveConfig.homePath + ? { CODEX_HOME: expandHomePath(effectiveConfig.homePath) } + : {}), }, cwd, shell: spawnCommand.shell, diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index df6d7ba044c8..f7286a11b60d 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -121,6 +121,95 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { + for (const explicitDefault of [true, false]) { + it.live( + `reads shared managed ${explicitDefault ? "explicit" : "legacy"} default and disabled extra account history once`, + () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const summary = yield* Effect.gen(function* () { + for (const [id, output] of [ + ["codex", 17], + ["codex-personal", 23], + ] as const) { + const sessions = NodePath.join(home, "shared-codex", "sessions"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(sessions, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(sessions, `${id}-rollout.jsonl`), + [ + { type: "session_meta", payload: { id } }, + { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, + { + type: "event_msg", + timestamp: "2026-08-01T10:00:00Z", + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 10, output_tokens: output } }, + }, + }, + ] + .map((line) => encodeUnknownJsonString(line)) + .join("\n") + "\n", + ); + }); + } + const service = yield* UsageService.make; + return yield* service.readSummary(WINDOW); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-managed-accounts", + home, + settings: { + ...settings, + providers: { + ...settings.providers, + codex: { setupMode: "managed", homePath: NodePath.join(home, "shared-codex") }, + }, + providerInstances: { + ...(explicitDefault + ? { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { + setupMode: "managed", + homePath: NodePath.join(home, "shared-codex"), + }, + }, + } + : {}), + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: { + setupMode: "managed", + homePath: NodePath.join(home, "shared-codex"), + shadowHomePath: NodePath.join(home, "personal-shadow"), + }, + environment: [ + { + name: "CODEX_HOME", + value: NodePath.join(home, "ignored-environment"), + sensitive: false, + }, + ], + }, + }, + }, + }), + ), + ); + assert.strictEqual(totalOutputTokens(summary), 40); + assert.strictEqual( + summary.sources.filter( + (source) => source.fingerprint.provider === "codex" && source.status === "ok", + ).length, + 1, + ); + }).pipe(Effect.scoped), + ); + } it.live("omits Cursor account usage when no file login is saved", () => Effect.gen(function* () { const { settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 1e5cb6db20fe..9e8cda9960ea 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -18,8 +18,8 @@ import { ClaudeSettings, CodexSettings, type ProviderInstanceConfig, - USAGE_CONTRACT_VERSION, ProviderInstanceId, + USAGE_CONTRACT_VERSION, type ServerSettings as ServerSettingsValue, type UsageProviderKind, type UsageSource, @@ -271,10 +271,16 @@ export const make = Effect.gen(function* () { for (const driver of ["claudeAgent", "codex", "grok"] as const) { // Disabled accounts still have history. Explicit default slots replace // the legacy settings, just as they do in the provider registry. - const instances: Array> = - Object.values(settings.providerInstances).filter((instance) => instance.driver === driver); + const instances: Array< + Pick & { instanceId: ProviderInstanceId } + > = Object.entries(settings.providerInstances) + .filter(([, instance]) => instance.driver === driver) + .map(([id, instance]) => ({ ...instance, instanceId: ProviderInstanceId.make(id) })); if (!Object.hasOwn(settings.providerInstances, driver)) { - instances.push({ config: settings.providers[driver] }); + instances.push({ + config: settings.providers[driver], + instanceId: ProviderInstanceId.make(driver), + }); } for (const instance of instances) { const environment = mergeProviderInstanceEnvironment(instance.environment, hostEnvironment); @@ -283,12 +289,15 @@ export const make = Effect.gen(function* () { if (driver === "codex") { const decoded = decodeCodexSettings(instance.config ?? {}); if (Option.isNone(decoded)) continue; - const config = decoded.value; + const codexConfig = decoded.value; const environmentHome = environment.CODEX_HOME?.trim(); const layout = yield* resolveCodexHomeLayout( - !config.homePath.trim() && !config.shadowHomePath.trim() && environmentHome - ? { ...config, homePath: environmentHome } - : config, + codexConfig.setupMode !== "managed" && + !codexConfig.homePath.trim() && + !codexConfig.shadowHomePath.trim() && + environmentHome + ? { ...codexConfig, homePath: environmentHome } + : codexConfig, ); home = layout.sharedHomePath; } else if (driver === "claudeAgent") { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 077087a7d84e..47974366a671 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -17,6 +17,8 @@ import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import { subscribeChatGptHandoff } from "./provider/CodexChatGptHandoff.ts"; +import { subscribeCodexAuthCallback } from "./provider/CodexAuthCallback.ts"; import { DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, AuthAccessStreamError, @@ -2508,6 +2510,18 @@ const makeWsRpcLayer = ( providerAuth.complete(input, currentSessionId), { "rpc.aggregate": "provider" }, ), + [WS_METHODS.chatGptReconnectProfile]: (input) => providerAuth.reconnectProfile(input), + [WS_METHODS.chatGptImportProfile]: (input) => providerAuth.importProfile(input), + [WS_METHODS.chatGptHandoffSubscribe]: (input) => + subscribeChatGptHandoff(input, currentSessionId), + [WS_METHODS.codexAuthCallbackSubscribe]: (input) => + observeRpcStream( + WS_METHODS.codexAuthCallbackSubscribe, + subscribeCodexAuthCallback(input), + { + "rpc.aggregate": "provider", + }, + ), [WS_METHODS.providerAuthCancel]: (input) => observeRpcEffect( WS_METHODS.providerAuthCancel, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 61f1770c4bef..db13c8f21c80 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,4 @@ +import { isChatGptUsageLimitError } from "@t3tools/shared/usageLimits"; import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; @@ -9809,6 +9810,7 @@ export default function ChatView(props: ChatViewProps) { /> { setThreadError(activeThread.id, null); dismissThreadErrorBannerForSession(threadErrorBannerKey); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index df7ea7b1c95e..b511f1f1b922 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -548,7 +548,7 @@ export const OpenAI: Icon = ({ className, ...props }) => ( {...props} preserveAspectRatio="xMidYMid" viewBox="100 100 411 411" - className={cn("fill-black dark:fill-white", className)} + className={cn("fill-current", className)} > + + + + + ); +} diff --git a/apps/web/src/components/chat/ComposerUsageLimits.tsx b/apps/web/src/components/chat/ComposerUsageLimits.tsx index bfc554c56e25..04d32f610855 100644 --- a/apps/web/src/components/chat/ComposerUsageLimits.tsx +++ b/apps/web/src/components/chat/ComposerUsageLimits.tsx @@ -2,6 +2,8 @@ import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts"; import { limitsNotice } from "@t3tools/shared/usageLimits"; import { GaugeIcon } from "lucide-react"; +import { ensureLocalApi } from "../../localApi"; +import { Button } from "../ui/button"; import { getDriverOption } from "../settings/providerDriverMeta"; import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; import { LimitWindows, ResetCredits } from "../usage/UsageLimits"; @@ -85,6 +87,7 @@ function UsageLimitsBannerBody({ account.resetCreditInput ?? (account.instanceId ? { instanceId: account.instanceId } : undefined); const notice = limitsNotice(account.limits); + const externalUsage = account.limits.externalUsage; return (
{report.accounts.length > 1 ? ( @@ -102,6 +105,16 @@ function UsageLimitsBannerBody({ now={now} /> )} + {externalUsage ? ( + + ) : null} {resetCreditInput && account.limits.resetCredits ? ( + {props.selectedModels === undefined ? ( + + ) : null} ); diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx index c61f7b12b9b9..69d5336f8078 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx @@ -3,6 +3,8 @@ import { Alert, AlertAction, AlertDescription } from "../ui/alert"; import { Button } from "../ui/button"; import { CircleAlertIcon, XIcon } from "lucide-react"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { OpenAI } from "../Icons"; +import { ChatGptUsageButton } from "../settings/ChatGptUsageButton"; export function getThreadErrorBannerKey(threadKey: string, error: string | null): string | null { return error === null ? null : `${threadKey}\u0000${error}`; @@ -36,28 +38,44 @@ export function isThreadErrorBannerDismissedForSession(bannerKey: string | null) export const ThreadErrorBanner = memo(function ThreadErrorBanner({ error, onDismiss, + chatGptUsageLimit = false, }: { error: string | null; onDismiss?: () => void; + chatGptUsageLimit?: boolean; }) { if (!error) return null; return (
- + {chatGptUsageLimit ? ( + diff --git a/apps/web/src/components/onboarding/WelcomeWizard.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.test.tsx index 92514d012b82..9eef24d5df39 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.test.tsx @@ -10,6 +10,8 @@ const mocks = vi.hoisted(() => ({ complete: vi.fn(), refresh: vi.fn(), toast: vi.fn(), + providers: [] as unknown[], + config: null as unknown, projects: [] as Array<{ id: string; environmentId: string; workspaceRoot: string }>, })); vi.mock("../../state/agentSessions", () => ({ agentSessionImport: "import" })); @@ -40,8 +42,8 @@ vi.mock("../../state/environments", () => { }); vi.mock("../../state/server", () => ({ serverEnvironment: { - providersValueAtom: () => [], - configValueAtom: () => null, + providersValueAtom: () => mocks.providers, + configValueAtom: () => mocks.config, refreshProviders: "refresh", }, })); @@ -74,6 +76,32 @@ vi.mock("../../state/terminal", () => ({ terminalEnvironment: {} })); vi.mock("../clerk/useT3ConnectAuthPrompt", () => ({ useT3ConnectAuthPrompt: vi.fn() })); vi.mock("../../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false })); vi.mock("../ThreadTerminalDrawer", () => ({ TerminalViewport: () => null })); +vi.mock("../settings/ChatGptWelcomeCoordinator", () => ({ ChatGptWelcomeCoordinator: () => null })); +vi.mock("../settings/CodexSetupSection", () => ({ + CodexSetupSection: (props: { + instanceId: string; + displayName?: string; + provider?: { displayName: string }; + mode: string; + }) => ( +
+ {props.displayName ?? props.provider?.displayName} +
+ ), + AddManagedCodexAccountDialog: (props: { + onAccountCreated: (id: string, name: string) => void; + onClose: () => void; + }) => ( + + ), +})); vi.mock("../cloud/CloudEnvironmentConnectList", () => ({ CloudEnvironmentConnectRows: () => null, })); @@ -88,6 +116,8 @@ let container: HTMLDivElement; beforeEach(() => { vi.clearAllMocks(); + mocks.providers = []; + mocks.config = null; vi.stubGlobal( "ResizeObserver", class { @@ -207,3 +237,45 @@ it("keeps setup open when saving completion fails and preserves the import warni }), ); }); + +it("keeps the added account in place when provider and settings snapshots arrive separately", async () => { + const codex = { + instanceId: "codex", + displayName: "Codex", + driver: "codex", + installed: true, + enabled: true, + status: "ready", + auth: { status: "authenticated" }, + }; + mocks.providers = [codex]; + mocks.config = { + settings: { + providerInstances: {}, + providers: { codex: { enabled: true, setupMode: "existing" } }, + }, + }; + const onDone = vi.fn(); + const render = () => + act(async () => root.render()); + await render(); + await click("Continue"); + await click("Connect another ChatGPT account"); + await click("Create test account"); + const account = document.querySelector('[data-account="codex_added"]'); + expect(account?.textContent).toBe("ChatGPT - Personal"); + expect(account?.getAttribute("data-mode")).toBe("managed"); + mocks.providers = [ + codex, + { + ...codex, + instanceId: "codex_added", + displayName: "ChatGPT - Personal", + auth: { status: "unauthenticated" }, + }, + ]; + await render(); + expect(document.querySelector('[data-account="codex_added"]')).toBe(account); + expect(account?.getAttribute("data-mode")).toBe("managed"); + expect(document.querySelectorAll('[data-account="codex_added"]')).toHaveLength(1); +}); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 942ffcd57ef6..124566efa39d 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -4,6 +4,7 @@ import type { AgentSessionProjectCandidate, EnvironmentId, ProjectId, + ProviderInstanceId, ScopedProjectRef, ServerConfig, ServerProvider, @@ -13,7 +14,12 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { CommandId, ProviderDriverKind, ThreadId } from "@t3tools/contracts"; +import { + CommandId, + defaultInstanceIdForDriver, + ProviderDriverKind, + ThreadId, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { ArrowRightIcon, @@ -60,6 +66,10 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { connectPairing } from "../../connection/onboarding"; import { getProviderSummary } from "../settings/providerStatus"; import { getDriverOption } from "../settings/providerDriverMeta"; +import { ChatGptWelcomeCoordinator } from "../settings/ChatGptWelcomeCoordinator"; +import { AddManagedCodexAccountDialog, CodexSetupSection } from "../settings/CodexSetupSection"; +import { readCodexSetupMode } from "../settings/CodexSetupSection.logic"; +import { buildProviderInstanceUpdatePatch } from "../settings/SettingsPanels.logic"; import { TerminalViewport } from "../ThreadTerminalDrawer"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; import { ClaudeAI, OpenAI } from "../Icons"; @@ -81,7 +91,7 @@ import { formatRelativeTime } from "../../timestampFormat"; * First-run welcome wizard. Rendered over the workspace at `/welcome` on a * fresh install (no completed-onboarding flag, empty workspace). Flow per the * onboarding overhaul spec: connection choice โ†’ sign-in/pair (remote paths) โ†’ - * agent setup with inline install terminal โ†’ project import โ†’ main screen. + * managed Codex setup or an inline CLI terminal โ†’ project import โ†’ main screen. * Every step past the connection gate is skippable; the whole wizard is * re-runnable by clearing the flag. */ @@ -96,17 +106,21 @@ const SCAN_LIMIT_MESSAGE = "Scan limit reached. Some projects or conversations m export function WelcomeWizard({ localAvailable, onDone, + resumeEnvironmentId, }: { /** Whether this client is authenticated to the server serving the app. */ readonly localAvailable: boolean; + readonly resumeEnvironmentId?: EnvironmentId | undefined; readonly onDone: (projectRef?: ScopedProjectRef) => void | Promise; }) { const completeOnboarding = useCompleteOnboarding(); - const [step, setStep] = useState("connection"); + const [step, setStep] = useState(resumeEnvironmentId ? "agents" : "connection"); const { environments } = useEnvironments(); const [selection, setSelection] = useState | null>(null); const autoSelectedComputers = useRef(new Set()); - const [setupIds, setSetupIds] = useState([]); + const [setupIds, setSetupIds] = useState( + resumeEnvironmentId ? [resumeEnvironmentId] : [], + ); const [isImporting, setIsImporting] = useState(false); const finishingPromiseRef = useRef | null>(null); const completionErrorToastIdRef = useRef | null>(null); @@ -197,6 +211,7 @@ export function WelcomeWizard({ return ( event.cancel()}> document.getElementById("onboarding-pairing-url") ?? true} @@ -261,6 +276,7 @@ export function WelcomeWizard({ )} + ); } @@ -613,7 +629,7 @@ function PairingForm({ // โ”€โ”€ Step 3: agents โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -const PRIMARY_AGENT_DRIVERS = ["claudeAgent", "codex"] as const; +const PRIMARY_AGENT_DRIVERS = ["codex", "claudeAgent"] as const; type OnboardingAgentDriver = (typeof PRIMARY_AGENT_DRIVERS)[number]; /** Setup values stay fixed while provider probes refresh the surrounding cards. */ @@ -626,13 +642,7 @@ interface AgentTerminalSession { readonly keybindings: ServerConfig["keybindings"]; } -/** - * Claude Code and Codex use live probe status. Install opens the built-in - * terminal inline with the vendor's standalone installer pre-typed. The update - * RPC can't install a binary that isn't there yet (it infers the installer from - * the installed binary's path), and the terminal also handles the interactive - * login that follows. - */ +/** Codex uses managed setup; existing CLI installs retain the terminal path. */ function AgentsStep({ environmentIds, onContinue, @@ -642,8 +652,11 @@ function AgentsStep({ }) { const { environments } = useEnvironments(); return ( - - + +
{environmentIds.map((environmentId) => ( (null); + const [addingAccount, setAddingAccount] = useState(false); + const [createdAccount, setCreatedAccount] = useState<{ + instanceId: ProviderInstanceId; + displayName: string; + autoStart: boolean; + } | null>(null); // Re-probe on entry so freshly installed CLIs show up without a manual // refresh; harmless when nothing changed (single-flighted per environment). @@ -689,44 +708,114 @@ function ConnectedAgentsStep({ const byDriver = useMemo(() => selectOnboardingProvidersByDriver(providers), [providers]); - const primaryAgents = PRIMARY_AGENT_DRIVERS.map((driver) => ({ - driver, - provider: byDriver.get(driver), - })); + const primaryAgents = PRIMARY_AGENT_DRIVERS.flatMap((driver) => { + const instances = + driver === "codex" ? providers?.filter((provider) => provider.driver === driver) : undefined; + return instances?.length + ? instances.map((provider) => ({ driver, provider, instanceId: provider.instanceId })) + : [{ driver, provider: byDriver.get(driver), instanceId: byDriver.get(driver)?.instanceId }]; + }); + // Keep the newly created row mounted while settings and provider snapshots catch up. + if (createdAccount) { + const index = primaryAgents.findIndex( + (agent) => agent.instanceId === createdAccount.instanceId, + ); + const [existing] = index >= 0 ? primaryAgents.splice(index, 1) : []; + primaryAgents.unshift( + existing ?? { + driver: "codex", + provider: undefined, + instanceId: createdAccount.instanceId, + }, + ); + } return (

{machineLabel}

- {primaryAgents.map(({ driver, provider }) => ( - { - if (provider === undefined || serverConfig === null) return; - setTerminalSession({ - environmentId, - driver, - providerInstanceId: provider.instanceId, - cwd: serverConfig.cwd, - command: provider.installed - ? resolveOnboardingProviderLoginCommand( - provider, - serverConfig.settings, - serverConfig.environment.platform.os, - ) - : resolveOnboardingProviderInstallCommand( - driver, - serverConfig.environment.platform.os, - ), - keybindings: serverConfig.keybindings, - }); - }} - /> - ))} + {primaryAgents.map(({ driver, provider, instanceId }) => + driver === "codex" && serverConfig !== null ? ( + + setCreatedAccount((account) => (account ? { ...account, autoStart: false } : null)) + } + terminalOpen={terminalSession?.driver === driver} + onOpenTerminal={() => { + if (provider === undefined) return; + setTerminalSession({ + environmentId, + driver, + providerInstanceId: provider.instanceId, + cwd: serverConfig.cwd, + command: provider.installed + ? resolveOnboardingProviderLoginCommand( + provider, + serverConfig.settings, + serverConfig.environment.platform.os, + ) + : resolveOnboardingProviderInstallCommand( + driver, + serverConfig.environment.platform.os, + ), + keybindings: serverConfig.keybindings, + }); + }} + /> + ) : ( + { + if (provider === undefined || serverConfig === null) return; + setTerminalSession({ + environmentId, + driver, + providerInstanceId: provider.instanceId, + cwd: serverConfig.cwd, + command: provider.installed + ? resolveOnboardingProviderLoginCommand( + provider, + serverConfig.settings, + serverConfig.environment.platform.os, + ) + : resolveOnboardingProviderInstallCommand( + driver, + serverConfig.environment.platform.os, + ), + keybindings: serverConfig.keybindings, + }); + }} + /> + ), + )}
+ {providers?.some( + (provider) => + provider.driver === "codex" && getOnboardingProviderState(provider) === "ready", + ) ? ( +
+ +
+ ) : null} + {addingAccount ? ( + setAddingAccount(false)} + onAccountCreated={(instanceId, displayName) => + setCreatedAccount({ instanceId, displayName, autoStart: true }) + } + /> + ) : null} {terminalSession !== null ? ( void; + readonly createdAccount: { + instanceId: ProviderInstanceId; + displayName: string; + autoStart: boolean; + } | null; + readonly onAutoStartConsumed: () => void; +}) { + const update = useAtomCommand(serverEnvironment.updateSettings, "Codex setup settings"); + const instanceId = + createdAccount?.instanceId ?? + provider?.instanceId ?? + defaultInstanceIdForDriver(ProviderDriverKind.make("codex")); + const settings = serverConfig.settings; + const instance = settings.providerInstances[instanceId] ?? { + driver: ProviderDriverKind.make("codex"), + enabled: settings.providers.codex.enabled, + config: createdAccount ? { enabled: true, setupMode: "managed" } : settings.providers.codex, + }; + 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") => { + void update({ + environmentId, + input: { + patch: buildProviderInstanceUpdatePatch({ + settings, + instanceId, + driver: ProviderDriverKind.make("codex"), + isDefault: instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("codex")), + instance: { + ...instance, + enabled: true, + config: { + ...(instance.config !== null && typeof instance.config === "object" + ? instance.config + : {}), + enabled: true, + setupMode, + }, + }, + }), + }, + }); + }; + return existingChosen ? ( + + ) : ( + + ); +} + function AgentCard({ driver, provider, @@ -756,20 +932,21 @@ function AgentCard({ }) { const meta = getDriverOption(ProviderDriverKind.make(driver)); const Icon = meta?.icon; - const displayName = driver === "claudeAgent" ? "Claude Code" : (meta?.label ?? driver); + const displayName = + provider?.displayName || (driver === "claudeAgent" ? "Claude Code" : (meta?.label ?? driver)); const summary = getProviderSummary(provider); const providerState = getOnboardingProviderState(provider); return ( -
+
{Icon ? ( ) : null}
{displayName}

- {summary.headline} - {summary.detail ? ` ยท ${summary.detail}` : ""} + {providerState === "ready" ? "Ready to code." : summary.headline} + {providerState !== "ready" && summary.detail ? ` ยท ${summary.detail}` : ""}

diff --git a/apps/web/src/components/settings/AddCodexAccountDialog.test.tsx b/apps/web/src/components/settings/AddCodexAccountDialog.test.tsx new file mode 100644 index 000000000000..512de24edc28 --- /dev/null +++ b/apps/web/src/components/settings/AddCodexAccountDialog.test.tsx @@ -0,0 +1,226 @@ +// @vitest-environment jsdom +import { act, useState, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + providers: [] as ServerProvider[], + update: vi.fn(), +})); +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: string) => + atom === "providers" + ? mocks.providers + : new Map([ + [ + "remote-test", + { + connection: { phase: "connected" }, + entry: { target: { label: "Remote computer" } }, + serverConfig: { providers: mocks.providers }, + }, + ], + ]), +})); +vi.mock("../../hooks/useSettings", () => ({ + useEnvironmentSettings: () => ({ providerInstances: {} }), +})); +vi.mock("../../state/server", () => ({ + serverEnvironment: { providersValueAtom: () => "providers", updateSettings: "update" }, +})); +vi.mock("../../state/presentation", () => ({ + environmentPresentations: { presentationsAtom: "presentations" }, +})); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => mocks.update })); +vi.mock("../../lib/utils", async (importOriginal) => ({ + ...(await importOriginal()), + randomUUID: () => "test", +})); +vi.mock("./settingsLayout", () => ({ + SettingsRow: ({ title, control }: { title: string; control: ReactNode }) => ( +
+ {title} + {control} +
+ ), +})); +vi.mock("./ChatGptUsageButton", () => ({ + ChatGptUsageButton: () => , +})); + +import { AddCodexAccountDialog } from "./AddCodexAccountDialog"; +import { ChatGptWelcomeCoordinator } from "./ChatGptWelcomeCoordinator"; +import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; + +const environmentId = EnvironmentId.make("remote-test"); +const instanceId = ProviderInstanceId.make("codex_test"); +function provider(auth: ServerProvider["auth"]): ServerProvider { + return { + instanceId, + driver: ProviderDriverKind.make("codex"), + displayName: "ChatGPT - Personal", + installed: true, + enabled: true, + version: "test", + status: "ready", + auth, + checkedAt: "2026-09-28T00:00:00.000Z", + models: [], + skills: [], + slashCommands: [], + setup: { canAuthenticate: true, canInstall: true }, + }; +} +function Onboarding({ inline = false }: { inline?: boolean }) { + const [adding, setAdding] = useState(true); + const [createdAccount, setCreatedAccount] = useState(null); + return ( + + + Connect your agents + {createdAccount &&

Pending account in onboarding

} +
+ {adding && ( + setAdding(false)} + onAccountCreated={inline ? setCreatedAccount : undefined} + renderSetup={() =>

Waiting for destination connection

} + /> + )} + +
+ ); +} +let root: Root; +let container: HTMLDivElement; +beforeEach(() => { + mocks.providers = []; + mocks.update.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); + localStorage.clear(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + Object.defineProperty(Element.prototype, "getAnimations", { + configurable: true, + value: () => [], + }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); +async function render(inline = false) { + await act(async () => root.render()); +} +async function click(label: string) { + const button = [...document.querySelectorAll("button")].find( + (element) => element.textContent?.trim() === label, + ); + expect(button).toBeDefined(); + await act(async () => button!.click()); +} +it("replaces account setup with one confirmation after the destination enables sharing", async () => { + await render(); + await click("Continue"); + mocks.providers = [provider({ status: "unauthenticated" })]; + await render(); + expect(document.body.textContent).toContain("Waiting for destination connection"); + // An identity login or incomplete transfer is not a usable sharing connection. + mocks.providers = [provider({ status: "authenticated", subscriptionSharing: false })]; + await render(); + expect(document.body.textContent).toContain("Waiting for destination connection"); + expect( + [...document.querySelectorAll('[role="dialog"][data-open]')] + .map((dialog) => dialog.textContent) + .join(" "), + ).not.toContain("Your ChatGPT plan is connected"); + mocks.providers = [ + provider({ status: "authenticated", subscriptionSharing: true, profileId: "profile-test" }), + ]; + await render(); + expect(document.body.textContent).not.toContain("Waiting for destination connection"); + expect( + [...document.querySelectorAll('[role="dialog"]')].filter((dialog) => + dialog.textContent?.includes("Your ChatGPT plan is connected"), + ), + ).toHaveLength(1); + await click("Continue"); + expect(document.body.textContent).toContain("Connect your agents"); + expect( + [...document.querySelectorAll('[role="dialog"][data-open]')] + .map((dialog) => dialog.textContent) + .join(" "), + ).not.toContain("Your ChatGPT plan is connected"); + expect(document.body.textContent).not.toContain("Finish later"); + await render(); + expect( + [...document.querySelectorAll('[role="dialog"][data-open]')] + .map((dialog) => dialog.textContent) + .join(" "), + ).not.toContain("Your ChatGPT plan is connected"); +}); +it("keeps unsuccessful setup open and allows finishing later without confirming a connection", async () => { + await render(); + await click("Continue"); + mocks.providers = [provider({ status: "unauthenticated" })]; + await render(); + expect(document.body.textContent).toContain("Waiting for destination connection"); + await click("Finish later"); + expect(document.body.textContent).toContain("Connect your agents"); + expect(document.body.textContent).not.toContain("Waiting for destination connection"); + expect( + [...document.querySelectorAll('[role="dialog"][data-open]')] + .map((dialog) => dialog.textContent) + .join(" "), + ).not.toContain("Your ChatGPT plan is connected"); +}); + +it("dismisses the name dialog before sign-in finishes when onboarding owns setup", async () => { + await render(true); + await click("Continue"); + expect(document.body.textContent).toContain("Pending account in onboarding"); + expect(document.body.textContent).not.toContain("Add ChatGPT account"); + expect(document.body.textContent).not.toContain("Waiting for destination connection"); + mocks.providers = [provider({ status: "unauthenticated" })]; + await render(true); + expect([...document.querySelectorAll('[role="dialog"][data-open]')]).toHaveLength(1); + mocks.providers = [provider({ status: "authenticated", subscriptionSharing: false })]; + await render(true); + expect([...document.querySelectorAll('[role="dialog"][data-open]')]).toHaveLength(1); + mocks.providers = [ + provider({ status: "authenticated", subscriptionSharing: true, profileId: "profile-test" }), + ]; + await render(true); + expect(document.body.textContent).toContain("Your ChatGPT plan is connected"); + await click("Continue"); + expect(document.body.textContent).toContain("Connect your agents"); + expect(document.body.textContent).not.toContain("Finish later"); +}); +it("keeps the name dialog available when creating the onboarding account fails", async () => { + mocks.update.mockResolvedValue({ _tag: "Failure" }); + await render(true); + await click("Continue"); + expect(document.body.textContent).toContain("Add ChatGPT account"); + expect(document.body.textContent).not.toContain("Pending account in onboarding"); + mocks.update.mockResolvedValue({ _tag: "Success", value: undefined }); + await click("Continue"); + expect(document.body.textContent).toContain("Pending account in onboarding"); + expect(document.body.textContent).not.toContain("Add ChatGPT account"); +}); diff --git a/apps/web/src/components/settings/AddCodexAccountDialog.tsx b/apps/web/src/components/settings/AddCodexAccountDialog.tsx new file mode 100644 index 000000000000..802e5d912c24 --- /dev/null +++ b/apps/web/src/components/settings/AddCodexAccountDialog.tsx @@ -0,0 +1,143 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + ProviderDriverKind, + ProviderInstanceId, + type EnvironmentId, + type ServerProvider, +} from "@t3tools/contracts"; +import { useEffect, useEffectEvent, useState, type ReactNode } from "react"; +import { usesChatGptSharing } from "@t3tools/shared/usageLimits"; + +import { useEnvironmentSettings } from "../../hooks/useSettings"; +import { randomUUID } from "../../lib/utils"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { Dialog } from "../ui/dialog"; +import { Input } from "../ui/input"; +import { WizardFooter, WizardHeader, WizardPanel, WizardPopup } from "../ui/wizard"; +import { SettingsRow } from "./settingsLayout"; + +export function AddCodexAccountDialog({ + environmentId, + onClose, + renderSetup, + onAccountCreated, +}: { + readonly environmentId: EnvironmentId; + readonly onClose: () => void; + readonly onAccountCreated?: + | ((instanceId: ProviderInstanceId, displayName: string) => void) + | undefined; + readonly renderSetup: (instanceId: ProviderInstanceId, provider: ServerProvider) => ReactNode; +}) { + const settings = useEnvironmentSettings(environmentId); + const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId)); + const update = useAtomCommand(serverEnvironment.updateSettings, "Add ChatGPT account"); + const [name, setName] = useState("Personal"); + const displayName = `ChatGPT - ${name.trim()}`; + const [instanceId, setInstanceId] = useState(null); + const [pending, setPending] = useState(false); + const provider = providers?.find((candidate) => candidate.instanceId === instanceId); + const connected = usesChatGptSharing(provider); + const closeAfterConnection = useEffectEvent(onClose); + useEffect(() => { + // The destination snapshot confirms remote transfer as well as local sign-in. + if (connected) closeAfterConnection(); + }, [connected]); + + const createAccount = async () => { + if (pending || !name.trim()) return; + setPending(true); + // The ID is routing identity; the name is editable and need not be unique. + const id = ProviderInstanceId.make(`codex_${randomUUID()}`); + const result = await update({ + environmentId, + input: { + patch: { + providerInstances: { + ...settings.providerInstances, + [id]: { + driver: ProviderDriverKind.make("codex"), + displayName, + enabled: true, + config: { enabled: true, setupMode: "managed" }, + }, + }, + }, + }, + }); + if (result._tag === "Success") { + if (onAccountCreated) { + onAccountCreated(id, displayName); + onClose(); + } else { + setInstanceId(id); + } + } + setPending(false); + }; + + return ( + { + if (!open) onClose(); + }} + > + + + + {instanceId ? ( + provider?.setup ? ( + renderSetup(instanceId, provider) + ) : ( + + ) + ) : ( +
{ + event.preventDefault(); + void createAccount(); + }} + > + setName(event.target.value)} + placeholder="e.g. Personal or Work" + /> + } + /> + + )} +
+ + {instanceId ? ( + + ) : ( + <> + + + + )} + +
+
+ ); +} diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 84d0a966880d..b1cb8bf3555f 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -14,6 +14,7 @@ import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hook import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Button } from "../ui/button"; +import { ChatGptConnectionButton } from "./ChatGptConnectionButton"; import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; import { Dialog } from "../ui/dialog"; import { Badge } from "../ui/badge"; @@ -30,6 +31,7 @@ import { type WizardNavigation, } from "./AddProviderInstanceDialog.logic"; import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps"; +import { AddManagedCodexAccountDialog } from "./CodexSetupSection"; const PROVIDER_ACCENT_SWATCHES = [ "#2563eb", @@ -126,6 +128,7 @@ export function AddProviderInstanceDialog({ const updateSettings = useUpdateEnvironmentSettings(environmentId); const [wizardStep, setWizardStep] = useState(0); + const [addingChatGptAccount, setAddingChatGptAccount] = useState(false); const [driver, setDriver] = useState(DEFAULT_DRIVER_KIND); const [label, setLabel] = useState(""); const [accentColor, setAccentColor] = useState(""); @@ -185,7 +188,10 @@ export function AddProviderInstanceDialog({ setHasAttemptedSubmit(true); if (instanceIdError !== null) return; - const config = configByDriver[driver] ?? {}; + const config = + driver === "codex" + ? { ...configByDriver[driver], setupMode: "existing" } + : (configByDriver[driver] ?? {}); const hasConfig = Object.keys(config).length > 0; const normalizedAccentColor = normalizeProviderAccentColor(accentColor); @@ -222,17 +228,21 @@ export function AddProviderInstanceDialog({ } }; + if (addingChatGptAccount) { + return ( + onOpenChange(false)} + /> + ); + } + return ( - + - Configure an additional provider instance on {environmentLabel} โ€” for example, a - second Codex install pointed at a different workspace. - - } + description={<>Add an account or configure a provider on {environmentLabel}.} > - {wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? ( + {wizardStep === 0 && driver === "codex" ? ( + <> + + setAddingChatGptAccount(true)} /> + + ) : wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? ( ) : ( diff --git a/apps/web/src/components/settings/ChatGptAccountPicker.tsx b/apps/web/src/components/settings/ChatGptAccountPicker.tsx new file mode 100644 index 000000000000..f3eaa907765d --- /dev/null +++ b/apps/web/src/components/settings/ChatGptAccountPicker.tsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import type { ProviderAuthMethod } from "@t3tools/contracts"; +import { RadioGroup, Radio } from "../ui/radio-group"; +import { + Dialog, + DialogPopup, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "../ui/dialog"; +import { ChatGptConnectionButton } from "./ChatGptConnectionButton"; + +export function ChatGptAccountPicker({ + open, + methods, + onClose, + onSelect, +}: { + open: boolean; + methods: readonly ProviderAuthMethod[]; + onClose: () => void; + onSelect: (methodId: string) => void; +}) { + const profiles = methods.filter((method) => method.id.startsWith("chatgpt-profile:")); + const [selection, setSelection] = useState(null); + const selectedMethodId = + selection === "chatgpt-change-account" + ? selection + : (profiles.find((profile) => profile.id === selection)?.id ?? + profiles[0]?.id ?? + "chatgpt-change-account"); + return ( + { + if (!value) onClose(); + }} + > + + + Reconnect ChatGPT + + On OpenAI, sign in with the account you choose here. + + +
+ setSelection(value)} + > + {profiles.map((profile) => ( + + ))} + + +
+ + onSelect(selectedMethodId)} /> + +
+
+ ); +} diff --git a/apps/web/src/components/settings/ChatGptConnectionButton.tsx b/apps/web/src/components/settings/ChatGptConnectionButton.tsx new file mode 100644 index 000000000000..d3e13c507266 --- /dev/null +++ b/apps/web/src/components/settings/ChatGptConnectionButton.tsx @@ -0,0 +1,12 @@ +import type { ComponentProps } from "react"; +import { OpenAI } from "../Icons"; +import { Button } from "../ui/button"; + +export function ChatGptConnectionButton({ children, ...props }: ComponentProps) { + return ( + + ); +} diff --git a/apps/web/src/components/settings/ChatGptUsageButton.tsx b/apps/web/src/components/settings/ChatGptUsageButton.tsx new file mode 100644 index 000000000000..e79fbdb96fea --- /dev/null +++ b/apps/web/src/components/settings/ChatGptUsageButton.tsx @@ -0,0 +1,19 @@ +import type { ComponentProps } from "react"; +import { ExternalLinkIcon } from "lucide-react"; +import { CHATGPT_USAGE_URL } from "@t3tools/shared/usageLimits"; +import { ensureLocalApi } from "../../localApi"; +import { Button } from "../ui/button"; + +export function ChatGptUsageButton(props: Omit, "onClick">) { + return ( + + ); +} diff --git a/apps/web/src/components/settings/ChatGptWelcomeCoordinator.tsx b/apps/web/src/components/settings/ChatGptWelcomeCoordinator.tsx new file mode 100644 index 000000000000..2d689ef6e6ee --- /dev/null +++ b/apps/web/src/components/settings/ChatGptWelcomeCoordinator.tsx @@ -0,0 +1,83 @@ +import { useAtomValue } from "@effect/atom-react"; +import { usesChatGptSharing } from "@t3tools/shared/usageLimits"; +import { useState } from "react"; +import { environmentPresentations } from "../../state/presentation"; +import { OpenAI } from "../Icons"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogPopup, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "../ui/dialog"; +import { ChatGptUsageButton } from "./ChatGptUsageButton"; + +const STORAGE_KEY = "t3:chatgpt-sharing-welcome:v1"; +function readAcknowledgedProfiles(): string[] { + try { + const value: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); + return Array.isArray(value) + ? value.filter((key): key is string => typeof key === "string") + : []; + } catch { + return []; + } +} + +/** Read the verified environment snapshot, never the browser callback acknowledgment. */ +export function ChatGptWelcomeCoordinator() { + const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const [acknowledged, setAcknowledged] = useState(readAcknowledgedProfiles); + const profiles = [...presentations].flatMap(([environmentId, presentation]) => + presentation.connection.phase !== "connected" + ? [] + : (presentation.serverConfig?.providers ?? []).filter(usesChatGptSharing).map((provider) => ({ + key: JSON.stringify([ + environmentId, + provider.instanceId, + provider.auth.profileId ?? provider.auth.email ?? "default", + ]), + environmentLabel: presentation.entry.target.label, + providerName: provider.displayName ?? "Codex", + })), + ); + const next = profiles.find((profile) => !acknowledged.includes(profile.key)); + const dismiss = () => { + if (!next) return; + const updated = [...acknowledged, next.key]; + setAcknowledged(updated); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + } catch { + /* Session dismissal still works. */ + } + }; + return ( + { + if (!open) dismiss(); + }} + > + + + + + + + + + + ); +} diff --git a/apps/web/src/components/settings/CodexSetupSection.logic.ts b/apps/web/src/components/settings/CodexSetupSection.logic.ts new file mode 100644 index 000000000000..46868d513db0 --- /dev/null +++ b/apps/web/src/components/settings/CodexSetupSection.logic.ts @@ -0,0 +1,8 @@ +export function readCodexSetupMode(config: unknown): "managed" | "existing" { + return config !== null && + typeof config === "object" && + "setupMode" in config && + config.setupMode === "managed" + ? "managed" + : "existing"; +} diff --git a/apps/web/src/components/settings/CodexSetupSection.test.tsx b/apps/web/src/components/settings/CodexSetupSection.test.tsx new file mode 100644 index 000000000000..c382aeb63cd9 --- /dev/null +++ b/apps/web/src/components/settings/CodexSetupSection.test.tsx @@ -0,0 +1,342 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ProviderAuthState, + type ServerProvider, +} from "@t3tools/contracts"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + auth: null as ProviderAuthState | null, + start: vi.fn(), + refresh: vi.fn(), + openExternal: vi.fn(), + cancel: vi.fn(), +})); +vi.mock("../../state/query", () => ({ + useEnvironmentQuery: (query: string | null) => ({ + data: + query === "auth" + ? mocks.auth + : query === "install" + ? { + phase: "idle", + installedVersion: "0.156.1", + version: "0.156.1", + source: "local", + } + : null, + error: null, + }), +})); +vi.mock("../../state/server", () => ({ + serverEnvironment: { + providerAuthState: () => "auth", + providerInstallState: () => "install", + startProviderAuth: "start", + refreshProviders: "refresh", + cancelProviderAuth: "cancel", + }, +})); +vi.mock("../../state/use-atom-command", () => ({ + useAtomCommand: (command: string) => + command === "start" + ? mocks.start + : command === "refresh" + ? mocks.refresh + : command === "cancel" + ? mocks.cancel + : vi.fn(), +})); +vi.mock("../../state/environments", () => ({ + useEnvironmentHttpBaseUrl: () => "http://127.0.0.1:15041", + usePrimaryEnvironmentId: () => null, + usePrimaryEnvironment: () => null, + useEnvironment: () => null, +})); +vi.mock("../../localApi", () => ({ + ensureLocalApi: () => ({ shell: { openExternal: mocks.openExternal } }), +})); +vi.mock("./ChatGptAccountPicker", () => ({ ChatGptAccountPicker: () => null })); +vi.mock("./ChatGptUsageButton", () => ({ ChatGptUsageButton: () => null })); +vi.mock("./AddCodexAccountDialog", () => ({ AddCodexAccountDialog: () => null })); +vi.mock("./RedactedSensitiveText", () => ({ + RedactedSensitiveText: () => Account email, +})); + +import { CodexSetupSection } from "./CodexSetupSection"; + +const environmentId = EnvironmentId.make("test"); +const instanceId = ProviderInstanceId.make("codex_test"); +let root: Root; +let container: HTMLDivElement; +let provider: ServerProvider; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + mocks.auth = { + instanceId, + phase: "idle", + flowId: null, + authorizationUrl: null, + expiresAt: null, + message: null, + methods: [], + }; + mocks.start.mockReset().mockImplementation(async () => { + mocks.auth = { + ...mocks.auth!, + phase: "starting", + flowId: "flow-test" as ProviderAuthState["flowId"], + }; + return { _tag: "Success", value: mocks.auth }; + }); + mocks.refresh.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); + mocks.openExternal.mockReset().mockResolvedValue(undefined); + mocks.cancel.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); + provider = { + instanceId, + driver: ProviderDriverKind.make("codex"), + displayName: "ChatGPT - Personal", + installed: true, + enabled: true, + version: "0.156.1", + status: "ready", + auth: { status: "unauthenticated" }, + checkedAt: "2026-09-28T00:00:00.000Z", + models: [], + skills: [], + slashCommands: [], + setup: { canAuthenticate: true, canInstall: true }, + }; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); +async function render( + autoStart = false, + currentProvider: ServerProvider | null = provider, + presentation: "onboarding" | "settings" = "onboarding", + onSignInCancelled?: () => void, +) { + await act(async () => + root.render( + {}} + onSignInCancelled={onSignInCancelled} + />, + ), + ); +} +async function signIn() { + await render(); + await act(async () => + [...container.querySelectorAll("button")] + .find((button) => button.textContent?.includes("Continue with ChatGPT"))! + .click(), + ); + mocks.auth = { ...mocks.auth!, phase: "verifying", message: "Checking provider sign-in." }; + await render(); +} +it("keeps successful sign-in pending until the provider snapshot arrives", async () => { + await signIn(); + mocks.auth = { + ...mocks.auth!, + phase: "succeeded", + message: "Sign-in complete.", + methods: [{ id: "chatgpt-profile:test", name: "Personal", description: null, type: "agent" }], + }; + await render(); + expect(mocks.refresh).toHaveBeenCalledTimes(1); + expect(mocks.refresh).toHaveBeenCalledWith({ environmentId, input: { instanceId } }); + expect(container.textContent).toContain("Finishing sign-in..."); + expect(container.textContent).not.toContain("Reconnect account"); + expect(container.textContent).not.toContain("Use a different account"); + expect(container.textContent).not.toContain("Cancel"); + expect(container.querySelector("button")?.disabled).toBe(true); + provider = { ...provider, auth: { status: "authenticated", subscriptionSharing: true } }; + await render(); + expect(container.textContent).toContain("Ready"); + expect(container.textContent).not.toContain("Finishing sign-in..."); + // A later loss of credentials must allow reconnecting rather than wait forever. + provider = { ...provider, auth: { status: "unauthenticated" } }; + await render(); + expect(container.textContent).toContain("Reconnect account"); + expect(container.textContent).not.toContain("Finishing sign-in..."); +}); +it("lets a failed sign-in retry instead of waiting for an authenticated snapshot", async () => { + await signIn(); + mocks.auth = { ...mocks.auth!, phase: "failed", message: "Sign-in could not finish." }; + await render(); + expect(container.textContent).toContain("Sign-in could not finish."); + expect(container.textContent).not.toContain("Finishing sign-in..."); + expect( + [...container.querySelectorAll("button")].find((button) => + button.textContent?.includes("Continue with ChatGPT"), + )?.disabled, + ).toBe(false); +}); +it("keeps one subtitle while starting sign-in and exposes help on that line", async () => { + vi.stubGlobal("desktopBridge", { + receiveProviderAuthCallback: vi.fn(() => new Promise(() => {})), + }); + await render(); + await act(async () => + [...container.querySelectorAll("button")] + .find((button) => button.textContent?.includes("Continue with ChatGPT"))! + .click(), + ); + mocks.auth = { ...mocks.auth!, phase: "starting", message: "Starting sign-in." }; + await render(); + expect(container.textContent).not.toContain("Starting sign-in."); + expect(container.textContent).toContain("Complete sign-in in your browser."); + mocks.auth = { + ...mocks.auth!, + phase: "waiting", + message: "Complete sign-in to continue.", + authorizationUrl: "https://auth.openai.com/test-sign-in", + }; + await render(); + expect(container.textContent?.match(/Complete sign-in in your browser\./g)).toHaveLength(1); + expect(container.textContent).not.toContain("Complete sign-in to continue."); + const trigger = container.querySelector( + 'button[aria-label="Having trouble signing in?"]', + ); + expect(trigger?.textContent).toBe("Complete sign-in in your browser."); + expect(container.querySelector('input[aria-label="ChatGPT sign-in redirect URL"]')).toBeNull(); + await act(async () => trigger!.click()); + expect( + container.querySelector('input[aria-label="ChatGPT sign-in redirect URL"]'), + ).not.toBeNull(); + const input = container.querySelector( + 'input[aria-label="ChatGPT sign-in redirect URL"]', + )!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call( + input, + "http://localhost/callback?code=test", + ); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => trigger!.click()); + expect(container.querySelector('input[aria-label="ChatGPT sign-in redirect URL"]')).toBeNull(); + await act(async () => trigger!.click()); + expect( + container.querySelector('input[aria-label="ChatGPT sign-in redirect URL"]') + ?.value, + ).toBe("http://localhost/callback?code=test"); + expect(mocks.start).toHaveBeenCalledTimes(1); +}); + +it("keeps a newly added account pending across delayed provider and auth snapshots", async () => { + let finishStart = () => {}; + mocks.start.mockImplementation( + () => + new Promise((resolve) => { + finishStart = () => resolve({ _tag: "Success", value: mocks.auth }); + }), + ); + await render(true, null); + expect(container.textContent).toContain("ChatGPT - Personal"); + expect(container.textContent).toContain("Complete sign-in in your browser."); + expect(container.textContent).not.toContain("Continue with ChatGPT"); + expect(mocks.start).not.toHaveBeenCalled(); + + await render(true); + expect(mocks.start).toHaveBeenCalledTimes(1); + expect(container.textContent).not.toContain("Continue with ChatGPT"); + expect(container.textContent).not.toContain("Starting sign-in"); + expect(container.textContent).toContain("Open sign-in page"); + await act(async () => finishStart()); + await render(false); + expect(container.textContent).not.toContain("Finishing sign-in"); + expect(container.textContent).not.toContain("Continue with ChatGPT"); + expect(container.textContent).toContain("Open sign-in page"); + expect(container.textContent).toContain("Complete sign-in in your browser."); + const idleAuth = mocks.auth; + mocks.auth = null; + await render(false); + expect(container.textContent).not.toContain("Finishing sign-in"); + expect(container.textContent).not.toContain("Continue with ChatGPT"); + expect(container.textContent).toContain("Open sign-in page"); + expect(container.textContent).toContain("Complete sign-in in your browser."); + mocks.auth = idleAuth; + mocks.auth = { + ...mocks.auth!, + phase: "waiting", + authorizationUrl: "https://auth.openai.com/test", + }; + await render(false); + expect(container.textContent).toContain("Open sign-in page"); + expect(mocks.start).toHaveBeenCalledTimes(1); +}); + +it("offers callback recovery in the local web settings dialog with a server-owned callback", async () => { + mocks.auth = { + ...mocks.auth!, + phase: "waiting", + flowId: "flow-test" as ProviderAuthState["flowId"], + authorizationUrl: "https://auth.openai.com/test-sign-in", + }; + await render(false, provider, "settings"); + const trigger = container.querySelector( + 'button[aria-label="Having trouble signing in?"]', + ); + expect(trigger).not.toBeNull(); + expect(container.querySelector('input[aria-label="ChatGPT sign-in redirect URL"]')).toBeNull(); + await act(async () => trigger!.click()); + expect( + container.querySelector('input[aria-label="ChatGPT sign-in redirect URL"]'), + ).not.toBeNull(); + await act(async () => + [...container.querySelectorAll("button")] + .find((button) => button.textContent?.includes("Try sign-in in your browser"))! + .click(), + ); + expect(mocks.openExternal).toHaveBeenCalledWith("https://auth.openai.com/test-sign-in"); +}); + +it("dismisses account setup immediately when cancelling a pending sign-in", async () => { + let finishCancellation!: () => void; + mocks.cancel.mockImplementation( + () => + new Promise((resolve) => { + finishCancellation = () => resolve({ _tag: "Success", value: undefined }); + }), + ); + mocks.auth = { + ...mocks.auth!, + phase: "waiting", + flowId: "flow-test" as ProviderAuthState["flowId"], + authorizationUrl: "https://auth.openai.com/test-sign-in", + }; + await render(false, provider, "settings", () => root.render(null)); + await act(async () => + [...container.querySelectorAll("button")] + .find((button) => button.textContent?.trim() === "Cancel")! + .click(), + ); + expect(container.querySelector('[aria-label="Codex setup"]')).toBeNull(); + expect(container.textContent).not.toContain("Continue with ChatGPT"); + expect(mocks.cancel).toHaveBeenCalledWith({ + environmentId, + input: { instanceId, flowId: "flow-test" }, + }); + await act(async () => finishCancellation()); +}); diff --git a/apps/web/src/components/settings/CodexSetupSection.tsx b/apps/web/src/components/settings/CodexSetupSection.tsx new file mode 100644 index 000000000000..32aa640b1e75 --- /dev/null +++ b/apps/web/src/components/settings/CodexSetupSection.tsx @@ -0,0 +1,1144 @@ +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import type { + ChatGptHandoffInput, + ChatGptTransferredProfile, + EnvironmentId, + ProviderInstanceId, + ServerProvider, +} from "@t3tools/contracts"; +import { codexAuthHandoffUrl } from "@t3tools/shared/codexAuthHandoff"; +import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl"; +import { isLoopbackHost } from "@t3tools/shared/preview"; +import { CheckIcon, ChevronRightIcon, ExternalLinkIcon } from "lucide-react"; +import { Children, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react"; + +import { ensureLocalApi } from "../../localApi"; +import { + useEnvironmentHttpBaseUrl, + usePrimaryEnvironmentId, + useEnvironment, +} from "../../state/environments"; +import { useEnvironmentQuery } from "../../state/query"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { ChatGptConnectionButton } from "./ChatGptConnectionButton"; +import { ChatGptUsageButton } from "./ChatGptUsageButton"; +import { ChatGptAccountPicker } from "./ChatGptAccountPicker"; +import { Input } from "../ui/input"; +import { OpenAI } from "../Icons"; +import { RedactedSensitiveText } from "./RedactedSensitiveText"; +import { SettingsRow } from "./settingsLayout"; +import { AddCodexAccountDialog } from "./AddCodexAccountDialog"; +import { getOnboardingProviderState } from "../../onboarding/providerReadiness.logic"; +import { getProviderSummary } from "./providerStatus"; + +const noop = () => undefined; + +interface CodexSetupSectionProps { + readonly environmentId: EnvironmentId; + readonly instanceId: ProviderInstanceId; + readonly provider: ServerProvider | undefined; + readonly mode: "managed" | "existing"; + readonly enabled: boolean; + readonly readOnly?: boolean; + readonly presentation?: "settings" | "onboarding"; + readonly onModeChange: (mode: "managed" | "existing") => void; + readonly autoStart?: boolean; + readonly displayName?: string | undefined; + readonly onAutoStartConsumed?: () => void; + readonly onSignInCancelled?: (() => void) | undefined; +} + +/** Welcome and provider settings run the same environment-owned setup flow. */ +export function CodexSetupSection(props: CodexSetupSectionProps) { + const [requested, setRequested] = useState(false); + const existingState = getOnboardingProviderState(props.provider); + const existingReady = props.enabled && existingState === "ready"; + const existingAuthenticated = props.provider?.auth.status === "authenticated"; + const existingChecking = existingState === "checking"; + const existingSummary = getProviderSummary(props.provider); + const content = + props.mode === "existing" && props.presentation === "onboarding" ? ( + + Signed in as{" "} + + . + + ) : ( + "Connected with your Codex CLI." + ) + ) : existingChecking ? ( + "Checking your Codex CLI..." + ) : props.provider?.installed ? ( + existingSummary.headline + ) : ( + "Code with your ChatGPT subscription." + ) + } + control={ + existingReady ? ( + + + Ready + + ) : existingChecking ? ( + Checking... + ) : existingAuthenticated ? ( + {existingSummary.headline} + ) : ( + { + setRequested(true); + props.onModeChange("managed"); + }} + > + Continue with ChatGPT + + ) + } + secondaryControl={ + !existingAuthenticated && !existingReady && !existingChecking ? ( + + ) : null + } + /> + ) : props.mode === "existing" ? null : props.provider?.setup === undefined ? ( + props.presentation === "onboarding" ? ( + } + control={ + + } + secondaryControl={ + + } + /> + ) : ( + + ) + ) : ( + { + setRequested(false); + props.onAutoStartConsumed?.(); + }} + /> + ); + return content; +} + +/** All add-Codex entry points use the same managed account setup. */ +export function AddManagedCodexAccountDialog({ + environmentId, + onClose, + onAccountCreated, +}: { + readonly environmentId: EnvironmentId; + readonly onClose: () => void; + readonly onAccountCreated?: + | ((instanceId: ProviderInstanceId, displayName: string) => void) + | undefined; +}) { + return ( + ( + + )} + /> + ); +} + +function ManagedCodexSetup({ + environmentId, + instanceId, + provider, + enabled, + readOnly, + onModeChange, + autoStart, + onAutoStartConsumed, + allowExistingCli = true, + presentation, + displayName, + onSignInCancelled, +}: CodexSetupSectionProps & { + readonly autoStart: boolean; + readonly onAutoStartConsumed: () => void; + readonly allowExistingCli?: boolean; +}) { + const target = { environmentId, input: { instanceId } }; + const authQuery = useEnvironmentQuery(serverEnvironment.providerAuthState(target)); + const installQuery = useEnvironmentQuery(serverEnvironment.providerInstallState(target)); + const [handoff, setHandoff] = useState<{ + environmentId: EnvironmentId; + input: ChatGptHandoffInput; + } | null>(null); + const handoffQuery = useEnvironmentQuery( + handoff ? serverEnvironment.chatGptHandoffState(handoff) : null, + ); + const auth = handoffQuery.data?.phase === "auth" ? handoffQuery.data.state : authQuery.data; + const [accountPickerOpen, setAccountPickerOpen] = useState(false); + const [requestedMethodId, setRequestedMethodId] = useState("chatgpt"); + const reconnectEmail = auth?.methods?.find((method) => method.id === "chatgpt")?.accountEmail; + const requestedAccountEmail = auth?.methods?.find( + (method) => method.id === requestedMethodId, + )?.accountEmail; + const hasSavedAccount = auth?.methods?.some((method) => method.id.startsWith("chatgpt-profile:")); + const url = auth?.interaction?.type === "browser" ? auth.interaction.url : auth?.authorizationUrl; + const installation = installQuery.data; + const httpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); + const local = httpBaseUrl !== null && isLoopbackHost(new URL(httpBaseUrl).hostname); + const options = { reportFailure: false, reportDefect: false }; + const startAuth = useAtomCommand(serverEnvironment.startProviderAuth, options); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, options); + const completeAuth = useAtomCommand(serverEnvironment.completeProviderAuth, options); + const cancelAuth = useAtomCommand(serverEnvironment.cancelProviderAuth, options); + const logoutAuth = useAtomCommand(serverEnvironment.logoutProviderAuth, options); + const startInstall = useAtomCommand(serverEnvironment.startProviderInstall, options); + const cancelInstall = useAtomCommand(serverEnvironment.cancelProviderInstall, options); + const reconnectProfile = useAtomCommand(serverEnvironment.chatGptReconnectProfile, options); + const importProfile = useAtomCommand(serverEnvironment.chatGptImportProfile, options); + const clientCallback = + !handoff && (!local || window.desktopBridge?.receiveProviderAuthCallback !== undefined); + const remoteWeb = clientCallback && !window.desktopBridge?.receiveProviderAuthCallback; + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const primaryEnvironment = useEnvironment(primaryEnvironmentId); + const primaryHttpBaseUrl = useEnvironmentHttpBaseUrl(primaryEnvironmentId); + const primaryAuthEnvironmentId = + !local && + primaryEnvironmentId && + primaryEnvironment?.connection.phase === "connected" && + primaryHttpBaseUrl && + isLoopbackHost(new URL(primaryHttpBaseUrl).hostname) && + (window.desktopBridge !== undefined || isLoopbackHost(window.location.hostname)) + ? primaryEnvironmentId + : null; + const returnUrl = new URL(window.location.href); + if (returnUrl.pathname === "/welcome") returnUrl.hash = `agents:${environmentId}`; + if (returnUrl.pathname === "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/settings/providers") + returnUrl.searchParams.set("instanceId", instanceId); + const needsManualCallback = remoteWeb; + const callbackHelpId = useId(); + const [callbackHelpOpen, setCallbackHelpOpen] = useState(false); + const [callbackDraft, setCallbackDraft] = useState({ flowId: "", value: "" }); + const callbackUrl = callbackDraft.flowId === auth?.flowId ? callbackDraft.value : ""; + const [pending, setPending] = useState(false); + const [awaitingProvider, setAwaitingProvider] = useState<"sign-in" | "handoff" | null>(null); + const pendingRef = useRef(false); + const [error, setError] = useState(null); + const continueWithSignIn = useRef(null); + const openRequested = useRef(false); + const openedFlow = useRef(null); + const [autoStartHandled, setAutoStartHandled] = useState(false); + const installed = installation?.installedVersion != null; + const authenticated = provider?.auth.status === "authenticated"; + const updateAvailable = + installed && + installation?.source !== "local" && + installation?.version != null && + installation.version !== installation.installedVersion; + // Auth receipts and provider snapshots arrive independently. Keep the current + // attempt pending until its authenticated snapshot arrives, even after success. + const finishingSignIn = + awaitingProvider !== null && + !authenticated && + (auth?.phase === "succeeded" || awaitingProvider === "handoff"); + useEffect(() => { + if (authenticated || auth?.phase === "failed" || auth?.phase === "cancelled") { + setAwaitingProvider(null); + } + }, [authenticated, auth?.phase]); + useEffect(() => { + if (awaitingProvider && auth?.phase === "succeeded") { + void refreshProviders({ environmentId, input: { instanceId } }); + } + }, [awaitingProvider, auth?.phase, refreshProviders, environmentId, instanceId]); + const startingAutomatically = autoStart && !autoStartHandled; + const waitingForAuthState = + awaitingProvider === "sign-in" && !authenticated && (auth === null || auth.phase === "idle"); + const authInProgress = + finishingSignIn || + waitingForAuthState || + handoff !== null || + auth?.phase === "starting" || + auth?.phase === "waiting" || + auth?.phase === "verifying"; + const installActive = + installation?.phase === "downloading" || + installation?.phase === "extracting" || + installation?.phase === "verifying"; + const authActive = startingAutomatically || authInProgress || (pending && !installActive); + const unavailable = + readOnly || !enabled || pending || authQuery.error !== null || installQuery.error !== null; + const busy = pending || authInProgress || installActive; + + const run = useCallback( + async ( + request: () => Promise>, + onSuccess?: (value: A) => void, + ) => { + if (pendingRef.current) return false; + pendingRef.current = true; + setPending(true); + setError(null); + let succeeded = false; + try { + const result = await request(); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const failure = squashAtomCommandFailure(result); + setError(failure instanceof Error ? failure.message : "Codex setup failed. Try again."); + } + } else { + succeeded = true; + onSuccess?.(result.value); + } + } catch { + setError("Codex setup failed. Try again."); + } + pendingRef.current = false; + setPending(false); + return succeeded; + }, + [], + ); + + const handoffId = useId(); + const handoffSequence = useRef(0); + const importedAttempt = useRef(null); + const [transferFailed, setTransferFailed] = useState(false); + const transferProfile = useCallback( + async (profile: ChatGptTransferredProfile) => { + const succeeded = await run(() => + importProfile({ environmentId, input: { instanceId, profile } }), + ); + if (succeeded) { + setAwaitingProvider("handoff"); + setHandoff(null); + } + setTransferFailed(!succeeded); + }, + [run, importProfile, environmentId, instanceId], + ); + useEffect(() => { + if (!handoff || handoffQuery.data?.phase !== "finished") return; + const attemptId = handoff.input.attemptId; + if (importedAttempt.current === attemptId) return; + importedAttempt.current = attemptId; + void transferProfile(handoffQuery.data.profile); + }, [handoff, handoffQuery.data, transferProfile]); + useEffect(() => { + if (!handoff) return; + if ( + handoffQuery.error || + (handoffQuery.data?.phase === "auth" && + ["failed", "cancelled"].includes(handoffQuery.data.state.phase)) + ) { + setError( + handoffQuery.data?.phase === "auth" + ? (handoffQuery.data.state.message ?? "ChatGPT sign-in could not finish. Try again.") + : "ChatGPT sign-in on the primary environment was interrupted. Try again.", + ); + setHandoff(null); + } + }, [handoff, handoffQuery.data, handoffQuery.error]); + const cancelSignIn = useCallback(() => { + setAwaitingProvider(null); + if (handoff) { + setHandoff(null); + return Promise.resolve(); + } + return run(() => cancelAuth({ environmentId, input: { instanceId, flowId: auth!.flowId! } })); + }, [handoff, run, cancelAuth, environmentId, instanceId, auth]); + + const signIn = useCallback( + async (methodId = "chatgpt") => { + if (pendingRef.current) return; + openRequested.current = true; + setTransferFailed(false); + setRequestedMethodId(methodId); + const returnUrl = new URL(window.location.href); + if (returnUrl.pathname === "/welcome") returnUrl.hash = `agents:${environmentId}`; + if (returnUrl.pathname === "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/settings/providers") + returnUrl.searchParams.set("instanceId", instanceId); + if (primaryAuthEnvironmentId) { + const attemptId = `${handoffId}:${++handoffSequence.current}`; + const succeeded = await run( + () => reconnectProfile({ environmentId, input: { instanceId, methodId } }), + (profile) => + setHandoff({ + environmentId: primaryAuthEnvironmentId, + input: { + instanceId, + environmentId, + attemptId, + returnUrl: returnUrl.toString(), + profile, + }, + }), + ); + if (!succeeded) openRequested.current = false; + return; + } + if ( + !(await run( + () => + startAuth({ + environmentId, + input: { + instanceId, + methodId, + returnUrl: returnUrl.toString(), + callbackMode: clientCallback ? "client" : "server", + }, + }), + () => setAwaitingProvider("sign-in"), + )) + ) { + openRequested.current = false; + } + }, + [ + environmentId, + instanceId, + run, + startAuth, + clientCallback, + primaryAuthEnvironmentId, + reconnectProfile, + handoffId, + ], + ); + + const setup = useCallback( + async (methodId = "chatgpt") => { + if (unavailable || busy) return; + if (installed && !updateAvailable) { + await signIn(methodId); + } else { + continueWithSignIn.current = methodId; + if (!(await run(() => startInstall({ environmentId, input: { instanceId } })))) { + continueWithSignIn.current = null; + } + } + }, + [ + unavailable, + busy, + installed, + updateAvailable, + signIn, + run, + startInstall, + environmentId, + instanceId, + ], + ); + + useEffect(() => { + if ( + !autoStart || + autoStartHandled || + unavailable || + busy || + installation === null || + !provider?.setup?.canInstall + ) + return; + setAutoStartHandled(true); + onAutoStartConsumed(); + void setup(); + }, [ + autoStart, + autoStartHandled, + unavailable, + busy, + installation, + provider?.setup?.canInstall, + onAutoStartConsumed, + setup, + ]); + + useEffect(() => { + if (!continueWithSignIn.current || pending || installActive) return; + if (installation?.phase === "failed" || installation?.phase === "cancelled") { + continueWithSignIn.current = null; + } else if (installed) { + const methodId = continueWithSignIn.current; + continueWithSignIn.current = null; + void signIn(methodId); + } + }, [pending, installActive, installation?.phase, installed, signIn]); + + const receivingCallback = useRef(null); + const flowId = auth?.flowId; + const openPage = useCallback( + async (authorizationUrl: string) => { + try { + const receive = window.desktopBridge?.receiveProviderAuthCallback; + if (receive && clientCallback && flowId) { + if (receivingCallback.current === authorizationUrl) { + await ensureLocalApi().shell.openExternal(authorizationUrl); + return; + } + receivingCallback.current = authorizationUrl; + const callbackUrl = await receive(authorizationUrl); + if (receivingCallback.current !== authorizationUrl) return; + receivingCallback.current = null; + await run(() => + completeAuth({ environmentId, input: { instanceId, flowId, callbackUrl } }), + ); + } else { + await ensureLocalApi().shell.openExternal(authorizationUrl); + } + } catch { + setError( + "Could not finish sign-in on this computer. Try again or paste the redirect URL below.", + ); + } + }, + [clientCallback, flowId, run, completeAuth, environmentId, instanceId], + ); + + useEffect(() => { + if (!clientCallback || !url || !window.desktopBridge?.cancelProviderAuthCallback) return; + return () => { + if (receivingCallback.current === url) receivingCallback.current = null; + void window.desktopBridge?.cancelProviderAuthCallback?.(url).catch(() => undefined); + }; + }, [clientCallback, url]); + + useEffect(() => { + if ( + !openRequested.current || + auth?.phase !== "waiting" || + !auth.flowId || + !url || + openedFlow.current === auth.flowId + ) + return; + openedFlow.current = auth.flowId; + openRequested.current = false; + if (!remoteWeb) void openPage(url); + }, [auth?.phase, auth?.flowId, url, openPage, remoteWeb]); + + const runtimeDescription = + installation?.phase === "downloading" + ? `Downloading ${(installation.downloadedBytes / 1_000_000).toFixed(1)}${installation.totalBytes === null ? "" : ` of ${(installation.totalBytes / 1_000_000).toFixed(1)}`} MB.` + : installation?.phase === "extracting" + ? "Installing Codex." + : installation?.phase === "verifying" + ? "Checking Codex." + : installed + ? `${installation?.source === "local" ? "Using your installed Codex" : "Managed by T3 Code"}${installation?.installedVersion ? ` ยท v${installation.installedVersion}` : ""}.` + : (installation?.message ?? "T3 Code downloads and manages Codex for you."); + const accountDescription = finishingSignIn ? ( + "Finishing sign-in..." + ) : installActive ? ( + runtimeDescription + ) : authActive || auth?.phase === "failed" || auth?.phase === "cancelled" ? ( + auth?.phase === "waiting" && requestedAccountEmail ? ( + `Continue as ${requestedAccountEmail} on OpenAI.` + ) : ( + (auth?.message ?? "Finish signing in in your browser.") + ) + ) : authenticated ? ( + provider?.auth.email?.trim() ? ( + <> + Signed in as{" "} + + . + + ) : ( + "Signed in with ChatGPT." + ) + ) : ( + (reconnectEmail ?? "Use your ChatGPT subscription.") + ); + + const handoffUrl = + needsManualCallback && url && auth?.flowId && providerAuthReturnUrl(returnUrl.toString()) + ? codexAuthHandoffUrl( + { + authorizationUrl: url, + returnUrl: returnUrl.toString(), + environmentId, + instanceId, + flowId: auth.flowId, + }, + import.meta.env.DEV, + ) + : null; + const waitingControl = ( + + ); + const callbackCompletion = + !handoff && auth?.phase === "waiting" && url ? ( +
+

If sign-in doesn't return to T3 Code, paste the URL from the final localhost page.

+
{ + event.preventDefault(); + if (!auth.flowId || !callbackUrl.trim()) return; + const value = callbackUrl.trim(); + const submittedFlowId = auth.flowId; + void run(() => + completeAuth({ + environmentId, + input: { instanceId, flowId: submittedFlowId, callbackUrl: value }, + }), + ).then((connected) => { + if (connected) setCallbackDraft({ flowId: submittedFlowId, value: "" }); + }); + }} + > +
+ + setCallbackDraft({ flowId: auth.flowId ?? "", value: event.target.value }) + } + /> +
+ +
+ {!remoteWeb ? ( +
+ +
+ ) : null} + {handoffUrl ? ( +
+ Other ways to connect + +
+ ) : null} +
+ ) : null; + const callbackHelpContent = + callbackCompletion && callbackHelpOpen ? ( +
+ {callbackCompletion} +
+ ) : null; + const callbackFallback = needsManualCallback ? ( + callbackCompletion + ) : callbackCompletion ? ( +
+ setCallbackHelpOpen((open) => !open)} + /> + {callbackHelpContent} +
+ ) : null; + + const accountPicker = ( + setAccountPickerOpen(false)} + onSelect={(methodId) => { + setAccountPickerOpen(false); + void setup( + auth?.methods?.some((method) => method.id === methodId) + ? methodId + : "chatgpt-change-account", + ); + }} + /> + ); + + const logoutWarning = + auth?.phase === "idle" && auth.message?.startsWith("Signed out locally.") ? auth.message : null; + + if (presentation === "onboarding") { + const setupError = + logoutWarning ?? + error ?? + (authQuery.error || installQuery.error + ? "Could not read setup status. Reconnect and try again." + : installation?.phase === "failed" + ? installation.message + : null); + return ( + <> + {accountPicker} + + Signed in as{" "} + + . + + ) : ( + "Connected to ChatGPT." + ) + ) : callbackCompletion && !needsManualCallback ? ( + setCallbackHelpOpen((open) => !open)} + /> + ) : startingAutomatically || + waitingForAuthState || + auth?.phase === "starting" || + auth?.phase === "waiting" || + (pending && !installActive) ? ( + + ) : authActive || auth?.phase === "failed" || auth?.phase === "cancelled" ? ( + accountDescription + ) : ( + "Code with your ChatGPT subscription." + ) + } + control={ + authenticated && !busy ? ( + + + Ready + + ) : authActive ? ( + waitingControl + ) : ( + { + if (hasSavedAccount) setAccountPickerOpen(true); + else void setup(); + }} + > + {installActive || pending + ? "Setting up..." + : hasSavedAccount + ? "Reconnect account" + : "Continue with ChatGPT"} + + ) + } + secondaryControl={ + (authActive && !finishingSignIn) || installActive ? ( + + ) : !authActive && !authenticated && hasSavedAccount ? ( + + ) : !authActive && !authenticated && allowExistingCli ? ( + + ) : null + } + > + {needsManualCallback ? callbackFallback : callbackHelpContent} + {setupError ? ( +

+ {setupError} +

+ ) : null} +
+ + ); + } + + return ( +
+ {accountPicker} + + {authActive ? ( + <> + {waitingControl} + {!finishingSignIn && ( + + )} + + ) : installActive ? ( + + ) : authenticated ? ( + <> + + + + ) : ( + <> + { + if (hasSavedAccount) setAccountPickerOpen(true); + else void setup(); + }} + > + {pending + ? "Setting up..." + : hasSavedAccount + ? "Reconnect account" + : "Continue with ChatGPT"} + + {hasSavedAccount ? ( + + ) : null} + + )} +
+ } + /> + {authenticated ? ( +
+ +
+ ) : null} + {logoutWarning ? ( +

+ {logoutWarning} +

+ ) : null} + {callbackFallback ?
{callbackFallback}
: null} + {error || authQuery.error || installQuery.error || installation?.phase === "failed" ? ( +

+ {error ?? + (installation?.phase === "failed" + ? installation.message + : "Could not read Codex setup status. Reconnect and try again.")} +

+ ) : null} +
+ ); +} + +/** The server selects the executable for managed instances; local config cannot override it. */ +export function CodexManagedRuntimeFields({ + environmentId, + instanceId, + provider, +}: { + readonly environmentId: EnvironmentId; + readonly instanceId: ProviderInstanceId; + readonly provider: ServerProvider | undefined; +}) { + const installation = useEnvironmentQuery( + serverEnvironment.providerInstallState({ + environmentId, + input: { instanceId }, + }), + ); + const executablePath = installation.data?.executablePath ?? ""; + return ( + <> + + +
+ } + /> + + +
+ } + /> + + +
+ } + /> + + ); +} + +function CodexSignInDescription({ + label = "Complete sign-in in your browser.", + expanded = false, + controls, + onToggle, +}: { + readonly label?: string; + readonly expanded?: boolean; + readonly controls?: string; + readonly onToggle?: () => void; +}) { + if (!onToggle) return
{label}
; + return ( + + ); +} + +/** A single, calm setup row for the first-run welcome screen. */ +function CodexWelcomeCard({ + title, + description, + control, + secondaryControl, + children, +}: { + readonly title: string; + readonly description: ReactNode; + readonly control?: ReactNode; + readonly secondaryControl?: ReactNode; + readonly children?: ReactNode; +}) { + const footer = Children.toArray(children); + return ( +
+
+ +
+

{title}

+
{description}
+
+ {control || secondaryControl ? ( +
+ {secondaryControl} + {control} +
+ ) : null} +
+ {footer.length > 0 ? ( +
{footer}
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/settings/FoldedSettingsSection.tsx b/apps/web/src/components/settings/FoldedSettingsSection.tsx index d12af6209235..cb6f09af1001 100644 --- a/apps/web/src/components/settings/FoldedSettingsSection.tsx +++ b/apps/web/src/components/settings/FoldedSettingsSection.tsx @@ -16,12 +16,14 @@ export function FoldedSettingsSection({ title, summary, control, + headerPlacement = "inside", children, }: { readonly id: string; readonly title: string; readonly summary?: string | null; readonly control?: ReactNode; + readonly headerPlacement?: "inside" | "outside"; readonly children: ReactNode; }) { const [open, setOpen] = useState(false); @@ -34,6 +36,38 @@ export function FoldedSettingsSection({ if (!open) setOpen(true); } + if (headerPlacement === "outside") { + return ( +
+ +
+
+

+ + {title} + + +

+ {control} +
+ + {children} + +
+
+
+ ); + } + return (
}> diff --git a/apps/web/src/components/settings/ProviderAuthCallbackCoordinator.tsx b/apps/web/src/components/settings/ProviderAuthCallbackCoordinator.tsx new file mode 100644 index 000000000000..a4cf60b60cda --- /dev/null +++ b/apps/web/src/components/settings/ProviderAuthCallbackCoordinator.tsx @@ -0,0 +1,50 @@ +import { useEffect, useRef, useState } from "react"; +import { pendingProviderAuthDelivery, clearProviderAuthDelivery } from "../../providerAuthDelivery"; +import { serverEnvironment } from "../../state/server"; +import { useEnvironments } from "../../state/environments"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { toastManager } from "../ui/toast"; + +/** Hosted web receives only the one-time code; the selected environment verifies and stores tokens. */ +export function ProviderAuthCallbackCoordinator() { + const completeAuth = useAtomCommand(serverEnvironment.completeProviderAuth, { + reportFailure: false, + }); + const { environments } = useEnvironments(); + const [delivery, setDelivery] = useState(pendingProviderAuthDelivery); + const started = useRef(false); + const environmentId = delivery?.environmentId; + const connected = environments.some( + (environment) => + environment.environmentId === environmentId && environment.connection.phase === "connected", + ); + useEffect(() => { + const input = delivery; + if (!input || started.current || !connected) return; + started.current = true; + void completeAuth({ + environmentId: input.environmentId, + input: { instanceId: input.instanceId, flowId: input.flowId, callbackUrl: input.callbackUrl }, + }) + .then((result) => { + if (result._tag === "Failure") + toastManager.add({ + type: "error", + title: "ChatGPT sign-in couldn't finish", + description: "Return to the provider and try again.", + }); + }) + .catch(() => + toastManager.add({ + type: "error", + title: "ChatGPT sign-in couldn't finish", + description: "Reconnect to the environment and try again.", + }), + ) + .finally(() => { + setDelivery(undefined); + clearProviderAuthDelivery(); + }); + }, [completeAuth, connected, delivery]); + return null; +} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index ff0546f5012d..fea12160a3cf 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -49,6 +49,8 @@ import { ProviderInstanceIcon, providerInstanceInitials } from "../chat/Provider import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { SettingsRow, SettingsSection } from "./settingsLayout"; +import { FoldedSettingsSection } from "./FoldedSettingsSection"; +import { readCodexSetupMode } from "./CodexSetupSection.logic"; import { getProviderVersionAdvisoryPresentation, PROVIDER_STATUS_STYLES, @@ -387,6 +389,7 @@ interface ProviderInstanceCardProps { */ readonly headerAction?: ReactNode | undefined; readonly setup?: ReactNode; + readonly runtime?: ReactNode; readonly hiddenModels: ReadonlyArray; readonly favoriteModels: ReadonlyArray; readonly modelOrder: ReadonlyArray; @@ -430,6 +433,7 @@ export function ProviderInstanceCard({ onDelete, headerAction, setup, + runtime, hiddenModels, favoriteModels, modelOrder, @@ -654,11 +658,6 @@ export function ProviderInstanceCard({ {displayName} - {String(instanceId) !== String(instance.driver) ? ( - - {instanceId} - - ) : null} {versionLabel ? ( {versionLabel} @@ -860,6 +859,26 @@ export function ProviderInstanceCard({ ); + const runtimeFields = driverOption ? ( + + ) : ( + + This instance uses {String(instance.driver)}, + which is not available in this build. Its configuration is preserved. + + } + /> + ); + return ( <> @@ -907,33 +926,31 @@ export function ProviderInstanceCard({ {setup ? {setup} : null} - - {driverOption ? ( - - ) : ( - - This instance uses{" "} - {String(instance.driver)}, which is not - available in this build. Its configuration is preserved. - - } - /> - )} - + {instance.driver === "codex" && readCodexSetupMode(instance.config) === "managed" ? ( +
+ + {runtime ?? runtimeFields} + +
+ ) : ( + + {runtimeFields} + + )} { ).toEqual([primaryId, relayId, sshId]); }); + it("restricts device selection to the settings scope and falls back inside that scope", () => { + const options = buildProviderEnvironmentOptions(environments, primaryId, [sshId, relayId]); + expect(options.map((environment) => environment.environmentId)).toEqual([relayId, sshId]); + expect(resolveSelectedProviderEnvironmentId(options, primaryId, primaryId)).toBe(relayId); + expect(resolveSelectedProviderEnvironmentId(options, sshId, primaryId)).toBe(sshId); + expect(buildProviderEnvironmentOptions(environments, primaryId, [])).toEqual([]); + }); + it("keeps a valid selection, then falls back to primary or the first environment", () => { const options = buildProviderEnvironmentOptions(environments, primaryId); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts index b415b5f69b07..e6748ae0fc7f 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -20,18 +20,22 @@ export function isProviderSettingsEnvironmentAvailable(input: { export function buildProviderEnvironmentOptions( environments: ReadonlyArray, primaryEnvironmentId: EnvironmentId | null, + environmentIds?: readonly EnvironmentId[], ): ReadonlyArray { - return environments.toSorted((left, right) => { - const leftIsPrimary = left.environmentId === primaryEnvironmentId; - const rightIsPrimary = right.environmentId === primaryEnvironmentId; - if (leftIsPrimary !== rightIsPrimary) { - return leftIsPrimary ? -1 : 1; - } - return ( - left.label.localeCompare(right.label) || - String(left.environmentId).localeCompare(String(right.environmentId)) - ); - }); + const allowed = environmentIds ? new Set(environmentIds) : null; + return environments + .filter((environment) => !allowed || allowed.has(environment.environmentId)) + .toSorted((left, right) => { + const leftIsPrimary = left.environmentId === primaryEnvironmentId; + const rightIsPrimary = right.environmentId === primaryEnvironmentId; + if (leftIsPrimary !== rightIsPrimary) { + return leftIsPrimary ? -1 : 1; + } + return ( + left.label.localeCompare(right.label) || + String(left.environmentId).localeCompare(String(right.environmentId)) + ); + }); } export function resolveSelectedProviderEnvironmentId( diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 6722969075a1..aa947d5d1de7 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -84,6 +84,8 @@ import { ExpandableText } from "./ExpandableText"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { UsageProviderSettings } from "./UsageProviderSettings"; import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection"; +import { CodexSetupSection, CodexManagedRuntimeFields } from "./CodexSetupSection"; +import { readCodexSetupMode } from "./CodexSetupSection.logic"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { searchableSetting } from "./settingsSearch"; import { @@ -265,6 +267,7 @@ interface ProviderSettingsTarget { readonly environmentId?: EnvironmentId; readonly instanceId?: ProviderInstanceId; readonly scoped?: boolean; + readonly environmentIds?: readonly EnvironmentId[]; } export function ProviderSettingsPanel(target: ProviderSettingsTarget) { @@ -283,8 +286,9 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { const primaryEnvironmentId = usePrimaryEnvironmentId(); const searchTargetId = useSettingsSearchTargetId(); const options = useMemo( - () => buildProviderEnvironmentOptions(environments, primaryEnvironmentId), - [environments, primaryEnvironmentId], + () => + buildProviderEnvironmentOptions(environments, primaryEnvironmentId, target.environmentIds), + [environments, primaryEnvironmentId, target.environmentIds], ); // Raw user intent; the effective selection is re-derived every render so a // device that drops out of the catalog falls back without erasing the pick โ€” @@ -935,6 +939,17 @@ export function EnvironmentProviderSettings({ selected={mode === "list" && selectedRow?.instanceId === row.instanceId} onSelect={mode === "list" ? () => setSelectedInstanceId(row.instanceId) : undefined} readOnly={readOnly} + runtime={ + mode === "editor" && + row.driver === "codex" && + readCodexSetupMode(row.instance.config) === "managed" ? ( + + ) : undefined + } setup={ mode === "editor" && row.driver === "antigravity" ? ( updateProviderInstance(row, { ...row.instance, enabled: true })} /> + ) : mode === "editor" && + row.driver === "codex" && + readCodexSetupMode(row.instance.config) === "managed" ? ( + + updateProviderInstance(row, { + ...row.instance, + enabled: true, + config: { + ...(row.instance.config !== null && typeof row.instance.config === "object" + ? row.instance.config + : {}), + enabled: true, + setupMode, + }, + }) + } + /> ) : null } onUpdate={(next) => { @@ -1021,10 +1060,11 @@ export function EnvironmentProviderSettings({ return ( <> - -
- {deviceTabs} -
+ {readOnly ? ( @@ -1073,7 +1113,11 @@ export function EnvironmentProviderSettings({ )}
-
+ } + > + {deviceTabs ? ( +
{deviceTabs}
+ ) : null} {readOnly ? ( ) { ); } -function useResolvedSettingsScope(search: SettingsScopeSearch) { +function useResolvedSettingsScope(rawSearch: SettingsScopeSearch, singleEnvironment: boolean) { const groups = useSettingsProjectGroups(); const { environments: availableEnvironments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const search = useMemo( + () => + singleEnvironment + ? selectSingleEnvironmentScope( + rawSearch, + resolveSettingsScope(rawSearch, groups, availableEnvironments), + availableEnvironments, + primaryEnvironmentId, + ) + : rawSearch, + [availableEnvironments, groups, primaryEnvironmentId, rawSearch, singleEnvironment], + ); const scope = useMemo( () => resolveSettingsScope(search, groups, availableEnvironments), [availableEnvironments, groups, search], @@ -81,12 +94,13 @@ function useResolvedSettingsScope(search: SettingsScopeSearch) { ) ?? targets[0] ?? null; - return { scope, groups, ...selected, targets, target }; - }, [availableEnvironments, groups, primaryEnvironmentId, projectFiles, scope]); + return { scope, groups, search, ...selected, targets, target }; + }, [availableEnvironments, groups, primaryEnvironmentId, projectFiles, scope, search]); } const SettingsScopeContext = createContext< | (ReturnType & { + singleEnvironment: boolean; search: SettingsScopeSearch; selectScope: (next: SettingsScopeSearch) => void; }) @@ -97,15 +111,17 @@ export function SettingsScopeProvider({ search, onChange, children, + singleEnvironment = false, }: { + singleEnvironment?: boolean; search: SettingsScopeSearch; onChange: (next: SettingsScopeSearch) => void; children: ReactNode; }) { - const resolved = useResolvedSettingsScope(search); + const resolved = useResolvedSettingsScope(search, singleEnvironment); const value = useMemo( - () => ({ ...resolved, search, selectScope: onChange }), - [onChange, resolved, search], + () => ({ ...resolved, singleEnvironment, selectScope: onChange }), + [onChange, resolved, singleEnvironment], ); return {children}; } diff --git a/apps/web/src/components/settings/SettingsScopeSentence.tsx b/apps/web/src/components/settings/SettingsScopeSentence.tsx index 5341b72e73e5..4223ebc90775 100644 --- a/apps/web/src/components/settings/SettingsScopeSentence.tsx +++ b/apps/web/src/components/settings/SettingsScopeSentence.tsx @@ -40,6 +40,7 @@ interface SettingsScopeMenuProps { readonly value: SettingsScopeSearch; readonly groups: readonly SidebarProjectSnapshot[]; readonly environments: readonly EnvironmentPresentation[]; + readonly singleEnvironment: boolean; readonly onChange: (next: SettingsScopeSearch) => void; } @@ -56,6 +57,7 @@ export function SettingsScopeSentence() { if (scope === null || SETTINGS_DEVICE_ONLY_PATHS.has(pathname)) return null; const props: SettingsScopeMenuProps = { value: scope.search, + singleEnvironment: scope.singleEnvironment, groups: scope.groups, environments, onChange: scope.selectScope, @@ -105,7 +107,13 @@ function ScopeMenu({ ); } -function EnvironmentScopeMenu({ value, groups, environments, onChange }: SettingsScopeMenuProps) { +function EnvironmentScopeMenu({ + value, + groups, + environments, + onChange, + singleEnvironment, +}: SettingsScopeMenuProps) { const resolved = resolveSettingsScope(value, groups, environments); const environmentValue = environmentAxisValue( value, @@ -131,7 +139,9 @@ function EnvironmentScopeMenu({ value, groups, environments, onChange }: Setting ? settingsScopeEnvironmentLabel(selected, environments) : environmentValue !== ALL_ENVIRONMENTS_VALUE ? "Unavailable environment" - : "All environments" + : singleEnvironment + ? "No environments" + : "All environments" } > - - - - All environments - - - - + {!singleEnvironment ? ( + <> + + + + All environments + + + + + + ) : null} {environments.map((environment) => ( diff --git a/apps/web/src/components/settings/settingsScopeAxis.test.ts b/apps/web/src/components/settings/settingsScopeAxis.test.ts index 54a87dc956a2..02ba0f134d5d 100644 --- a/apps/web/src/components/settings/settingsScopeAxis.test.ts +++ b/apps/web/src/components/settings/settingsScopeAxis.test.ts @@ -6,9 +6,12 @@ import { projectAxisValue, selectEnvironmentAxis, selectProjectAxis, + selectSingleEnvironmentScope, settingsScopeEnvironmentLabel, } from "./settingsScopeAxis"; +import { resolveSettingsScope } from "./settingsScope"; + const first = { environmentId: EnvironmentId.make("first"), label: "Development", @@ -86,3 +89,64 @@ describe("environmentAxisValue", () => { expect(environmentAxisValue({ machine: "desk" }, "laptop")).toBe("desk"); }); }); + +describe("single environment provider scope", () => { + const environments = [first, second].map((environment) => ({ + ...environment, + connection: { phase: "connected" as const }, + })); + + it("defaults to the primary environment and resolves exactly one write target", () => { + const search = selectSingleEnvironmentScope( + {}, + resolveSettingsScope({}, [], environments), + environments, + second.environmentId, + ); + expect(search).toEqual({ machine: second.environmentId }); + expect(resolveSettingsScope(search, [], environments).environmentIds).toEqual([ + second.environmentId, + ]); + }); + + it("keeps an explicit offline or removed environment instead of switching targets", () => { + const search = { machine: "removed" }; + expect( + selectSingleEnvironmentScope( + search, + resolveSettingsScope(search, [], environments), + environments, + first.environmentId, + ), + ).toEqual(search); + const offline = environments.map((environment) => ({ + ...environment, + connection: { phase: "offline" as const }, + })); + expect( + selectSingleEnvironmentScope( + { machine: second.environmentId }, + resolveSettingsScope({ machine: second.environmentId }, [], offline), + offline, + first.environmentId, + ), + ).toEqual({ machine: second.environmentId }); + }); + + it("falls back to a connected candidate while retaining project and checkout narrowing", () => { + const search = { project: "app", checkout: "checkout" }; + const scope = { + kind: "all" as const, + label: "app", + members: [], + environmentIds: [second.environmentId], + }; + expect(selectSingleEnvironmentScope(search, scope, environments, first.environmentId)).toEqual({ + ...search, + machine: second.environmentId, + }); + expect(selectSingleEnvironmentScope({}, resolveSettingsScope({}, [], []), [], null)).toEqual( + {}, + ); + }); +}); diff --git a/apps/web/src/components/settings/settingsScopeAxis.ts b/apps/web/src/components/settings/settingsScopeAxis.ts index 166a31a40c05..0c0915c54c88 100644 --- a/apps/web/src/components/settings/settingsScopeAxis.ts +++ b/apps/web/src/components/settings/settingsScopeAxis.ts @@ -1,5 +1,5 @@ import type { EnvironmentPresentation } from "../../state/environments"; -import type { SettingsScopeSearch } from "./settingsScope"; +import type { ResolvedSettingsScope, SettingsScopeSearch } from "./settingsScope"; type ScopeEnvironment = Pick; @@ -53,3 +53,25 @@ export function selectProjectAxis(search: SettingsScopeSearch, value: string): S if (search.machine) next.machine = search.machine; return next; } + +/** Provider configuration always belongs to one environment, including project scopes. */ +export function selectSingleEnvironmentScope( + search: SettingsScopeSearch, + scope: ResolvedSettingsScope, + environments: readonly { + readonly environmentId: EnvironmentPresentation["environmentId"]; + readonly connection: Pick; + }[], + primaryEnvironmentId: string | null, +): SettingsScopeSearch { + if (search.machine || scope.kind === "unavailable") return search; + const selectedIds = new Set(scope.environmentIds); + const candidates = environments.filter((environment) => + selectedIds.has(environment.environmentId), + ); + const selected = + candidates.find((environment) => environment.environmentId === primaryEnvironmentId) ?? + candidates.find((environment) => environment.connection.phase === "connected") ?? + candidates[0]; + return selected ? { ...search, machine: selected.environmentId } : search; +} diff --git a/apps/web/src/components/ui/wizard.tsx b/apps/web/src/components/ui/wizard.tsx index 925b263f4530..39878e7d7d7f 100644 --- a/apps/web/src/components/ui/wizard.tsx +++ b/apps/web/src/components/ui/wizard.tsx @@ -8,10 +8,19 @@ import { DialogPopup, DialogHeader, DialogTitle, DialogDescription, DialogFooter /** Compose a wizard from its header, panel, and footer; flow logic stays with the caller. */ export function WizardPopup({ children, + size = "default", ...props -}: Omit, "className" | "style">) { +}: Omit, "className" | "style"> & { + readonly size?: "default" | "wide"; +}) { return ( - +
{children}
); diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index bdb291836328..d99ce30b475c 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -1,5 +1,7 @@ import { + CHATGPT_USAGE_URL, collectLimitAccounts, + collectExternalUsageLinks, collectLimitNotices, collectLimitPools, cursorUsageWindowDetails, @@ -11,9 +13,10 @@ import { type LimitPoolWindow, remainingPercent, } from "@t3tools/shared/usageLimits"; -import { AlertTriangleIcon, TicketIcon } from "lucide-react"; +import { AlertTriangleIcon, ExternalLinkIcon, TicketIcon } from "lucide-react"; import { Fragment, type ReactNode, useState } from "react"; +import { ensureLocalApi } from "../../localApi"; import { usePrimarySettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { formatUpcomingTimestamp } from "../../timestampFormat"; @@ -21,6 +24,7 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { getDriverOption } from "../settings/providerDriverMeta"; import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; import { Button } from "../ui/button"; +import { OpenAI } from "../Icons"; import { Alert, AlertTitle } from "../ui/alert"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { @@ -573,6 +577,7 @@ export function UsageLimitsPooled({ }) { const pools = collectLimitPools(collectLimitAccounts(presentations), now); const notices = collectLimitNotices(presentations); + const externalLinks = collectExternalUsageLinks(presentations); const cursorPromptAt = Math.max( pools.findIndex((pool) => pool.driver === "codex"), @@ -580,7 +585,7 @@ export function UsageLimitsPooled({ ) + 1; return (
- {pools.length === 0 && notices.length === 0 && !cursorPrompt ? ( + {pools.length === 0 && notices.length === 0 && !cursorPrompt && externalLinks.length === 0 ? (

No provider on the selected environments reports subscription limits.

@@ -592,6 +597,36 @@ export function UsageLimitsPooled({ ))} {cursorPromptAt === pools.length ? cursorPrompt : null} + {externalLinks.map((link) => ( +
+
+ {link.url === CHATGPT_USAGE_URL ? ( +
+ +
+ ))}
); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index f77963ebac77..09611858b139 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -1,3 +1,5 @@ +import { ChatGptUsageButton } from "../settings/ChatGptUsageButton"; +import { usesChatGptSharing } from "@t3tools/shared/usageLimits"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import { @@ -516,6 +518,17 @@ export function UsagePage() {
+ {[...presentations].some( + ([id, presentation]) => + (selectedEnvironmentIds === null || selectedEnvironmentIds.has(id)) && + presentation.serverConfig?.providers.some(usesChatGptSharing), + ) ? ( +
+ ChatGPT shared usage + +
+ ) : null} + {summaryRows.map((row) => { if (row.kind === "enable") { return ( diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 8cafbd5009b4..81d1c2140af6 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -4,6 +4,7 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router" import "./index.css"; +import { prepareProviderAuthDelivery } from "./providerAuthDelivery"; import { isElectron } from "./env"; import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { getRouter } from "./router"; @@ -14,6 +15,8 @@ import { import { AppRoot } from "./AppRoot"; import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; +prepareProviderAuthDelivery(); + // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); diff --git a/apps/web/src/onboarding/providerReadiness.logic.test.ts b/apps/web/src/onboarding/providerReadiness.logic.test.ts index b0b3a4d57515..22a1d4d95fa6 100644 --- a/apps/web/src/onboarding/providerReadiness.logic.test.ts +++ b/apps/web/src/onboarding/providerReadiness.logic.test.ts @@ -80,6 +80,16 @@ describe("getOnboardingProviderState", () => { it("waits for a provider snapshot before offering an action", () => { expect(getOnboardingProviderState(undefined)).toBe("checking"); }); + it("waits for the initial CLI probe before offering installation or sign-in", () => { + expect( + getOnboardingProviderState({ + ...readyCodex, + installed: false, + status: "warning", + auth: { status: "unknown" }, + }), + ).toBe("checking"); + }); }); describe("selectOnboardingProvidersByDriver", () => { diff --git a/apps/web/src/onboarding/providerReadiness.logic.ts b/apps/web/src/onboarding/providerReadiness.logic.ts index c9d0f910ef53..08dd69bd3b03 100644 --- a/apps/web/src/onboarding/providerReadiness.logic.ts +++ b/apps/web/src/onboarding/providerReadiness.logic.ts @@ -36,6 +36,8 @@ function quoteProviderBinary( export function getOnboardingProviderState(provider: ServerProvider | undefined) { if (provider === undefined) return "checking"; if (!provider.enabled || provider.status === "disabled") return "disabled"; + if (!provider.installed && provider.status === "warning" && provider.auth.status === "unknown") + return "checking"; if (!provider.installed) return "install"; if (provider.auth.status === "unauthenticated") return "signIn"; if (provider.status === "ready") return "ready"; diff --git a/apps/web/src/providerAuthDelivery.test.ts b/apps/web/src/providerAuthDelivery.test.ts new file mode 100644 index 000000000000..7e98b848215d --- /dev/null +++ b/apps/web/src/providerAuthDelivery.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { codexAuthDeliveryUrl } from "@t3tools/shared/codexAuthHandoff"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import { + prepareProviderAuthDelivery, + pendingProviderAuthDelivery, + clearProviderAuthDelivery, +} from "./providerAuthDelivery"; + +afterEach(() => { + clearProviderAuthDelivery(); + vi.unstubAllGlobals(); +}); + +describe("hosted provider callback bootstrap", () => { + it("restores the welcome step and removes the code before router initialization", () => { + const authorizationUrl = new URL("https://auth.openai.com/api/accounts/authorize"); + authorizationUrl.search = new URLSearchParams({ + client_id: "dynamic_agent_client", + response_type: "code", + redirect_uri: "http://127.0.0.1:54213/auth/callback", + state: "a".repeat(43), + code_challenge_method: "S256", + code_challenge: "b".repeat(43), + }).toString(); + const input = { + authorizationUrl: authorizationUrl.toString(), + returnUrl: "https://app.t3.codes/welcome#agents:remote-environment", + environmentId: EnvironmentId.make("remote-environment"), + instanceId: ProviderInstanceId.make("work"), + flowId: "flow-one", + }; + const callbackUrl = `http://127.0.0.1:54213/auth/callback?state=${"a".repeat(43)}&code=test-code&client_id=oaiapp_test`; + const href = codexAuthDeliveryUrl(input, callbackUrl); + const replaceState = vi.fn(); + vi.stubGlobal("window", { + location: new URL(href), + history: { state: { navigation: 1 }, replaceState }, + }); + prepareProviderAuthDelivery(); + expect(replaceState).toHaveBeenCalledWith({ navigation: 1 }, "", input.returnUrl); + expect(pendingProviderAuthDelivery()?.callbackUrl).toBe(callbackUrl); + expect(pendingProviderAuthDelivery()?.environmentId).toBe(input.environmentId); + }); + it("also removes malformed callback fragments", () => { + const replaceState = vi.fn(); + vi.stubGlobal("window", { + location: new URL("https://app.t3.codes/settings/providers#codex-auth=invalid-code"), + history: { state: null, replaceState }, + }); + prepareProviderAuthDelivery(); + expect(pendingProviderAuthDelivery()).toBeUndefined(); + expect(replaceState).toHaveBeenCalledWith(null, "", "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/settings/providers"); + }); +}); diff --git a/apps/web/src/providerAuthDelivery.ts b/apps/web/src/providerAuthDelivery.ts new file mode 100644 index 000000000000..369ef217fd00 --- /dev/null +++ b/apps/web/src/providerAuthDelivery.ts @@ -0,0 +1,19 @@ +import { readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff"; + +let pending: ReturnType; + +/** Remove the code before the router, tracing, or welcome screen sees the incoming address. */ +export function prepareProviderAuthDelivery() { + if (!window.location.hash.startsWith("#codex-auth=")) return; + pending = readCodexAuthDelivery(window.location.href); + window.history.replaceState( + window.history.state, + "", + pending?.returnUrl ?? `${window.location.pathname}${window.location.search}`, + ); +} + +export const pendingProviderAuthDelivery = () => pending; +export const clearProviderAuthDelivery = () => { + pending = undefined; +}; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index b9dcbae5c327..3f82749f81db 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -32,6 +32,8 @@ import { ThreadNotificationCoordinator } from "../components/ThreadNotificationC import { QueuedMessageSender } from "../components/QueuedMessageSender"; import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; +import { ChatGptWelcomeCoordinator } from "../components/settings/ChatGptWelcomeCoordinator"; +import { ProviderAuthCallbackCoordinator } from "../components/settings/ProviderAuthCallbackCoordinator"; import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useDefaultThemeAdoption } from "../hooks/useDefaultTheme"; @@ -216,6 +218,8 @@ function RootRouteView() { + + { // Send every axis so the retain middleware sees an explicit target // even when the choice is "all", which is the absence of a key. diff --git a/apps/web/src/routes/welcome.tsx b/apps/web/src/routes/welcome.tsx index 3a86f1462b3d..b69911c0abcb 100644 --- a/apps/web/src/routes/welcome.tsx +++ b/apps/web/src/routes/welcome.tsx @@ -1,10 +1,15 @@ import { createFileRoute, redirect, useLocation, useNavigate } from "@tanstack/react-router"; import { useState } from "react"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Option from "effect/Option"; import { NoProjectsHero } from "../components/NoProjectsHero"; import { WelcomeWizard } from "../components/onboarding/WelcomeWizard"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +const decodeEnvironmentId = Schema.decodeOption(EnvironmentId); + /** Onboarding overlays the workspace. Visiting /welcome reopens setup. */ export const Route = createFileRoute("/welcome")({ beforeLoad: ({ context }) => { @@ -19,6 +24,10 @@ export const Route = createFileRoute("/welcome")({ function WelcomeRouteView() { const { authGateState } = Route.useRouteContext(); const navigate = useNavigate(); + const hash = useLocation({ select: (location) => location.hash }); + const resumeEnvironmentId = hash.startsWith("agents:") + ? Option.getOrUndefined(decodeEnvironmentId(hash.slice("agents:".length))) + : undefined; // The root shell can remount this pending outlet after the location changes. // Never reopen setup while the destination route is still loading. const isWelcomeRoute = useLocation({ select: (location) => location.pathname === "/welcome" }); @@ -35,6 +44,7 @@ function WelcomeRouteView() { {isWelcomeRoute && !dismissed ? ( { setDismissed(true); if (projectRef !== undefined) { diff --git a/docs/internals/providers.md b/docs/internals/providers.md index b843b26cb0e8..2e9330ebac7f 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -48,6 +48,13 @@ loopback listener may be on another machine. Forward only the callback for the o a successful callback HTTP request is not proof that provider authentication finished. The native process owns token exchange and storage. +Managed ChatGPT sign-in for a remote environment can finish on a local primary. The +[primary handoff](../../apps/server/src/provider/CodexChatGptHandoff.ts) uses an ephemeral +credential store and the destination's environment ID. It exchanges and verifies the code before +transferring the issued client registration and tokens. Only the destination persists and refreshes +that session; retaining a primary refresh session would race refresh-token rotation. Without a local +primary, the client uses the remote callback completion flow. + Antigravity sign-out closes admission to new processes and stops existing processes before clearing account metadata. Otherwise a helper or resumed session could retain the old account. Cached model lists do not establish current access, and an authoritative empty catalog must clear the old list. diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 4f725378ffbf..055bfe87db81 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -41,6 +41,7 @@ export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string; type RpcMethod = WsRpcProtocolClient[TTag]; export type EnvironmentSubscriptionRpcTag = + | typeof WS_METHODS.codexAuthCallbackSubscribe | typeof WS_METHODS.providerAuthSubscribe | typeof WS_METHODS.providerInstallSubscribe | typeof ORCHESTRATION_WS_METHODS.subscribeShell @@ -62,6 +63,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.terminalAttach; export type EnvironmentStreamCommandRpcTag = + | typeof WS_METHODS.chatGptHandoffSubscribe | typeof WS_METHODS.cloudInstallRelayClient | typeof WS_METHODS.serverUpdateServerWithProgress | typeof WS_METHODS.gitRunStackedAction; diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 84946bf9e165..0b9b0006e9fe 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -60,6 +60,7 @@ interface EnvironmentQueryAtomOptions extends EnvironmentAtomOpt } interface EnvironmentSubscriptionAtomOptions { + readonly sensitiveInput?: boolean; readonly label: string; readonly subscribe: (input: Input) => Stream.Stream; readonly idleTtlMs?: number; @@ -581,7 +582,11 @@ export function createEnvironmentSubscriptionAtomFamily( .atom(followStreamInEnvironment(target.environmentId, options.subscribe(target.input))) .pipe( Atom.setIdleTTL(options.idleTtlMs ?? 5 * 60_000), - Atom.withLabel(`${options.label}:${key}`), + Atom.withLabel( + options.sensitiveInput + ? `${options.label}:${target.environmentId}` + : `${options.label}:${key}`, + ), ); }); return (target: { readonly environmentId: EnvironmentIdType; readonly input: Input }) => diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 5919c0af263a..07ac64ebf69d 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -29,6 +29,7 @@ import { createEnvironmentQueryAtomFamily, createEnvironmentRpcQueryAtomFamily, createEnvironmentRpcSubscriptionAtomFamily, + createEnvironmentSubscriptionAtomFamily, createRuntimeCommand, scheduleAtomCommandEffect, } from "./runtime.ts"; @@ -994,6 +995,27 @@ export function createServerEnvironmentAtoms( label: "environment-data:provider:auth-complete", tag: WS_METHODS.providerAuthComplete, }), + chatGptReconnectProfile: createEnvironmentRpcCommand(runtime, { + label: "environment-data:chatgpt:reconnect-profile", + tag: WS_METHODS.chatGptReconnectProfile, + }), + chatGptImportProfile: createEnvironmentRpcCommand(runtime, { + label: "environment-data:chatgpt:import-profile", + tag: WS_METHODS.chatGptImportProfile, + }), + chatGptHandoffState: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:chatgpt:handoff", + sensitiveInput: true, + // OAuth must not be replayed when the connection recovers. + subscribe: (input: EnvironmentRpcInput) => + runStream(WS_METHODS.chatGptHandoffSubscribe, input), + idleTtlMs: 0, + }), + codexAuthCallbackState: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:codex:auth-callback", + tag: WS_METHODS.codexAuthCallbackSubscribe, + idleTtlMs: 0, + }), cancelProviderAuth: createEnvironmentRpcCommand(runtime, { label: "environment-data:provider:auth-cancel", tag: WS_METHODS.providerAuthCancel, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 7caf6d1cac75..700e5184cf83 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1212,6 +1212,9 @@ export interface DesktopBridge { items: readonly ContextMenuItem[], position?: { x: number; y: number }, ) => Promise; + /** Receives a local OAuth code for a sign-in owned by a remote environment. */ + receiveProviderAuthCallback?: (authorizationUrl: string) => Promise; + cancelProviderAuthCallback?: (authorizationUrl: string) => Promise; openExternal: (url: string) => Promise; /** * Open a System Settings pane by identifier. Optional: older desktop builds diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 309b61935485..aa2b3b7e1580 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -809,6 +809,7 @@ export type RuntimeWarningPayload = typeof RuntimeWarningPayload.Type; const RuntimeErrorPayload = Schema.Struct({ message: TrimmedNonEmptyStringSchema, + code: Schema.optional(TrimmedNonEmptyStringSchema), class: Schema.optional(RuntimeErrorClass), detail: Schema.optional(Schema.Unknown), }); diff --git a/packages/contracts/src/providerSetup.ts b/packages/contracts/src/providerSetup.ts index 04261e934c89..985d29c38855 100644 --- a/packages/contracts/src/providerSetup.ts +++ b/packages/contracts/src/providerSetup.ts @@ -1,6 +1,7 @@ import * as Schema from "effect/Schema"; import { + EnvironmentId, ForwardCompatibleArray, ForwardCompatibleOptional, IsoDateTime, @@ -13,10 +14,27 @@ export const ProviderSetupInput = Schema.Struct({ }); export type ProviderSetupInput = typeof ProviderSetupInput.Type; +export const CodexAuthCallbackInput = Schema.Struct({ + authorizationUrl: Schema.String.check(Schema.isMaxLength(16_384)), + returnUrl: Schema.String.check(Schema.isMaxLength(4_096)), + environmentId: EnvironmentId, + instanceId: ProviderInstanceId, + flowId: TrimmedNonEmptyString.check(Schema.isMaxLength(128)), +}); +export type CodexAuthCallbackInput = typeof CodexAuthCallbackInput.Type; +export const CodexAuthCallbackState = Schema.Union([ + Schema.Struct({ phase: Schema.Literal("ready") }), + Schema.Struct({ + phase: Schema.Literal("finished"), + callbackUrl: Schema.String.check(Schema.isMaxLength(16_384)), + }), +]); + const SetupOperationId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); export const ProviderAuthMethod = Schema.Struct({ id: SetupOperationId, + accountEmail: Schema.optional(TrimmedNonEmptyString), name: TrimmedNonEmptyString, description: Schema.NullOr(Schema.String), type: Schema.Literals(["agent", "terminal", "credentials"]), @@ -86,6 +104,8 @@ export type ProviderAuthResponse = typeof ProviderAuthResponse.Type; export const ProviderAuthStartInput = Schema.Struct({ instanceId: ProviderInstanceId, methodId: Schema.optionalKey(SetupOperationId), + returnUrl: Schema.optionalKey(Schema.String), + callbackMode: Schema.optionalKey(Schema.Literals(["server", "client"])), }); export type ProviderAuthStartInput = typeof ProviderAuthStartInput.Type; @@ -153,6 +173,8 @@ export const ProviderInstallState = Schema.Struct({ totalBytes: Schema.NullOr(ByteCount), version: Schema.NullOr(TrimmedNonEmptyString), installedVersion: Schema.NullOr(TrimmedNonEmptyString), + executablePath: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), + source: Schema.optionalKey(Schema.NullOr(Schema.Literals(["managed", "local"]))), canRemove: Schema.Boolean, message: Schema.NullOr(Schema.String), }); @@ -178,3 +200,56 @@ export class ProviderSetupError extends Schema.TaggedError() return this.detail; } } + +// A selected registration is reused on the primary without copying refresh ownership. +export const ChatGptReconnectProfile = Schema.Struct({ + clientId: Schema.String.check(Schema.isPattern(/^oaiapp_[\w-]+$/u)), + subject: Schema.optionalKey(Schema.String), + email: Schema.optionalKey(Schema.NullOr(Schema.String)), + redirectUri: Schema.optionalKey( + Schema.String.check( + Schema.isPattern(/^http:\/\/(?:127\.0\.0\.1|localhost):[1-9]\d{0,4}\/auth\/callback$/u), + ), + ), + connectionLabel: Schema.optionalKey(Schema.String), + sharingEnabled: Schema.optionalKey(Schema.Boolean), + idTokenHint: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(16_384))), +}); +export type ChatGptReconnectProfile = typeof ChatGptReconnectProfile.Type; +export const ChatGptTransferredProfile = Schema.Struct({ + registration: ChatGptReconnectProfile, + credentials: Schema.Struct({ + clientId: Schema.String, + accessToken: Schema.NonEmptyString.check(Schema.isMaxLength(16_384)), + refreshToken: Schema.NullOr(Schema.String.check(Schema.isMaxLength(16_384))), + idToken: Schema.NonEmptyString.check(Schema.isMaxLength(16_384)), + issuer: Schema.String, + expiresAt: Schema.Finite, + earliestRefreshAt: Schema.NullOr(Schema.Finite), + scopes: Schema.Array(Schema.String), + subject: Schema.String, + email: Schema.NullOr(Schema.String), + }), +}); +export type ChatGptTransferredProfile = typeof ChatGptTransferredProfile.Type; +export const ChatGptReconnectProfileInput = Schema.Struct({ + instanceId: ProviderInstanceId, + methodId: Schema.String, +}); +export const ChatGptImportProfileInput = Schema.Struct({ + instanceId: ProviderInstanceId, + profile: ChatGptTransferredProfile, +}); +export const ChatGptHandoffInput = Schema.Struct({ + instanceId: ProviderInstanceId, + environmentId: EnvironmentId, + attemptId: Schema.String.check(Schema.isMaxLength(128)), + returnUrl: Schema.String.check(Schema.isMaxLength(4_096)), + profile: Schema.NullOr(ChatGptReconnectProfile), +}); +export type ChatGptHandoffInput = typeof ChatGptHandoffInput.Type; +export const ChatGptHandoffState = Schema.Union([ + Schema.Struct({ phase: Schema.Literal("auth"), state: ProviderAuthState }), + Schema.Struct({ phase: Schema.Literal("finished"), profile: ChatGptTransferredProfile }), +]); +export type ChatGptHandoffState = typeof ChatGptHandoffState.Type; diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 0f126f291aec..5576d27f5dbb 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -51,6 +51,13 @@ export const ServerProviderUsageLimits = Schema.Struct({ checkedAt: IsoDateTime, windows: ForwardCompatibleArray(ServerProviderUsageWindow), resetCredits: Schema.optional(ServerProviderResetCredits), + /** Provider-owned usage settings when quota windows are not available to the client. */ + externalUsage: Schema.optional( + Schema.Struct({ + label: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + }), + ), unavailable: Schema.optional( Schema.Struct({ reason: Schema.Literals(["unsupported", "probeFailed"]), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index dbc143048a72..6e436a46db5d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1,8 +1,17 @@ +import { + ChatGptReconnectProfileInput, + ChatGptReconnectProfile, + ChatGptImportProfileInput, + ChatGptHandoffInput, + ChatGptHandoffState, +} from "./providerSetup.ts"; import * as Schema from "effect/Schema"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { + CodexAuthCallbackInput, + CodexAuthCallbackState, ProviderAuthCancelInput, ProviderAuthCompleteInput, ProviderAuthState, @@ -301,6 +310,10 @@ export const WS_METHODS = { providerAuthStart: "provider.auth.start", providerConsumeResetCredit: "provider.consumeResetCredit", providerAuthComplete: "provider.auth.complete", + chatGptReconnectProfile: "provider.chatgpt.reconnect-profile", + chatGptImportProfile: "provider.chatgpt.import-profile", + chatGptHandoffSubscribe: "provider.chatgpt.handoff.subscribe", + codexAuthCallbackSubscribe: "provider.codex.auth-callback.subscribe", providerAuthRespond: "provider.auth.respond", providerAuthCancel: "provider.auth.cancel", providerAuthLogout: "provider.auth.logout", @@ -519,6 +532,29 @@ const WsProviderAuthCompleteRpc = Rpc.make(WS_METHODS.providerAuthComplete, { error: ProviderSetupRpcError, }); +const WsChatGptReconnectProfileRpc = Rpc.make(WS_METHODS.chatGptReconnectProfile, { + payload: ChatGptReconnectProfileInput, + success: Schema.NullOr(ChatGptReconnectProfile), + error: ProviderSetupRpcError, +}); +const WsChatGptImportProfileRpc = Rpc.make(WS_METHODS.chatGptImportProfile, { + payload: ChatGptImportProfileInput, + success: ProviderAuthState, + error: ProviderSetupRpcError, +}); +const WsChatGptHandoffSubscribeRpc = Rpc.make(WS_METHODS.chatGptHandoffSubscribe, { + payload: ChatGptHandoffInput, + success: ChatGptHandoffState, + error: ProviderSetupRpcError, + stream: true, +}); +const WsCodexAuthCallbackSubscribeRpc = Rpc.make(WS_METHODS.codexAuthCallbackSubscribe, { + payload: CodexAuthCallbackInput, + success: CodexAuthCallbackState, + error: ProviderSetupRpcError, + stream: true, +}); + const WsProviderAuthCancelRpc = Rpc.make(WS_METHODS.providerAuthCancel, { payload: ProviderAuthCancelInput, success: ProviderAuthState, @@ -1399,6 +1435,10 @@ export const WsRpcGroup = RpcGroup.make( WsProviderConsumeResetCreditRpc, WsProviderAuthStartRpc, WsProviderAuthCompleteRpc, + WsChatGptReconnectProfileRpc, + WsChatGptImportProfileRpc, + WsChatGptHandoffSubscribeRpc, + WsCodexAuthCallbackSubscribeRpc, WsProviderAuthRespondRpc, WsProviderAuthCancelRpc, WsProviderAuthLogoutRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c137cac9ac7a..0c2498ef6471 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -63,6 +63,8 @@ export const ServerProviderAuth = Schema.Struct({ type: Schema.optional(TrimmedNonEmptyString), label: Schema.optional(TrimmedNonEmptyString), email: Schema.optional(TrimmedNonEmptyString), + subscriptionSharing: Schema.optional(Schema.Boolean), + profileId: Schema.optional(TrimmedNonEmptyString), }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; @@ -226,6 +228,12 @@ export const ServerProvider = Schema.Struct({ canInstall: Schema.Boolean, }), ), + runtimePaths: Schema.optionalKey( + Schema.Struct({ + homePath: TrimmedNonEmptyString, + shadowHomePath: Schema.NullOr(TrimmedNonEmptyString), + }), + ), enabled: Schema.Boolean, installed: Schema.Boolean, version: Schema.NullOr(TrimmedNonEmptyString), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3fd27b7f7379..44f6f0f2eb8a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -576,6 +576,9 @@ function makeProviderSettingsSchema( export const CodexSettings = makeProviderSettingsSchema( { + setupMode: Schema.optionalKey(Schema.Literals(["managed", "existing"])).pipe( + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), diff --git a/packages/shared/package.json b/packages/shared/package.json index b810abeaff9c..b6e7180b2f48 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -346,6 +346,18 @@ "./gitPatchPath": { "types": "./src/gitPatchPath.ts", "import": "./src/gitPatchPath.ts" + }, + "./providerAuthReturnUrl": { + "types": "./src/providerAuthReturnUrl.ts", + "import": "./src/providerAuthReturnUrl.ts" + }, + "./codexAuthCallback": { + "types": "./src/codexAuthCallback.ts", + "import": "./src/codexAuthCallback.ts" + }, + "./codexAuthHandoff": { + "types": "./src/codexAuthHandoff.ts", + "import": "./src/codexAuthHandoff.ts" } }, "scripts": { diff --git a/packages/shared/src/codexAuthCallback.ts b/packages/shared/src/codexAuthCallback.ts new file mode 100644 index 000000000000..e668340a2752 --- /dev/null +++ b/packages/shared/src/codexAuthCallback.ts @@ -0,0 +1,100 @@ +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off - Native loopback helper uses a bounded Node listener with AbortController cleanup. +import * as Schema from "effect/Schema"; +import * as NodeHttp from "node:http"; +import { codexAuthorizationRequest, codexCallbackUrl } from "@t3tools/shared/codexAuthHandoff"; + +export class CodexAuthCallbackError extends Schema.TaggedError()( + "CodexAuthCallbackError", + { detail: Schema.String }, +) { + override get message() { + return this.detail; + } +} + +const listeners = new Map(); + +export function cancelCodexAuthCallback(authorizationUrl: string) { + const { state } = codexAuthorizationRequest(authorizationUrl); + listeners.get(state)?.abort(); +} + +/** Receive an authorization code locally. Credentials and PKCE stay on the target environment. */ +export async function receiveCodexAuthCallback( + authorizationUrl: string, + openBrowser: (url: string) => Promise, + destination?: (callbackUrl: string) => string, + signal?: AbortSignal, +) { + const request = codexAuthorizationRequest(authorizationUrl); + if (listeners.has(request.state)) + throw new Error("This sign-in is already open on this computer."); + const abort = new AbortController(); + const interrupted = () => abort.abort(); + signal?.addEventListener("abort", interrupted, { once: true }); + listeners.set(request.state, abort); + const callback = Promise.withResolvers(); + // Keep early open/bind failures from leaving an unobserved rejection behind. + void callback.promise.catch(() => undefined); + const server = NodeHttp.createServer((incoming, response) => { + try { + if (incoming.method !== "GET") throw new Error("method"); + const url = codexCallbackUrl( + new URL(incoming.url ?? "/", request.redirectUri).toString(), + request.redirectUri, + request.state, + ).toString(); + const returnUrl = destination?.(url); + response.setHeader("cache-control", "no-store"); + response.setHeader("referrer-policy", "no-referrer"); + response.setHeader("x-content-type-options", "nosniff"); + if (returnUrl) { + response.writeHead(303, { location: returnUrl }).end(); + } else { + response.setHeader( + "content-security-policy", + "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'", + ); + response + .writeHead(200, { "content-type": "text/html; charset=utf-8" }) + .end( + 'T3 Code

Return to T3 Code

Your sign-in response has been received. T3 Code is finishing the connection. You can close this tab.

', + ); + } + callback.resolve(url); + } catch { + response.writeHead(400).end("This response does not belong to the active sign-in."); + } + }); + const cancelled = () => callback.reject(new Error("Sign-in cancelled on this computer.")); + abort.signal.addEventListener("abort", cancelled, { once: true }); + const timer = setTimeout( + () => callback.reject(new Error("Sign-in expired. Try again.")), + 300_000, + ); + timer.unref(); + try { + if (signal?.aborted) throw new Error("Sign-in cancelled on this computer."); + await new Promise((resolve, reject) => { + server.once("error", () => + reject( + new Error( + "The ChatGPT callback port is in use on this computer. Close the other sign-in and try again, or paste the redirect URL in T3 Code.", + ), + ), + ); + server.listen(Number(new URL(request.redirectUri).port), "127.0.0.1", resolve); + }); + if (abort.signal.aborted) throw new Error("Sign-in cancelled on this computer."); + if (!(await openBrowser(request.authorizationUrl))) + throw new Error("Could not open your sign-in browser."); + return await callback.promise; + } finally { + clearTimeout(timer); + signal?.removeEventListener("abort", interrupted); + abort.signal.removeEventListener("abort", cancelled); + listeners.delete(request.state); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +} diff --git a/packages/shared/src/codexAuthHandoff.test.ts b/packages/shared/src/codexAuthHandoff.test.ts new file mode 100644 index 000000000000..687103cfe6e6 --- /dev/null +++ b/packages/shared/src/codexAuthHandoff.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vite-plus/test"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import { + codexAuthorizationRequest, + codexAuthDeliveryUrl, + codexAuthHandoffUrl, + readCodexAuthDelivery, + readCodexAuthHandoff, +} from "./codexAuthHandoff.ts"; + +const authorizationUrl = () => { + const url = new URL("https://auth.openai.com/api/accounts/authorize"); + url.search = new URLSearchParams({ + client_id: "dynamic_agent_client", + response_type: "code", + redirect_uri: "http://127.0.0.1:54213/auth/callback", + state: "a".repeat(43), + code_challenge_method: "S256", + code_challenge: "b".repeat(43), + }).toString(); + return url.toString(); +}; +const input = { + authorizationUrl: authorizationUrl(), + returnUrl: "https://app.t3.codes/welcome#agents:remote-environment", + environmentId: EnvironmentId.make("remote-environment"), + instanceId: ProviderInstanceId.make("work-codex"), + flowId: "flow-one", +}; +const callbackUrl = `http://127.0.0.1:54213/auth/callback?state=${"a".repeat(43)}&code=one-time-code&client_id=oaiapp_test`; + +describe("Codex desktop handoff", () => { + it("keeps the hosted return route, account, and environment with the code in a fragment", () => { + expect(readCodexAuthHandoff(codexAuthHandoffUrl(input), false)).toEqual(input); + const delivery = codexAuthDeliveryUrl(input, callbackUrl); + expect(new URL(delivery).search).toBe(""); + expect(readCodexAuthDelivery(delivery)).toEqual({ + callbackUrl, + environmentId: input.environmentId, + instanceId: input.instanceId, + flowId: input.flowId, + returnHash: "#agents:remote-environment", + returnUrl: input.returnUrl, + }); + }); + it("rejects other handlers, schemes, arbitrary return sites, and non-OpenAI authorization", () => { + const link = codexAuthHandoffUrl(input); + expect(readCodexAuthHandoff(link, true)).toBeUndefined(); + expect(readCodexAuthHandoff(link.replace("auth/codex", "auth/other"), false)).toBeUndefined(); + expect( + readCodexAuthHandoff( + codexAuthHandoffUrl({ ...input, returnUrl: "https://attacker.example/welcome" }), + false, + ), + ).toBeUndefined(); + expect( + readCodexAuthHandoff( + codexAuthHandoffUrl({ + ...input, + authorizationUrl: input.authorizationUrl.replace("auth.openai.com", "attacker.example"), + }), + false, + ), + ).toBeUndefined(); + }); + it("rejects duplicated authorization parameters and non-loopback callback addresses", () => { + expect(() => + codexAuthorizationRequest( + input.authorizationUrl + "&redirect_uri=http://localhost:1/auth/callback", + ), + ).toThrow(); + const url = new URL(input.authorizationUrl); + url.searchParams.set("redirect_uri", "http://localhost:54213/auth/callback"); + expect(() => codexAuthorizationRequest(url.toString())).toThrow(); + url.searchParams.set("redirect_uri", "https://attacker.example/auth/callback"); + expect(() => codexAuthorizationRequest(url.toString())).toThrow(); + expect(() => codexAuthDeliveryUrl(input, callbackUrl + "&state=another")).toThrow(); + }); +}); diff --git a/packages/shared/src/codexAuthHandoff.ts b/packages/shared/src/codexAuthHandoff.ts new file mode 100644 index 000000000000..d6632355e363 --- /dev/null +++ b/packages/shared/src/codexAuthHandoff.ts @@ -0,0 +1,149 @@ +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { providerAuthReturnUrl } from "./providerAuthReturnUrl.ts"; + +export const CodexAuthHandoff = Schema.Struct({ + authorizationUrl: Schema.String.check(Schema.isMaxLength(16_384)), + returnUrl: Schema.String.check(Schema.isMaxLength(4_096)), + environmentId: EnvironmentId, + instanceId: ProviderInstanceId, + flowId: Schema.NonEmptyString.check(Schema.isMaxLength(128)), +}); +export type CodexAuthHandoff = typeof CodexAuthHandoff.Type; +const Delivery = Schema.Struct({ + environmentId: EnvironmentId, + instanceId: ProviderInstanceId, + flowId: Schema.NonEmptyString.check(Schema.isMaxLength(128)), + callbackUrl: Schema.String.check(Schema.isMaxLength(16_384)), + returnHash: Schema.String.check(Schema.isMaxLength(128)), +}); + +const encodeHandoff = Schema.encodeSync(Schema.fromJsonString(CodexAuthHandoff)); +const decodeHandoff = Schema.decodeUnknownSync(Schema.fromJsonString(CodexAuthHandoff)); +const encodeDelivery = Schema.encodeSync(Schema.fromJsonString(Delivery)); +const decodeDelivery = Schema.decodeUnknownSync(Schema.fromJsonString(Delivery)); + +/** The helper only opens OpenAI's authorize endpoint and receives a loopback callback. */ +export function codexAuthorizationRequest(value: string) { + const url = new URL(value); + if ( + value.length > 16_384 || + url.origin !== "https://auth.openai.com" || + url.pathname !== "/api/accounts/authorize" || + url.username || + url.password || + url.hash + ) + throw new Error("Invalid ChatGPT sign-in request."); + const single = (key: string) => { + const values = url.searchParams.getAll(key); + if (values.length !== 1 || !values[0]) throw new Error("Invalid ChatGPT sign-in request."); + return values[0]; + }; + const redirectUri = single("redirect_uri"); + const redirect = new URL(redirectUri); + const state = single("state"); + if ( + !/^http:\/\/127\.0\.0\.1:[1-9]\d{0,4}\/auth\/callback$/u.test(redirectUri) || + Number(redirect.port) > 65_535 || + !/^[\w-]{16,128}$/u.test(state) || + single("response_type") !== "code" || + single("code_challenge_method") !== "S256" || + !/^[\w-]{43}$/u.test(single("code_challenge")) || + !/^(dynamic_agent_client|oaiapp_[\w-]+)$/u.test(single("client_id")) + ) + throw new Error("Invalid ChatGPT sign-in request."); + return { authorizationUrl: url.toString(), redirectUri, state }; +} + +/** Validation is shared by the desktop listener and the environment receiving the code. */ +export function codexCallbackUrl(value: string, redirectUri: string, state: string) { + const callback = new URL(value); + const expected = new URL(redirectUri); + const states = callback.searchParams.getAll("state"); + const codes = callback.searchParams.getAll("code"); + const errors = callback.searchParams.getAll("error"); + const clients = callback.searchParams.getAll("client_id"); + if ( + value.length > 16_384 || + callback.origin !== expected.origin || + callback.pathname !== expected.pathname || + callback.username || + callback.password || + callback.hash || + states.length !== 1 || + states[0] !== state || + clients.length > 1 || + (clients.length === 1 && !/^oaiapp_[\w-]+$/u.test(clients[0]!)) || + !( + (codes.length === 1 && Boolean(codes[0]) && errors.length === 0) || + (errors.length === 1 && Boolean(errors[0]) && codes.length === 0) + ) + ) + throw new Error("This redirect URL does not belong to the current sign-in."); + return callback; +} + +export function codexAuthHandoffUrl(input: CodexAuthHandoff, development = false) { + const url = new URL(`${development ? "t3code-dev" : "t3code"}://auth/codex`); + url.searchParams.set("request", encodeHandoff(input)); + return url.toString(); +} + +export function readCodexAuthHandoff(value: string, development: boolean) { + try { + const url = new URL(value); + if ( + value.length > 32_768 || + url.protocol !== (development ? "t3code-dev:" : "t3code:") || + url.host !== "auth" || + url.pathname !== "/codex" || + url.username || + url.password || + url.hash || + url.searchParams.getAll("request").length !== 1 + ) + return undefined; + const input = decodeHandoff(url.searchParams.get("request")); + codexAuthorizationRequest(input.authorizationUrl); + if (!providerAuthReturnUrl(input.returnUrl)) return undefined; + return input; + } catch { + return undefined; + } +} + +/** Codes travel in a fragment, never in hosted web requests or a token store on the helper. */ +export function codexAuthDeliveryUrl(input: CodexAuthHandoff, callbackUrl: string) { + const request = codexAuthorizationRequest(input.authorizationUrl); + codexCallbackUrl(callbackUrl, request.redirectUri, request.state); + const destination = providerAuthReturnUrl(input.returnUrl); + if (!destination) throw new Error("Invalid T3 Code return address."); + const url = new URL(destination); + const delivery = { + environmentId: input.environmentId, + instanceId: input.instanceId, + flowId: input.flowId, + callbackUrl, + returnHash: url.hash, + }; + url.hash = `codex-auth=${encodeURIComponent(encodeDelivery(delivery))}`; + return url.toString(); +} + +export function readCodexAuthDelivery(value: string) { + try { + const url = new URL(value); + if (!url.hash.startsWith("#codex-auth=") || url.hash.length > 32_768) return undefined; + const input = decodeDelivery(decodeURIComponent(url.hash.slice("#codex-auth=".length))); + const destination = providerAuthReturnUrl(value); + if (!destination) return undefined; + const returnUrl = new URL(destination); + returnUrl.hash = input.returnHash; + const sanitized = providerAuthReturnUrl(returnUrl.toString()); + if (!sanitized) return undefined; + return { ...input, returnUrl: sanitized }; + } catch { + return undefined; + } +} diff --git a/packages/shared/src/providerAuthReturnUrl.test.ts b/packages/shared/src/providerAuthReturnUrl.test.ts new file mode 100644 index 000000000000..f90736738e73 --- /dev/null +++ b/packages/shared/src/providerAuthReturnUrl.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vite-plus/test"; +import { providerAuthReturnUrl } from "./providerAuthReturnUrl.ts"; + +describe("provider auth return destinations", () => { + it.each(["t3code", "t3code-dev"])( + "returns to %s Welcome and the selected settings instance", + (scheme) => { + expect(providerAuthReturnUrl(`${scheme}://app/welcome?code=secret#agents:machine-id`)).toBe( + `${scheme}://app/welcome#agents:machine-id`, + ); + expect( + providerAuthReturnUrl(`${scheme}://app/settings/providers?instanceId=work&code=secret`), + ).toBe(`${scheme}://app/settings/providers?instanceId=work`); + }, + ); + it.each([ + "t3code://attacker/welcome", + "t3code://app:123/welcome", + "t3code://app/auth/callback", + "t3code://user@ app/welcome", + "t3code://app/welcome/../evil", + "https://attacker.example/welcome", + "file:///welcome", + "javascript:alert(1)", + ])("rejects %s", (url) => expect(providerAuthReturnUrl(url)).toBeUndefined()); +}); diff --git a/packages/shared/src/providerAuthReturnUrl.ts b/packages/shared/src/providerAuthReturnUrl.ts new file mode 100644 index 000000000000..5a0e825117ee --- /dev/null +++ b/packages/shared/src/providerAuthReturnUrl.ts @@ -0,0 +1,34 @@ +import { isLoopbackHost } from "./preview.ts"; + +/** Only return to a local client or the hosted T3 client, never an arbitrary OAuth-supplied URL. */ +export function providerAuthReturnUrl(value: string | undefined): string | undefined { + if (!value) return undefined; + try { + const url = new URL(value); + const desktop = ["t3code:", "t3code-dev:"].includes(url.protocol) && url.host === "app"; + const web = + ["http:", "https:"].includes(url.protocol) && + (isLoopbackHost(url.hostname) || url.origin === "https://app.t3.codes"); + if ( + url.username || + url.password || + (!desktop && !web) || + (url.pathname !== "/welcome" && + url.pathname !== "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/settings" && + !url.pathname.startsWith("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/settings/")) + ) + return undefined; + for (const key of Array.from(url.searchParams.keys())) { + if ( + url.pathname === "/welcome" || + !["machine", "project", "checkout", "environmentId", "instanceId"].includes(key) + ) { + url.searchParams.delete(key); + } + } + if (url.pathname !== "/welcome" || !/^#agents:[\w-]+$/u.test(url.hash)) url.hash = ""; + return url.toString(); + } catch { + return undefined; + } +} diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 0646953a5f08..94ec8983ea0c 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -3,6 +3,8 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider, + EventId, + type OrchestrationThreadActivity, UsageLimitSourceId, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -14,6 +16,7 @@ import { sameUsageLimitCommandCoverage, withUsageLimitsCommands, collectLimitAccounts, + collectExternalUsageLinks, collectLimitNotices, collectLimitPools, displayLimitWindows, @@ -23,6 +26,8 @@ import { paceOf, providersWithLimits, remainingPercent, + isChatGptUsageLimitError, + usesChatGptSharing, } from "./usageLimits.ts"; const now = Date.parse("2026-09-03T12:00:00.000Z"); @@ -1056,3 +1061,108 @@ describe("isUsageLimitsCommand", () => { expect(isUsageLimitsCommand("/usage")).toBe(false); }); }); + +describe("external usage settings", () => { + it("deduplicates destinations across accounts and environments without inventing quota pools", () => { + const managed = provider({ + usageLimits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [], + unavailable: { reason: "unsupported", message: "Track usage in ChatGPT." }, + externalUsage: { label: "ChatGPT usage", url: "https://chatgpt.com/#settings/Usage" }, + }, + }); + const presentations = new Map([ + [ + EnvironmentId.make("a"), + { + entry: { target: { label: "A" } }, + serverConfig: { + providers: [managed, { ...managed, instanceId: ProviderInstanceId.make("personal") }], + }, + }, + ], + [ + EnvironmentId.make("b"), + { entry: { target: { label: "B" } }, serverConfig: { providers: [managed] } }, + ], + ]); + expect(collectExternalUsageLinks(presentations)).toEqual([ + { + ...managed.usageLimits!.externalUsage, + message: "Track usage in ChatGPT.", + accounts: [`${managed.instanceId} on A`, "personal on A", `${managed.instanceId} on B`], + }, + ]); + expect(collectLimitAccounts(presentations)).toEqual([]); + expect(collectLimitNotices(presentations)).toEqual([]); + }); + it("omits disabled, uninstalled and signed-out providers", () => { + const managed = provider({ + usageLimits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [], + externalUsage: { label: "ChatGPT usage", url: "https://chatgpt.com/#settings/Usage" }, + }, + }); + const presentations = new Map([ + [ + EnvironmentId.make("a"), + { + entry: { target: { label: "A" } }, + serverConfig: { + providers: [ + { ...managed, enabled: false }, + { ...managed, installed: false }, + { ...managed, auth: { status: "unauthenticated" as const } }, + provider({}), + ], + }, + }, + ], + ]); + expect(collectExternalUsageLinks(presentations)).toEqual([]); + }); +}); + +describe("ChatGPT sharing presentation", () => { + it("requires verified sharing metadata rather than the Codex driver or login type", () => { + const codex = provider({ auth: { status: "authenticated", type: "chatgpt" } }); + expect(usesChatGptSharing(codex)).toBe(false); + expect( + usesChatGptSharing({ ...codex, auth: { ...codex.auth, subscriptionSharing: true } }), + ).toBe(true); + expect( + usesChatGptSharing({ + ...codex, + auth: { status: "unauthenticated", subscriptionSharing: true }, + }), + ).toBe(false); + }); + it("only gives the matching current structured limit error a management action", () => { + const limit: OrchestrationThreadActivity = { + id: EventId.make("sharing-limit"), + tone: "error", + kind: "runtime.error", + summary: "Runtime error", + turnId: null, + createdAt: "2026-09-03T12:00:00.000Z", + payload: { code: "subscription_sharing_usage_limit_exceeded", message: "Limit reached" }, + }; + expect(isChatGptUsageLimitError([limit], "Limit reached")).toBe(true); + expect(isChatGptUsageLimitError([limit], "A different failure")).toBe(false); + expect(isChatGptUsageLimitError([limit], null)).toBe(false); + expect( + isChatGptUsageLimitError( + [{ ...limit, payload: { message: "Limit reached" } }], + "Limit reached", + ), + ).toBe(false); + expect( + isChatGptUsageLimitError( + [limit, { ...limit, payload: { code: "unrelated", message: "Limit reached" } }], + "Limit reached", + ), + ).toBe(false); + }); +}); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index f2b5cfe53b84..4ccfdee6c813 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -13,6 +13,7 @@ import { type ServerProviderSlashCommand, isProviderAvailable, type ServerProvider, + type OrchestrationThreadActivity, type ServerProviderUsageLimits, type ServerProviderUsageWindow, type UsageLimitSourceSnapshots, @@ -24,6 +25,35 @@ const MINUTE = 60_000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; +export const CHATGPT_USAGE_URL = "https://chatgpt.com/#settings/Usage"; +export const CHATGPT_USAGE_LIMIT_CODE = "subscription_sharing_usage_limit_exceeded"; + +export function usesChatGptSharing(provider: ServerProvider | null | undefined): boolean { + return provider?.auth.status === "authenticated" && provider.auth.subscriptionSharing === true; +} + +/** A historical limit must not turn an unrelated current failure into a usage notice. */ +export function isChatGptUsageLimitError( + activities: readonly OrchestrationThreadActivity[], + error: string | null | undefined, +): boolean { + if (!error) return false; + for (let index = activities.length - 1; index >= 0; index--) { + const activity = activities[index]!; + if (activity.kind !== "runtime.error") continue; + const payload = activity.payload; + return ( + typeof payload === "object" && + payload !== null && + "code" in payload && + payload.code === CHATGPT_USAGE_LIMIT_CODE && + "message" in payload && + payload.message === error + ); + } + return false; +} + export const CURSOR_USAGE_WINDOWS = [ { id: "totalPercentUsed", @@ -79,6 +109,33 @@ export type LimitPresentations = ReadonlyMap< } >; +/** One destination per service, even when several accounts or environments use it. */ +export function collectExternalUsageLinks(presentations: LimitPresentations) { + const links = new Map< + string, + { + readonly label: string; + readonly url: string; + readonly message: string | undefined; + readonly accounts: readonly string[]; + } + >(); + for (const presentation of presentations.values()) { + for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { + const external = provider.usageLimits?.externalUsage; + if (external && provider.auth.status === "authenticated") { + const account = `${provider.displayName ?? provider.instanceId} on ${presentation.entry.target.label}`; + links.set(external.url, { + ...external, + message: provider.usageLimits?.unavailable?.message, + accounts: [...new Set([...(links.get(external.url)?.accounts ?? []), account])], + }); + } + } + } + return [...links.values()]; +} + function accountKey(driver: ServerProvider["driver"], email: string | undefined): string | null { const normalizedEmail = email?.trim().toLowerCase(); return normalizedEmail ? `${driver}:${normalizedEmail}` : null; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d244cfee891c..e70207f2445f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -531,9 +531,15 @@ importers: effect: specifier: 4.0.0-rc.115 version: 4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5) + jose: + specifier: 'catalog:' + version: 6.2.2 node-pty: specifier: ^1.2.0-beta.15 version: 1.2.0-beta.15(patch_hash=f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414) + proper-lockfile: + specifier: 4.1.2 + version: 4.1.2 stream-chain: specifier: ^4.2.5 version: 4.2.5 @@ -568,6 +574,9 @@ importers: '@types/node': specifier: 24.12.4 version: 24.12.4 + '@types/proper-lockfile': + specifier: ^4.1.4 + version: 4.1.4 '@types/yauzl': specifier: ^3.4.0 version: 3.4.0 @@ -5411,6 +5420,9 @@ packages: '@types/pngjs@6.0.5': resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -5431,6 +5443,9 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/retry@0.12.5': + resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} + '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} @@ -15799,6 +15814,10 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.5 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -15819,6 +15838,8 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/retry@0.12.5': {} + '@types/send@1.2.1': dependencies: '@types/node': 24.12.4 From 5105f3180e3dc7a801b2c0fb46828be2e4a6c19d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 28 Sep 2026 19:53:29 -0700 Subject: [PATCH 2/7] docs(codex): explain ChatGPT setup and account management --- docs/user/install.md | 19 ++++++++++--------- docs/user/providers-codex.md | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/docs/user/install.md b/docs/user/install.md index 53ad5fbbed8d..f76be5c658fc 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -111,19 +111,20 @@ and enable the provider you want. Installation, login, and configuration belong to that environment's machine, even when you connect from a phone or another computer. -| Provider | Install and authenticate | -| ----------- | -------------------------------------------------------------------------------------------- | -| Codex | Install [Codex CLI](https://developers.openai.com/codex/cli), then run `codex login`. | -| Claude | Install [Claude Code](https://claude.com/product/claude-code), then run `claude auth login`. | -| Cursor | Install [Cursor CLI](https://cursor.com/cli), then run `agent login`. | -| Grok Build | Install [Grok Build CLI](https://x.ai/cli), then run `grok login`. | -| OpenCode | Install [OpenCode](https://opencode.ai), then run `opencode auth login`. | -| Antigravity | Install and sign in with Google from T3 Code's provider settings. | +| Provider | Install and authenticate | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Codex | [Connect with ChatGPT](./providers-codex.md#connect-with-chatgpt), or install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`. | +| Claude | Install [Claude Code](https://claude.com/product/claude-code), then run `claude auth login`. | +| Cursor | Install [Cursor CLI](https://cursor.com/cli), then run `agent login`. | +| Grok Build | Install [Grok Build CLI](https://x.ai/cli), then run `grok login`. | +| OpenCode | Install [OpenCode](https://opencode.ai), then run `opencode auth login`. | +| Antigravity | Install and sign in with Google from T3 Code's provider settings. | Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set its **Binary path** in provider settings, especially when using a version manager. Cursor's executable is `cursor-agent`, although its login command is -`agent login`. Antigravity can use its managed runtime without a `PATH` entry. +`agent login`. Codex connected through ChatGPT and Antigravity can use their +managed runtimes without a `PATH` entry. T3 Code warns when a provider version has known compatibility problems with your release. Check **Settings โ†’ Providers** on that environment for the recommended diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 06c59c6f6aee..bdc87902a63b 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -1,11 +1,38 @@ # Codex -For one account, use the default Codex provider with your normal Codex login. -[Provider setup](./install.md#providers) covers installation, Settings > Providers, -and custom binaries or environment variables. +Use your ChatGPT plan or an existing Codex CLI login to code in T3 Code. + +## Connect with ChatGPT + +Connect during onboarding or in **Settings โ†’ Providers**. For a remote machine, +select that environment first. T3 Code handles Codex installation; sign in on +OpenAI and allow sharing of your ChatGPT plan. + +Manage shared usage and credits in ChatGPT through **Manage usage** in T3 Code. +If a request uses a feature that ChatGPT sharing does not support, use another +provider for that request. + +When reconnecting, choose the same account in T3 Code and on OpenAI's sign-in +page. Disconnecting stops running threads but keeps their history and lets you +reconnect later. + +If remote sign-in cannot return automatically, paste the full URL from the final +localhost page into the sign-in panel, even if that page could not load. + +## Use an existing Codex login + +T3 Code can use your installed Codex and its existing login. Run `codex login` +on the environment's machine to sign in. [Provider setup](./install.md#providers) +covers installation and custom configuration. ## Use multiple accounts +Add another ChatGPT account in **Settings โ†’ Providers**, then select the account +from the thread's model picker. Compatible accounts can continue the same thread. +Connecting accounts through T3 Code leaves your CLI login unchanged. + +### Multiple CLI logins + A shared Codex home with a shadow home lets work and personal accounts continue the same threads. The accounts share Codex sessions and configuration while keeping their own login and available models. From 030cd1535d683ea2b8d1f3f52bcd156a63c56ae7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 29 Sep 2026 10:52:46 -0700 Subject: [PATCH 3/7] fix(codex): keep managed runtime constants internal --- apps/server/src/provider/CodexManagedRuntime.ts | 2 +- packages/shared/src/usageLimits.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/CodexManagedRuntime.ts b/apps/server/src/provider/CodexManagedRuntime.ts index f9c653142350..528673fc3406 100644 --- a/apps/server/src/provider/CodexManagedRuntime.ts +++ b/apps/server/src/provider/CodexManagedRuntime.ts @@ -16,7 +16,7 @@ export interface CodexEffectiveRuntime { } const decodeSettings = Schema.decodeSync(CodexSettings); // Managed sign-in stores tokens in T3's credential store and never writes native auth.json. -export const managedCodexLaunchArgs = [ +const managedCodexLaunchArgs = [ 'model_provider="openai_token_sharing"', 'model_providers.openai_token_sharing.name="OpenAI Token Sharing"', 'model_providers.openai_token_sharing.base_url="https://api.openai.com/v1"', diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 4ccfdee6c813..53b4a0a3c7da 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -26,7 +26,7 @@ const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; export const CHATGPT_USAGE_URL = "https://chatgpt.com/#settings/Usage"; -export const CHATGPT_USAGE_LIMIT_CODE = "subscription_sharing_usage_limit_exceeded"; +const CHATGPT_USAGE_LIMIT_CODE = "subscription_sharing_usage_limit_exceeded"; export function usesChatGptSharing(provider: ServerProvider | null | undefined): boolean { return provider?.auth.status === "authenticated" && provider.auth.subscriptionSharing === true; From a2c28a240687d5a495ebf88f083aa25cd1686d0e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 29 Sep 2026 11:00:10 -0700 Subject: [PATCH 4/7] fix(chatgpt): finish hosted onboarding callbacks and preserve managed defaults --- apps/desktop/src/app/DesktopClerk.test.ts | 4 +-- .../CodexTextGeneration.test.ts | 28 +++++++++++++++++++ .../src/textGeneration/CodexTextGeneration.ts | 2 +- apps/web/src/routes/__root.tsx | 1 + 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 6c04291b6846..ba3a2cba04e1 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -268,7 +268,7 @@ for (const entry of ["startup", "open-url"] as const) { createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); const port = yield* Effect.promise(async () => { const server = NodeHttp.createServer(); - await new Promise((resolve) => server.listen(0, "localhost", resolve)); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); if (!address || typeof address === "string") throw new Error("address"); await new Promise((resolve) => server.close(() => resolve())); @@ -278,7 +278,7 @@ for (const entry of ["startup", "open-url"] as const) { authorize.search = new URLSearchParams({ client_id: "dynamic_agent_client", response_type: "code", - redirect_uri: `http://localhost:${port}/auth/callback`, + redirect_uri: `http://127.0.0.1:${port}/auth/callback`, state: "a".repeat(43), code_challenge_method: "S256", code_challenge: "b".repeat(43), diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 15220fd9d0af..161a18abb2b7 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -135,6 +135,7 @@ function withFakeCodexEnv( launchArgs?: string; environment?: NodeJS.ProcessEnv; models?: ReadonlyArray; + managedRuntime?: boolean; }, effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { @@ -154,6 +155,13 @@ function withFakeCodexEnv( capabilities: null, })), ), + input.managedRuntime + ? Effect.succeed({ + config, + environment: input.environment ?? process.env, + revision: "test", + }) + : undefined, ); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); @@ -236,6 +244,26 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { ), ); + it.effect("omits a persisted service tier for managed ChatGPT text generation", () => + withFakeCodexEnv( + { + output: JSON.stringify({ subject: "Update project", body: "" }), + managedRuntime: true, + forbidArg: 'service_tier="priority"', + }, + (textGeneration) => + textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/chatgpt", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "serviceTier", value: "priority" }, + ]), + }), + ), + ); + it.effect("passes exec-safe launch args into codex exec", () => withFakeCodexEnv( { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index b43df46da90b..8384aa6e550c 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -206,7 +206,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const reasoningEffort = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? DEFAULT_TEXT_GENERATION_REASONING_EFFORT; - const serviceTier = getCodexServiceTierOptionValue(modelSelection); + const serviceTier = resolved ? undefined : getCodexServiceTierOptionValue(modelSelection); const spawnCommand = yield* resolveSpawnCommand( effectiveConfig.binaryPath || "codex", [ diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 3f82749f81db..d90bdb6f0a90 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -178,6 +178,7 @@ function RootRouteView() { + From b93232aeebad4c4fc7efdabe273f50b1c973d665 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 29 Sep 2026 11:05:29 -0700 Subject: [PATCH 5/7] test(web): rerender mocked account snapshots with React Compiler --- apps/web/src/components/settings/AddCodexAccountDialog.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/settings/AddCodexAccountDialog.test.tsx b/apps/web/src/components/settings/AddCodexAccountDialog.test.tsx index 512de24edc28..a7ab08510f3f 100644 --- a/apps/web/src/components/settings/AddCodexAccountDialog.test.tsx +++ b/apps/web/src/components/settings/AddCodexAccountDialog.test.tsx @@ -78,6 +78,8 @@ function provider(auth: ServerProvider["auth"]): ServerProvider { }; } function Onboarding({ inline = false }: { inline?: boolean }) { + "use no memo"; + // The atom mock is refreshed by render(), rather than an external-store subscription. const [adding, setAdding] = useState(true); const [createdAccount, setCreatedAccount] = useState(null); return ( From b7c34f97dc77a11ca58918c16e404042c9af23c8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 29 Sep 2026 11:05:45 -0700 Subject: [PATCH 6/7] test(web): remove integration-specific UI tests --- .../onboarding/WelcomeWizard.test.tsx | 76 +--- .../settings/AddCodexAccountDialog.test.tsx | 228 ------------ .../settings/CodexSetupSection.test.tsx | 342 ------------------ .../ProviderSettingsPanel.logic.test.ts | 8 - .../settings/settingsScopeAxis.test.ts | 64 ---- .../providerReadiness.logic.test.ts | 10 - apps/web/src/providerAuthDelivery.test.ts | 55 --- 7 files changed, 2 insertions(+), 781 deletions(-) delete mode 100644 apps/web/src/components/settings/AddCodexAccountDialog.test.tsx delete mode 100644 apps/web/src/components/settings/CodexSetupSection.test.tsx delete mode 100644 apps/web/src/providerAuthDelivery.test.ts diff --git a/apps/web/src/components/onboarding/WelcomeWizard.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.test.tsx index 9eef24d5df39..92514d012b82 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.test.tsx @@ -10,8 +10,6 @@ const mocks = vi.hoisted(() => ({ complete: vi.fn(), refresh: vi.fn(), toast: vi.fn(), - providers: [] as unknown[], - config: null as unknown, projects: [] as Array<{ id: string; environmentId: string; workspaceRoot: string }>, })); vi.mock("../../state/agentSessions", () => ({ agentSessionImport: "import" })); @@ -42,8 +40,8 @@ vi.mock("../../state/environments", () => { }); vi.mock("../../state/server", () => ({ serverEnvironment: { - providersValueAtom: () => mocks.providers, - configValueAtom: () => mocks.config, + providersValueAtom: () => [], + configValueAtom: () => null, refreshProviders: "refresh", }, })); @@ -76,32 +74,6 @@ vi.mock("../../state/terminal", () => ({ terminalEnvironment: {} })); vi.mock("../clerk/useT3ConnectAuthPrompt", () => ({ useT3ConnectAuthPrompt: vi.fn() })); vi.mock("../../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false })); vi.mock("../ThreadTerminalDrawer", () => ({ TerminalViewport: () => null })); -vi.mock("../settings/ChatGptWelcomeCoordinator", () => ({ ChatGptWelcomeCoordinator: () => null })); -vi.mock("../settings/CodexSetupSection", () => ({ - CodexSetupSection: (props: { - instanceId: string; - displayName?: string; - provider?: { displayName: string }; - mode: string; - }) => ( -
- {props.displayName ?? props.provider?.displayName} -
- ), - AddManagedCodexAccountDialog: (props: { - onAccountCreated: (id: string, name: string) => void; - onClose: () => void; - }) => ( - - ), -})); vi.mock("../cloud/CloudEnvironmentConnectList", () => ({ CloudEnvironmentConnectRows: () => null, })); @@ -116,8 +88,6 @@ let container: HTMLDivElement; beforeEach(() => { vi.clearAllMocks(); - mocks.providers = []; - mocks.config = null; vi.stubGlobal( "ResizeObserver", class { @@ -237,45 +207,3 @@ it("keeps setup open when saving completion fails and preserves the import warni }), ); }); - -it("keeps the added account in place when provider and settings snapshots arrive separately", async () => { - const codex = { - instanceId: "codex", - displayName: "Codex", - driver: "codex", - installed: true, - enabled: true, - status: "ready", - auth: { status: "authenticated" }, - }; - mocks.providers = [codex]; - mocks.config = { - settings: { - providerInstances: {}, - providers: { codex: { enabled: true, setupMode: "existing" } }, - }, - }; - const onDone = vi.fn(); - const render = () => - act(async () => root.render()); - await render(); - await click("Continue"); - await click("Connect another ChatGPT account"); - await click("Create test account"); - const account = document.querySelector('[data-account="codex_added"]'); - expect(account?.textContent).toBe("ChatGPT - Personal"); - expect(account?.getAttribute("data-mode")).toBe("managed"); - mocks.providers = [ - codex, - { - ...codex, - instanceId: "codex_added", - displayName: "ChatGPT - Personal", - auth: { status: "unauthenticated" }, - }, - ]; - await render(); - expect(document.querySelector('[data-account="codex_added"]')).toBe(account); - expect(account?.getAttribute("data-mode")).toBe("managed"); - expect(document.querySelectorAll('[data-account="codex_added"]')).toHaveLength(1); -}); diff --git a/apps/web/src/components/settings/AddCodexAccountDialog.test.tsx b/apps/web/src/components/settings/AddCodexAccountDialog.test.tsx deleted file mode 100644 index a7ab08510f3f..000000000000 --- a/apps/web/src/components/settings/AddCodexAccountDialog.test.tsx +++ /dev/null @@ -1,228 +0,0 @@ -// @vitest-environment jsdom -import { act, useState, type ReactNode } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { - EnvironmentId, - ProviderDriverKind, - ProviderInstanceId, - type ServerProvider, -} from "@t3tools/contracts"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -const mocks = vi.hoisted(() => ({ - providers: [] as ServerProvider[], - update: vi.fn(), -})); -vi.mock("@effect/atom-react", () => ({ - useAtomValue: (atom: string) => - atom === "providers" - ? mocks.providers - : new Map([ - [ - "remote-test", - { - connection: { phase: "connected" }, - entry: { target: { label: "Remote computer" } }, - serverConfig: { providers: mocks.providers }, - }, - ], - ]), -})); -vi.mock("../../hooks/useSettings", () => ({ - useEnvironmentSettings: () => ({ providerInstances: {} }), -})); -vi.mock("../../state/server", () => ({ - serverEnvironment: { providersValueAtom: () => "providers", updateSettings: "update" }, -})); -vi.mock("../../state/presentation", () => ({ - environmentPresentations: { presentationsAtom: "presentations" }, -})); -vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => mocks.update })); -vi.mock("../../lib/utils", async (importOriginal) => ({ - ...(await importOriginal()), - randomUUID: () => "test", -})); -vi.mock("./settingsLayout", () => ({ - SettingsRow: ({ title, control }: { title: string; control: ReactNode }) => ( -
- {title} - {control} -
- ), -})); -vi.mock("./ChatGptUsageButton", () => ({ - ChatGptUsageButton: () => , -})); - -import { AddCodexAccountDialog } from "./AddCodexAccountDialog"; -import { ChatGptWelcomeCoordinator } from "./ChatGptWelcomeCoordinator"; -import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; - -const environmentId = EnvironmentId.make("remote-test"); -const instanceId = ProviderInstanceId.make("codex_test"); -function provider(auth: ServerProvider["auth"]): ServerProvider { - return { - instanceId, - driver: ProviderDriverKind.make("codex"), - displayName: "ChatGPT - Personal", - installed: true, - enabled: true, - version: "test", - status: "ready", - auth, - checkedAt: "2026-09-28T00:00:00.000Z", - models: [], - skills: [], - slashCommands: [], - setup: { canAuthenticate: true, canInstall: true }, - }; -} -function Onboarding({ inline = false }: { inline?: boolean }) { - "use no memo"; - // The atom mock is refreshed by render(), rather than an external-store subscription. - const [adding, setAdding] = useState(true); - const [createdAccount, setCreatedAccount] = useState(null); - return ( - - - Connect your agents - {createdAccount &&

Pending account in onboarding

} -
- {adding && ( - setAdding(false)} - onAccountCreated={inline ? setCreatedAccount : undefined} - renderSetup={() =>

Waiting for destination connection

} - /> - )} - -
- ); -} -let root: Root; -let container: HTMLDivElement; -beforeEach(() => { - mocks.providers = []; - mocks.update.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); - localStorage.clear(); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal( - "ResizeObserver", - class { - observe() {} - unobserve() {} - disconnect() {} - }, - ); - Object.defineProperty(Element.prototype, "getAnimations", { - configurable: true, - value: () => [], - }); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); -async function render(inline = false) { - await act(async () => root.render()); -} -async function click(label: string) { - const button = [...document.querySelectorAll("button")].find( - (element) => element.textContent?.trim() === label, - ); - expect(button).toBeDefined(); - await act(async () => button!.click()); -} -it("replaces account setup with one confirmation after the destination enables sharing", async () => { - await render(); - await click("Continue"); - mocks.providers = [provider({ status: "unauthenticated" })]; - await render(); - expect(document.body.textContent).toContain("Waiting for destination connection"); - // An identity login or incomplete transfer is not a usable sharing connection. - mocks.providers = [provider({ status: "authenticated", subscriptionSharing: false })]; - await render(); - expect(document.body.textContent).toContain("Waiting for destination connection"); - expect( - [...document.querySelectorAll('[role="dialog"][data-open]')] - .map((dialog) => dialog.textContent) - .join(" "), - ).not.toContain("Your ChatGPT plan is connected"); - mocks.providers = [ - provider({ status: "authenticated", subscriptionSharing: true, profileId: "profile-test" }), - ]; - await render(); - expect(document.body.textContent).not.toContain("Waiting for destination connection"); - expect( - [...document.querySelectorAll('[role="dialog"]')].filter((dialog) => - dialog.textContent?.includes("Your ChatGPT plan is connected"), - ), - ).toHaveLength(1); - await click("Continue"); - expect(document.body.textContent).toContain("Connect your agents"); - expect( - [...document.querySelectorAll('[role="dialog"][data-open]')] - .map((dialog) => dialog.textContent) - .join(" "), - ).not.toContain("Your ChatGPT plan is connected"); - expect(document.body.textContent).not.toContain("Finish later"); - await render(); - expect( - [...document.querySelectorAll('[role="dialog"][data-open]')] - .map((dialog) => dialog.textContent) - .join(" "), - ).not.toContain("Your ChatGPT plan is connected"); -}); -it("keeps unsuccessful setup open and allows finishing later without confirming a connection", async () => { - await render(); - await click("Continue"); - mocks.providers = [provider({ status: "unauthenticated" })]; - await render(); - expect(document.body.textContent).toContain("Waiting for destination connection"); - await click("Finish later"); - expect(document.body.textContent).toContain("Connect your agents"); - expect(document.body.textContent).not.toContain("Waiting for destination connection"); - expect( - [...document.querySelectorAll('[role="dialog"][data-open]')] - .map((dialog) => dialog.textContent) - .join(" "), - ).not.toContain("Your ChatGPT plan is connected"); -}); - -it("dismisses the name dialog before sign-in finishes when onboarding owns setup", async () => { - await render(true); - await click("Continue"); - expect(document.body.textContent).toContain("Pending account in onboarding"); - expect(document.body.textContent).not.toContain("Add ChatGPT account"); - expect(document.body.textContent).not.toContain("Waiting for destination connection"); - mocks.providers = [provider({ status: "unauthenticated" })]; - await render(true); - expect([...document.querySelectorAll('[role="dialog"][data-open]')]).toHaveLength(1); - mocks.providers = [provider({ status: "authenticated", subscriptionSharing: false })]; - await render(true); - expect([...document.querySelectorAll('[role="dialog"][data-open]')]).toHaveLength(1); - mocks.providers = [ - provider({ status: "authenticated", subscriptionSharing: true, profileId: "profile-test" }), - ]; - await render(true); - expect(document.body.textContent).toContain("Your ChatGPT plan is connected"); - await click("Continue"); - expect(document.body.textContent).toContain("Connect your agents"); - expect(document.body.textContent).not.toContain("Finish later"); -}); -it("keeps the name dialog available when creating the onboarding account fails", async () => { - mocks.update.mockResolvedValue({ _tag: "Failure" }); - await render(true); - await click("Continue"); - expect(document.body.textContent).toContain("Add ChatGPT account"); - expect(document.body.textContent).not.toContain("Pending account in onboarding"); - mocks.update.mockResolvedValue({ _tag: "Success", value: undefined }); - await click("Continue"); - expect(document.body.textContent).toContain("Pending account in onboarding"); - expect(document.body.textContent).not.toContain("Add ChatGPT account"); -}); diff --git a/apps/web/src/components/settings/CodexSetupSection.test.tsx b/apps/web/src/components/settings/CodexSetupSection.test.tsx deleted file mode 100644 index c382aeb63cd9..000000000000 --- a/apps/web/src/components/settings/CodexSetupSection.test.tsx +++ /dev/null @@ -1,342 +0,0 @@ -// @vitest-environment jsdom -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { - EnvironmentId, - ProviderDriverKind, - ProviderInstanceId, - type ProviderAuthState, - type ServerProvider, -} from "@t3tools/contracts"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -const mocks = vi.hoisted(() => ({ - auth: null as ProviderAuthState | null, - start: vi.fn(), - refresh: vi.fn(), - openExternal: vi.fn(), - cancel: vi.fn(), -})); -vi.mock("../../state/query", () => ({ - useEnvironmentQuery: (query: string | null) => ({ - data: - query === "auth" - ? mocks.auth - : query === "install" - ? { - phase: "idle", - installedVersion: "0.156.1", - version: "0.156.1", - source: "local", - } - : null, - error: null, - }), -})); -vi.mock("../../state/server", () => ({ - serverEnvironment: { - providerAuthState: () => "auth", - providerInstallState: () => "install", - startProviderAuth: "start", - refreshProviders: "refresh", - cancelProviderAuth: "cancel", - }, -})); -vi.mock("../../state/use-atom-command", () => ({ - useAtomCommand: (command: string) => - command === "start" - ? mocks.start - : command === "refresh" - ? mocks.refresh - : command === "cancel" - ? mocks.cancel - : vi.fn(), -})); -vi.mock("../../state/environments", () => ({ - useEnvironmentHttpBaseUrl: () => "http://127.0.0.1:15041", - usePrimaryEnvironmentId: () => null, - usePrimaryEnvironment: () => null, - useEnvironment: () => null, -})); -vi.mock("../../localApi", () => ({ - ensureLocalApi: () => ({ shell: { openExternal: mocks.openExternal } }), -})); -vi.mock("./ChatGptAccountPicker", () => ({ ChatGptAccountPicker: () => null })); -vi.mock("./ChatGptUsageButton", () => ({ ChatGptUsageButton: () => null })); -vi.mock("./AddCodexAccountDialog", () => ({ AddCodexAccountDialog: () => null })); -vi.mock("./RedactedSensitiveText", () => ({ - RedactedSensitiveText: () => Account email, -})); - -import { CodexSetupSection } from "./CodexSetupSection"; - -const environmentId = EnvironmentId.make("test"); -const instanceId = ProviderInstanceId.make("codex_test"); -let root: Root; -let container: HTMLDivElement; -let provider: ServerProvider; -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - mocks.auth = { - instanceId, - phase: "idle", - flowId: null, - authorizationUrl: null, - expiresAt: null, - message: null, - methods: [], - }; - mocks.start.mockReset().mockImplementation(async () => { - mocks.auth = { - ...mocks.auth!, - phase: "starting", - flowId: "flow-test" as ProviderAuthState["flowId"], - }; - return { _tag: "Success", value: mocks.auth }; - }); - mocks.refresh.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); - mocks.openExternal.mockReset().mockResolvedValue(undefined); - mocks.cancel.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); - provider = { - instanceId, - driver: ProviderDriverKind.make("codex"), - displayName: "ChatGPT - Personal", - installed: true, - enabled: true, - version: "0.156.1", - status: "ready", - auth: { status: "unauthenticated" }, - checkedAt: "2026-09-28T00:00:00.000Z", - models: [], - skills: [], - slashCommands: [], - setup: { canAuthenticate: true, canInstall: true }, - }; - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - vi.unstubAllGlobals(); -}); -async function render( - autoStart = false, - currentProvider: ServerProvider | null = provider, - presentation: "onboarding" | "settings" = "onboarding", - onSignInCancelled?: () => void, -) { - await act(async () => - root.render( - {}} - onSignInCancelled={onSignInCancelled} - />, - ), - ); -} -async function signIn() { - await render(); - await act(async () => - [...container.querySelectorAll("button")] - .find((button) => button.textContent?.includes("Continue with ChatGPT"))! - .click(), - ); - mocks.auth = { ...mocks.auth!, phase: "verifying", message: "Checking provider sign-in." }; - await render(); -} -it("keeps successful sign-in pending until the provider snapshot arrives", async () => { - await signIn(); - mocks.auth = { - ...mocks.auth!, - phase: "succeeded", - message: "Sign-in complete.", - methods: [{ id: "chatgpt-profile:test", name: "Personal", description: null, type: "agent" }], - }; - await render(); - expect(mocks.refresh).toHaveBeenCalledTimes(1); - expect(mocks.refresh).toHaveBeenCalledWith({ environmentId, input: { instanceId } }); - expect(container.textContent).toContain("Finishing sign-in..."); - expect(container.textContent).not.toContain("Reconnect account"); - expect(container.textContent).not.toContain("Use a different account"); - expect(container.textContent).not.toContain("Cancel"); - expect(container.querySelector("button")?.disabled).toBe(true); - provider = { ...provider, auth: { status: "authenticated", subscriptionSharing: true } }; - await render(); - expect(container.textContent).toContain("Ready"); - expect(container.textContent).not.toContain("Finishing sign-in..."); - // A later loss of credentials must allow reconnecting rather than wait forever. - provider = { ...provider, auth: { status: "unauthenticated" } }; - await render(); - expect(container.textContent).toContain("Reconnect account"); - expect(container.textContent).not.toContain("Finishing sign-in..."); -}); -it("lets a failed sign-in retry instead of waiting for an authenticated snapshot", async () => { - await signIn(); - mocks.auth = { ...mocks.auth!, phase: "failed", message: "Sign-in could not finish." }; - await render(); - expect(container.textContent).toContain("Sign-in could not finish."); - expect(container.textContent).not.toContain("Finishing sign-in..."); - expect( - [...container.querySelectorAll("button")].find((button) => - button.textContent?.includes("Continue with ChatGPT"), - )?.disabled, - ).toBe(false); -}); -it("keeps one subtitle while starting sign-in and exposes help on that line", async () => { - vi.stubGlobal("desktopBridge", { - receiveProviderAuthCallback: vi.fn(() => new Promise(() => {})), - }); - await render(); - await act(async () => - [...container.querySelectorAll("button")] - .find((button) => button.textContent?.includes("Continue with ChatGPT"))! - .click(), - ); - mocks.auth = { ...mocks.auth!, phase: "starting", message: "Starting sign-in." }; - await render(); - expect(container.textContent).not.toContain("Starting sign-in."); - expect(container.textContent).toContain("Complete sign-in in your browser."); - mocks.auth = { - ...mocks.auth!, - phase: "waiting", - message: "Complete sign-in to continue.", - authorizationUrl: "https://auth.openai.com/test-sign-in", - }; - await render(); - expect(container.textContent?.match(/Complete sign-in in your browser\./g)).toHaveLength(1); - expect(container.textContent).not.toContain("Complete sign-in to continue."); - const trigger = container.querySelector( - 'button[aria-label="Having trouble signing in?"]', - ); - expect(trigger?.textContent).toBe("Complete sign-in in your browser."); - expect(container.querySelector('input[aria-label="ChatGPT sign-in redirect URL"]')).toBeNull(); - await act(async () => trigger!.click()); - expect( - container.querySelector('input[aria-label="ChatGPT sign-in redirect URL"]'), - ).not.toBeNull(); - const input = container.querySelector( - 'input[aria-label="ChatGPT sign-in redirect URL"]', - )!; - await act(async () => { - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call( - input, - "http://localhost/callback?code=test", - ); - input.dispatchEvent(new Event("input", { bubbles: true })); - }); - await act(async () => trigger!.click()); - expect(container.querySelector('input[aria-label="ChatGPT sign-in redirect URL"]')).toBeNull(); - await act(async () => trigger!.click()); - expect( - container.querySelector('input[aria-label="ChatGPT sign-in redirect URL"]') - ?.value, - ).toBe("http://localhost/callback?code=test"); - expect(mocks.start).toHaveBeenCalledTimes(1); -}); - -it("keeps a newly added account pending across delayed provider and auth snapshots", async () => { - let finishStart = () => {}; - mocks.start.mockImplementation( - () => - new Promise((resolve) => { - finishStart = () => resolve({ _tag: "Success", value: mocks.auth }); - }), - ); - await render(true, null); - expect(container.textContent).toContain("ChatGPT - Personal"); - expect(container.textContent).toContain("Complete sign-in in your browser."); - expect(container.textContent).not.toContain("Continue with ChatGPT"); - expect(mocks.start).not.toHaveBeenCalled(); - - await render(true); - expect(mocks.start).toHaveBeenCalledTimes(1); - expect(container.textContent).not.toContain("Continue with ChatGPT"); - expect(container.textContent).not.toContain("Starting sign-in"); - expect(container.textContent).toContain("Open sign-in page"); - await act(async () => finishStart()); - await render(false); - expect(container.textContent).not.toContain("Finishing sign-in"); - expect(container.textContent).not.toContain("Continue with ChatGPT"); - expect(container.textContent).toContain("Open sign-in page"); - expect(container.textContent).toContain("Complete sign-in in your browser."); - const idleAuth = mocks.auth; - mocks.auth = null; - await render(false); - expect(container.textContent).not.toContain("Finishing sign-in"); - expect(container.textContent).not.toContain("Continue with ChatGPT"); - expect(container.textContent).toContain("Open sign-in page"); - expect(container.textContent).toContain("Complete sign-in in your browser."); - mocks.auth = idleAuth; - mocks.auth = { - ...mocks.auth!, - phase: "waiting", - authorizationUrl: "https://auth.openai.com/test", - }; - await render(false); - expect(container.textContent).toContain("Open sign-in page"); - expect(mocks.start).toHaveBeenCalledTimes(1); -}); - -it("offers callback recovery in the local web settings dialog with a server-owned callback", async () => { - mocks.auth = { - ...mocks.auth!, - phase: "waiting", - flowId: "flow-test" as ProviderAuthState["flowId"], - authorizationUrl: "https://auth.openai.com/test-sign-in", - }; - await render(false, provider, "settings"); - const trigger = container.querySelector( - 'button[aria-label="Having trouble signing in?"]', - ); - expect(trigger).not.toBeNull(); - expect(container.querySelector('input[aria-label="ChatGPT sign-in redirect URL"]')).toBeNull(); - await act(async () => trigger!.click()); - expect( - container.querySelector('input[aria-label="ChatGPT sign-in redirect URL"]'), - ).not.toBeNull(); - await act(async () => - [...container.querySelectorAll("button")] - .find((button) => button.textContent?.includes("Try sign-in in your browser"))! - .click(), - ); - expect(mocks.openExternal).toHaveBeenCalledWith("https://auth.openai.com/test-sign-in"); -}); - -it("dismisses account setup immediately when cancelling a pending sign-in", async () => { - let finishCancellation!: () => void; - mocks.cancel.mockImplementation( - () => - new Promise((resolve) => { - finishCancellation = () => resolve({ _tag: "Success", value: undefined }); - }), - ); - mocks.auth = { - ...mocks.auth!, - phase: "waiting", - flowId: "flow-test" as ProviderAuthState["flowId"], - authorizationUrl: "https://auth.openai.com/test-sign-in", - }; - await render(false, provider, "settings", () => root.render(null)); - await act(async () => - [...container.querySelectorAll("button")] - .find((button) => button.textContent?.trim() === "Cancel")! - .click(), - ); - expect(container.querySelector('[aria-label="Codex setup"]')).toBeNull(); - expect(container.textContent).not.toContain("Continue with ChatGPT"); - expect(mocks.cancel).toHaveBeenCalledWith({ - environmentId, - input: { instanceId, flowId: "flow-test" }, - }); - await act(async () => finishCancellation()); -}); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts index efc4eb671421..c04db646a47e 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -50,14 +50,6 @@ describe("provider environment selection", () => { ).toEqual([primaryId, relayId, sshId]); }); - it("restricts device selection to the settings scope and falls back inside that scope", () => { - const options = buildProviderEnvironmentOptions(environments, primaryId, [sshId, relayId]); - expect(options.map((environment) => environment.environmentId)).toEqual([relayId, sshId]); - expect(resolveSelectedProviderEnvironmentId(options, primaryId, primaryId)).toBe(relayId); - expect(resolveSelectedProviderEnvironmentId(options, sshId, primaryId)).toBe(sshId); - expect(buildProviderEnvironmentOptions(environments, primaryId, [])).toEqual([]); - }); - it("keeps a valid selection, then falls back to primary or the first environment", () => { const options = buildProviderEnvironmentOptions(environments, primaryId); diff --git a/apps/web/src/components/settings/settingsScopeAxis.test.ts b/apps/web/src/components/settings/settingsScopeAxis.test.ts index 02ba0f134d5d..54a87dc956a2 100644 --- a/apps/web/src/components/settings/settingsScopeAxis.test.ts +++ b/apps/web/src/components/settings/settingsScopeAxis.test.ts @@ -6,12 +6,9 @@ import { projectAxisValue, selectEnvironmentAxis, selectProjectAxis, - selectSingleEnvironmentScope, settingsScopeEnvironmentLabel, } from "./settingsScopeAxis"; -import { resolveSettingsScope } from "./settingsScope"; - const first = { environmentId: EnvironmentId.make("first"), label: "Development", @@ -89,64 +86,3 @@ describe("environmentAxisValue", () => { expect(environmentAxisValue({ machine: "desk" }, "laptop")).toBe("desk"); }); }); - -describe("single environment provider scope", () => { - const environments = [first, second].map((environment) => ({ - ...environment, - connection: { phase: "connected" as const }, - })); - - it("defaults to the primary environment and resolves exactly one write target", () => { - const search = selectSingleEnvironmentScope( - {}, - resolveSettingsScope({}, [], environments), - environments, - second.environmentId, - ); - expect(search).toEqual({ machine: second.environmentId }); - expect(resolveSettingsScope(search, [], environments).environmentIds).toEqual([ - second.environmentId, - ]); - }); - - it("keeps an explicit offline or removed environment instead of switching targets", () => { - const search = { machine: "removed" }; - expect( - selectSingleEnvironmentScope( - search, - resolveSettingsScope(search, [], environments), - environments, - first.environmentId, - ), - ).toEqual(search); - const offline = environments.map((environment) => ({ - ...environment, - connection: { phase: "offline" as const }, - })); - expect( - selectSingleEnvironmentScope( - { machine: second.environmentId }, - resolveSettingsScope({ machine: second.environmentId }, [], offline), - offline, - first.environmentId, - ), - ).toEqual({ machine: second.environmentId }); - }); - - it("falls back to a connected candidate while retaining project and checkout narrowing", () => { - const search = { project: "app", checkout: "checkout" }; - const scope = { - kind: "all" as const, - label: "app", - members: [], - environmentIds: [second.environmentId], - }; - expect(selectSingleEnvironmentScope(search, scope, environments, first.environmentId)).toEqual({ - ...search, - machine: second.environmentId, - }); - expect(selectSingleEnvironmentScope({}, resolveSettingsScope({}, [], []), [], null)).toEqual( - {}, - ); - }); -}); diff --git a/apps/web/src/onboarding/providerReadiness.logic.test.ts b/apps/web/src/onboarding/providerReadiness.logic.test.ts index 22a1d4d95fa6..b0b3a4d57515 100644 --- a/apps/web/src/onboarding/providerReadiness.logic.test.ts +++ b/apps/web/src/onboarding/providerReadiness.logic.test.ts @@ -80,16 +80,6 @@ describe("getOnboardingProviderState", () => { it("waits for a provider snapshot before offering an action", () => { expect(getOnboardingProviderState(undefined)).toBe("checking"); }); - it("waits for the initial CLI probe before offering installation or sign-in", () => { - expect( - getOnboardingProviderState({ - ...readyCodex, - installed: false, - status: "warning", - auth: { status: "unknown" }, - }), - ).toBe("checking"); - }); }); describe("selectOnboardingProvidersByDriver", () => { diff --git a/apps/web/src/providerAuthDelivery.test.ts b/apps/web/src/providerAuthDelivery.test.ts deleted file mode 100644 index 7e98b848215d..000000000000 --- a/apps/web/src/providerAuthDelivery.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { codexAuthDeliveryUrl } from "@t3tools/shared/codexAuthHandoff"; -import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; -import { - prepareProviderAuthDelivery, - pendingProviderAuthDelivery, - clearProviderAuthDelivery, -} from "./providerAuthDelivery"; - -afterEach(() => { - clearProviderAuthDelivery(); - vi.unstubAllGlobals(); -}); - -describe("hosted provider callback bootstrap", () => { - it("restores the welcome step and removes the code before router initialization", () => { - const authorizationUrl = new URL("https://auth.openai.com/api/accounts/authorize"); - authorizationUrl.search = new URLSearchParams({ - client_id: "dynamic_agent_client", - response_type: "code", - redirect_uri: "http://127.0.0.1:54213/auth/callback", - state: "a".repeat(43), - code_challenge_method: "S256", - code_challenge: "b".repeat(43), - }).toString(); - const input = { - authorizationUrl: authorizationUrl.toString(), - returnUrl: "https://app.t3.codes/welcome#agents:remote-environment", - environmentId: EnvironmentId.make("remote-environment"), - instanceId: ProviderInstanceId.make("work"), - flowId: "flow-one", - }; - const callbackUrl = `http://127.0.0.1:54213/auth/callback?state=${"a".repeat(43)}&code=test-code&client_id=oaiapp_test`; - const href = codexAuthDeliveryUrl(input, callbackUrl); - const replaceState = vi.fn(); - vi.stubGlobal("window", { - location: new URL(href), - history: { state: { navigation: 1 }, replaceState }, - }); - prepareProviderAuthDelivery(); - expect(replaceState).toHaveBeenCalledWith({ navigation: 1 }, "", input.returnUrl); - expect(pendingProviderAuthDelivery()?.callbackUrl).toBe(callbackUrl); - expect(pendingProviderAuthDelivery()?.environmentId).toBe(input.environmentId); - }); - it("also removes malformed callback fragments", () => { - const replaceState = vi.fn(); - vi.stubGlobal("window", { - location: new URL("https://app.t3.codes/settings/providers#codex-auth=invalid-code"), - history: { state: null, replaceState }, - }); - prepareProviderAuthDelivery(); - expect(pendingProviderAuthDelivery()).toBeUndefined(); - expect(replaceState).toHaveBeenCalledWith(null, "", "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/settings/providers"); - }); -}); From 6f148348dfe69821f0cdebff89199837ea5fc224 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 29 Sep 2026 11:11:19 -0700 Subject: [PATCH 7/7] test(web): isolate existing onboarding fixture from account setup --- apps/web/src/components/onboarding/WelcomeWizard.test.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/src/components/onboarding/WelcomeWizard.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.test.tsx index 92514d012b82..b36a8e815238 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.test.tsx @@ -74,6 +74,11 @@ vi.mock("../../state/terminal", () => ({ terminalEnvironment: {} })); vi.mock("../clerk/useT3ConnectAuthPrompt", () => ({ useT3ConnectAuthPrompt: vi.fn() })); vi.mock("../../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false })); vi.mock("../ThreadTerminalDrawer", () => ({ TerminalViewport: () => null })); +vi.mock("../settings/ChatGptWelcomeCoordinator", () => ({ ChatGptWelcomeCoordinator: () => null })); +vi.mock("../settings/CodexSetupSection", () => ({ + CodexSetupSection: () => null, + AddManagedCodexAccountDialog: () => null, +})); vi.mock("../cloud/CloudEnvironmentConnectList", () => ({ CloudEnvironmentConnectRows: () => null, }));