diff --git a/.coderabbit.yaml b/.coderabbit.yaml index ccfeca966dfa..bf28b1b342d2 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,4 +1,5 @@ reviews: + high_level_summary: false review_status: false auto_review: enabled: true 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 29633dd6889d..256700783561 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -1,4 +1,9 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - Hosted handoff test uses a real localhost listener without an OpenAI account. import * as NodePath from "@effect/platform-node/NodePath"; +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"; @@ -23,9 +28,11 @@ 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"; @@ -38,6 +45,11 @@ const makeDesktopClerkLayer = ( fileSystemLayer: Layer.Layer = FileSystem.layerNoop({ exists: () => Effect.succeed(false), }), + shell: ElectronShell.ElectronShell["Service"] = { + openExternal: () => Effect.succeed(true), + openSystemSettings: () => Effect.succeed(false), + copyText: () => Effect.void, + }, ) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ stateDir: "/tmp/t3-state", @@ -60,6 +72,7 @@ const makeDesktopClerkLayer = ( Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment), Layer.succeed(ElectronApp.ElectronApp, electronApp), fileSystemLayer, + Layer.succeed(ElectronShell.ElectronShell, shell), ), ), ); @@ -207,7 +220,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), @@ -243,3 +256,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, "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; + }); + 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://127.0.0.1:${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, [], "darwin", undefined, 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 8e27112acea6..1bc390ac3c62 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; // The SDK bridge acquires Electron's profile-scoped single-instance lock. // Must not yield: the bridge registers a scheme Electron rejects once ready. @@ -132,13 +138,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 9b3a4a0a9cc7..fa6bfb7170db 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -123,3 +123,6 @@ export const BROWSER_VIEWPORT_CHANNEL = "desktop:browser-viewport"; export const BROWSER_STREAM_CHANNEL = "desktop:browser-stream"; export const BROWSER_CURSOR_CHANNEL = "desktop:browser-cursor"; + +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 ed52d1b7c7a6..d1c32d5ae7cd 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -208,6 +208,7 @@ const desktopApplicationLayer = Layer.mergeAll( // Clerk resolves userData before Electron is ready, so it gets the synchronous FileSystem. const desktopClerkLayer = DesktopClerk.layer.pipe( Layer.provide(DesktopPreReadyFileSystem.layer), + 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 7837c66bedf2..4740e7c17e47 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/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index ccf0e736ce08..b6c6771ca1c9 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -1,6 +1,3 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import { assert, describe, it } from "@effect/vitest"; import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -65,14 +62,10 @@ describe("DesktopUpdates", () => { it.effect("replaces an upstream feed and keeps both release channels on Fold", () => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const resourcesPath = yield* fs.makeTempDirectoryScoped({ prefix: "fold-update-feed-" }); - yield* fs.writeFileString( - path.join(resourcesPath, "app-update.yml"), - "provider: github\nowner: pingdotgg\nrepo: t3code\n", - ); - const harness = makeHarness({ resourcesPath, env: { T3CODE_DESKTOP_MOCK_UPDATES: "false" } }); + const harness = makeHarness({ + appUpdateYml: "provider: github\nowner: pingdotgg\nrepo: t3code\n", + env: { T3CODE_DESKTOP_MOCK_UPDATES: "false" }, + }); yield* Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; yield* updates.configure; @@ -91,7 +84,7 @@ describe("DesktopUpdates", () => { }, ]); }).pipe(Effect.provide(harness.layer)); - }).pipe(Effect.provide(NodeServices.layer)), + }), ); it.effect("configures the updater and runs startup checks on the test clock", () => { diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 3556d569b3e2..e9055f3c33ab 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -38,6 +38,8 @@ export interface UpdatesHarnessOptions { readonly platform?: NodeJS.Platform; /** Contents of the resources/package-type marker a Linux package ships. */ readonly packageType?: string | undefined; + /** Contents of the packaged resources/app-update.yml feed config. */ + readonly appUpdateYml?: string | undefined; } export function makeHarness(options: UpdatesHarnessOptions = {}) { @@ -216,14 +218,16 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { readFileString: (path) => path === "/missing/resources/package-type" && options.packageType !== undefined ? Effect.succeed(options.packageType) - : Effect.fail( - PlatformError.systemError({ - module: "FileSystem", - method: "readFileString", - _tag: "NotFound", - pathOrDescriptor: path, - }), - ), + : path === "/missing/resources/app-update.yml" && options.appUpdateYml !== undefined + ? Effect.succeed(options.appUpdateYml) + : Effect.fail( + PlatformError.systemError({ + module: "FileSystem", + method: "readFileString", + _tag: "NotFound", + pathOrDescriptor: path, + }), + ), makeDirectory: () => Effect.void, writeFileString: (path) => Effect.sync(() => { 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..1cbe32a9c3f5 --- /dev/null +++ b/apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx @@ -0,0 +1,29 @@ +import { CHATGPT_USAGE_URL, isChatGptUsageLimitError } from "@t3tools/shared/usageLimits"; +import { Linking, Pressable, View } from "react-native"; +import { AppText as Text } from "../../components/AppText"; +import { ProviderIcon } from "../../components/ProviderIcon"; + +export function ChatGptUsageLimitNotice({ lastError }: { lastError: string | null | undefined }) { + if (!isChatGptUsageLimitError(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 6172b61a2d5f..1430ddb517c7 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 type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; @@ -786,6 +787,7 @@ 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") ? ( @@ -812,7 +814,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ? "assertive" : "polite" } - className="px-3 py-2 text-xs text-foreground" + className={ + selectedProviderStatus.compatibilityAdvisory.status === "broken" + ? "bg-danger px-3 py-2 text-xs text-danger-foreground" + : "px-3 py-2 text-xs text-foreground" + } > {selectedProviderStatus.compatibilityAdvisory.message} diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index b505e884ab86..5b86a2845f95 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -62,7 +62,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"; @@ -710,6 +711,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 @@ -717,6 +724,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 01b2e8c8e263..5d28afe56fca 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -35,7 +35,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:", @@ -49,6 +51,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/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 8936f5f1825a..b9cef89c7b21 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -865,6 +865,12 @@ const program = Effect.gen(function* () { const requestedSessionId = String(request.sessionId ?? sessionId); beginAcpMockPrompt(cancelledSessions, requestedSessionId); promptCount += 1; + if ( + process.env.T3_ACP_CRASH_PROMPT === "1" && + request.prompt.some((part) => part.type === "text" && part.text === "crash now") + ) { + return yield* Effect.sync(() => process.exit(23)); + } if (emitV2Fidelity) { yield* agent.client.sessionUpdate({ diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index 78273a9ef790..4c6e04bb510e 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -28,6 +28,7 @@ import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts"; +import * as ServerSettings from "../src/serverSettings.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "../src/vcs/VcsProjectConfig.ts"; @@ -155,7 +156,8 @@ await Effect.runPromise( GitLabCli.layer, ForgejoCli.layer, AzureDevOpsCli.layer, - BitbucketApi.layer, + // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables. + BitbucketApi.layer.pipe(Layer.provide(ServerSettings.layerTest())), ), ), Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))), diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index e196b23aaad4..e6c9545ec709 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -42,6 +42,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-v2/AcpRegistryOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts index 7c984a255906..374f44808594 100644 --- a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { CommandId, + EnvironmentId, type ModelSelection, MessageId, ProjectId, @@ -13,7 +14,10 @@ import { import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as CodexResetCredit from "../provider/Layers/codexResetCredit.ts"; +import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts"; +import { CodexInstallation } from "../provider/CodexInstallation.ts"; +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts"; import { FetchHttpClient } from "effect/unstable/http"; import { describe } from "vite-plus/test"; @@ -99,6 +103,13 @@ const providerInstanceRegistryLayer = ProviderInstanceRegistryHydrationLive.pipe OpenCodeRuntimeLive.pipe(Layer.provide(PlatformTestLayer)), Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), ModelManifest.layerTest, + Layer.mock(CodexInstallation)({ managedDirectory: "unused-managed-installation" }), + Layer.mock(ServerSecretStore)({}), + Layer.succeed(ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed( + EnvironmentId.make("00000000-0000-4000-8000-000000000001"), + ), + }), AntigravityInstallation.layer.pipe( Layer.provide(serverConfigLayer.pipe(Layer.provide(PlatformTestLayer))), Layer.provide(FetchHttpClient.layer), @@ -115,7 +126,7 @@ const liveLayer = OrchestrationV2LayerLive.pipe( Layer.provide(serverConfigLayer), Layer.provide(serverSettingsLayer), Layer.provide(providerInstanceRegistryLayer), - Layer.provide(CodexResetCredit.layer), + Layer.provide(ResetCreditCoordinator.layer), Layer.provide(backgroundPolicyLayer), Layer.provide(worktreeRepairDependenciesTestLayer), Layer.provide(PlatformTestLayer), diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index d0668533d8c3..2f03ae0d1196 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -35,6 +35,7 @@ import { } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { CHATGPT_USAGE_LIMIT_MESSAGE } from "@t3tools/shared/usageLimits"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as CodexClient from "effect-codex-app-server/client"; import * as CodexReplay from "effect-codex-app-server/replay"; @@ -73,6 +74,7 @@ import { codexFileChangeApprovalPrompt, codexProviderTurnTokenUsage, codexThreadRuntimeParams, + type CodexAdapterV2Options, type CodexAppServerClientFactoryShape, makeCodexAdapterV2, makeCodexAppServerProtocolLogger, @@ -1411,6 +1413,7 @@ function codexReplayPreamble(input: { id: input.nativeThreadId, sessionId: input.nativeThreadId, forkedFromId: null, + projectId: null, preview: "", ephemeral: false, modelProvider: "openai", @@ -1509,6 +1512,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { transcript: CodexReplay.CodexAppServerReplayTranscript, onEvent: (event: ProviderAdapterV2Event) => Effect.Effect = () => Effect.void, onRequest: (method: string) => Effect.Effect = () => Effect.void, + managed?: CodexAdapterV2Options["managed"], ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1532,6 +1536,13 @@ describe("CodexAdapterV2 post-settle continuation", () => { (client) => ({ ...client, + raw: { + ...client.raw, + request: (method, params) => + onRequest(method).pipe( + Effect.andThen(client.raw.request(method, params)), + ), + }, request: (method, params) => onRequest(method).pipe(Effect.andThen(client.request(method, params))), }) satisfies CodexClient.CodexAppServerClient["Service"], @@ -1555,6 +1566,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { continuationRequests.push(request); }), }, + ...(managed === undefined ? {} : { managed }), }); const threadId = ThreadId.make(`thread-${transcript.scenario}`); const runtime = yield* adapter.openSession({ @@ -1788,6 +1800,68 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe(Effect.scoped, Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), ); + it.effect("reports ChatGPT sharing failures with the managed message and code", () => + Effect.gen(function* () { + const nativeThreadId = "managed-thread"; + const nativeTurnId = "managed-turn"; + const prompt = "Keep going."; + const transcript = makeCodexReplayTranscript({ + scenario: "managed-chatgpt-usage-limit", + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }), + { + type: "emit_inbound", + label: "turn/failed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: { + ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), + error: { + message: + 'unexpected status 429: {"code":"subscription_sharing_usage_limit_exceeded"}', + codexErrorInfo: null, + additionalDetails: null, + }, + }, + }, + }, + }, + ], + }); + let revoked = 0; + const harness = yield* makeCodexReplayHarness( + transcript, + () => Effect.void, + () => Effect.void, + { + resolve: Effect.succeed({ + config: DEFAULT_CODEX_SETTINGS, + environment: {}, + revision: "token", + }), + onConnectionRevoked: Effect.sync(() => revoked++), + }, + ); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("managed-attempt"), + text: prompt, + }), + ); + yield* harness.firstTerminal; + const terminal = harness.terminalEvents()[0]; + assert.equal(terminal?.status, "failed"); + assert.equal(terminal?.failure?.message, CHATGPT_USAGE_LIMIT_MESSAGE); + assert.equal(terminal?.failure?.code, "subscription_sharing_usage_limit_exceeded"); + assert.equal(revoked, 0, "A usage limit keeps the ChatGPT connection"); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ); + it.effect("bounds Stop when a queued native turn never starts", () => Effect.gen(function* () { const nativeThreadId = "early-stop-thread"; @@ -5720,6 +5794,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { id: input.nativeThreadId, sessionId: input.nativeThreadId, forkedFromId: input.forkedFromId, + projectId: null, preview: "", ephemeral: false, modelProvider: "openai", @@ -5960,7 +6035,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(forkedProviderThread.nativeThreadRef?.nativeId, forkThreadId); assert.notEqual(forkedProviderThread.id, harness.providerThread.id); assert.equal(forkedProviderThread.forkedFrom?.providerTurnId, firstTurn.id); - assert.deepEqual(outbound.slice(-2), ["thread/fork", "thread/rollback"]); + assert.deepEqual(outbound.slice(-3), ["thread/fork", "thread/read", "thread/rollback"]); }).pipe(Effect.scoped, Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), ); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 326a4313f1a2..2dde81a32262 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -23,7 +23,12 @@ import { codexUsageLimitMessage, type CodexRateLimitSnapshot, } from "../../provider/Layers/codexUsageLimits.ts"; -import { CodexSettings, defaultInstanceIdForDriver, ProviderDriverKind } from "@t3tools/contracts"; +import { + CodexSettings, + defaultInstanceIdForDriver, + ProviderDriverKind, + type ProviderSetupError, +} from "@t3tools/contracts"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -55,6 +60,9 @@ import type { ThreadId, } from "@t3tools/contracts"; import * as CodexClient from "effect-codex-app-server/client"; +import { classifyCodexManagedError } from "../../provider/CodexManagedErrors.ts"; +import type { CodexEffectiveRuntime } from "../../provider/CodexManagedRuntime.ts"; +import { makeManagedCodexClient } from "./CodexManagedClient.ts"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexSchema from "effect-codex-app-server/schema"; import * as Context from "effect/Context"; @@ -657,6 +665,8 @@ export function buildCodexTurnStartParams(input: { readonly hasT3Mcp?: boolean; readonly browserToolsAvailable?: boolean; readonly deviceToolsAvailable?: boolean; + /** ChatGPT-managed sessions have no service tiers. */ + readonly serviceTierSupported?: boolean; }) { return Effect.gen(function* () { const runtimeModeDefaults = codexRuntimeModeTurnDefaults(input.runtimePolicy.runtimeMode); @@ -674,7 +684,10 @@ export function buildCodexTurnStartParams(input: { ); const effort = selectedEffort === undefined ? undefined : yield* decodeTurnReasoningEffort(selectedEffort); - const serviceTier = getCodexServiceTierOptionValue(input.modelSelection); + const serviceTier = + input.serviceTierSupported === false + ? undefined + : getCodexServiceTierOptionValue(input.modelSelection); const developerInstructions = input.hasT3Mcp !== true ? undefined @@ -1369,7 +1382,7 @@ export type CodexAdapterV2DriverEnv = export const createCodexAdapterV2 = ( { instanceId, environment, enabled, config }: ProviderAdapterDriverCreateInput, - hooks: Pick = {}, + hooks: Pick = {}, ) => Effect.gen(function* () { const clientFactory = yield* CodexAppServerClientFactory; @@ -1451,6 +1464,14 @@ export interface CodexAdapterV2Options { readonly environment: NodeJS.ProcessEnv; readonly clientFactory: CodexAppServerClientFactoryShape; readonly onUsageLimits?: ServerProviderShape["applyUsageLimits"]; + /** + * Set for ChatGPT-managed instances: each Codex process launches from the resolved runtime, + * and ChatGPT sharing failures are reported with their managed message. + */ + readonly managed?: { + readonly resolve: Effect.Effect; + readonly onConnectionRevoked: Effect.Effect; + }; readonly fileSystem: FileSystem.FileSystem; readonly idAllocator: IdAllocatorV2Shape; readonly serverConfig: ServerConfig["Service"]; @@ -1475,14 +1496,36 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), openSession: (input) => Effect.gen(function* () { - const client = yield* clientFactory.open({ - instanceId: adapterOptions.instanceId, - threadId: input.threadId, - providerSessionId: input.providerSessionId, - runtimePolicy: input.runtimePolicy, - settings: adapterOptions.settings, - environment: adapterOptions.environment, - }); + const openClient = (settings: CodexSettings, environment: NodeJS.ProcessEnv) => + clientFactory.open({ + instanceId: adapterOptions.instanceId, + threadId: input.threadId, + providerSessionId: input.providerSessionId, + runtimePolicy: input.runtimePolicy, + settings, + environment, + }); + const managed = adapterOptions.managed; + const client = managed + ? yield* makeManagedCodexClient({ + resolve: managed.resolve, + open: (runtime) => openClient(runtime.config, runtime.environment), + onOpenError: (cause) => + new ProviderAdapterOpenSessionError({ + driver: CODEX_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + }) + : yield* openClient(adapterOptions.settings, adapterOptions.environment); + const classifyManagedError = (value: unknown) => + managed === undefined + ? Effect.succeed(undefined) + : Effect.gen(function* () { + const failure = classifyCodexManagedError(value); + if (failure?.revoke) yield* managed.onConnectionRevoked; + return failure; + }); const initialized = yield* Ref.make(false); const ensureInitialized = Effect.gen(function* () { const alreadyInitialized = yield* Ref.get(initialized); @@ -3671,13 +3714,15 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi maxAttempts: progress?.maxAttempts ?? previous?.retry.maxAttempts ?? null, retryDelayMs: null, }; - const code = codexErrorInfoCode(payload.error.codexErrorInfo); + const managedError = yield* classifyManagedError(payload.error); + const code = managedError?.code ?? codexErrorInfoCode(payload.error.codexErrorInfo); const additionalDetails = payload.error.additionalDetails?.trim(); const failure = makeProviderFailure({ message: - additionalDetails === undefined || additionalDetails.length === 0 + managedError?.message ?? + (additionalDetails === undefined || additionalDetails.length === 0 ? payload.error.message - : additionalDetails, + : additionalDetails), code, class: code?.startsWith("http") === true || code?.startsWith("responseStream") === true @@ -4609,6 +4654,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi readonly context: ActiveCodexTurnContext; readonly status: OrchestrationV2ProviderTurn["status"]; readonly failureMessage?: string; + readonly failureCode?: string; readonly providerRetry?: ActiveCodexProviderRetry; }): Effect.fn.Return { const terminalStatus = providerTurnStatusToTerminal(input.status); @@ -4629,6 +4675,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi ? input.providerRetry.failure : makeProviderFailure({ message: input.failureMessage, + code: input.failureCode, class: "provider_error", }), ...(input.providerRetry === undefined @@ -4659,6 +4706,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi readonly nativeTurnId: string; readonly status: OrchestrationV2ProviderTurn["status"]; readonly failureMessage?: string; + readonly failureCode?: string; readonly providerRetry?: ActiveCodexProviderRetry; }) { const event = yield* makeRootTerminalEvent(input); @@ -4707,6 +4755,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi readonly status: OrchestrationV2ProviderTurn["status"]; readonly completedAt: DateTime.Utc; readonly failureMessage?: string; + readonly failureCode?: string; }) => turnTerminalizationPermit.withPermits(1)( Effect.gen(function* () { @@ -4952,12 +5001,19 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi (yield* Ref.get(interruptingNativeTurns)).has(payload.turn.id) ? "interrupted" : nativeStatus; + const managedError = + payload.turn.error === null || payload.turn.error === undefined + ? undefined + : yield* classifyManagedError(payload.turn.error); yield* finalizeCodexTurn({ context, nativeTurnId: payload.turn.id, status, completedAt: codexTimestamp(payload.turn.completedAt), - ...(payload.turn.error?.message === undefined + ...(managedError === undefined + ? {} + : { failureMessage: managedError.message, failureCode: managedError.code }), + ...(managedError !== undefined || payload.turn.error?.message === undefined ? {} : { failureMessage: @@ -5169,6 +5225,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi hasT3Mcp: mcpSession !== undefined, browserToolsAvailable: mcpSession?.browserToolsAvailable ?? true, deviceToolsAvailable: mcpSession?.capabilities?.has("device") ?? false, + serviceTierSupported: adapterOptions.managed === undefined, }); yield* Ref.update(pendingRootTurns, (current) => { const updated = new Map(current); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexManagedClient.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexManagedClient.test.ts new file mode 100644 index 000000000000..ec5f3f22599c --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/CodexManagedClient.test.ts @@ -0,0 +1,109 @@ +import { assert, describe, it } from "@effect/vitest"; +import { CodexSettings, ProviderDriverKind, ProviderSessionId } from "@t3tools/contracts"; +import type * as CodexClient from "effect-codex-app-server/client"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import { ProviderAdapterOpenSessionError } from "../ProviderAdapter.ts"; +import { makeManagedCodexClient } from "./CodexManagedClient.ts"; + +type Client = CodexClient.CodexAppServerClient["Service"]; +type Handler = (payload: unknown) => Effect.Effect; + +const config = Schema.decodeSync(CodexSettings)({}); + +function makeFakeCodex(label: string, log: Array) { + const notificationHandlers = new Map>(); + let turns = 0; + const responses: Record unknown> = { + initialize: () => ({}), + "thread/start": () => ({ thread: { id: "native-thread" } }), + "turn/start": () => ({ turn: { id: `${label}-turn-${++turns}` } }), + }; + const client = { + raw: { + request: (method: string, payload: { readonly threadId: string }) => + Effect.sync(() => { + log.push(`${label} ${method} ${payload.threadId}`); + return { thread: { id: payload.threadId } }; + }), + }, + request: (method: string) => + Effect.sync(() => { + log.push(`${label} ${method}`); + return responses[method]?.(); + }), + notify: (method: string) => Effect.sync(() => void log.push(`${label} notify ${method}`)), + handleServerNotification: (method: string, handler: Handler) => + Effect.sync(() => { + notificationHandlers.set(method, [...(notificationHandlers.get(method) ?? []), handler]); + }), + handleServerRequest: () => Effect.void, + handleUnknownServerRequest: () => Effect.void, + handleUnknownServerNotification: () => Effect.void, + } as unknown as Client; + return { + client, + handlerCount: (method: string) => notificationHandlers.get(method)?.length ?? 0, + emit: (method: string, payload: unknown) => + Effect.forEach(notificationHandlers.get(method) ?? [], (handler) => handler(payload), { + discard: true, + }), + }; +} + +describe("makeManagedCodexClient", () => { + it.effect("respawns Codex only when an idle turn starts with a rotated token", () => + Effect.gen(function* () { + const token = yield* Ref.make("token-1"); + const log: Array = []; + const processes: Array> = []; + let closedProcesses = 0; + const client = yield* makeManagedCodexClient({ + resolve: Ref.get(token).pipe( + Effect.map((revision) => ({ config, environment: {}, revision })), + ), + open: () => + Effect.gen(function* () { + const process = makeFakeCodex(`codex-${processes.length}`, log); + processes.push(process); + yield* Effect.addFinalizer(() => Effect.sync(() => closedProcesses++)); + return process.client; + }), + onOpenError: (cause) => + new ProviderAdapterOpenSessionError({ + driver: ProviderDriverKind.make("codex"), + providerSessionId: ProviderSessionId.make("session"), + cause, + }), + }); + + yield* client.handleServerNotification("item/started", () => Effect.void); + yield* client.request("initialize", {} as never); + yield* client.notify("initialized", undefined); + yield* client.request("thread/start", {} as never); + yield* client.request("turn/start", { threadId: "native-thread" } as never); + assert.strictEqual(processes.length, 1, "an unchanged token keeps the process"); + + yield* Ref.set(token, "token-2"); + yield* client.request("turn/start", { threadId: "native-thread" } as never); + assert.strictEqual(processes.length, 1, "a running turn keeps the old process"); + + yield* processes[0]!.emit("turn/completed", { turn: { id: "codex-0-turn-1" } }); + yield* processes[0]!.emit("turn/completed", { turn: { id: "codex-0-turn-2" } }); + log.length = 0; + yield* client.request("turn/start", { threadId: "native-thread" } as never); + + assert.strictEqual(processes.length, 2); + assert.strictEqual(closedProcesses, 1); + assert.strictEqual(processes[1]!.handlerCount("item/started"), 1); + assert.deepStrictEqual(log, [ + "codex-1 initialize", + "codex-1 notify initialized", + "codex-1 thread/resume native-thread", + "codex-1 turn/start", + ]); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexManagedClient.ts b/apps/server/src/orchestration-v2/Adapters/CodexManagedClient.ts new file mode 100644 index 000000000000..282f808290a2 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/CodexManagedClient.ts @@ -0,0 +1,200 @@ +import type { ProviderSetupError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import type * as CodexClient from "effect-codex-app-server/client"; +import * as CodexErrors from "effect-codex-app-server/errors"; + +import type { CodexEffectiveRuntime } from "../../provider/CodexManagedRuntime.ts"; +import type { ProviderAdapterOpenSessionError } from "../ProviderAdapter.ts"; + +type Client = CodexClient.CodexAppServerClient["Service"]; + +interface Connection { + readonly scope: Scope.Closeable; + readonly client: Client; + readonly revision: string; +} + +function responseThreadId(response: unknown): string | undefined { + if (typeof response !== "object" || response === null || !("thread" in response)) return; + const thread = response.thread; + if (typeof thread !== "object" || thread === null || !("id" in thread)) return; + return typeof thread.id === "string" ? thread.id : undefined; +} + +function payloadRecord(payload: unknown): Record { + return typeof payload === "object" && payload !== null ? { ...payload } : {}; +} + +/** + * A Codex client for ChatGPT-managed sessions. Managed Codex reads the ChatGPT access token from + * its process environment, so a rotated token needs a new process. Before a turn starts on an idle + * client, the runtime is resolved again; when the token changed, Codex is respawned, handler + * registrations and initialization are replayed, and every thread the old process had loaded is + * resumed. A turn that is already running keeps the old process until the next idle turn start. + */ +export const makeManagedCodexClient = Effect.fn("makeManagedCodexClient")(function* (input: { + readonly resolve: Effect.Effect; + readonly open: ( + runtime: CodexEffectiveRuntime, + ) => Effect.Effect; + readonly onOpenError: (error: ProviderSetupError) => ProviderAdapterOpenSessionError; +}) { + const sessionScope = yield* Scope.Scope; + const activeTurns = new Set(); + const trackTurns = (client: Client) => + Effect.all( + [ + client.handleServerNotification("turn/started", (payload) => + Effect.sync(() => activeTurns.add(payload.turn.id)), + ), + client.handleServerNotification("turn/completed", (payload) => + Effect.sync(() => activeTurns.delete(payload.turn.id)), + ), + ], + { discard: true }, + ); + const resolveIn = (scope: Scope.Closeable) => + input.resolve.pipe( + Effect.mapError(input.onOpenError), + Effect.provideService(Scope.Scope, scope), + Effect.onError(() => Scope.close(scope, Exit.void)), + ); + const openIn = (scope: Scope.Closeable, runtime: CodexEffectiveRuntime) => + Effect.gen(function* () { + const client = yield* input.open(runtime); + yield* trackTurns(client); + return { scope, client, revision: runtime.revision } satisfies Connection; + }).pipe( + Effect.provideService(Scope.Scope, scope), + Effect.onError(() => Scope.close(scope, Exit.void)), + ); + const connect = Effect.gen(function* () { + const scope = yield* Scope.fork(sessionScope, "sequential"); + return yield* openIn(scope, yield* resolveIn(scope)); + }); + + let current = yield* connect; + const registrations: Array<(client: Client) => Effect.Effect> = []; + const initialization: Array< + (client: Client) => Effect.Effect + > = []; + const loadedThreads = new Map>(); + const rotation = yield* Semaphore.make(1); + + const register = (registration: (client: Client) => Effect.Effect) => { + registrations.push(registration); + return registration(current.client); + }; + + const rotateIfStale = rotation + .withPermit( + Effect.gen(function* () { + if (activeTurns.size > 0) return; + const scope = yield* Scope.fork(sessionScope, "sequential"); + const runtime = yield* resolveIn(scope); + if (runtime.revision === current.revision) { + yield* Scope.close(scope, Exit.void); + return; + } + const next = yield* openIn(scope, runtime); + const previous = current; + current = next; + yield* Scope.close(previous.scope, Exit.void); + for (const registration of registrations) yield* registration(next.client); + for (const step of initialization) yield* step(next.client); + for (const [threadId, params] of loadedThreads) { + yield* next.client.raw + .request("thread/resume", { ...params, threadId, excludeTurns: true }) + .pipe( + Effect.catch((cause) => + Effect.logWarning("Managed Codex could not resume a thread after token rotation", { + threadId, + cause, + }), + ), + ); + } + }), + ) + .pipe( + Effect.catchTag("ProviderAdapterOpenSessionError", (error) => + Effect.fail( + new CodexErrors.CodexAppServerRequestError({ + code: -32000, + errorMessage: + typeof error.cause === "object" && + error.cause !== null && + "detail" in error.cause && + typeof error.cause.detail === "string" + ? error.cause.detail + : "Could not restart managed Codex.", + method: "turn/start", + }), + ), + ), + ); + + const request: Client["request"] = (method, payload) => + Effect.gen(function* () { + if (method === "turn/start") yield* rotateIfStale; + const response = yield* current.client.request(method, payload); + if (method === "initialize") { + initialization.push((client) => client.request(method, payload)); + } else if (method === "thread/start" || method === "thread/fork") { + const threadId = responseThreadId(response); + if (threadId !== undefined) { + loadedThreads.set(threadId, method === "thread/start" ? payloadRecord(payload) : {}); + } + } else if (method === "turn/start") { + const turnId = (response as { readonly turn?: { readonly id?: unknown } }).turn?.id; + if (typeof turnId === "string") activeTurns.add(turnId); + } + return response; + }); + + return { + raw: { + get notifications() { + return current.client.raw.notifications; + }, + get requests() { + return current.client.raw.requests; + }, + request: (method, payload) => + current.client.raw.request(method, payload).pipe( + Effect.tap((response) => + Effect.sync(() => { + if (method !== "thread/resume") return; + const threadId = responseThreadId(response); + if (threadId !== undefined) loadedThreads.set(threadId, payloadRecord(payload)); + }), + ), + ), + notify: (method, payload) => current.client.raw.notify(method, payload), + respond: (...args) => current.client.raw.respond(...args), + respondError: (...args) => current.client.raw.respondError(...args), + }, + request, + notify: (method, payload) => + current.client.notify(method, payload).pipe( + Effect.tap(() => + Effect.sync(() => { + if (method === "initialized") { + initialization.push((client) => client.notify(method, payload)); + } + }), + ), + ), + handleServerRequest: (method, handler) => + register((client) => client.handleServerRequest(method, handler)), + handleServerNotification: (method, handler) => + register((client) => client.handleServerNotification(method, handler)), + handleUnknownServerRequest: (handler) => + register((client) => client.handleUnknownServerRequest(handler)), + handleUnknownServerNotification: (handler) => + register((client) => client.handleUnknownServerNotification(handler)), + } satisfies Client; +}); diff --git a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts index 1237fd1f64c6..b98f520d56a2 100644 --- a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { CommandId, + EnvironmentId, MessageId, ProjectId, ThreadId, @@ -11,7 +12,10 @@ import { import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as CodexResetCredit from "../provider/Layers/codexResetCredit.ts"; +import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts"; +import { CodexInstallation } from "../provider/CodexInstallation.ts"; +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts"; import { FetchHttpClient } from "effect/unstable/http"; import { describe } from "vite-plus/test"; @@ -74,6 +78,13 @@ const providerInstanceRegistryLayer = ProviderInstanceRegistryHydrationLive.pipe OpenCodeRuntimeLive.pipe(Layer.provide(PlatformTestLayer)), Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), ModelManifest.layerTest, + Layer.mock(CodexInstallation)({ managedDirectory: "unused-managed-installation" }), + Layer.mock(ServerSecretStore)({}), + Layer.succeed(ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed( + EnvironmentId.make("00000000-0000-4000-8000-000000000001"), + ), + }), AntigravityInstallation.layer.pipe( Layer.provide(serverConfigLayer.pipe(Layer.provide(PlatformTestLayer))), Layer.provide(FetchHttpClient.layer), @@ -91,7 +102,7 @@ const liveLayer = OrchestrationV2LayerLive.pipe( Layer.provide(serverConfigLayer), Layer.provide(serverSettingsLayer), Layer.provide(providerInstanceRegistryLayer), - Layer.provide(CodexResetCredit.layer), + Layer.provide(ResetCreditCoordinator.layer), Layer.provide(backgroundPolicyLayer), Layer.provide(PlatformTestLayer), ); diff --git a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts index 32cb7e1b23ff..5751f7782f81 100644 --- a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { CommandId, + EnvironmentId, MessageId, type OrchestrationV2ThreadProjection, ProjectId, @@ -11,7 +12,10 @@ import { import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as CodexResetCredit from "../provider/Layers/codexResetCredit.ts"; +import * as ResetCreditCoordinator from "../provider/Layers/resetCreditCoordinator.ts"; +import { CodexInstallation } from "../provider/CodexInstallation.ts"; +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts"; import { FetchHttpClient } from "effect/unstable/http"; import { describe } from "vite-plus/test"; @@ -73,6 +77,13 @@ const providerInstanceRegistryLayer = ProviderInstanceRegistryHydrationLive.pipe OpenCodeRuntimeLive.pipe(Layer.provide(PlatformTestLayer)), Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers), ModelManifest.layerTest, + Layer.mock(CodexInstallation)({ managedDirectory: "unused-managed-installation" }), + Layer.mock(ServerSecretStore)({}), + Layer.succeed(ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed( + EnvironmentId.make("00000000-0000-4000-8000-000000000001"), + ), + }), AntigravityInstallation.layer.pipe( Layer.provide(serverConfigLayer.pipe(Layer.provide(PlatformTestLayer))), Layer.provide(FetchHttpClient.layer), @@ -90,7 +101,7 @@ const liveLayer = OrchestrationV2LayerLive.pipe( Layer.provide(serverConfigLayer), Layer.provide(serverSettingsLayer), Layer.provide(providerInstanceRegistryLayer), - Layer.provide(CodexResetCredit.layer), + Layer.provide(ResetCreditCoordinator.layer), Layer.provide(backgroundPolicyLayer), Layer.provide(PlatformTestLayer), ); 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/project/AgentSessionJson.test.ts b/apps/server/src/project/AgentSessionJson.test.ts new file mode 100644 index 000000000000..515aa50b1b97 --- /dev/null +++ b/apps/server/src/project/AgentSessionJson.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { createTranscriptJsonReader, TranscriptJsonLimitError } from "./AgentSessionJson.ts"; + +function read( + json: string, + size: number, + select: (path: ReadonlyArray) => boolean = () => true, +) { + const reader = createTranscriptJsonReader(() => {}, select); + for (let offset = 0; offset < json.length; offset += size) + reader.write(json.slice(offset, offset + size)); + return reader.finish(); +} + +describe("transcript JSON projection", () => { + it.each([1, 2, 7, 64, 1024])("matches JSON.parse across %i-character boundaries", (size) => { + for (const text of [ + '{"a":1,"a":2,"b":"before","b":"after"}', + '{"a":{"x":1},"a":{"y":2},"b":[],"c":{}}', + '{"a":[null,true,false,1,-2.3e4,"😀\\u0061\\\\\\\"",{},[],[1,2]]}', + '{"a":"s","a":null,"b":null,"b":"s","c":0,"c":false}', + '{"__proto__":{"polluted":true},"constructor":1,"__proto__":2}', + ]) + expect(read(text, size)).toEqual(JSON.parse(text)); + }); + + it("projects siblings and array elements without merging repeated parent objects", () => { + const text = + '{"message":{"usage":{"input":100},"content":"large"},"message":{"usage":{"output":5},"content":[1,2]},"rows":[{"keep":1,"drop":2},{"keep":3}],"drop":{"keep":4}}'; + const projected = read(text, 1, (path) => { + if (path[0] === "drop") return false; + return !path.includes("content") && !path.includes("drop"); + }); + expect(projected).toEqual({ + message: { usage: { output: 5 } }, + rows: [{ keep: 1 }, { keep: 3 }], + }); + }); + + it.each(['{"a":', '{"a":1} trailing', '{"a":1}{"a":2}', '{"a":"bad\\x"}', '{"a":[1,]}'])( + "rejects malformed input %s", + (text) => { + expect(read(text, 1)).toBeUndefined(); + }, + ); + + it("retains the import allocation and depth limits", () => { + const limited = createTranscriptJsonReader( + () => { + throw new TranscriptJsonLimitError("budget"); + }, + () => true, + ); + expect(() => limited.write('{"a":1}')).toThrow(TranscriptJsonLimitError); + expect(() => read("[".repeat(129) + "0" + "]".repeat(129), 10)).toThrow( + TranscriptJsonLimitError, + ); + }); +}); diff --git a/apps/server/src/project/AgentSessionJson.ts b/apps/server/src/project/AgentSessionJson.ts index d31c47833d70..4deba0a67794 100644 --- a/apps/server/src/project/AgentSessionJson.ts +++ b/apps/server/src/project/AgentSessionJson.ts @@ -1,7 +1,6 @@ import * as SchemaAST from "effect/SchemaAST"; -import { isMany, none, type Many } from "stream-chain/defs.js"; +import { none, type Many } from "stream-chain/defs.js"; import { Assembler } from "stream-json/core/assembler.js"; -import { filter } from "stream-json/core/filters/filter.js"; import * as StreamJson from "stream-json/core/parser.js"; import type { ParserOptions, Token } from "stream-json/core/parser.js"; @@ -55,6 +54,7 @@ export class TranscriptJsonLimitError extends Error {} export function createTranscriptJsonReader( reserve: (bytes: number) => void, selectPath: (path: JsonPath) => boolean, + options?: { readonly maxDepth?: number }, ) { // The synchronous tokenizer is exported at runtime in 3.6.0, but omitted // from its bundled types. Unlike parser(), it does not wrap tokens in an @@ -65,9 +65,6 @@ export function createTranscriptJsonReader( ) => (input: string | typeof none) => Many | typeof none; }; const tokenize = jsonParser({ packValues: false }); - const select = filter({ filter: selectPath, streamKeys: false }) as ( - input: Token | typeof none, - ) => Token | Many | typeof none; const assembler = new Assembler(); let key: string | null = null; let value = ""; @@ -100,13 +97,62 @@ export function createTranscriptJsonReader( assembler.consume(token); } }; + // Forward actual selected keys instead of reconstructing them from path + // changes: adjacent duplicate keys have the same path but JSON.parse keeps + // the last value. Reconstructing paths can silently retain the first value. + const stack: Array<{ path: JsonPath; key: string | number | null; selected: boolean }> = []; + let selectedValue = false; + const startValue = () => { + const parent = stack.at(-1); + const path = parent?.selected ? [...parent.path, parent.key] : []; + const selected = (parent?.selected ?? true) && selectPath(path); + if (selected && typeof parent?.key === "string") { + assemble({ name: "keyValue", value: parent.key }); + } + return { path, selected }; + }; + const endValue = () => { + const parent = stack.at(-1); + if (parent && typeof parent.key === "number") parent.key++; + }; const selectToken = (token: Token | typeof none) => { - const selected = select(token); - if (selected === none) return; - if (isMany(selected)) { - for (const item of selected.values) assemble(item); - } else { - assemble(selected); + if (token === none) return; + switch (token.name) { + case "keyValue": { + const parent = stack.at(-1); + if (parent) parent.key = token.value; + return; + } + case "startObject": + case "startArray": { + const frame = startValue(); + stack.push({ ...frame, key: token.name === "startArray" ? 0 : null }); + if (frame.selected) assemble(token); + return; + } + case "endObject": + case "endArray": + if (stack.pop()?.selected) assemble(token); + endValue(); + return; + case "startString": + case "startNumber": + selectedValue = startValue().selected; + if (selectedValue) assemble(token); + return; + case "endString": + case "endNumber": + if (selectedValue) assemble(token); + endValue(); + return; + case "nullValue": + case "trueValue": + case "falseValue": + if (startValue().selected) assemble(token); + endValue(); + return; + default: + if (selectedValue) assemble(token); } }; const consume = (input: string | typeof none) => { @@ -116,8 +162,8 @@ export function createTranscriptJsonReader( if (tokens === none) return; for (const token of tokens.values) { if (token.name === "startObject" || token.name === "startArray") { - if (++depth > 128) - throw new TranscriptJsonLimitError("Transcript JSON nesting exceeds 128 levels"); + if (++depth > (options?.maxDepth ?? 128)) + throw new TranscriptJsonLimitError("Transcript JSON nesting exceeds the depth limit"); } else if (token.name === "endObject" || token.name === "endArray") { if (--depth === 0) complete = true; } 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, "/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,"","/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("/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 !== "/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..906d04bab699 --- /dev/null +++ b/apps/server/src/provider/CodexInstallation.test.ts @@ -0,0 +1,366 @@ +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: "https://github.com/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( + "outside the supported range", + ); + 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: "https://github.com/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: "https://github.com/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: "https://github.com/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: "https://github.com/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: "https://github.com/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: "https://github.com/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..15e223c29363 --- /dev/null +++ b/apps/server/src/provider/CodexManagedErrors.ts @@ -0,0 +1,88 @@ +import { CHATGPT_USAGE_LIMIT_MESSAGE } from "@t3tools/shared/usageLimits"; + +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: CHATGPT_USAGE_LIMIT_MESSAGE, + 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: "/custom/shared", shadowHomePath: "/custom/shadow" }), + ); + assert.equal(configured.sharedHomePath, "/custom/shared"); + assert.equal(configured.effectiveHomePath, "/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: "/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, "/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..528673fc3406 --- /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. +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 7a8b0c664ac9..993297355ab2 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"; @@ -28,6 +36,7 @@ import { import { CodexDriver } from "./CodexDriver.ts"; import { CodexAppServerClientFactory } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; import { layer as idAllocatorLayer } from "../../orchestration-v2/IdAllocator.ts"; +import * as ProviderCredentialStore from "../ProviderCredentialStore.ts"; const testLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-codex-driver-maintenance-", @@ -39,6 +48,15 @@ const testLayer = ServerConfig.layerTest(process.cwd(), { open: () => Effect.die("Maintenance resolution must not open a Codex session"), }), ), + 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), @@ -62,14 +80,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"); @@ -144,7 +292,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, @@ -201,7 +351,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") @@ -276,7 +428,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"); @@ -369,7 +523,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 1c7e73e943cc..d4fd57b4d0cb 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -72,6 +72,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"); @@ -116,7 +120,10 @@ export type CodexDriverEnv = | Path.Path | ProviderEventLoggers | ServerConfig - | ServerSettingsService; + | ServerSettingsService + | ServerSecretStore + | ServerEnvironmentIdentity + | CodexInstallation; export const CodexDriver: ProviderDriver = { driverKind: DRIVER_KIND, @@ -128,6 +135,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; const fileSystem = yield* FileSystem.FileSystem; @@ -326,16 +342,19 @@ export const CodexDriver: ProviderDriver = { // The windows just changed; re-probe so the snapshot says so. A // failed probe republishes the pre-redemption limits rather than // marking them failed, so "confirmed" means `checkedAt` moved - // past what was published before the redemption started. - Effect.tap(() => + // past what was published before the redemption started. Only a + // reset claims the limits changed, so only a reset reports an + // unconfirmed refresh. + Effect.tap((outcome) => Effect.gen(function* () { const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt; const refreshed = yield* snapshot.refresh; const after = refreshed.usageLimits?.checkedAt; if ( - after === undefined || - after === before || - refreshed.usageLimits?.unavailable?.reason === "probeFailed" + outcome === "reset" && + (after === undefined || + after === before || + refreshed.usageLimits?.unavailable?.reason === "probeFailed") ) { return yield* new ProviderDriverError({ driver: DRIVER_KIND, diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts new file mode 100644 index 000000000000..18b5a771dc98 --- /dev/null +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -0,0 +1,291 @@ +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 { + 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"; +import { createCodexAdapterV2 } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; +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 orchestrationAdapter = yield* createCodexAdapterV2(input, { + managed: { + resolve: resolveRuntime, + onConnectionRevoked: runtime.auth.revoke.pipe(Effect.ignore), + }, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Failed to build the managed Codex orchestration adapter.", + cause, + }), + ), + ); + 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, + orchestrationAdapter, + 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.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 0c7a40d9bd9e..2c67b6f144db 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -81,6 +81,11 @@ it("uses standard routing when the catalog has no default service tier", () => { name: "Fast", description: "1.5x speed, increased usage", }, + { + id: "ultrafast", + name: "Ultrafast", + description: "The fastest available responses for latency-sensitive work.", + }, ], supportedReasoningEfforts: [], }); @@ -97,6 +102,11 @@ it("uses standard routing when the catalog has no default service tier", () => { label: "Fast", description: "1.5x speed, increased usage", }, + { + id: "ultrafast", + label: "Ultrafast", + description: "Even faster, more expensive", + }, ], currentValue: "default", }, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 8708fc55e84c..dcb606932ab9 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -91,6 +91,11 @@ const REASONING_EFFORT_LABELS: Readonly> = { const DEFAULT_SERVICE_TIER_ID = "default"; +/** Shorter copy for tiers whose catalog description wraps in the traits menu. */ +const SERVICE_TIER_DESCRIPTIONS: Readonly> = { + ultrafast: "Even faster, more expensive", +}; + function reasoningEffortLabel(reasoningEffort: string): string { return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; } @@ -116,6 +121,8 @@ export function codexPlanLabel(planType: string | null | undefined): string | un return "ChatGPT Pro 20x Subscription"; case "prolite": return "ChatGPT Pro 5x Subscription"; + case "promax": + return "ChatGPT Pro Max Subscription"; case "team": return "ChatGPT Team Subscription"; case "self_serve_business_prolite": @@ -196,12 +203,15 @@ export function mapCodexModelCapabilities( label: "Standard", ...(defaultServiceTier === DEFAULT_SERVICE_TIER_ID ? { isDefault: true } : {}), }, - ...serviceTiers.map((tier) => ({ - id: tier.id, - label: tier.name, - ...(tier.description ? { description: tier.description } : {}), - ...(defaultServiceTier === tier.id ? { isDefault: true } : {}), - })), + ...serviceTiers.map((tier) => { + const description = SERVICE_TIER_DESCRIPTIONS[tier.id] ?? tier.description; + return { + id: tier.id, + label: tier.name, + ...(description ? { description } : {}), + ...(defaultServiceTier === tier.id ? { isDefault: true } : {}), + }; + }), ], currentValue: defaultServiceTier, }); @@ -337,8 +347,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: { @@ -410,6 +420,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); @@ -435,31 +446,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 ?? []), @@ -561,12 +574,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, @@ -600,6 +615,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)), @@ -648,7 +664,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" }) @@ -685,7 +703,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/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index ecd73af72dbe..fb17d6839fda 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -363,6 +363,66 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("retires a crashed process so a deliberate retry can resume", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-crash-recovery"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-crash-recovery-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapper = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_CRASH_PROMPT: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapper); + const exited = + yield* Deferred.make>(); + const events = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "session.exited" + ? Deferred.succeed(exited, event).pipe(Effect.asVoid) + : Effect.void, + ).pipe(Effect.forkChild); + const input = { + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access" as const, + }; + const session = yield* adapter.startSession(input); + const failure = yield* Effect.flip( + adapter.sendTurn({ threadId, input: "crash now", attachments: [] }), + ); + assert.isDefined(failure); + assert.isFalse(yield* adapter.hasSession(threadId)); + const retryDuringTeardown = yield* Effect.flip( + adapter.sendTurn({ threadId, input: "retry during teardown", attachments: [] }), + ); + assert.equal(retryDuringTeardown._tag, "ProviderAdapterSessionNotFoundError"); + assert.equal((yield* Deferred.await(exited)).payload.exitKind, "error"); + assert.deepStrictEqual(yield* adapter.listSessions(), []); + yield* Fiber.interrupt(events); + yield* adapter.startSession({ ...input, resumeCursor: session.resumeCursor }); + const turn = yield* adapter.sendTurn({ threadId, input: "retry now", attachments: [] }); + assert.equal(turn.threadId, threadId); + yield* adapter.stopSession(threadId); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.equal(requests.filter((request) => request.method === "session/new").length, 1); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + const resumes = requests.filter((request) => request.method === "session/load"); + assert.equal(resumes.length, 1); + assert.deepStrictEqual(resumes[0]?.params, { + sessionId: "mock-session-1", + cwd: process.cwd(), + mcpServers: [], + }); + }), + ); + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-mock-thread"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index a8bc7600b567..cfb4f8ed4ae5 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -175,6 +175,7 @@ interface GrokSessionContext { currentModelId: string | undefined; currentReasoningEffort: string | undefined; stopped: boolean; + terminated: boolean; /** Live monitor/shell identities and their originating turns. */ readonly backgroundTasks: Map; } @@ -345,6 +346,7 @@ export function grokPromptSettlementBelongsToContext(input: { export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapterLiveOptions) { return Effect.gen(function* () { + const ownerScope = yield* Effect.scope; const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("grok"); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -922,7 +924,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte threadId: ThreadId, ): Effect.Effect => { const ctx = sessions.get(threadId); - if (!ctx || ctx.stopped) { + if (!ctx || ctx.stopped || ctx.terminated) { return Effect.fail( new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), ); @@ -946,7 +948,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ...(yield* makeEventStamp()), provider: PROVIDER, threadId: ctx.threadId, - payload: { exitKind: "graceful" }, + payload: { exitKind: ctx.terminated ? "error" : "graceful" }, }); }); @@ -1316,12 +1318,35 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ? normalizeGrokReasoningEffort(requestedStartReasoningEffort) : currentStartReasoningEffort, stopped: false, + terminated: false, backgroundTasks: new Map(), }; const nf = yield* Stream.runDrain( Stream.mapEffect(acp.getEvents(), (event) => Effect.gen(function* () { + if (event._tag === "ConnectionTerminated") { + ctx.terminated = true; + yield* withThreadLock( + ctx.threadId, + Effect.gen(function* () { + if (sessions.get(ctx.threadId) !== ctx) return; + if (ctx.activeTurnId) { + yield* settlePromptInFlight( + ctx.threadId, + ctx.activeTurnId, + ctx.acpSessionId, + { + errorMessage: "Grok connection terminated.", + settleAllPrompts: true, + }, + ); + } + yield* stopSessionInternal(ctx); + }), + ).pipe(Effect.forkIn(ownerScope)); + return; + } if (event._tag === "EventStreamBarrier") { yield* Deferred.succeed(event.acknowledge, undefined); return; @@ -2165,12 +2190,16 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); const listSessions: GrokAdapterShape["listSessions"] = () => - Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + Effect.sync(() => + Array.from(sessions.values()) + .filter((c) => !c.terminated) + .map((c) => ({ ...c.session })), + ); const hasSession: GrokAdapterShape["hasSession"] = (threadId) => Effect.sync(() => { const c = sessions.get(threadId); - return c !== undefined && !c.stopped; + return c !== undefined && !c.stopped && !c.terminated; }); const stopAll: GrokAdapterShape["stopAll"] = () => diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index baded8094ba6..40d034089ae9 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -2,6 +2,7 @@ import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; @@ -781,6 +782,56 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect( + "stopSession completes after sendTurn is interrupted before the prompt is submitted", + () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-interrupted-before-submit"); + runtimeMock.state.createdSessionIds.push("ses_interrupted_before_submit"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const baseClock = yield* Clock.clockWith(Effect.succeed); + const reached = yield* Deferred.make(); + const release = yield* Deferred.make(); + let calls = 0; + const gatedClock: Clock.Clock = { + ...baseClock, + currentTimeMillisUnsafe: () => baseClock.currentTimeMillisUnsafe(), + currentTimeMillis: Effect.suspend(() => { + calls += 1; + return calls === 2 + ? Deferred.succeed(reached, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(baseClock.currentTimeMillis), + ) + : baseClock.currentTimeMillis; + }), + }; + + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "This prompt must not be submitted", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.provideService(Clock.Clock, gatedClock), Effect.exit, Effect.forkChild); + yield* Deferred.await(reached); + yield* Fiber.interrupt(sendFiber); + + const stopExit = yield* adapter.stopSession(threadId).pipe(Effect.exit); + NodeAssert.equal(Exit.isSuccess(stopExit), true); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + it.effect("aborts a held teardown request before closing the session scope", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 04535edbd3af..34ebd1b544fb 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -3531,7 +3531,18 @@ export function makeOpenCodeAdapter( ? { resumeCursor: context.session.resumeCursor } : {}), }; - }), + }).pipe( + // stopSession waits on submissionSettled; an interrupted sendTurn + // must not leave it pending. + Effect.ensuring( + Effect.suspend(() => { + const admission = context.promptAdmission; + return admission + ? Deferred.succeed(admission.submissionSettled, undefined).pipe(Effect.ignore) + : Effect.void; + }), + ), + ), ); }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index b50a51f6cb31..55e159197e6b 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -1,4 +1,5 @@ import * as NodeAssert from "node:assert/strict"; +import * as NodeCrypto from "node:crypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; @@ -71,6 +72,10 @@ it.effect("reads Go limits with the instance's XDG credentials and preserves res Effect.provide(NodeServices.layer), ); NodeAssert.equal(limits.unavailable, undefined); + NodeAssert.equal( + limits.credentialFingerprint, + NodeCrypto.createHash("sha256").update("opencode-go\0instance-key").digest("hex"), + ); NodeAssert.deepEqual( limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({ kind, @@ -139,6 +144,7 @@ it.effect("keeps Go entitlement absence distinct from failed or malformed usage Effect.provide(NodeServices.layer), ); NodeAssert.equal(limits.unavailable?.reason, reason); + NodeAssert.equal(limits.credentialFingerprint, undefined); NodeAssert.deepEqual(limits.windows, []); } }), diff --git a/apps/server/src/provider/Layers/ProviderAuthService.ts b/apps/server/src/provider/Layers/ProviderAuthService.ts index 54cd828f1a53..b0057aaf8fee 100644 --- a/apps/server/src/provider/Layers/ProviderAuthService.ts +++ b/apps/server/src/provider/Layers/ProviderAuthService.ts @@ -152,6 +152,33 @@ export const makeProviderAuthService = Effect.gen(function* () { }); return 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* () { @@ -161,6 +188,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 eb3b174cad71..fd3fd0e7e1df 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -1,4 +1,7 @@ -import * as CodexResetCredit from "./codexResetCredit.ts"; +import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; +import { CodexInstallation } from "../CodexInstallation.ts"; +import { ServerSecretStore } from "../../auth/ServerSecretStore.ts"; +import { ServerEnvironmentIdentity } from "../../environment/ServerEnvironment.ts"; /** * Multi-instance validation slices for `ProviderInstanceRegistryLive`. * @@ -26,6 +29,7 @@ import * as CodexResetCredit from "./codexResetCredit.ts"; import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { + EnvironmentId, type ClaudeSettings, type CodexSettings, type CursorSettings, @@ -244,6 +248,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), @@ -596,7 +611,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 baseLayer = 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 f125584b9872..ad6edd850c6d 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, @@ -384,7 +388,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/Layers/openCodeUsageLimits.ts b/apps/server/src/provider/Layers/openCodeUsageLimits.ts index 22b542142cc4..74d0bd75b9f8 100644 --- a/apps/server/src/provider/Layers/openCodeUsageLimits.ts +++ b/apps/server/src/provider/Layers/openCodeUsageLimits.ts @@ -1,4 +1,5 @@ import * as NodeOS from "node:os"; +import * as NodeCrypto from "node:crypto"; import type { ServerProviderUsageWindow } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -95,7 +96,16 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt), }, ]; - return makeUsageLimits({ checkedAt, windows }); + return { + ...makeUsageLimits({ checkedAt, windows }), + // Go's usage response has no account ID. An unkeyed hash matches across + // environments without a shared secret. It permits offline guesses, but + // Go keys are randomly generated. + credentialFingerprint: NodeCrypto.createHash("sha256") + .update("opencode-go\0") + .update(apiKey) + .digest("hex"), + }; }).pipe( Effect.timeout("5 seconds"), Effect.orElseSucceed(() => 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/model-manifest.json b/apps/server/src/provider/model-manifest.json index a946bad8331a..42721eace6d8 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,10 +1,20 @@ { "version": 1, - "updatedAt": "2026-09-28T20:00:00Z", + "updatedAt": "2026-09-29T20:20:00Z", "compatibility": [ { "driver": "codex", - "t3CodeRange": ">=0.0.42", + "t3CodeRange": ">=0.0.44", + "recommendedRange": ">=0.159.0", + "ranges": [ + { "range": ">=0.156.0", "status": "supported" }, + { "range": ">=0.149.0 <0.156.0", "status": "unsupported" }, + { "range": "<0.149.0", "status": "broken" } + ] + }, + { + "driver": "codex", + "t3CodeRange": ">=0.0.42 <0.0.44", "recommendedRange": ">=0.156.0", "ranges": [ { "range": ">=0.156.0", "status": "supported" }, @@ -43,9 +53,11 @@ { "driver": "opencode", "t3CodeRange": ">=0.0.42", - "recommendedRange": ">=1.14.19", + "recommendedRange": ">=1.14.19 <2.0.0", + "recommendedVersion": "1.14.19", "ranges": [ - { "range": ">=1.14.19", "status": "supported" }, + { "range": ">=2.0.0", "status": "broken" }, + { "range": ">=1.14.19 <2.0.0", "status": "supported" }, { "range": "<1.14.19", "status": "broken" } ] }, @@ -69,8 +81,8 @@ "currentModels": { "codex": [ "gpt-6-astra", + "gpt-6.1-sol", "gpt-6-luna", - "gpt-6-sol", "gpt-daybreak-blue-latest", "gpt-daybreak-red-latest" ], diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index 34fd71321cc6..4fb8f2d74810 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -67,24 +67,6 @@ describe("provider compatibility", () => { } }); - it("supports Codex 0.156 and marks Codex without Thread.projectId broken", () => { - const bundled = ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility; - for (const [t3CodeVersion, codexVersion, expected] of [ - ["0.0.42", "0.148.0", "broken"], - ["0.0.42", "0.149.0", "unsupported"], - ["0.0.42", "0.155.0", "unsupported"], - ["0.0.42", "0.156.0", "supported"], - ["0.0.43-nightly.20260924.2200", "0.153.3", "unsupported"], - ["0.0.43-nightly.20260924.2200", "0.156.1", "supported"], - ] as const) { - assert.strictEqual( - resolveProviderCompatibility(bundled, driver, codexVersion, t3CodeVersion)?.status, - expected, - `T3 Code ${t3CodeVersion} with Codex ${codexVersion}`, - ); - } - }); - it("compares Cursor build dates without treating semver prereleases as stable", () => { const cursor = ProviderDriverKind.make("cursor"); const cursorPolicy: ProviderCompatibilityPolicy = { diff --git a/apps/server/src/provider/providerInstallation.test.ts b/apps/server/src/provider/providerInstallation.test.ts index cfd7b62a1921..33e33ed450a2 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/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 5c1a99abdf2f..8d059c1fe545 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -216,7 +216,11 @@ const makeTestRunner = ( manifest: ModelManifest.ModelManifestData = { version: 1, currentModels: {}, - compatibility: [{ driver: CODEX_DRIVER, t3CodeRange: ">=0.0.42", ranges: [] }], + compatibility: [CODEX_DRIVER, OPENCODE_DRIVER].map((driver) => ({ + driver, + t3CodeRange: ">=0.0.42", + ranges: [], + })), }, ) => Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 04dc90ff3405..dabfd0c218e2 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -90,6 +90,7 @@ import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts"; import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts"; +import { CodexInstallation } from "./provider/CodexInstallation.ts"; import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; import { layerFromProviderInstanceRegistry as providerAdapterRegistryLayerFromProviderInstances } from "./orchestration-v2/ProviderAdapterRegistry.ts"; @@ -491,22 +492,27 @@ const PullRequestSyncWorkerLive = Layer.effectDiscard( PullRequestSyncReactor.PullRequestSyncReactor.pipe(Effect.flatMap((service) => service.start())), ).pipe(Layer.provide(PullRequestSyncServiceLive)); -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 }, ), @@ -532,7 +538,7 @@ const RuntimeCoreDependenciesBaseLive = Layer.mergeAll( // Subscribes to `account.rate-limits.updated` so usage bars track live // telemetry instead of waiting for the next status probe. ProviderUsageLimitsIngestionLive, - AntigravityInstallationRefreshLive, + ProviderInstallationRefreshLive, ).pipe( // Core Services Layer.provideMerge(OrchestrationApplicationLayerLive), @@ -560,7 +566,7 @@ const RuntimeCoreDependenciesBaseLive = Layer.mergeAll( // `providerInstances` hydration merges `settings.providers.` // with explicit `providerInstances` entries on boot. Layer.provideMerge(ProviderInstanceRegistryHydrationLive), - Layer.provideMerge(AntigravityInstallation.layer), + Layer.provideMerge(Layer.mergeAll(AntigravityInstallation.layer, CodexInstallation.layer)), ); const RuntimeCoreDependenciesLive = RuntimeCoreDependenciesBaseLive.pipe( diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 488b95b0522f..992f9a283a4a 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -47,6 +47,20 @@ const makeServerSettingsLayer = () => ), ); +/** Like `makeServerSettingsLayer`, but also exposes the secret store for assertions. */ +const makeServerSettingsLayerWithSecrets = () => + ServerSettingsModule.layer.pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-server-settings-test-", + }), + ), + ), + ); + const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) => Layer.succeed( ServerSecretStore.ServerSecretStore, @@ -1394,6 +1408,128 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect( + "keeps Bitbucket tokens in the secret store and tells clients only that one is set", + () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + + const saved = yield* serverSettings.updateSettings({ + bitbucket: { email: "me@example.com", accessToken: "bb-access", apiToken: "bb-api" }, + }); + assert.deepEqual(saved.bitbucket, { + email: "me@example.com", + accessToken: "bb-access", + apiToken: "bb-api", + }); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.notInclude(raw, "bb-access"); + assert.notInclude(raw, "bb-api"); + assert.include(raw, "me@example.com"); + + const forClient = ServerSettingsModule.redactServerSettingsForClient(saved).bitbucket; + assert.equal(forClient.email, "me@example.com"); + assert.notInclude(forClient.accessToken, "bb-access"); + assert.notInclude(forClient.apiToken, "bb-api"); + assert.isAbove(forClient.accessToken.length, 0); + assert.isAbove(forClient.apiToken.length, 0); + + // A client echoing the redacted values back, or omitting them, keeps the saved tokens. + yield* serverSettings.updateSettings({ bitbucket: forClient }); + yield* serverSettings.updateSettings({ bitbucket: { email: "other@example.com" } }); + assert.deepEqual((yield* serverSettings.getSettings).bitbucket, { + email: "other@example.com", + accessToken: "bb-access", + apiToken: "bb-api", + }); + + const cleared = yield* serverSettings.updateSettings({ bitbucket: { accessToken: "" } }); + assert.equal(cleared.bitbucket.accessToken, ""); + assert.equal(cleared.bitbucket.apiToken, "bb-api"); + assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token"))); + assert.equal( + ServerSettingsModule.redactServerSettingsForClient(cleared).bitbucket.accessToken, + "", + ); + }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())), + ); + + it.effect("removes a Bitbucket secret once its token is cleared by hand in settings.json", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + // A token was saved, then the user deleted it from settings.json directly. + yield* secrets.set("bitbucket-access-token", new TextEncoder().encode("stale-token")); + yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}"); + + yield* serverSettings.updateSettings({ cursorKeychainUsageEnabled: true }); + + assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token"))); + }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())), + ); + + it.effect("moves a hand-edited Bitbucket token into the secret store when settings load", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"bitbucket":{"accessToken":"hand-edited-token"}}', + ); + + // Loading alone moves it: no settings update is needed. + const loaded = yield* serverSettings.getSettings; + + assert.equal(loaded.bitbucket.accessToken, "hand-edited-token"); + assert.notInclude( + yield* fileSystem.readFileString(serverConfig.settingsPath), + "hand-edited-token", + ); + const stored = yield* secrets.get("bitbucket-access-token"); + assert.equal( + Option.isSome(stored) ? new TextDecoder().decode(stored.value) : null, + "hand-edited-token", + ); + }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())), + ); + + it.effect( + "moves a hand-edited Bitbucket token into the secret store when a client echoes the marker", + () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"bitbucket":{"email":"me@example.com","apiToken":"hand-edited-token"}}', + ); + + // The form resends the redacted token when only the email changes. + const forClient = ServerSettingsModule.redactServerSettingsForClient( + yield* serverSettings.getSettings, + ).bitbucket; + const updated = yield* serverSettings.updateSettings({ + bitbucket: { email: "new@example.com", apiToken: forClient.apiToken }, + }); + + assert.equal(updated.bitbucket.apiToken, "hand-edited-token"); + assert.equal((yield* serverSettings.getSettings).bitbucket.apiToken, "hand-edited-token"); + assert.notInclude( + yield* fileSystem.readFileString(serverConfig.settingsPath), + "hand-edited-token", + ); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("materializes provider secrets for terminal environment resolution", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index da1f45ab22c1..0e373c7c8dd9 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -143,16 +143,24 @@ function providerEnvironmentSecretName(input: { } /** - * On disk the hub key is replaced by this marker and the real value lives in - * the secret store, mirroring provider environment secrets. A client that - * sends the marker back means "keep what you have". + * On disk a hub key or Bitbucket token is replaced by this marker and the + * real value lives in the secret store, mirroring provider environment + * secrets. A client that sends the marker back means "keep what you have". */ -const USAGE_LIMIT_SOURCE_KEY_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; +const SECRET_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; function usageLimitSourceSecretName(sourceId: string): string { return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`; } +const BITBUCKET_SECRET_NAMES = { + accessToken: "bitbucket-access-token", + apiToken: "bitbucket-api-token", +} as const; +const BITBUCKET_SECRET_FIELDS = ["accessToken", "apiToken"] as const; + +const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : ""); + function redactProviderEnvironmentVariable( variable: ProviderInstanceEnvironmentVariable, ): ProviderInstanceEnvironmentVariable { @@ -185,11 +193,16 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS id, { ...source, - managementKey: source.managementKey.length > 0 ? USAGE_LIMIT_SOURCE_KEY_REDACTED : "", + managementKey: redactSecret(source.managementKey), }, ]), ); - return { ...settings, providerInstances, usageLimitSources }; + const bitbucket = { + ...settings.bitbucket, + accessToken: redactSecret(settings.bitbucket.accessToken), + apiToken: redactSecret(settings.bitbucket.apiToken), + }; + return { ...settings, providerInstances, usageLimitSources, bitbucket }; } export function applyProviderInstanceMutation( @@ -653,6 +666,35 @@ const make = Effect.gen(function* () { ), ); + /** + * Moves Bitbucket tokens hand-edited into settings.json into the secret store as they load, + * so plaintext does not stay on disk. If the store is unavailable, the token keeps working + * from the file and the move is retried on the next load. + */ + const moveInlineBitbucketTokens = (settings: ServerSettings) => + Effect.gen(function* () { + const bitbucket = { ...settings.bitbucket }; + let moved = false; + for (const field of BITBUCKET_SECRET_FIELDS) { + const value = bitbucket[field]; + if (value.length === 0 || value === SECRET_REDACTED) continue; + const stored = yield* secretStore + .set(BITBUCKET_SECRET_NAMES[field], textEncoder.encode(value)) + .pipe( + Effect.as(true), + Effect.catch(() => + Effect.logWarning("failed to move a Bitbucket token into the secret store", { + field, + }).pipe(Effect.as(false)), + ), + ); + if (!stored) continue; + bitbucket[field] = SECRET_REDACTED; + moved = true; + } + return moved ? { ...settings, bitbucket } : settings; + }); + const loadSettingsFromDisk = Effect.gen(function* () { let settings = DEFAULT_SERVER_SETTINGS; let persisted: typeof PersistedOptionalProviderSettings.Type = {}; @@ -737,10 +779,12 @@ const make = Effect.gen(function* () { const folded = settingsFileTrusted ? foldLegacyProjectSettings(loaded, legacyProjectRows) : loaded; - if (folded !== loaded) { - yield* writeSettingsAtomically(folded); + // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair. + const migrated = settingsFileTrusted ? yield* moveInlineBitbucketTokens(folded) : folded; + if (migrated !== loaded) { + yield* writeSettingsAtomically(migrated); } - return folded; + return migrated; }); const settingsCache = yield* Cache.make({ @@ -791,7 +835,7 @@ const make = Effect.gen(function* () { } const usageLimitSources: Record = {}; for (const [sourceId, source] of Object.entries(settings.usageLimitSources)) { - if (source.managementKey !== USAGE_LIMIT_SOURCE_KEY_REDACTED) { + if (source.managementKey !== SECRET_REDACTED) { usageLimitSources[sourceId] = source; continue; } @@ -807,10 +851,23 @@ const make = Effect.gen(function* () { managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", }; } + const bitbucket = { ...settings.bitbucket }; + for (const field of BITBUCKET_SECRET_FIELDS) { + if (bitbucket[field] !== SECRET_REDACTED) continue; + const secret = yield* secretStore + .get(BITBUCKET_SECRET_NAMES[field]) + .pipe( + Effect.mapError( + (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }), + ), + ); + bitbucket[field] = Option.isSome(secret) ? textDecoder.decode(secret.value) : ""; + } return { ...settings, providerInstances: providerInstances as ServerSettings["providerInstances"], usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], + bitbucket, }; }); @@ -927,7 +984,7 @@ const make = Effect.gen(function* () { const usageLimitSources: Record = {}; for (const [sourceId, source] of Object.entries(next.usageLimitSources)) { const secretName = usageLimitSourceSecretName(sourceId); - if (source.managementKey === USAGE_LIMIT_SOURCE_KEY_REDACTED) { + if (source.managementKey === SECRET_REDACTED) { usageLimitSources[sourceId] = source; continue; } @@ -941,7 +998,7 @@ const make = Effect.gen(function* () { secretName, value: textEncoder.encode(source.managementKey), }); - usageLimitSources[sourceId] = { ...source, managementKey: USAGE_LIMIT_SOURCE_KEY_REDACTED }; + usageLimitSources[sourceId] = { ...source, managementKey: SECRET_REDACTED }; } for (const sourceId of Object.keys(current.usageLimitSources)) { if (sourceId in next.usageLimitSources) continue; @@ -952,11 +1009,31 @@ const make = Effect.gen(function* () { }); } + const bitbucket = { ...next.bitbucket }; + for (const field of BITBUCKET_SECRET_FIELDS) { + let value = bitbucket[field]; + if (value === SECRET_REDACTED) { + // The marker keeps what is saved. A plaintext value hand-edited into settings.json + // is not in the secret store yet, so move it there instead of dropping it. + const inline = current.bitbucket[field]; + if (inline === SECRET_REDACTED || inline.length === 0) continue; + value = inline; + } + const secretName = BITBUCKET_SECRET_NAMES[field]; + if (value.length === 0) { + changes.push({ kind: "remove", secretName, operation: "remove-secret" }); + continue; + } + changes.push({ kind: "write", secretName, value: textEncoder.encode(value) }); + bitbucket[field] = SECRET_REDACTED; + } + return { settings: { ...next, providerInstances: providerInstances as ServerSettings["providerInstances"], usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], + bitbucket, }, changes, }; diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index b27d56bef399..83965a38b21c 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -16,6 +16,7 @@ import { import { GitCommandError } from "@t3tools/contracts"; import * as BitbucketApi from "./BitbucketApi.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import type * as VcsDriver from "../vcs/VcsDriver.ts"; @@ -64,6 +65,7 @@ function makeLayer(input: { request: HttpClientRequest.HttpClientRequest, ) => HttpClientError.HttpClientError; readonly git?: Partial; + readonly env?: Record; }) { const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => input.requestFailure @@ -150,7 +152,7 @@ function makeLayer(input: { Layer.provide( ConfigProvider.layer( ConfigProvider.fromEnv({ - env: { + env: input.env ?? { T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0", T3CODE_BITBUCKET_EMAIL: "user@example.com", T3CODE_BITBUCKET_API_TOKEN: "token", @@ -158,6 +160,7 @@ function makeLayer(input: { }), ), ), + Layer.provideMerge(ServerSettings.layerTest()), Layer.provideMerge(NodeServices.layer), ); @@ -509,6 +512,78 @@ it.effect("reports auth status through the Bitbucket REST /user endpoint", () => }).pipe(Effect.provide(layer)); }); +it.effect("prefers credentials saved in settings over the environment, without a restart", () => { + const { execute, layer } = makeLayer({ + response: () => Response.json({ username: "bitbucket-user" }), + }); + const lastAuthorization = () => execute.mock.calls.at(-1)?.[0].headers.authorization; + const basic = (user: string, password: string) => `Basic ${btoa(`${user}:${password}`)}`; + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const settings = yield* ServerSettings.ServerSettingsService; + + yield* bitbucket.probeAuth; + assert.strictEqual(lastAuthorization(), basic("user@example.com", "token")); + + yield* settings.updateSettings({ + bitbucket: { email: "saved@example.com", apiToken: "saved-api-token" }, + }); + yield* bitbucket.probeAuth; + assert.strictEqual(lastAuthorization(), basic("saved@example.com", "saved-api-token")); + + yield* settings.updateSettings({ bitbucket: { accessToken: "saved-access-token" } }); + yield* bitbucket.probeAuth; + assert.strictEqual(lastAuthorization(), "Bearer saved-access-token"); + + yield* settings.updateSettings({ bitbucket: { accessToken: "", apiToken: "" } }); + yield* bitbucket.probeAuth; + assert.strictEqual(lastAuthorization(), basic("user@example.com", "token")); + }).pipe(Effect.provide(layer)); +}); + +it.effect("never puts a saved token that is unsafe for an HTTP header on the wire", () => { + const { execute, layer } = makeLayer({ + response: () => Response.json({ username: "bitbucket-user" }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const settings = yield* ServerSettings.ServerSettingsService; + + // Fetch would reject this header with an error quoting the token, and that error reaches + // clients. The unusable token is ignored, so the environment credential is used instead. + yield* settings.updateSettings({ bitbucket: { accessToken: "saved\ntoken" } }); + yield* bitbucket.probeAuth; + assert.strictEqual( + execute.mock.calls.at(-1)?.[0].headers.authorization, + `Basic ${btoa("user@example.com:token")}`, + ); + }).pipe(Effect.provide(layer)); +}); + +it.effect("reports saved credentials as configured when Bitbucket cannot confirm them", () => { + const { layer } = makeLayer({ + response: () => new Response(null, { status: 401 }), + env: { T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0" }, + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const settings = yield* ServerSettings.ServerSettingsService; + + assert.strictEqual((yield* bitbucket.probeAuth).status, "unauthenticated"); + + yield* settings.updateSettings({ bitbucket: { accessToken: "saved-access-token" } }); + assert.deepStrictEqual(yield* bitbucket.probeAuth, { + status: "unknown", + account: Option.none(), + host: Option.some("bitbucket.org"), + detail: Option.some("An access token is configured."), + }); + }).pipe(Effect.provide(layer)); +}); + it.effect("preserves the HTTP client failure without deriving the domain message from it", () => { const transportCause = new Error("socket reset by peer"); let requestFailure: HttpClientError.HttpClientError | undefined; diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 202740680be8..2404e6b497e3 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -7,8 +7,10 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { + DEFAULT_SERVER_SETTINGS, NonNegativeInt, TrimmedNonEmptyString, + type BitbucketSettings, type SourceControlProviderAuth, type SourceControlRepositoryCloneUrls, type SourceControlRepositoryVisibility, @@ -28,6 +30,7 @@ import { } from "./bitbucketPullRequests.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import { retryAtFromHeader } from "./SourceControlRateLimit.ts"; @@ -537,24 +540,64 @@ function repositoryOwnerName(repositoryName: string): string { return repositoryName.split("/")[0]?.trim() || "bitbucket"; } -function authFromConfig( - config: Config.Success, -): SourceControlProviderAuth { - if (Option.isSome(config.accessToken)) { +type BitbucketCredential = + | { readonly kind: "access-token"; readonly accessToken: string } + | { readonly kind: "api-token"; readonly email: string; readonly apiToken: string }; + +/** + * Visible ASCII only. A value the HTTP stack rejects makes it throw an error quoting the whole + * header, and that error travels to clients as a cause, so an unusable token is treated as unset. + */ +const HEADER_SAFE = /^[\x21-\x7e]+$/u; + +function credentialFrom(input: { + readonly accessToken: string; + readonly email: string; + readonly apiToken: string; +}): BitbucketCredential | null { + if (HEADER_SAFE.test(input.accessToken)) { + return { kind: "access-token", accessToken: input.accessToken }; + } + if (HEADER_SAFE.test(input.email) && HEADER_SAFE.test(input.apiToken)) { + return { kind: "api-token", email: input.email, apiToken: input.apiToken }; + } + return null; +} + +/** + * Credentials saved in settings win over the `T3CODE_BITBUCKET_*` environment variables, which + * stay as a fallback. Within each source the access token wins. + */ +function resolveCredential( + settings: BitbucketSettings, + env: Config.Success, +): BitbucketCredential | null { + return ( + credentialFrom(settings) ?? + credentialFrom({ + accessToken: Option.getOrElse(env.accessToken, () => ""), + email: Option.getOrElse(env.email, () => ""), + apiToken: Option.getOrElse(env.apiToken, () => ""), + }) + ); +} + +function authFromCredential(credential: BitbucketCredential | null): SourceControlProviderAuth { + if (credential?.kind === "access-token") { return { status: "unknown", account: Option.none(), host: Option.some("bitbucket.org"), - detail: Option.some("Bitbucket access token is configured."), + detail: Option.some("An access token is configured."), }; } - if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { + if (credential?.kind === "api-token") { return { status: "unknown", - account: config.email, + account: Option.some(credential.email), host: Option.some("bitbucket.org"), - detail: Option.some("Bitbucket API token is configured."), + detail: Option.some("An API token is configured."), }; } @@ -563,7 +606,7 @@ function authFromConfig( account: Option.none(), host: Option.some("bitbucket.org"), detail: Option.some( - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.", ), }; } @@ -612,6 +655,7 @@ function responseError( /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const config = yield* BitbucketApiEnvConfig; + const serverSettings = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; const fileSystem = yield* FileSystem.FileSystem; const git = yield* GitVcsDriver.GitVcsDriver; @@ -619,15 +663,27 @@ export const make = Effect.gen(function* () { const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; - const withAuth = (request: HttpClientRequest.HttpClientRequest) => { - if (Option.isSome(config.accessToken)) { - return request.pipe(HttpClientRequest.bearerToken(config.accessToken.value)); - } - if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { - return request.pipe(HttpClientRequest.basicAuth(config.email.value, config.apiToken.value)); - } - return request; - }; + // Read on every request so credentials saved in settings apply without a restart. + const currentCredential = serverSettings.getSettings.pipe( + Effect.map((settings) => resolveCredential(settings.bitbucket, config)), + Effect.catch((error) => + // No cause: a settings decode error can quote a hand-edited token. + Effect.logWarning("failed to read Bitbucket credentials from settings", { + operation: error.operation, + }).pipe(Effect.as(resolveCredential(DEFAULT_SERVER_SETTINGS.bitbucket, config))), + ), + ); + + const withAuth = (request: HttpClientRequest.HttpClientRequest) => + currentCredential.pipe( + Effect.map((credential) => + credential === null + ? request + : credential.kind === "access-token" + ? request.pipe(HttpClientRequest.bearerToken(credential.accessToken)) + : request.pipe(HttpClientRequest.basicAuth(credential.email, credential.apiToken)), + ), + ); const decodeResponse = ( operation: BitbucketApiOperation, @@ -654,7 +710,8 @@ export const make = Effect.gen(function* () { request: HttpClientRequest.HttpClientRequest, schema: S, ): Effect.Effect => - httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe( + withAuth(request.pipe(HttpClientRequest.acceptJson)).pipe( + Effect.flatMap(httpClient.execute), Effect.mapError( (cause) => new BitbucketRequestError({ @@ -847,7 +904,8 @@ export const make = Effect.gen(function* () { input.body === undefined ? base : base.pipe(HttpClientRequest.bodyText(input.body, "application/json")); - return httpClient.execute(withAuth(withBody)).pipe( + return withAuth(withBody).pipe( + Effect.flatMap(httpClient.execute), Effect.mapError( (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }), ), @@ -913,7 +971,7 @@ export const make = Effect.gen(function* () { host: Option.some("bitbucket.org"), detail: Option.none(), })), - Effect.orElseSucceed(() => authFromConfig(config)), + Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))), ), listPullRequests: (input) => resolveRepository(input).pipe( diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index e27aa4c7dc88..f1a01056fbd0 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -192,8 +192,7 @@ export const makeDiscovery = Effect.gen(function* () { type: "api", kind: "bitbucket", label: "Bitbucket", - installHint: - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN on the server (use a Bitbucket API token with pull request, repository, and user read scopes).", + installHint: "Add a Bitbucket token in Settings → Source Control.", probeAuth: bitbucket.probeAuth, } satisfies SourceControlApiDiscoverySpec; }); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 00ac4baee61c..15a6d454172a 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -465,7 +465,7 @@ it.effect("reports implemented tools separately from locally available executabl account: Option.none(), host: Option.some("bitbucket.org"), detail: Option.some( - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.", ), }), }, diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 235097767c01..ff463d3f7d28 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -137,6 +137,7 @@ function withFakeCodexEnv( launchArgs?: string; environment?: NodeJS.ProcessEnv; models?: ReadonlyArray; + managedRuntime?: boolean; }, effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { @@ -156,6 +157,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); @@ -238,6 +246,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 335163a8be8e..782e44e24bbe 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -3,6 +3,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -47,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; @@ -194,6 +200,15 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func ); 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 = @@ -202,13 +217,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 serviceTier = resolved ? undefined : getCodexServiceTierOptionValue(modelSelection); const spawnCommand = yield* resolveSpawnCommand( - codexConfig.binaryPath || "codex", + effectiveConfig.binaryPath || "codex", [ "exec", ...codexExecLaunchArgs(launchArgs), @@ -228,12 +243,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 15ea4b673f22..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; @@ -645,6 +734,59 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live( + "keeps large-record totals and costs exact through append, dedupe, restart and cleanup", + () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const large = claudeLine(1, 9900).replace( + '"message":', + '"padding":' + encodeUnknownJsonString("x".repeat(9 * 1024 * 1024)) + ',"message":', + ); + yield* Effect.promise(() => NodeFSP.writeFile(transcript, large)); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(first), 9900); + assert.closeTo( + first.buckets.reduce((sum, bucket) => sum + bucket.costUsd, 0), + 0.4951, + 1e-12, + ); + const warm = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(warm.buckets, first.buckets); + // The repeated content block has the same message/request identity. + yield* Effect.promise(() => NodeFSP.appendFile(transcript, large + claudeLine(2, 100))); + const appended = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(appended), 10000); + assert.strictEqual( + appended.buckets.reduce((sum, bucket) => sum + bucket.totals.uncachedInputTokens, 0), + 20, + ); + const restarted = yield* UsageService.make; + const restored = yield* restarted.readSummary(WINDOW); + assert.deepStrictEqual(restored.buckets, appended.buckets); + yield* Effect.promise(() => NodeFSP.rm(transcript)); + const afterCleanup = yield* UsageService.make; + assert.deepStrictEqual( + (yield* afterCleanup.readSummary(WINDOW)).buckets, + appended.buckets, + ); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-large-record-test", + home, + settings, + ratesDocument: { + "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, + }, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("preserves saved tokens, costs and sessions after transcript cleanup and restart", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 535ed33b09f2..ab116d560ed2 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/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 9e5ab6e0c9e0..faa686990777 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -17,15 +17,21 @@ */ import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; +import * as NodeStringDecoder from "node:string_decoder"; import type { UsageProviderKind } from "@t3tools/contracts"; +import { createTranscriptJsonReader } from "../project/AgentSessionJson.ts"; + import { initialCodexScanState, mightCarryUsage, parseClaudeLine, + parseClaudeRecord, parseCodexLine, + parseCodexRecord, parseGrokLine, + parseGrokRecord, type CodexScanState, type UsageRecord, } from "./usageTranscripts.ts"; @@ -74,9 +80,62 @@ export interface TranscriptParseResult { /** 64 bytes of JSONL tail is ample to distinguish a replaced file. */ export const GUARD_LENGTH = 64; +// Native parsing is faster for common 1–4 MiB context/tool records. Above +// 8 MiB, project usage without allocating the whole record. This switches +// readers; it never discards a record because of its size. +const STREAMING_THRESHOLD_BYTES = 8 * 1024 * 1024; const NEWLINE = 0x0a; const CARRIAGE_RETURN = 0x0d; +type SelectedFields = { readonly [key: string]: true | SelectedFields }; + +// Keep the fields consumed by usageTranscripts, including reducer state and +// dedupe/cost metadata. A selected subtree (usage) keeps future token fields. +const USAGE_FIELDS: Record<"claude" | "codex" | "grok", SelectedFields> = { + claude: { + type: true, + timestamp: true, + requestId: true, + sessionId: true, + costUSD: true, + message: { id: true, model: true, usage: true }, + }, + codex: { + type: true, + timestamp: true, + payload: { + type: true, + id: true, + session_id: true, + model: true, + forked_from_id: true, + source: { subagent: { thread_spawn: { parent_thread_id: true } } }, + info: { last_token_usage: true }, + }, + }, + grok: { + timestamp: true, + params: { + sessionId: true, + _meta: { agentTimestampMs: true }, + update: { sessionUpdate: true, prompt_id: true, usage: true }, + }, + }, +}; + +function selectUsageFields(provider: UsageProviderKind) { + const fields = USAGE_FIELDS[provider === "codex" || provider === "grok" ? provider : "claude"]; + return (path: ReadonlyArray): boolean => { + let selected: true | SelectedFields = fields; + for (const key of path) { + if (selected === true) return true; + if (typeof key !== "string" || !Object.hasOwn(selected, key)) return false; + selected = selected[key]!; + } + return true; + }; +} + function fnv1a(buffer: Buffer): number { let hash = 0x811c9dc5; for (let index = 0; index < buffer.length; index += 1) { @@ -194,7 +253,9 @@ export async function readTranscriptRecords( filePath: string, provider: UsageProviderKind, resumeFrom?: TranscriptParsePosition, + options?: { readonly streamingThresholdBytes?: number }, ): Promise { + const streamingThresholdBytes = options?.streamingThresholdBytes ?? STREAMING_THRESHOLD_BYTES; let handle: NodeFSP.FileHandle; try { handle = await NodeFSP.open(filePath, "r"); @@ -248,43 +309,87 @@ export async function readTranscriptRecords( }; const records: UsageRecord[] = []; - // Buffer-level line splitting rather than `readline`, because resuming - // needs byte-exact offsets and decoded strings cannot provide them. - // Newline-free chunks are collected rather than concatenated as they - // arrive, so a single huge line costs one copy instead of one per chunk. + // Byte offsets remain independent of UTF-8 decoding. Only complete lines + // commit the resume point; an unfinished tail is replayed on the next scan. let resumeOffset = start; + let scanOffset = start; let pendingChunks: Buffer[] = []; + let pendingBytes = 0; + let streaming: ReturnType | undefined; + let decoder: NodeStringDecoder.StringDecoder | undefined; + const selectPath = selectUsageFields(provider); + + const append = (segment: Buffer) => { + if (!streaming && pendingBytes + segment.length <= streamingThresholdBytes) { + if (segment.length > 0) pendingChunks.push(segment); + pendingBytes += segment.length; + return; + } + if (!streaming) { + // Usage has no import-history budget: retain all selected usage fields, + // regardless of the size of the surrounding unselected tool content. + streaming = createTranscriptJsonReader(() => {}, selectPath, { maxDepth: Infinity }); + decoder = new NodeStringDecoder.StringDecoder("utf8"); + for (const pending of pendingChunks) streaming.write(decoder.write(pending)); + pendingChunks = []; + pendingBytes = 0; + } + streaming.write(decoder!.write(segment)); + }; + const finish = (state: CodexScanState, out: UsageRecord[]) => { + if (streaming) { + streaming.write(decoder!.end()); + const projected = streaming.finish(); + if (provider === "grok") { + out.push(...parseGrokRecord(projected)); + } else { + const record = + provider === "codex" + ? parseCodexRecord(projected, state) + : parseClaudeRecord(projected); + if (record !== null) out.push(record); + } + } else if (pendingBytes > 0) { + const line = + pendingChunks.length === 1 + ? pendingChunks[0]! + : Buffer.concat(pendingChunks, pendingBytes); + parseLine(toLineString(line), state, out); + } + pendingChunks = []; + pendingBytes = 0; + streaming = undefined; + decoder = undefined; + }; const stream = handle.createReadStream({ start, autoClose: false, + highWaterMark: 256 * 1024, }) as AsyncIterable; for await (const chunk of stream) { - if (!chunk.includes(NEWLINE)) { - pendingChunks.push(chunk); - continue; - } - const buffer: Buffer = - pendingChunks.length === 0 ? chunk : Buffer.concat([...pendingChunks, chunk]); - pendingChunks = []; let lineStart = 0; - for (;;) { - const newlineIndex = buffer.indexOf(NEWLINE, lineStart); - if (newlineIndex === -1) break; - parseLine(toLineString(buffer.subarray(lineStart, newlineIndex)), codexState, records); + while (lineStart < chunk.length) { + const newlineIndex = chunk.indexOf(NEWLINE, lineStart); + if (newlineIndex === -1) { + append(chunk.subarray(lineStart)); + break; + } + // Most lines fit in the current chunk. Avoid buffering/streaming + // machinery on this hot path. + if (!streaming && pendingBytes === 0) { + parseLine(toLineString(chunk.subarray(lineStart, newlineIndex)), codexState, records); + } else { + append(chunk.subarray(lineStart, newlineIndex)); + finish(codexState, records); + } lineStart = newlineIndex + 1; + resumeOffset = scanOffset + lineStart; } - resumeOffset += lineStart; - if (lineStart < buffer.length) pendingChunks.push(buffer.subarray(lineStart)); + scanOffset += chunk.length; } - // A trailing segment without its newline is parsed for this result but not - // consumed: a writer may still be appending to it, and counting a half - // record now and its full form later would double count. const tailRecords: UsageRecord[] = []; - if (pendingChunks.length > 0) { - const pending = pendingChunks.length === 1 ? pendingChunks[0]! : Buffer.concat(pendingChunks); - if (pending.length > 0) parseLine(toLineString(pending), { ...codexState }, tailRecords); - } + finish({ ...codexState }, tailRecords); const guardLength = Math.min(GUARD_LENGTH, resumeOffset); let guardHash = 0; diff --git a/apps/server/src/usage/usageTranscriptStreaming.test.ts b/apps/server/src/usage/usageTranscriptStreaming.test.ts new file mode 100644 index 000000000000..2187fd4aa431 --- /dev/null +++ b/apps/server/src/usage/usageTranscriptStreaming.test.ts @@ -0,0 +1,323 @@ +// @effect-diagnostics nodeBuiltinImport:off - exercise the real byte reader. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; + +import { + readTranscriptRecords as readWithDefaultThreshold, + type TranscriptParsePosition, +} from "./usageTranscriptReader.ts"; + +// Exercise the same transition with compact fixtures. UsageService tests and +// external 65/517 MiB fixtures also exercise the production threshold. +const readTranscriptRecords = ( + path: string, + provider: "claude" | "codex" | "grok", + position?: TranscriptParsePosition, +) => readWithDefaultThreshold(path, provider, position, { streamingThresholdBytes: 256 * 1024 }); + +let dir: string; +beforeEach(async () => { + dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-stream-")); +}); +afterEach(async () => { + await NodeFSP.rm(dir, { recursive: true, force: true }); +}); + +const timestamp = "2026-08-01T10:00:00Z"; +const content = '工具 output \\" usage token_count '.repeat(40_000); +const claude = (id = "m1", output = 99) => ({ + type: "assistant", + timestamp, + sessionId: "s1", + requestId: `r-${id}`, + costUSD: 0.25, + message: { + content: [{ type: "tool_use", input: { text: content } }], + id, + model: "claude-fable-5", + usage: { + input_tokens: 100, + output_tokens: output, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 5, + speed: "fast", + }, + }, +}); +const codex = [ + { type: "session_meta", timestamp, payload: { id: "s1" } }, + { type: "turn_context", timestamp, payload: { model: "gpt-5.6-sol" } }, + { + type: "event_msg", + timestamp, + payload: { + type: "token_count", + info: { + last_token_usage: { + input_tokens: 100, + output_tokens: 99, + cached_input_tokens: 20, + cache_write_input_tokens: 5, + reasoning_output_tokens: 10, + }, + }, + }, + }, +]; +const grok = { + timestamp: 1785578400, + params: { + sessionId: "s1", + _meta: { agentTimestampMs: 1785578400123 }, + update: { + sessionUpdate: "turn_completed", + prompt_id: "p1", + usage: { + inputTokens: 100, + outputTokens: 99, + costUsdTicks: 2500000000, + modelUsage: { + "grok-4.5-build": { + inputTokens: 100, + outputTokens: 99, + cachedReadTokens: 20, + cacheCreationTokens: 5, + reasoningTokens: 10, + }, + }, + }, + }, + }, +}; + +async function scan( + lines: readonly unknown[], + provider: "claude" | "codex" | "grok", + name = "history", +) { + const path = NodePath.join(dir, `${name}.jsonl`); + await NodeFSP.writeFile(path, lines.map((line) => JSON.stringify(line)).join("\n") + "\n"); + const result = await readTranscriptRecords(path, provider); + expect(result).not.toBeNull(); + return result!; +} + +describe("large usage records", () => { + it("keeps usage after large Claude tool input, including fast-mode cost and dedupe metadata", async () => { + const result = await scan([claude()], "claude"); + expect(result.records).toEqual([ + { + provider: "claude", + timestampMs: Date.parse(timestamp), + sessionId: "s1", + model: "claude-fable-5", + totals: { + uncachedInputTokens: 100, + outputTokens: 99, + cachedInputTokens: 20, + cacheCreationTokens: 5, + reasoningTokens: 0, + }, + reportedCostUsd: 0.25, + fast: true, + dedupeKey: "m1:r-m1", + }, + ]); + }); + + it.each(["claude", "codex", "grok"] as const)( + "matches ordinary %s records with large irrelevant fields in either order", + async (provider) => { + const small = + provider === "codex" + ? codex + : provider === "grok" + ? [grok] + : [{ ...claude(), message: { ...claude().message, content: [] } }]; + const expected = await scan(small, provider, "small"); + for (const first of [true, false]) { + const large = small.map((record) => + first ? { padding: content, ...record } : { ...record, padding: content }, + ); + const actual = await scan(large, provider); + expect(actual.records).toEqual(expected.records); + expect(actual.position.codexState).toEqual(expected.position.codexState); + } + }, + ); + + it("preserves Grok model allocation, precise timestamp and prompt identity", async () => { + const result = await scan([{ padding: content, ...grok }], "grok"); + expect(result.records[0]).toMatchObject({ + timestampMs: 1785578400123, + model: "grok-4.5-build", + sessionId: "s1", + totals: { + uncachedInputTokens: 75, + cachedInputTokens: 20, + cacheCreationTokens: 5, + outputTokens: 99, + reasoningTokens: 10, + }, + dedupeKey: "s1:p1:grok-4.5-build", + reportedCostUsd: 0.25, + }); + }); + + it("does not count usage-looking text or nested objects inside tool output", async () => { + const result = await scan( + [ + { type: "user", padding: content, toolOutput: claude() }, + { padding: content, message: { content: JSON.stringify(claude()) } }, + { type: "assistant", timestamp, message: { model: "claude-fable-5", content: [claude()] } }, + ], + "claude", + ); + expect(result.records).toEqual([]); + }); + + it("replays partial UTF-8 and JSON tails, commits exact CRLF offsets, and replaces the tail once", async () => { + const path = NodePath.join(dir, "tail.jsonl"); + const first = JSON.stringify(claude("first", 5)) + "\r\n"; + const second = Buffer.from(JSON.stringify(claude("second", 7))); + const split = second.lastIndexOf(Buffer.from("工具")) + 1; + await NodeFSP.writeFile(path, Buffer.concat([Buffer.from(first), second.subarray(0, split)])); + const partial = await readTranscriptRecords(path, "claude"); + expect(partial?.records.map((r) => r.totals.outputTokens)).toEqual([5]); + expect(partial?.tailRecords).toEqual([]); + expect(partial?.position.resumeOffset).toBe(Buffer.byteLength(first)); + await NodeFSP.appendFile(path, second.subarray(split)); + const complete = await readTranscriptRecords(path, "claude", partial!.position); + expect(complete?.resumed).toBe(true); + expect(complete?.records).toEqual([]); + expect(complete?.tailRecords.map((r) => r.totals.outputTokens)).toEqual([7]); + expect(complete?.position.resumeOffset).toBe(Buffer.byteLength(first)); + await NodeFSP.appendFile(path, "\r\n" + JSON.stringify(claude("third", 11)) + "\n"); + const appended = await readTranscriptRecords(path, "claude", complete!.position); + expect(appended?.records.map((r) => r.totals.outputTokens)).toEqual([7, 11]); + expect(appended?.tailRecords).toEqual([]); + expect(appended?.position.resumeOffset).toBe((await NodeFSP.stat(path)).size); + const full = await readTranscriptRecords(path, "claude"); + expect([...partial!.records, ...appended!.records]).toEqual(full?.records); + }); + + it("preserves Codex model switches and duplicate suppression across streaming resumes", async () => { + const path = NodePath.join(dir, "history.jsonl"); + const first = await scan( + codex.map((record) => ({ padding: content, ...record })), + "codex", + ); + await NodeFSP.appendFile( + path, + [ + { padding: content, ...codex[2] }, + { type: "turn_context", payload: { padding: content, model: "gpt-6" } }, + { + type: "event_msg", + timestamp, + payload: { + type: "token_count", + padding: content, + info: { last_token_usage: { input_tokens: 200, output_tokens: 101 } }, + }, + }, + ] + .map((line) => JSON.stringify(line)) + .join("\n") + "\n", + ); + const result = await readTranscriptRecords(path, "codex", first.position); + expect(result?.resumed).toBe(true); + expect(result?.records).toHaveLength(1); + expect(result?.records[0]).toMatchObject({ + model: "gpt-6", + sessionId: "s1", + totals: { outputTokens: 101 }, + }); + const full = await readTranscriptRecords(path, "codex"); + expect([...first.records, ...result!.records]).toEqual(full?.records); + }); + + it.each(["forked_from_id", "source"])( + "preserves Codex fork-copy suppression from %s", + async (field) => { + const fork = + field === "source" + ? { source: { subagent: { thread_spawn: { parent_thread_id: "parent" } } } } + : { forked_from_id: "parent" }; + const lines = [ + { type: "session_meta", timestamp, payload: { padding: content, id: "child", ...fork } }, + codex[1], + codex[2], + { + ...codex[2], + timestamp: "2026-08-01T10:00:05Z", + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 100, output_tokens: 101 } }, + }, + }, + ]; + const result = await scan(lines, "codex"); + expect(result.records).toHaveLength(1); + expect(result.records[0]).toMatchObject({ + sessionId: "child", + totals: { outputTokens: 101 }, + }); + }, + ); + + it("rejects malformed large lines without accepting partial usage or losing following lines", async () => { + const valid = JSON.stringify(claude()); + const path = NodePath.join(dir, "broken.jsonl"); + await NodeFSP.writeFile( + path, + [valid + " junk", valid.slice(0, -1), valid + valid, JSON.stringify(claude("good", 7))].join( + "\n", + ) + "\n", + ); + const result = await readTranscriptRecords(path, "claude"); + expect(result?.records.map((r) => r.totals.outputTokens)).toEqual([7]); + }); + + it("matches JSON.parse for reordered and escaped keys, duplicate fields, arrays and unknown key names", async () => { + const path = NodePath.join(dir, "odd.jsonl"); + const small = JSON.stringify({ ...claude(), message: { ...claude().message, content: [] } }); + const variants = [ + small.replace('"message":', '"mess\\u0061ge":'), + small.replace('"costUSD":0.25', '"costUSD":5,"costUSD":0.25'), + small.replace('"type":"assistant"', '"type":"user","type":"assistant"'), + small.replace('"message":', '"__proto__":{"type":"user"},"message":'), + small.replace('"message":', '"message.usage":{"output_tokens":1234},"message":'), + ]; + for (const line of variants) { + await NodeFSP.writeFile(path, line + "\n"); + const expected = await readTranscriptRecords(path, "claude"); + await NodeFSP.writeFile( + path, + '{"padding":' + JSON.stringify(content) + "," + line.slice(1) + "\n", + ); + const actual = await readTranscriptRecords(path, "claude"); + expect(actual?.records).toEqual(expected?.records); + } + }); + it("reads usage after deeply nested discarded tool content", async () => { + const path = NodePath.join(dir, "deep.jsonl"); + const record = JSON.stringify(claude()); + const nested = "[".repeat(300) + "0" + "]".repeat(300); + await NodeFSP.writeFile(path, '{"toolOutput":' + nested + "," + record.slice(1) + "\n"); + const result = await readTranscriptRecords(path, "claude"); + expect(result?.records[0]?.totals.outputTokens).toBe(99); + }); + + it("keeps JSON number semantics for non-finite values without serializing them into null", async () => { + const path = NodePath.join(dir, "numbers.jsonl"); + const record = JSON.stringify(claude()).replace('"costUSD":0.25', '"costUSD":1e400'); + await NodeFSP.writeFile(path, record + "\n"); + const result = await readTranscriptRecords(path, "claude"); + expect(result?.records[0]?.reportedCostUsd).toBeNull(); + expect(result?.records[0]?.totals.outputTokens).toBe(99); + }); +}); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 6e01c2c5a8ed..c3903ef47194 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -113,6 +113,10 @@ export function parseClaudeLine(line: string): UsageRecord | null { } catch { return null; } + return parseClaudeRecord(parsed); +} + +export function parseClaudeRecord(parsed: unknown): UsageRecord | null { if (typeof parsed !== "object" || parsed === null) return null; const record = parsed as Record; @@ -228,6 +232,10 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord } catch { return null; } + return parseCodexRecord(parsed, state); +} + +export function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | null { if (typeof parsed !== "object" || parsed === null) return null; const record = parsed as Record; @@ -385,6 +393,10 @@ export function parseGrokLine(line: string): readonly UsageRecord[] { } catch { return []; } + return parseGrokRecord(parsed); +} + +export function parseGrokRecord(parsed: unknown): readonly UsageRecord[] { if (typeof parsed !== "object" || parsed === null) return []; const record = parsed as Record; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a0d4edf4b971..27fa436e1c7d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -42,6 +42,8 @@ import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; +import { subscribeChatGptHandoff } from "./provider/CodexChatGptHandoff.ts"; +import { subscribeCodexAuthCallback } from "./provider/CodexAuthCallback.ts"; import { DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, AcpRegistryOperationError, @@ -2590,6 +2592,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/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 90471ed8c4b6..ef52240c437a 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -706,7 +706,7 @@ export function BranchToolbarBranchSelector({ number={prNumber} url={prUrl} status={displayedPrStatus} - onOpenStack={() => useRightPanelStore.getState().open(threadRef, "pull-requests")} + onOpenList={() => useRightPanelStore.getState().open(threadRef, "pull-requests")} onOpenPullRequest={(event, targetUrl = prUrl) => { if (targetUrl) openPrLink(event, targetUrl); }} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5e2e3bd658be..da35c4d31119 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 type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; import { collectProviderUsageLimits, @@ -10413,6 +10414,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 fa2399068238..9d27c94f188d 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)} > { + const handlePrListClick = useCallback(() => { useRightPanelStore.getState().open(threadRef, "pull-requests"); if (!props.isActive) onThreadActivate(threadRef); }, [onThreadActivate, props.isActive, threadRef]); @@ -1500,7 +1501,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { number={pr?.number ?? currentLinkedPr?.number} url={pr?.url ?? currentLinkedPr?.url} status={prStatus} - onOpenStack={handlePrStackClick} + onOpenList={handlePrListClick} onOpenPullRequest={handlePrClick} /> ) : null; diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index ec7c9d643b80..495fe119e8ec 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -211,7 +211,8 @@ export function resolveThreadPullRequestBadgePresentation({ * The linked-PR badge shared by the sidebar and composer footer. The badge owns what it shows: * the state glyph and number at the meta size, in the state's color. The caller owns the control * it sits in through `render` (an inline link in a sidebar row, a toolbar control in the - * composer), and the badge fills in the link or stack button behavior. + * composer), and the badge fills in the behavior: a single PR is a link to it, while a stack or + * several linked PRs is a button that opens the thread's pull requests tab. */ export function ThreadPullRequestBadgeControl({ render, @@ -220,7 +221,7 @@ export function ThreadPullRequestBadgeControl({ number, url, status, - onOpenStack, + onOpenList, onOpenPullRequest, }: { render: ReactElement<{ render?: useRender.RenderProp }>; @@ -229,20 +230,19 @@ export function ThreadPullRequestBadgeControl({ number?: number | undefined; url?: string | undefined; status: PrStatusIndicator | null; - onOpenStack: () => void; + onOpenList: () => void; onOpenPullRequest: (event: MouseEvent, url?: string) => void; }) { const presentation = resolveThreadPullRequestBadgePresentation({ badge, number, url, status }); if (presentation === null) return null; - const isStack = badge?.kind === "stack"; return ( 0)} pullRequests={pullRequests} url={url} - onOpenStack={onOpenStack} + onOpenList={onOpenList} onOpenPullRequest={(event) => onOpenPullRequest(event, url)} /> ); @@ -251,29 +251,29 @@ export function ThreadPullRequestBadgeControl({ function PullRequestBadge({ render, presentation, - isStack, + opensList, pullRequests, url, - onOpenStack, + onOpenList, onOpenPullRequest, }: { render: ReactElement<{ render?: useRender.RenderProp }>; presentation: NonNullable>; - isStack: boolean; + opensList: boolean; pullRequests: ReadonlyArray; url: string | undefined; - onOpenStack: () => void; + onOpenList: () => void; onOpenPullRequest: (event: MouseEvent) => void; }) { - const showList = isStack || pullRequests.length > 1; - const onClick = isStack + const showList = opensList || pullRequests.length > 1; + const onClick = opensList ? (event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); - onOpenStack(); + onOpenList(); } : onOpenPullRequest; - const element = isStack ? ( + const element = opensList ? ( + ) : null} {resetCreditInput && account.limits.resetCredits ? ( { // V2 renders one typed question row rather than a legacy activity wrapper. const questionToggle = renderer!.root.find( (node) => - node.props["aria-label"]?.startsWith("Question answer submitted:") && + node.props["aria-label"]?.startsWith("Provide a spec") && node.props["aria-expanded"] === false, ); expect(questionToggle.props["aria-label"]).toContain( Object.values(answers)[0] ?? "spec.txt", ); - expect(JSON.stringify(renderer!.toJSON())).not.toContain("Provide a spec"); + // The question leads the collapsed row so the exchange reads as a + // question and answer without expanding (heading + accessible label). + expect(JSON.stringify(renderer!.toJSON()).match(/Provide a spec/g)).toHaveLength(2); await act(() => questionToggle.props.onClick()); const markup = JSON.stringify(renderer!.toJSON()); - expect(markup.match(/Provide a spec/g)).toHaveLength(1); + // Expanded, the question also appears in the history: label, heading, history. + expect(markup.match(/Provide a spec/g)).toHaveLength(3); expect(markup).toContain("spec.txt"); expect(markup).toContain("Provide a screenshot"); expect(markup).toContain("shot.png"); for (const answer of Object.values(answers)) expect(markup).toContain(answer); await act(() => questionToggle.props.onClick()); - expect(JSON.stringify(renderer!.toJSON())).not.toContain("Provide a spec"); + // Collapsing hides the history but keeps the question heading. + expect(JSON.stringify(renderer!.toJSON())).toContain("Provide a spec"); } finally { await act(() => renderer?.unmount()); } }, ); + it("leads an unanswered question row with the question text", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("requestAnimationFrame", () => 0); + vi.stubGlobal("cancelAnimationFrame", () => {}); + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create( + , + ); + }); + const questionToggle = renderer!.root.find( + (node) => + node.props["aria-label"] === "Which repository?" && node.props["aria-expanded"] === false, + ); + const markup = JSON.stringify(renderer!.toJSON()); + // Heading + accessible label. + expect(markup.match(/Which repository\?/g)).toHaveLength(2); + await act(() => questionToggle.props.onClick()); + // Expanded history adds a third occurrence alongside heading and label. + expect(JSON.stringify(renderer!.toJSON()).match(/Which repository\?/g)).toHaveLength(3); + } finally { + await act(() => renderer?.unmount()); + } + }); + it.each([ { toolLifecycleStatus: "inProgress", isAtEnd: true }, { toolLifecycleStatus: "inProgress", isAtEnd: false }, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a69fbde3a309..74090ea25a54 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -4,6 +4,7 @@ import { useRightPanelStore } from "~/rightPanelStore"; import { getQuestionAnswerPreview, getQuestionAnswerText, + getQuestionTextPreview, hasQuestionAnswer, } from "@t3tools/client-runtime/work-log/user-input"; import { @@ -3270,7 +3271,10 @@ function LiveActivityContent({ function LiveWorkEntryTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); - const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); + const questionHeading = row.entry.questionAnswer + ? getQuestionTextPreview(row.entry.questionAnswer) + : ""; + const label = questionHeading || liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); const failed = workEntryDisplayIndicatesToolFailure(row.entry); return ( @@ -3283,17 +3287,10 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract - {label} - + {label} + {getQuestionAnswerPreview(row.entry.questionAnswer)} @@ -4611,12 +4608,18 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { showWarningIndicator || showDestructiveRowStyle ? undefined : (workEntry.toolIcon ?? workEntry.toolSource?.icon); - const previewText = workEntry.questionAnswer - ? workEntry.label - : (displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot)); - const answerPreview = workEntry.questionAnswer - ? getQuestionAnswerPreview(workEntry.questionAnswer) - : null; + // The question is the row's identity: a generic "User input submitted" + // label buries what was asked, so lead with the question text and keep the + // answer as the trailing preview. + const questionHeading = workEntry.questionAnswer + ? getQuestionTextPreview(workEntry.questionAnswer) + : ""; + const previewText = + displayLabel ?? (questionHeading || workEntryDisplayLabel(workEntry, workspaceRoot)); + const answerPreview = + workEntry.questionAnswer && hasQuestionAnswer(workEntry.questionAnswer) + ? getQuestionAnswerPreview(workEntry.questionAnswer) + : null; const viewedImagePath = workEntryViewedImagePath(workEntry); const viewedImage = viewedImagePath && threadRef @@ -4717,7 +4720,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {

+ {props.selectedModels === undefined ? ( + + ) : null} ); diff --git a/apps/web/src/components/chat/ProviderStatusBanner.test.ts b/apps/web/src/components/chat/ProviderStatusBanner.test.ts index 0ebaf180b51a..a500549e7f7d 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.test.ts +++ b/apps/web/src/components/chat/ProviderStatusBanner.test.ts @@ -56,6 +56,60 @@ describe("compatibility banners", () => { ).toBeNull(); }); + it("shows downgrade guidance instead of a broken OpenCode inventory timeout", () => { + const message = "This provider version is known to be incompatible. Use 1.14.19."; + const broken: ServerProvider = { + ...provider, + driver: ProviderDriverKind.make("opencode"), + version: "2.0.3", + status: "error", + auth: { status: "unknown" }, + message: "Failed to load OpenCode provider inventory: Timed out waiting for server start.", + compatibilityAdvisory: { + status: "broken", + message, + recommendedVersion: "1.14.19", + recommendedRange: ">=1.14.19 <2.0.0", + }, + }; + expect(shouldShowProviderStatusBanner(broken, null)).toBe(true); + const timeoutOnly: ServerProvider = { + ...broken, + compatibilityAdvisory: { + ...broken.compatibilityAdvisory!, + status: "supported", + message: null, + }, + }; + expect(shouldShowProviderStatusBanner(broken, getProviderStatusBannerKey(timeoutOnly))).toBe( + true, + ); + expect(shouldShowProviderStatusBanner(broken, getProviderStatusBannerKey(broken))).toBe(false); + expect( + shouldShowProviderStatusBanner( + { + ...broken, + compatibilityAdvisory: { ...broken.compatibilityAdvisory!, message: "Use 1.14.20." }, + }, + getProviderStatusBannerKey(broken), + ), + ).toBe(true); + expect(getProviderStatusMessage(broken)).toBe(message); + expect( + getProviderStatusMessage({ + ...broken, + compatibilityAdvisory: { + ...broken.compatibilityAdvisory!, + status: "supported", + message: null, + }, + }), + ).toBe(broken.message); + expect(getProviderStatusMessage({ ...broken, auth: { status: "unauthenticated" } })).toBe( + broken.message, + ); + }); + it("keeps authentication failures ahead of compatibility warnings even without a probe message", () => { const unauthenticated: ServerProvider = { ...provider, diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index 58d723c9a92a..c83aef7caf51 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -9,19 +9,25 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; /** Unsupported and broken versions fail mid-turn, so they warn even when ready. */ function getIncompatibleVersion(status: ServerProvider) { const compatibility = status.compatibilityAdvisory; - return compatibility?.status === "unsupported" || compatibility?.status === "broken" + if (status.status === "error" && status.auth.status === "unauthenticated") return null; + return compatibility?.status === "broken" || + (status.status === "ready" && compatibility?.status === "unsupported") ? compatibility : null; } export function getProviderStatusBannerKey(status: ServerProvider | null): string | null { if (!status || status.status === "disabled") return null; - if (status.status === "ready") { - const incompatible = getIncompatibleVersion(status); - return incompatible - ? [status.instanceId, incompatible.status, status.version ?? ""].join("\u0000") - : null; + const incompatible = getIncompatibleVersion(status); + if (incompatible) { + return [ + status.instanceId, + incompatible.status, + status.version ?? "", + incompatible.message ?? "", + ].join("\u0000"); } + if (status.status === "ready") return null; // Antigravity checks saved credentials when a session starts. Its local // health check leaves auth unknown after a restart, which is not a failure. if ( @@ -53,8 +59,15 @@ export function hasProviderSetup(status: ServerProvider): boolean { ); } -/** Keep the environment's error intact in both the banner and model picker. */ +/** Broken-version guidance takes precedence over startup failures it can cause. */ export function getProviderStatusMessage(status: ServerProvider): string { + if ( + status.auth.status !== "unauthenticated" && + status.compatibilityAdvisory?.status === "broken" && + status.compatibilityAdvisory.message + ) { + return status.compatibilityAdvisory.message; + } if (status.message) return status.message; const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); if (!status.installed && hasProviderSetup(status)) { @@ -90,14 +103,15 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated"; - const incompatible = status.status === "ready" ? getIncompatibleVersion(status) : null; + const incompatible = getIncompatibleVersion(status); const title = isUnauthenticated ? `${providerName} is unauthenticated` : incompatible ? `${providerName} ${status.version ?? ""} is ${incompatible.status === "broken" ? "known to be broken" : "unsupported"}` : `${providerName} provider status`; const message = incompatible?.message ?? getProviderStatusMessage(status); - const isWarning = status.status === "warning" || incompatible !== null; + const isWarning = + incompatible?.status !== "broken" && (status.status === "warning" || incompatible !== null); return (

diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx index 9658ab43c2be..555a76972db4 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx @@ -4,6 +4,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}`; @@ -38,30 +40,46 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({ error, onDismiss, errorClass, + chatGptUsageLimit = false, }: { error: string | null; errorClass?: OrchestrationV2ProviderFailureClass | null; onDismiss?: () => void; + chatGptUsageLimit?: boolean; }) { if (!error) return null; const variant = errorClass === "usage_limit" ? "warning" : "error"; return (
- + {chatGptUsageLimit ? ( + diff --git a/apps/web/src/components/files/FilePreviewPanel.test.ts b/apps/web/src/components/files/FilePreviewPanel.test.ts index 5ef590847c4b..ebebf510994b 100644 --- a/apps/web/src/components/files/FilePreviewPanel.test.ts +++ b/apps/web/src/components/files/FilePreviewPanel.test.ts @@ -7,6 +7,7 @@ import { } from "./fileCommentAnnotations"; import { isMarkdownPreviewFile, + resolveFilePreviewPath, setMarkdownTaskChecked, shouldShowFileExplorer, } from "./filePreviewMode"; @@ -120,3 +121,24 @@ describe("setMarkdownTaskChecked", () => { expect(setMarkdownTaskChecked(markdown, 200, true)).toBe(markdown); }); }); + +describe("resolveFilePreviewPath", () => { + it.each([ + ["/repo/project", null], + ["/repo/project/", null], + [".", null], + [null, null], + ["/repo/project/src", "/repo/project/src"], + ["/repo/project/src/main.ts", "/repo/project/src/main.ts"], + ["src/main.ts", "src/main.ts"], + ["/repo/project-other", "/repo/project-other"], + ])("opens %s in the appropriate workspace surface", (path, expected) => { + const relativePath = resolveFilePreviewPath(path, "/repo/project"); + expect(relativePath).toBe(expected); + if (expected === null) { + expect( + shouldShowFileExplorer({ relativePath, explorerOpen: false, attachmentOpen: false }), + ).toBe(true); + } + }); +}); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 3b74f8448f84..2ffccd753e67 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -82,6 +82,7 @@ import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { isMarkdownPreviewFile, + resolveFilePreviewPath, setMarkdownTaskChecked, shouldShowFileExplorer, } from "./filePreviewMode"; @@ -908,7 +909,7 @@ export default function FilePreviewPanel({ environmentId, cwd, projectName, - relativePath, + relativePath: requestedPath, attachment, threadRef, composerDraftTarget, @@ -921,6 +922,8 @@ export default function FilePreviewPanel({ selectedFilePending, workspaceMutationId, }: FilePreviewPanelProps) { + const relativePath = + attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath; const { resolvedTheme } = useTheme(); const wordWrap = useClientSettings((settings) => settings.wordWrap); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -946,7 +949,12 @@ export default function FilePreviewPanel({ // shown. The read still runs: a folder named `assets.png` is only knowable as a // folder from the read failure, and the server stats before reading, so a folder // costs an open and a stat and returns no body. - const file = useProjectFileQuery(environmentId, cwd, relativePath, attachment === undefined); + const file = useProjectFileQuery( + environmentId, + cwd, + relativePath, + attachment === undefined && relativePath !== null, + ); // A chat link cannot tell a folder from a file, so a folder arrives here as // a file surface and the read fails. Keep the breadcrumbs, drop the preview // pane, and let the tree fill the surface with the folder revealed. Mutation diff --git a/apps/web/src/components/files/filePreviewMode.ts b/apps/web/src/components/files/filePreviewMode.ts index 9770d36fa2c3..12a3eb95a3e8 100644 --- a/apps/web/src/components/files/filePreviewMode.ts +++ b/apps/web/src/components/files/filePreviewMode.ts @@ -1,5 +1,12 @@ +import { workspaceRelativeFilePath } from "@t3tools/client-runtime/markdown-links"; import { isAbsolutePath } from "~/terminal-links"; +/** Resolve workspace links before choosing between the explorer and a file preview. */ +export function resolveFilePreviewPath(path: string | null, cwd: string): string | null { + if (path === null) return null; + return path === "." || workspaceRelativeFilePath(path, cwd) === "." ? null : path; +} + export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path); export function shouldShowFileExplorer(input: { 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, })); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index cb0326c15e44..5e033cb8b702 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -5,6 +5,7 @@ import type { AgentSessionProjectCandidate, EnvironmentId, ProjectId, + ProviderInstanceId, ScopedProjectRef, ServerConfig, ServerProvider, @@ -14,7 +15,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, @@ -61,6 +67,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 { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; @@ -83,7 +93,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. */ @@ -98,17 +108,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); @@ -199,6 +213,7 @@ export function WelcomeWizard({ return ( event.cancel()}> document.getElementById("onboarding-pairing-url") ?? true} @@ -263,6 +278,7 @@ export function WelcomeWizard({ )} + ); } @@ -612,7 +628,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. */ @@ -625,13 +641,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, @@ -641,8 +651,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). @@ -688,44 +707,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, @@ -754,12 +930,13 @@ function AgentCard({ readonly onOpenTerminal: () => void; }) { const meta = getDriverOption(ProviderDriverKind.make(driver)); - 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 ( -
+
{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/pullRequest/PullRequestLinkPreview.tsx b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx index 76aaebbf62d1..3f183b264b2b 100644 --- a/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx +++ b/apps/web/src/components/pullRequest/PullRequestLinkPreview.tsx @@ -15,6 +15,7 @@ import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; import { useEnvironmentQuery } from "~/state/query"; import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "../ui/preview-card"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PullRequestActorAvatar, resolvePullRequestState } from "./pullRequestPresentation"; interface PullRequestLinkPreviewTarget { @@ -81,6 +82,8 @@ export function PullRequestLinkPreview({ }) : link; const detail = detailQuery.data; + const showCard = detail !== null || (detailQuery.error !== null && fallback !== undefined); + const showUrlTooltip = open && detailQuery.error !== null && !showCard; const state = detail === null ? null @@ -94,16 +97,19 @@ export function PullRequestLinkPreview({ return ( - - {detail !== null || detailQuery.error !== null ? ( + + } + delay={350} + closeDelay={120} + /> + {originalUrl} + + {showCard ? (
{detail === null ? ( - (fallback ?? ( -

- {originalUrl} -

- )) + fallback ) : (
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 fa2394025e61..970008a6454a 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -21,6 +21,7 @@ import { 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, @@ -52,6 +53,7 @@ import { import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps"; import { AcpRegistrySearchStep } from "./AcpRegistrySearchStep"; import { resolveOfficialAcpRegistryIconUrl } from "./AcpRegistryIcon"; +import { AddManagedCodexAccountDialog } from "./CodexSetupSection"; /** * Normalize a user-provided label into a slug suffix for the instance id. @@ -130,6 +132,7 @@ export function AddProviderInstanceDialog({ const persistProviderInstance = usePersistEnvironmentProviderInstanceMutation(environmentId); const [wizardStep, setWizardStep] = useState(0); + const [addingChatGptAccount, setAddingChatGptAccount] = useState(false); const [driver, setDriver] = useState(DEFAULT_DRIVER_KIND); const [identityByDriver, setIdentityByDriver] = useState>( {}, @@ -256,7 +259,10 @@ export function AddProviderInstanceDialog({ setHasAttemptedSubmit(true); if (instanceIdError !== null || (isAcpRegistry && acpSelectionError !== 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); @@ -307,17 +313,21 @@ export function AddProviderInstanceDialog({ onOpenChange(false); }; + if (addingChatGptAccount) { + return ( + onOpenChange(false)} + /> + ); + } + return ( - + - Configure an additional provider instance on {environmentLabel}. For example, add a - second Codex install pointed at a different workspace. - - } + description={<>Add an account or configure a provider on {environmentLabel}.} > {isAcpRegistry ? ( - {isAcpRegistry && - wizardStep === 1 && - !isManualAcpConfiguration && - !selectedAcp ? null : wizardStep < + {wizardStep === 0 && driver === "codex" ? ( + <> + + setAddingChatGptAccount(true)} /> + + ) : isAcpRegistry && + wizardStep === 1 && + !isManualAcpConfiguration && + !selectedAcp ? null : wizardStep < (isAcpRegistry ? ACP_REGISTRY_WIZARD_STEPS : ADD_PROVIDER_WIZARD_STEPS).length - 1 ? ( + ) : null} + +
+
+ + + ); +} 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.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 === "/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 === "/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/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 87c4b692aeae..2af775898d9c 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -59,8 +59,8 @@ describe("KeybindingsSettings.logic", () => { ); it("orders Usage bindings and command choices like the page", () => { const expected = [ - "usage.cost", "usage.open", + "usage.cost", "usage.tokens", "usage.limits", "usage.period.day", diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 527442943aee..032f4db840a0 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -15,8 +15,12 @@ import { shortcutKeyFromEvent } from "../../keybindings"; import { isMacPlatform } from "../../lib/utils"; import { METRIC_OPTIONS, WINDOW_OPTIONS } from "../usage/usageShortcuts"; +// Opening the page leads, then the page's own controls in on-page order. const usageCommandOrder = new Map( - [...METRIC_OPTIONS, ...WINDOW_OPTIONS].map((option, index) => [option.command, index]), + [ + "usage.open" as const, + ...[...METRIC_OPTIONS, ...WINDOW_OPTIONS].map((option) => option.command), + ].map((command, index) => [command, index]), ); function compareUsageCommands(left: KeybindingCommand, right: KeybindingCommand): number | null { 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 70aacd76332a..9985a5b7e967 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -54,6 +54,8 @@ import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { SettingsRow, SettingsSection } from "./settingsLayout"; import { AcpSessionManagementSection } from "./AcpSessionManagementSection"; +import { FoldedSettingsSection } from "./FoldedSettingsSection"; +import { readCodexSetupMode } from "./CodexSetupSection.logic"; import { getProviderVersionAdvisoryPresentation, PROVIDER_STATUS_STYLES, @@ -494,6 +496,7 @@ interface ProviderInstanceCardProps { */ readonly headerAction?: ReactNode | undefined; readonly setup?: ReactNode; + readonly runtime?: ReactNode; readonly hiddenModels: ReadonlyArray; readonly favoriteModels: ReadonlyArray; readonly modelOrder: ReadonlyArray; @@ -548,6 +551,7 @@ export function ProviderInstanceCard({ onDelete, headerAction, setup, + runtime, hiddenModels, favoriteModels, modelOrder, @@ -788,11 +792,6 @@ export function ProviderInstanceCard({ {displayName} - {String(instanceId) !== String(instance.driver) ? ( - - {instanceId} - - ) : null} {versionLabel ? ( {versionLabel} @@ -994,6 +993,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 ( <> @@ -1061,33 +1080,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} + + )} ( 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 76bcde542b88..454caa0b47b5 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -88,6 +88,8 @@ import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { UsageProviderSettings } from "./UsageProviderSettings"; import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection"; import { ProviderAuthenticationSection } from "./ProviderAuthenticationSection"; +import { CodexSetupSection, CodexManagedRuntimeFields } from "./CodexSetupSection"; +import { readCodexSetupMode } from "./CodexSetupSection.logic"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { searchableSetting } from "./settingsSearch"; import { @@ -274,6 +276,7 @@ interface ProviderSettingsTarget { readonly environmentId?: EnvironmentId; readonly instanceId?: ProviderInstanceId; readonly scoped?: boolean; + readonly environmentIds?: readonly EnvironmentId[]; } /** Provider list for one environment; the Agents page supplies the `@container/providers` container. */ @@ -291,8 +294,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 — @@ -1043,6 +1047,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, + }, + }) + } + /> ) : mode === "editor" && !readOnly && liveProvider && @@ -1148,10 +1187,11 @@ export function EnvironmentProviderSettings({ return ( <> - -
- {deviceTabs} -
+ {readOnly ? ( @@ -1200,7 +1240,11 @@ export function EnvironmentProviderSettings({ )}
-
+ } + > + {deviceTabs ? ( +
{deviceTabs}
+ ) : null} {readOnly ? ( void) { ...(settings.responseStreamingMode !== DEFAULT_UNIFIED_SETTINGS.responseStreamingMode ? ["Response streaming"] : []), + ...(settings.persistComposerContextStrip !== + DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip + ? ["Composer context"] + : []), + ...(settings.autoResumeLimitedThreads !== DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads + ? ["Auto-resume limited threads"] + : []), + ...(settings.snoozeLimitedThreads !== DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads + ? ["Snooze limited threads"] + : []), ...(settings.enableProviderUpdateChecks !== DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks ? ["Provider update checks"] @@ -734,6 +744,8 @@ export function useSettingsRestore(onRestored?: () => void) { settings.panelAnimationDurationMs, settings.responseStreamingMode, settings.persistComposerContextStrip, + settings.autoResumeLimitedThreads, + settings.snoozeLimitedThreads, settings.enableProviderUpdateChecks, settings.continueThreadsAfterServerUpdate, settings.sidebarAutoSettleAfterDays, @@ -825,6 +837,8 @@ export function useSettingsRestore(onRestored?: () => void) { inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, persistComposerContextStrip: DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip, + autoResumeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads, + snoozeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads, diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout, diff --git a/apps/web/src/components/settings/SettingsScopeContext.tsx b/apps/web/src/components/settings/SettingsScopeContext.tsx index 1602c29d76e2..add38f3e1880 100644 --- a/apps/web/src/components/settings/SettingsScopeContext.tsx +++ b/apps/web/src/components/settings/SettingsScopeContext.tsx @@ -10,6 +10,7 @@ import { getProjectFileQueryAtom, optimisticFileAtom } from "../files/projectFil import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; import { resolveScopedSettingsTargets, selectScopedSettingsEnvironments } from "./scopedSettings"; import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; +import { selectSingleEnvironmentScope } from "./settingsScopeAxis"; /** * Each member's decoded t3.json, so file-backed settings show the file as a @@ -53,10 +54,22 @@ function useMemberProjectFiles(scope: ReturnType) { ); } -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/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 8a704ab91c3e..2882fcb650a6 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -56,6 +56,7 @@ import { JujutsuIcon, type Icon, } from "../Icons"; +import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; import { @@ -233,7 +234,9 @@ function itemSummary({ ); } - if (!item.executable) { + // API integrations have no CLI to sign in with; an unverified saved credential falls + // through to the "could not verify" detail instead of repeating the setup hint. + if (!item.executable && auth.status === "unauthenticated") { return Available. {item.installHint}; } @@ -276,7 +279,11 @@ function DiscoveryItemRow({ const searchTargetId = useSettingsSearchTargetId(); useEffect(() => { - if (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) { + if ( + (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) || + (item.kind === "bitbucket" && + searchTargetId === searchableSetting("bitbucket-credentials").id) + ) { setIsExpanded(true); } }, [item.kind, searchTargetId]); @@ -586,7 +593,18 @@ export function SourceControlSettings() { headerAction={hasVersionControlSystems ? null : scanButton} > {result.sourceControlProviders.map((item) => ( - + + {item.kind === "bitbucket" ? ( + + + + ) : undefined} + ))}
) : null} 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/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 304f8a563bba..e804b4ce49bd 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -770,6 +770,14 @@ export const SETTINGS_SEARCH_ITEMS = [ environmentOnly: true, scope: "environment-defaults", }, + { + id: "bitbucket-credentials", + title: "Bitbucket credentials", + to: "/settings/git", + searchTerms: ["bitbucket atlassian access token api token email credentials sign in"], + environmentOnly: true, + scope: "environment-defaults", + }, { id: "source-control-writing-style", title: "Source control writing style", diff --git a/apps/web/src/components/ui/wizard.tsx b/apps/web/src/components/ui/wizard.tsx index c46da0bbf69f..8a4c955071fd 100644 --- a/apps/web/src/components/ui/wizard.tsx +++ b/apps/web/src/components/ui/wizard.tsx @@ -9,10 +9,20 @@ import { DialogPopup, DialogHeader, DialogTitle, DialogDescription, DialogFooter export function WizardPopup({ children, className, + size = "default", ...props -}: Omit, "style">) { +}: Omit, "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 06aea4467270..76a381a5d565 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -1,4 +1,6 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; +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 { @@ -405,6 +407,17 @@ export function UsagePage() { + {[...presentations].some( + ([id, presentation]) => + (selectedEnvironmentIds === null || selectedEnvironmentIds.has(id)) && + presentation.serverConfig?.providers.some(usesChatGptSharing), + ) ? ( +
+ ChatGPT shared usage + +
+ ) : null} + {activeProviders.map((provider) => { const totals = merged.providers.find((entry) => entry.provider === provider); const share = 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/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 5421c0d8781d..fe352ec1a44e 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -522,3 +522,11 @@ describe("directory paths with a trailing separator", () => { expect(meta?.basename).not.toBe(""); }); }); + +it("routes the project-root code link to the workspace explorer", () => { + const cwd = "/Users/saphid/.t3/worktrees/ov2-standalone-20260918"; + expect(resolveInlineCodeFileLinkMeta(cwd, cwd)).toMatchObject({ + workspaceRelativePath: ".", + filePath: cwd, + }); +}); 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.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/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index ef27a64130b4..47ae74540ab4 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -385,45 +385,44 @@ describe("rightPanelStore", () => { }); }); - it.each([ - { kind: "plan", isOpen: true }, - { kind: "agents", isOpen: true }, - { kind: "agents", isOpen: false }, - ])("drops $kind with isOpen=$isOpen and falls back", ({ kind, isOpen }) => { - expect( - migratePersistedRightPanelState({ + it.each([{ kind: "plan", isOpen: true }])( + "drops $kind with isOpen=$isOpen and falls back", + ({ kind, isOpen }) => { + expect( + migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + isOpen, + activeSurfaceId: kind, + surfaces: [{ id: kind, kind }], + }, + "env-1:thread-B": { + isOpen, + activeSurfaceId: kind, + surfaces: [ + { id: kind, kind }, + { id: "diff", kind: "diff" }, + ], + }, + }, + }), + ).toEqual({ byThreadKey: { "env-1:thread-A": { - isOpen, - activeSurfaceId: kind, - surfaces: [{ id: kind, kind }], + isOpen: false, + activeSurfaceId: null, + surfaces: [], }, "env-1:thread-B": { isOpen, - activeSurfaceId: kind, - surfaces: [ - { id: kind, kind }, - { id: "diff", kind: "diff" }, - ], + activeSurfaceId: "diff", + surfaces: [{ id: "diff", kind: "diff" }], }, }, - }), - ).toEqual({ - byThreadKey: { - "env-1:thread-A": { - isOpen: false, - activeSurfaceId: null, - surfaces: [], - }, - "env-1:thread-B": { - isOpen, - activeSurfaceId: "diff", - surfaces: [{ id: "diff", kind: "diff" }], - }, - }, - threadPanelVisibilityByThreadKey: {}, - }); - }); + threadPanelVisibilityByThreadKey: {}, + }); + }, + ); it("persists inline preference without restoring an open popover", () => { expect( @@ -535,6 +534,24 @@ describe("rightPanelStore", () => { }); }); + it("opens workspace-root links as the singleton files explorer", () => { + const store = useRightPanelStore.getState(); + store.openFile(refA, "README.md"); + store.openFile(refA, "."); + store.openFile(refA, "."); + const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); + expect(state.activeSurfaceId).toBe("files"); + expect(state.surfaces.map((surface) => surface.id)).toEqual(["file:README.md", "files"]); + store.closeSurface(refA, "files"); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).activeSurfaceId, + ).toBe("file:README.md"); + store.openFile(refA, "."); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).activeSurfaceId, + ).toBe("files"); + }); + it("replaces the standalone explorer with peer file surfaces", () => { useRightPanelStore.getState().open(refA, "files"); useRightPanelStore.getState().openFile(refA, "src/index.ts"); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index ca84d1bfe3e8..3a5b0e0fd129 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -698,6 +698,9 @@ export const useRightPanelStore = create()( openFile: (ref, requestedPath, line) => set((state) => userAction(state, scopedThreadKey(ref), (current) => { + if (requestedPath === ".") { + return upsertSurface(current, singletonSurface("files")); + } // Workspace entry paths use '/', including on Windows. const relativePath = /^[A-Za-z]:\/+$/.test(requestedPath) ? requestedPath diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index a6f85892b546..d14d5ebbcd6c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -33,6 +33,8 @@ import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationT import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; 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"; @@ -179,6 +181,7 @@ function RootRouteView() { + @@ -222,6 +225,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/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts index b4a1376e79c3..afe159dd402b 100644 --- a/apps/web/src/workspaceBasenameLookup.test.ts +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -15,6 +15,8 @@ describe("needsWorkspaceBasenameLookup", () => { it("leaves anything with a directory alone", () => { expect(needsWorkspaceBasenameLookup("apps/web/src/components/ChatView.tsx")).toBe(false); expect(needsWorkspaceBasenameLookup("apps\\web\\ChatView.tsx")).toBe(false); + expect(needsWorkspaceBasenameLookup(".")).toBe(false); + expect(needsWorkspaceBasenameLookup("..")).toBe(false); expect(needsWorkspaceBasenameLookup(" ")).toBe(false); }); }); diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts index b99d3ba4ded9..0efdfcf3a44d 100644 --- a/apps/web/src/workspaceBasenameLookup.ts +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -25,7 +25,13 @@ function basenameOfPath(path: string): string { export function needsWorkspaceBasenameLookup(relativePath: string): boolean { const trimmed = relativePath.trim(); - return trimmed.length > 0 && !trimmed.includes("/") && !trimmed.includes("\\"); + return ( + trimmed !== "." && + trimmed !== ".." && + trimmed.length > 0 && + !trimmed.includes("/") && + !trimmed.includes("\\") + ); } export function pickWorkspaceBasenameMatch( diff --git a/docs/internals/providers.md b/docs/internals/providers.md index b9d987c7d0d6..c1def5f3528a 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -55,6 +55,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/docs/user/install.md b/docs/user/install.md index e27df32b5ffe..0096f77bd676 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -104,23 +104,24 @@ 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. | -| Pi | Install [Pi](https://pi.dev), then run `pi` once to finish its login or API-key setup. | +| 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. | +| Pi | Install [Pi](https://pi.dev), then run `pi` once to finish its login or API-key setup. | 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 +release. Check **Settings → Agents** on that environment for the recommended version or range. When its package manager supports installing a specific version, you can install the recommendation there. Otherwise use the provider's installer on the environment's machine. An unlisted version is unverified. diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index c2e01fba580f..c866328db8ac 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 > Agents, -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 → Agents**. 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 → Agents**, 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. diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 047d2fbe0418..a8364446d93a 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -47,23 +47,29 @@ glab auth login ### Bitbucket -Set an access token in the server's environment: +Open **Settings → Source Control**, expand **Bitbucket**, and choose how to sign in: -```bash -export T3CODE_BITBUCKET_ACCESS_TOKEN="your-access-token" -``` +- **Access token**: a token created for one repository, project, or workspace. It can only reach + what it was created for. +- **API token**: an Atlassian API token for your account, used with your account email. It can + reach every repository you can. Give it read/write access to repositories and pull requests, plus + user read access (`read:user:bitbucket`). -Or use an Atlassian account email and API token with read/write access to repositories and pull -requests, plus user read access (`read:user:bitbucket`): +Choose **Save**; the change applies right away, and replaces any credential saved with the other +method. Credentials are saved on the environment's server, so select a remote environment to +configure it. Saved tokens can't be viewed again; enter a new one to replace it, or choose +**Remove**. + +If no credentials are saved, T3 Code falls back to these variables in the server's environment. +Restart the server after changing them: ```bash +export T3CODE_BITBUCKET_ACCESS_TOKEN="your-access-token" +# or export T3CODE_BITBUCKET_EMAIL="you@example.com" export T3CODE_BITBUCKET_API_TOKEN="your-token" ``` -The access token takes precedence if both are configured. Restart the server after changing these -variables. - ### Azure DevOps Install [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/), add the DevOps extension, and sign in: @@ -154,7 +160,8 @@ back. Merging, labels, and reviewer requests are available on web and desktop. ## Troubleshooting - **Not authenticated:** run the provider's login command on the server, then rescan. For Bitbucket, - confirm the running server received the environment variables. + check the credentials saved in Settings → Source Control, or confirm the running server received + the environment variables. - **GitHub sign-in cannot be verified:** update GitHub CLI to at least 2.81.0. - **Push fails despite a connected account:** check the Git remote's credentials. SSH and HTTPS remotes can require separate setup from the hosting provider's API access. @@ -173,7 +180,7 @@ on the Pull Requests page, **Link to thread** lets you search for an active thre also lists the threads that link to it, including archived threads, so you can return to their context. Thread badges show a stack's layer count or the current review number with a count of additional -links. On mobile, the Git overview lists linked reviews and their stacks; tap a review to open it. +links. Clicking a badge with more than one review opens the **Linked pull requests** panel. On mobile, the Git overview lists linked reviews and their stacks; tap a review to open it. Linking and unlinking are available in the web and desktop clients. The **Linked pull requests** panel lists every review and groups stacks. Unlink a review from its diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts index 4aea947b87d6..deea6960478e 100644 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -22,6 +22,15 @@ describe("inlineCodeFilePathCandidate", () => { ["127.0.0.1:3000", null], ["example.com/index.html", null], ["example.pl/index.html", null], + ["z-ai/glm-5.3", null], + ["z-ai/glm-5.3:12", null], + ["python/3.12", null], + ["Qwen/Qwen2.5-Coder", null], + ["meta-llama/Llama-3.1-8B", null], + ["share/man/ls.1", "share/man/ls.1"], + ["usr/lib/libfoo.so.1", "usr/lib/libfoo.so.1"], + ["vendor/jquery-3.6.0.min.js", "vendor/jquery-3.6.0.min.js"], + ["./models/glm-5.3", "./models/glm-5.3"], ])("distinguishes file paths from code and hostnames in %s", (source, candidate) => { expect(inlineCodeFilePathCandidate(source)).toBe(candidate); }); @@ -146,6 +155,11 @@ describe("fileBasename", () => { describe("workspaceRelativeFilePath", () => { it.each([ + ["/repo/project", "/repo/project", "."], + ["/repo/project/", "/repo/project/", "."], + ["/", "/", "."], + ["C:/USERS/mike/project", "c:/users/MIKE/project", "."], + ["C:/", "c:/", "."], ["/repo/project/src/main.ts", "/repo/project", "src/main.ts"], ["/repo/project/src/main.ts", "/repo/project/", "src/main.ts"], ["C:\\Users\\mike\\t3code\\apps\\web\\a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"], diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts index 29a337e49c42..0a32f5061083 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/client-runtime/src/markdownLinks.ts @@ -14,6 +14,9 @@ const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; const PATH_SEPARATOR_PATTERN = /[\\/]/; const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; +// A final dot between digits marks a version or model id (`glm-5.3`, +// `Qwen2.5-Coder`), not an extension. `ls.1` and `libfoo.so.1` stay files. +const VERSION_SUFFIX_PATTERN = /\d\.\d[^.]*$/; const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; // Standard OS and dev-container roots; deliberately excludes app-route-ish // prefixes like /app/ or /chat/ so SPA routes never read as files. @@ -176,6 +179,7 @@ export function inlineCodeFilePathCandidate(codeText: string): string | null { .replace(/[/\\]+$/, "") .split(/[\\/]/) .at(-1) ?? ""; + if (VERSION_SUFFIX_PATTERN.test(basename)) return null; if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basename)) return null; } return candidate; @@ -336,6 +340,7 @@ export function workspaceRelativeFilePath( const caseInsensitive = isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot)); const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath; const rootForCompare = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot; + if (pathForCompare.replace(/\/+$/, "") === rootForCompare) return "."; if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null; return normalizedPath.slice(normalizedRoot.length + 1); } diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 46f88b9be32d..df01f3a87931 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -43,6 +43,7 @@ type RpcMethod = WsRpcProtocolClient[TTag]; export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.providerRealtimeVoiceEvents | typeof WS_METHODS.fleetConnect + | typeof WS_METHODS.codexAuthCallbackSubscribe | typeof WS_METHODS.providerAuthSubscribe | typeof WS_METHODS.providerInstallSubscribe | typeof ORCHESTRATION_V2_WS_METHODS.subscribeShell @@ -66,6 +67,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 f4a69cc4804d..0a18b62803d5 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -30,6 +30,7 @@ import { createEnvironmentQueryAtomFamily, createEnvironmentRpcQueryAtomFamily, createEnvironmentRpcSubscriptionAtomFamily, + createEnvironmentSubscriptionAtomFamily, createRuntimeCommand, scheduleAtomCommandEffect, } from "./runtime.ts"; @@ -1010,6 +1011,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/client-runtime/src/work-log/userInput.test.ts b/packages/client-runtime/src/work-log/userInput.test.ts new file mode 100644 index 000000000000..bfc7ea75296f --- /dev/null +++ b/packages/client-runtime/src/work-log/userInput.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vite-plus/test"; +import { ApprovalRequestId, type UserInputAttachmentAnswerPayload } from "@t3tools/contracts"; +import { getQuestionTextPreview } from "./userInput.ts"; + +function answer( + overrides: Partial = {}, +): UserInputAttachmentAnswerPayload { + return { + requestId: ApprovalRequestId.make("request-1"), + questionTextById: { scope: "Which repository?" }, + answers: { scope: "Use the private repository" }, + attachmentsByQuestionId: {}, + ...overrides, + }; +} + +describe("getQuestionTextPreview", () => { + it("joins the question texts", () => { + expect( + getQuestionTextPreview( + answer({ questionTextById: { scope: "Which repository?", name: "What name?" } }), + ), + ).toBe("Which repository? · What name?"); + }); + + it("normalizes whitespace and skips blank texts", () => { + expect( + getQuestionTextPreview( + answer({ questionTextById: { scope: "Which\nrepository?", x: " " } }), + ), + ).toBe("Which repository?"); + }); + + it("returns an empty string without question texts", () => { + expect(getQuestionTextPreview(answer({ questionTextById: undefined }))).toBe(""); + }); +}); diff --git a/packages/client-runtime/src/work-log/userInput.ts b/packages/client-runtime/src/work-log/userInput.ts index 19e927d9dab4..fe36126d70e3 100644 --- a/packages/client-runtime/src/work-log/userInput.ts +++ b/packages/client-runtime/src/work-log/userInput.ts @@ -13,6 +13,13 @@ export function getQuestionAnswerText(value: unknown): string { return nested ? getQuestionAnswerText(nested.answers) : ""; } +export function getQuestionTextPreview(answer: UserInputAttachmentAnswerPayload): string { + return Object.values(answer.questionTextById ?? {}) + .map((text) => text.replace(/\s+/g, " ").trim()) + .filter(Boolean) + .join(" · "); +} + export function getQuestionAnswerPreview(answer: UserInputAttachmentAnswerPayload): string { const answers = Object.values(answer.answers).map(getQuestionAnswerText).filter(Boolean); const attachments = Object.values(answer.attachmentsByQuestionId) diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 21623abc33b0..4ae20f239d0a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1285,6 +1285,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 f5a273263112..37a737e9d40b 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -815,6 +815,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..c692a9b7d519 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -50,7 +50,16 @@ export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type; export const ServerProviderUsageLimits = Schema.Struct({ checkedAt: IsoDateTime, windows: ForwardCompatibleArray(ServerProviderUsageWindow), + /** Opaque credential identity when the provider does not report an account. */ + credentialFingerprint: Schema.optional(TrimmedNonEmptyString), 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/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 75e9312f92aa..c06c54de8918 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -1283,9 +1283,9 @@ const PROVIDER_REQUIREMENT: Partial< }, bitbucket: { missing: - "Bitbucket needs API credentials on the server. Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + "Bitbucket needs API credentials on the server. Add them in Settings → Source Control.", unauthenticated: - "Bitbucket rejected the configured credentials. Check T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN.", + "Bitbucket rejected the configured credentials. Check them in Settings → Source Control.", }, }; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index f1e4376f6ada..352ed24ee883 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -17,11 +17,20 @@ import { } from "./fleet.ts"; import { OrchestratorMcpFailure } from "./orchestratorMcp.ts"; import { OrchestrationDispatchCommandError } from "./orchestration.ts"; +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, @@ -377,6 +386,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", @@ -632,6 +645,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, @@ -1767,6 +1803,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 5170e8be7207..886d0a9c3a3e 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -67,6 +67,8 @@ export const ServerProviderAuth = Schema.Struct({ email: Schema.optional(TrimmedNonEmptyString), action: Schema.optional(AcpRegistryUrlAuthAction), canLogout: Schema.optional(Schema.Boolean), + subscriptionSharing: Schema.optional(Schema.Boolean), + profileId: Schema.optional(TrimmedNonEmptyString), }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; @@ -244,6 +246,12 @@ export const ServerProvider = Schema.Struct({ }), ), configurableProviders: Schema.optional(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 2a223202188e..a5b1173668a8 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -634,6 +634,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 } }), @@ -1032,6 +1035,19 @@ export const UsageLimitSourceConfig = Schema.Struct({ }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; +/** + * Bitbucket API credentials for this environment, used before the + * `T3CODE_BITBUCKET_*` environment variables. The tokens live in the server's + * secret store; settings and clients only see a redaction marker when one is + * set. The access token wins when both kinds are configured. + */ +export const BitbucketSettings = Schema.Struct({ + email: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + accessToken: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + apiToken: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), +}); +export type BitbucketSettings = typeof BitbucketSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -1431,6 +1447,7 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed({})), ), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + bitbucket: BitbucketSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), // Keyed by a user-chosen id so a source keeps its rows across edits. Entries // this build cannot decode round-trip untouched, as provider instances do. usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( @@ -1705,6 +1722,14 @@ export const ServerSettingsPatch = Schema.Struct({ otlpLogsUrl: Schema.optionalKey(TrimmedString), }), ), + /** An empty token clears it; an omitted one keeps what the server has. */ + bitbucket: Schema.optionalKey( + Schema.Struct({ + email: Schema.optionalKey(TrimmedString), + accessToken: Schema.optionalKey(TrimmedString), + apiToken: Schema.optionalKey(TrimmedString), + }), + ), providers: Schema.optionalKey( Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), diff --git a/packages/effect-codex-app-server/scripts/generate.ts b/packages/effect-codex-app-server/scripts/generate.ts index b2696097ef8c..f7cab8e59b93 100644 --- a/packages/effect-codex-app-server/scripts/generate.ts +++ b/packages/effect-codex-app-server/scripts/generate.ts @@ -17,7 +17,7 @@ import { } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -const UPSTREAM_REF = "fe74a774532af67b5a4a3dec03ce9469e17f89af"; +const UPSTREAM_REF = "687a119f0fcaace47e1f1abcc77cec6c813fd6da"; const USER_AGENT = "effect-codex-app-server-generator"; const GITHUB_API_BASE = "https://api.github.com/repos/openai/codex/contents/codex-rs/app-server-protocol"; @@ -145,6 +145,13 @@ const ManualSchemas: Record = { }, }; +// Codex adds plan slugs between our protocol refreshes (0.159 added `promax`). +// T3 Code only uses the plan for labels, so an unknown slug must not fail the +// whole `account/read` decode and take the provider down with it. +const DefinitionOverrides: Record = { + PlanType: { type: "string" }, +}; + const getGeneratedPaths = Effect.fn("getGeneratedPaths")(function* () { const path = yield* Path.Path; const generatedDir = path.join(import.meta.dirname, "..", "src", "_generated"); @@ -430,6 +437,9 @@ function resolveResponseTypeName( generatedSchemaNames: ReadonlySet, ): string { const overrides: Record = { + "account/gatewayOAuth/cancel": "GatewayOAuthCancelResponse", + "account/gatewayOAuth/login": "GatewayOAuthLoginResponse", + "account/gatewayOAuth/read": "GatewayOAuthReadResponse", "account/logout": "LogoutAccountResponse", "account/rateLimits/read": "GetAccountRateLimitsResponse", "account/usage/read": "GetAccountTokenUsageResponse", @@ -643,7 +653,7 @@ const generateFiles = Effect.fn("generateFiles")(function* () { aggregateSchemas[localDefinitionNames.get(definitionName)!] = stripNullDefaults( normalizeNullableTypes( rewriteExternalRefs( - definitionSchema, + DefinitionOverrides[definitionName] ?? definitionSchema, localDefinitionNames, file.namespace, exportNameByQualifiedName, diff --git a/packages/effect-codex-app-server/src/_generated/meta.gen.ts b/packages/effect-codex-app-server/src/_generated/meta.gen.ts index 88ffb842ac9b..1c9d47e58523 100644 --- a/packages/effect-codex-app-server/src/_generated/meta.gen.ts +++ b/packages/effect-codex-app-server/src/_generated/meta.gen.ts @@ -1,5 +1,5 @@ // This file is generated by the effect-codex-app-server package. Do not edit manually. -// Upstream protocol ref: fe74a774532af67b5a4a3dec03ce9469e17f89af +// Upstream protocol ref: 687a119f0fcaace47e1f1abcc77cec6c813fd6da import * as CodexSchema from "./schema.gen.ts"; @@ -71,6 +71,9 @@ export const CLIENT_REQUEST_METHODS = { "turn/interrupt": "turn/interrupt", "review/start": "review/start", "model/list": "model/list", + "account/gatewayOAuth/read": "account/gatewayOAuth/read", + "account/gatewayOAuth/login": "account/gatewayOAuth/login", + "account/gatewayOAuth/cancel": "account/gatewayOAuth/cancel", "modelProvider/capabilities/read": "modelProvider/capabilities/read", "experimentalFeature/list": "experimentalFeature/list", "permissionProfile/list": "permissionProfile/list", @@ -176,6 +179,7 @@ export const SERVER_NOTIFICATION_METHODS = { "mcpServer/startupStatus/updated": "mcpServer/startupStatus/updated", "mcpServer/event/stream/notification": "mcpServer/event/stream/notification", "account/updated": "account/updated", + "account/gatewayOAuth/changed": "account/gatewayOAuth/changed", "account/rateLimits/updated": "account/rateLimits/updated", "app/list/updated": "app/list/updated", "remoteControl/status/changed": "remoteControl/status/changed", @@ -287,6 +291,9 @@ export interface ClientRequestParamsByMethod { readonly "turn/interrupt": CodexSchema.V2TurnInterruptParams; readonly "review/start": CodexSchema.V2ReviewStartParams; readonly "model/list": CodexSchema.V2ModelListParams; + readonly "account/gatewayOAuth/read": undefined; + readonly "account/gatewayOAuth/login": undefined; + readonly "account/gatewayOAuth/cancel": undefined; readonly "modelProvider/capabilities/read": CodexSchema.V2ModelProviderCapabilitiesReadParams; readonly "experimentalFeature/list": CodexSchema.V2ExperimentalFeatureListParams; readonly "permissionProfile/list": CodexSchema.V2PermissionProfileListParams; @@ -394,6 +401,9 @@ export interface ClientRequestResponsesByMethod { readonly "turn/interrupt": CodexSchema.V2TurnInterruptResponse; readonly "review/start": CodexSchema.V2ReviewStartResponse; readonly "model/list": CodexSchema.V2ModelListResponse; + readonly "account/gatewayOAuth/read": CodexSchema.V2GatewayOAuthReadResponse; + readonly "account/gatewayOAuth/login": CodexSchema.V2GatewayOAuthLoginResponse; + readonly "account/gatewayOAuth/cancel": CodexSchema.V2GatewayOAuthCancelResponse; readonly "modelProvider/capabilities/read": CodexSchema.V2ModelProviderCapabilitiesReadResponse; readonly "experimentalFeature/list": CodexSchema.V2ExperimentalFeatureListResponse; readonly "permissionProfile/list": CodexSchema.V2PermissionProfileListResponse; @@ -512,6 +522,7 @@ export interface ServerNotificationParamsByMethod { readonly "mcpServer/startupStatus/updated": CodexSchema.V2McpServerStatusUpdatedNotification; readonly "mcpServer/event/stream/notification": CodexSchema.V2McpServerEventStreamNotification; readonly "account/updated": CodexSchema.V2AccountUpdatedNotification; + readonly "account/gatewayOAuth/changed": CodexSchema.V2GatewayOAuthChangedNotification; readonly "account/rateLimits/updated": CodexSchema.V2AccountRateLimitsUpdatedNotification; readonly "app/list/updated": CodexSchema.V2AppListUpdatedNotification; readonly "remoteControl/status/changed": CodexSchema.V2RemoteControlStatusChangedNotification; @@ -618,6 +629,9 @@ export const CLIENT_REQUEST_PARAMS = { "turn/interrupt": CodexSchema.V2TurnInterruptParams, "review/start": CodexSchema.V2ReviewStartParams, "model/list": CodexSchema.V2ModelListParams, + "account/gatewayOAuth/read": undefined, + "account/gatewayOAuth/login": undefined, + "account/gatewayOAuth/cancel": undefined, "modelProvider/capabilities/read": CodexSchema.V2ModelProviderCapabilitiesReadParams, "experimentalFeature/list": CodexSchema.V2ExperimentalFeatureListParams, "permissionProfile/list": CodexSchema.V2PermissionProfileListParams, @@ -726,6 +740,9 @@ export const CLIENT_REQUEST_RESPONSES = { "turn/interrupt": CodexSchema.V2TurnInterruptResponse, "review/start": CodexSchema.V2ReviewStartResponse, "model/list": CodexSchema.V2ModelListResponse, + "account/gatewayOAuth/read": CodexSchema.V2GatewayOAuthReadResponse, + "account/gatewayOAuth/login": CodexSchema.V2GatewayOAuthLoginResponse, + "account/gatewayOAuth/cancel": CodexSchema.V2GatewayOAuthCancelResponse, "modelProvider/capabilities/read": CodexSchema.V2ModelProviderCapabilitiesReadResponse, "experimentalFeature/list": CodexSchema.V2ExperimentalFeatureListResponse, "permissionProfile/list": CodexSchema.V2PermissionProfileListResponse, @@ -847,6 +864,7 @@ export const SERVER_NOTIFICATION_PARAMS = { "mcpServer/startupStatus/updated": CodexSchema.V2McpServerStatusUpdatedNotification, "mcpServer/event/stream/notification": CodexSchema.V2McpServerEventStreamNotification, "account/updated": CodexSchema.V2AccountUpdatedNotification, + "account/gatewayOAuth/changed": CodexSchema.V2GatewayOAuthChangedNotification, "account/rateLimits/updated": CodexSchema.V2AccountRateLimitsUpdatedNotification, "app/list/updated": CodexSchema.V2AppListUpdatedNotification, "remoteControl/status/changed": CodexSchema.V2RemoteControlStatusChangedNotification, diff --git a/packages/effect-codex-app-server/src/_generated/namespaces.gen.ts b/packages/effect-codex-app-server/src/_generated/namespaces.gen.ts index 3428ce049ed7..47474330d286 100644 --- a/packages/effect-codex-app-server/src/_generated/namespaces.gen.ts +++ b/packages/effect-codex-app-server/src/_generated/namespaces.gen.ts @@ -1,5 +1,5 @@ // This file is generated by the effect-codex-app-server package. Do not edit manually. -// Upstream protocol ref: fe74a774532af67b5a4a3dec03ce9469e17f89af +// Upstream protocol ref: 687a119f0fcaace47e1f1abcc77cec6c813fd6da import * as CodexSchema from "./schema.gen.ts"; @@ -88,6 +88,10 @@ export const v2 = { FsWatchResponse: CodexSchema.V2FsWatchResponse, FsWriteFileParams: CodexSchema.V2FsWriteFileParams, FsWriteFileResponse: CodexSchema.V2FsWriteFileResponse, + GatewayOAuthCancelResponse: CodexSchema.V2GatewayOAuthCancelResponse, + GatewayOAuthChangedNotification: CodexSchema.V2GatewayOAuthChangedNotification, + GatewayOAuthLoginResponse: CodexSchema.V2GatewayOAuthLoginResponse, + GatewayOAuthReadResponse: CodexSchema.V2GatewayOAuthReadResponse, GetAccountParams: CodexSchema.V2GetAccountParams, GetAccountRateLimitsResponse: CodexSchema.V2GetAccountRateLimitsResponse, GetAccountResponse: CodexSchema.V2GetAccountResponse, diff --git a/packages/effect-codex-app-server/src/_generated/schema.gen.ts b/packages/effect-codex-app-server/src/_generated/schema.gen.ts index 9bc7b1ec1e9e..38b46712257a 100644 --- a/packages/effect-codex-app-server/src/_generated/schema.gen.ts +++ b/packages/effect-codex-app-server/src/_generated/schema.gen.ts @@ -1,5 +1,5 @@ // This file is generated by the effect-codex-app-server package. Do not edit manually. -// Upstream protocol ref: fe74a774532af67b5a4a3dec03ce9469e17f89af +// Upstream protocol ref: 687a119f0fcaace47e1f1abcc77cec6c813fd6da import * as Schema from "effect/Schema"; @@ -57,6 +57,7 @@ export const ClientRequest__RequestId = Schema.Union([ export type ClientRequest__InitializeCapabilities = { readonly experimentalApi?: boolean; + readonly explicitGatewayOauth?: boolean; readonly extensions?: { readonly [x: string]: Schema.Json } | null; readonly mcpServerOpenaiFormElicitation?: boolean; readonly optOutNotificationMethods?: ReadonlyArray | null; @@ -69,6 +70,12 @@ export const ClientRequest__InitializeCapabilities = Schema.Struct({ default: false, }), ), + explicitGatewayOauth: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Use explicit gateway OAuth login instead of automatic browser authorization. Applies to this app-server's gateway runtime; later connections cannot undo it.", + }), + ), extensions: Schema.optionalKey( Schema.Union([ Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" })).annotate({ @@ -572,6 +579,23 @@ export const ClientRequest__TurnItemsView = Schema.Union( { mode: "oneOf" }, ).annotate({ identifier: "ClientRequest__TurnItemsView" }); +export type ClientRequest__ThreadItemsListAnchor = { + readonly itemId: string; + readonly type: "item"; +}; +export const ClientRequest__ThreadItemsListAnchor = Schema.Union( + [ + Schema.Struct({ + itemId: Schema.String, + type: Schema.Literal("item").annotate({ title: "ItemThreadItemsListAnchorType" }), + }).annotate({ title: "ItemThreadItemsListAnchor" }), + ], + { mode: "oneOf" }, +).annotate({ + description: "An exclusive item position within the requested visible turn.", + identifier: "ClientRequest__ThreadItemsListAnchor", +}); + export type ClientRequest__ThreadInjectItemsParams = { readonly items: ReadonlyArray; readonly threadId: string; @@ -1094,27 +1118,19 @@ export const ClientRequest__McpServerStatusDetail = Schema.Literals([ "toolsAndAuthOnly", ]).annotate({ identifier: "ClientRequest__McpServerStatusDetail" }); -export type ClientRequest__McpResourceReadParams = { - readonly connectorId?: string | null; - readonly originCallId?: string | null; - readonly server: string; - readonly threadId?: string | null; - readonly uri: string; +export type ClientRequest__McpResourceReadTarget = { + readonly connectorId: string; + readonly linkId: string | null; }; -export const ClientRequest__McpResourceReadParams = Schema.Struct({ - connectorId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - originCallId: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ - description: "Originating MCP tool call used to select the resource's app.", - }), - Schema.Null, - ]), - ), - server: Schema.String, - threadId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), - uri: Schema.String, -}).annotate({ identifier: "ClientRequest__McpResourceReadParams" }); +export const ClientRequest__McpResourceReadTarget = Schema.Struct({ + connectorId: Schema.String, + linkId: Schema.Union([ + Schema.String.annotate({ + description: "Null explicitly requests no-auth access, subject to the app's resource policy.", + }), + Schema.Null, + ]), +}).annotate({ identifier: "ClientRequest__McpResourceReadTarget" }); export type ClientRequest__McpServerToolCallParams = { readonly _meta?: Schema.Json; @@ -3204,43 +3220,22 @@ export const ServerNotification__AuthMode = Schema.Union( identifier: "ServerNotification__AuthMode", }); -export type ServerNotification__PlanType = - | "free" - | "go" - | "plus" - | "pro" - | "prolite" - | "team" - | "self_serve_business_prolite" - | "self_serve_business_usage_based" - | "business" - | "ent26" - | "enterprise_cbp_automation" - | "enterprise_cbp_usage_based" - | "enterprise" - | "edu" - | "edu_plus" - | "edu_pro" - | "unknown"; -export const ServerNotification__PlanType = Schema.Literals([ - "free", - "go", - "plus", - "pro", - "prolite", - "team", - "self_serve_business_prolite", - "self_serve_business_usage_based", - "business", - "ent26", - "enterprise_cbp_automation", - "enterprise_cbp_usage_based", - "enterprise", - "edu", - "edu_plus", - "edu_pro", - "unknown", -]).annotate({ identifier: "ServerNotification__PlanType" }); +export type ServerNotification__PlanType = string; +export const ServerNotification__PlanType = Schema.String.annotate({ + identifier: "ServerNotification__PlanType", +}); + +export type ServerNotification__GatewayOAuthStatus = + | "notReady" + | "started" + | "succeeded" + | "failed"; +export const ServerNotification__GatewayOAuthStatus = Schema.Literals([ + "notReady", + "started", + "succeeded", + "failed", +]).annotate({ identifier: "ServerNotification__GatewayOAuthStatus" }); export type ServerNotification__CreditsSnapshot = { readonly balance?: string | null; @@ -4041,6 +4036,7 @@ export const ToolRequestUserInputResponse__ToolRequestUserInputAnswer = Schema.S export type V1InitializeParams__InitializeCapabilities = { readonly experimentalApi?: boolean; + readonly explicitGatewayOauth?: boolean; readonly extensions?: { readonly [x: string]: Schema.Json } | null; readonly mcpServerOpenaiFormElicitation?: boolean; readonly optOutNotificationMethods?: ReadonlyArray | null; @@ -4053,6 +4049,12 @@ export const V1InitializeParams__InitializeCapabilities = Schema.Struct({ default: false, }), ), + explicitGatewayOauth: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Use explicit gateway OAuth login instead of automatic browser authorization. Applies to this app-server's gateway runtime; later connections cannot undo it.", + }), + ), extensions: Schema.optionalKey( Schema.Union([ Schema.Record(Schema.String, Schema.Json.annotate({ expected: "JSON value" })).annotate({ @@ -4138,43 +4140,10 @@ export const V2AccountRateLimitsUpdatedNotification__SpendControlLimitSnapshot = used: Schema.String, }).annotate({ identifier: "V2AccountRateLimitsUpdatedNotification__SpendControlLimitSnapshot" }); -export type V2AccountRateLimitsUpdatedNotification__PlanType = - | "free" - | "go" - | "plus" - | "pro" - | "prolite" - | "team" - | "self_serve_business_prolite" - | "self_serve_business_usage_based" - | "business" - | "ent26" - | "enterprise_cbp_automation" - | "enterprise_cbp_usage_based" - | "enterprise" - | "edu" - | "edu_plus" - | "edu_pro" - | "unknown"; -export const V2AccountRateLimitsUpdatedNotification__PlanType = Schema.Literals([ - "free", - "go", - "plus", - "pro", - "prolite", - "team", - "self_serve_business_prolite", - "self_serve_business_usage_based", - "business", - "ent26", - "enterprise_cbp_automation", - "enterprise_cbp_usage_based", - "enterprise", - "edu", - "edu_plus", - "edu_pro", - "unknown", -]).annotate({ identifier: "V2AccountRateLimitsUpdatedNotification__PlanType" }); +export type V2AccountRateLimitsUpdatedNotification__PlanType = string; +export const V2AccountRateLimitsUpdatedNotification__PlanType = Schema.String.annotate({ + identifier: "V2AccountRateLimitsUpdatedNotification__PlanType", +}); export type V2AccountRateLimitsUpdatedNotification__RateLimitWindow = { readonly resetsAt?: number | null; @@ -4260,43 +4229,10 @@ export const V2AccountUpdatedNotification__AuthMode = Schema.Union( identifier: "V2AccountUpdatedNotification__AuthMode", }); -export type V2AccountUpdatedNotification__PlanType = - | "free" - | "go" - | "plus" - | "pro" - | "prolite" - | "team" - | "self_serve_business_prolite" - | "self_serve_business_usage_based" - | "business" - | "ent26" - | "enterprise_cbp_automation" - | "enterprise_cbp_usage_based" - | "enterprise" - | "edu" - | "edu_plus" - | "edu_pro" - | "unknown"; -export const V2AccountUpdatedNotification__PlanType = Schema.Literals([ - "free", - "go", - "plus", - "pro", - "prolite", - "team", - "self_serve_business_prolite", - "self_serve_business_usage_based", - "business", - "ent26", - "enterprise_cbp_automation", - "enterprise_cbp_usage_based", - "enterprise", - "edu", - "edu_plus", - "edu_pro", - "unknown", -]).annotate({ identifier: "V2AccountUpdatedNotification__PlanType" }); +export type V2AccountUpdatedNotification__PlanType = string; +export const V2AccountUpdatedNotification__PlanType = Schema.String.annotate({ + identifier: "V2AccountUpdatedNotification__PlanType", +}); export type V2AppListUpdatedNotification__AppReview = { readonly status: string }; export const V2AppListUpdatedNotification__AppReview = Schema.Struct({ @@ -5438,6 +5374,30 @@ export const V2FsWriteFileParams__AbsolutePathBuf = Schema.String.annotate({ identifier: "V2FsWriteFileParams__AbsolutePathBuf", }); +export type V2GatewayOAuthChangedNotification__GatewayOAuthStatus = + | "notReady" + | "started" + | "succeeded" + | "failed"; +export const V2GatewayOAuthChangedNotification__GatewayOAuthStatus = Schema.Literals([ + "notReady", + "started", + "succeeded", + "failed", +]).annotate({ identifier: "V2GatewayOAuthChangedNotification__GatewayOAuthStatus" }); + +export type V2GatewayOAuthReadResponse__GatewayOAuthStatus = + | "notReady" + | "started" + | "succeeded" + | "failed"; +export const V2GatewayOAuthReadResponse__GatewayOAuthStatus = Schema.Literals([ + "notReady", + "started", + "succeeded", + "failed", +]).annotate({ identifier: "V2GatewayOAuthReadResponse__GatewayOAuthStatus" }); + export type V2GetAccountRateLimitsResponse__RateLimitResetType = "codexRateLimits" | "unknown"; export const V2GetAccountRateLimitsResponse__RateLimitResetType = Schema.Literals([ "codexRateLimits", @@ -5484,43 +5444,10 @@ export const V2GetAccountRateLimitsResponse__SpendControlLimitSnapshot = Schema. used: Schema.String, }).annotate({ identifier: "V2GetAccountRateLimitsResponse__SpendControlLimitSnapshot" }); -export type V2GetAccountRateLimitsResponse__PlanType = - | "free" - | "go" - | "plus" - | "pro" - | "prolite" - | "team" - | "self_serve_business_prolite" - | "self_serve_business_usage_based" - | "business" - | "ent26" - | "enterprise_cbp_automation" - | "enterprise_cbp_usage_based" - | "enterprise" - | "edu" - | "edu_plus" - | "edu_pro" - | "unknown"; -export const V2GetAccountRateLimitsResponse__PlanType = Schema.Literals([ - "free", - "go", - "plus", - "pro", - "prolite", - "team", - "self_serve_business_prolite", - "self_serve_business_usage_based", - "business", - "ent26", - "enterprise_cbp_automation", - "enterprise_cbp_usage_based", - "enterprise", - "edu", - "edu_plus", - "edu_pro", - "unknown", -]).annotate({ identifier: "V2GetAccountRateLimitsResponse__PlanType" }); +export type V2GetAccountRateLimitsResponse__PlanType = string; +export const V2GetAccountRateLimitsResponse__PlanType = Schema.String.annotate({ + identifier: "V2GetAccountRateLimitsResponse__PlanType", +}); export type V2GetAccountRateLimitsResponse__RateLimitWindow = { readonly resetsAt?: number | null; @@ -5563,43 +5490,10 @@ export const V2GetAccountRateLimitsResponse__RateLimitReachedType = Schema.Liter "workspace_member_usage_limit_reached", ]).annotate({ identifier: "V2GetAccountRateLimitsResponse__RateLimitReachedType" }); -export type V2GetAccountResponse__PlanType = - | "free" - | "go" - | "plus" - | "pro" - | "prolite" - | "team" - | "self_serve_business_prolite" - | "self_serve_business_usage_based" - | "business" - | "ent26" - | "enterprise_cbp_automation" - | "enterprise_cbp_usage_based" - | "enterprise" - | "edu" - | "edu_plus" - | "edu_pro" - | "unknown"; -export const V2GetAccountResponse__PlanType = Schema.Literals([ - "free", - "go", - "plus", - "pro", - "prolite", - "team", - "self_serve_business_prolite", - "self_serve_business_usage_based", - "business", - "ent26", - "enterprise_cbp_automation", - "enterprise_cbp_usage_based", - "enterprise", - "edu", - "edu_plus", - "edu_pro", - "unknown", -]).annotate({ identifier: "V2GetAccountResponse__PlanType" }); +export type V2GetAccountResponse__PlanType = string; +export const V2GetAccountResponse__PlanType = Schema.String.annotate({ + identifier: "V2GetAccountResponse__PlanType", +}); export type V2GetAccountResponse__AccountRoutingOverride = "NO_CONSTRAINT" | "us" | "us_cr"; export const V2GetAccountResponse__AccountRoutingOverride = Schema.Literals([ @@ -7131,6 +7025,20 @@ export const V2MarketplaceUpgradeResponse__AbsolutePathBuf = Schema.String.annot identifier: "V2MarketplaceUpgradeResponse__AbsolutePathBuf", }); +export type V2McpResourceReadParams__McpResourceReadTarget = { + readonly connectorId: string; + readonly linkId: string | null; +}; +export const V2McpResourceReadParams__McpResourceReadTarget = Schema.Struct({ + connectorId: Schema.String, + linkId: Schema.Union([ + Schema.String.annotate({ + description: "Null explicitly requests no-auth access, subject to the app's resource policy.", + }), + Schema.Null, + ]), +}).annotate({ identifier: "V2McpResourceReadParams__McpResourceReadTarget" }); + export type V2McpResourceReadResponse__ResourceContent = | { readonly _meta?: Schema.Json; @@ -9398,6 +9306,23 @@ export const V2ThreadGoalUpdatedNotification__ThreadGoalStatus = Schema.Literals "complete", ]).annotate({ identifier: "V2ThreadGoalUpdatedNotification__ThreadGoalStatus" }); +export type V2ThreadItemsListParams__ThreadItemsListAnchor = { + readonly itemId: string; + readonly type: "item"; +}; +export const V2ThreadItemsListParams__ThreadItemsListAnchor = Schema.Union( + [ + Schema.Struct({ + itemId: Schema.String, + type: Schema.Literal("item").annotate({ title: "ItemThreadItemsListAnchorType" }), + }).annotate({ title: "ItemThreadItemsListAnchor" }), + ], + { mode: "oneOf" }, +).annotate({ + description: "An exclusive item position within the requested visible turn.", + identifier: "V2ThreadItemsListParams__ThreadItemsListAnchor", +}); + export type V2ThreadItemsListParams__SortDirection = "asc" | "desc"; export const V2ThreadItemsListParams__SortDirection = Schema.Literals(["asc", "desc"]).annotate({ identifier: "V2ThreadItemsListParams__SortDirection", @@ -16441,51 +16366,6 @@ export const ClientRequest__ThreadMetadataUpdateParams = Schema.Struct({ threadId: Schema.String, }).annotate({ identifier: "ClientRequest__ThreadMetadataUpdateParams" }); -export type ClientRequest__ThreadItemsListParams = { - readonly cursor?: string | null; - readonly limit?: number | null; - readonly sortDirection?: ClientRequest__SortDirection | null; - readonly threadId: string; - readonly turnId?: string | null; -}; -export const ClientRequest__ThreadItemsListParams = Schema.Struct({ - cursor: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ - description: "Opaque cursor to pass to the next call to continue after the last item.", - }), - Schema.Null, - ]), - ), - limit: Schema.optionalKey( - Schema.Union([ - Schema.Number.annotate({ description: "Optional item page size.", format: "uint32" }) - .check(Schema.isInt().annotate({ expected: "an integer" })) - .check( - Schema.isGreaterThanOrEqualTo(0).annotate({ - expected: "a value greater than or equal to 0", - }), - ), - Schema.Null, - ]), - ), - sortDirection: Schema.optionalKey( - Schema.Union([ClientRequest__SortDirection, Schema.Null]).annotate({ - description: "Optional item pagination direction; defaults to ascending.", - }), - ), - threadId: Schema.String, - turnId: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ - description: - "Optional turn id to filter by. When omitted, returns items across the thread.", - }), - Schema.Null, - ]), - ), -}).annotate({ identifier: "ClientRequest__ThreadItemsListParams" }); - export type ClientRequest__ThreadListParams = { readonly archived?: boolean | null; readonly cursor?: string | null; @@ -16676,6 +16556,15 @@ export const ClientRequest__ThreadTurnsListParams = Schema.Struct({ threadId: Schema.String, }).annotate({ identifier: "ClientRequest__ThreadTurnsListParams" }); +export type ClientRequest__ThreadItemsListCursor = string | ClientRequest__ThreadItemsListAnchor; +export const ClientRequest__ThreadItemsListCursor = Schema.Union([ + Schema.String, + ClientRequest__ThreadItemsListAnchor, +]).annotate({ + description: "Starting position for an item-history page.", + identifier: "ClientRequest__ThreadItemsListCursor", +}); + export type ClientRequest__SkillsExtraRootsSetParams = { readonly extraRoots: ReadonlyArray; }; @@ -17168,6 +17057,7 @@ export type ClientRequest__ListMcpServerStatusParams = { readonly cursor?: string | null; readonly detail?: ClientRequest__McpServerStatusDetail | null; readonly limit?: number | null; + readonly serverName?: string | null; readonly threadId?: string | null; }; export const ClientRequest__ListMcpServerStatusParams = Schema.Struct({ @@ -17200,9 +17090,46 @@ export const ClientRequest__ListMcpServerStatusParams = Schema.Struct({ Schema.Null, ]), ), + serverName: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Limit discovery to one server. With a thread ID, reuse that thread's MCP connection.", + }), + Schema.Null, + ]), + ), threadId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), }).annotate({ identifier: "ClientRequest__ListMcpServerStatusParams" }); +export type ClientRequest__McpResourceReadParams = { + readonly connectorId?: string | null; + readonly originCallId?: string | null; + readonly server: string; + readonly target?: ClientRequest__McpResourceReadTarget | null; + readonly threadId?: string | null; + readonly uri: string; +}; +export const ClientRequest__McpResourceReadParams = Schema.Struct({ + connectorId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + originCallId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Originating MCP tool call used to select the resource's app.", + }), + Schema.Null, + ]), + ), + server: Schema.String, + target: Schema.optionalKey( + Schema.Union([ClientRequest__McpResourceReadTarget, Schema.Null]).annotate({ + description: "Explicit hosted app/account. Omit to retain legacy resource discovery.", + }), + ), + threadId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + uri: Schema.String, +}).annotate({ identifier: "ClientRequest__McpResourceReadParams" }); + export type ClientRequest__WindowsSandboxSetupStartParams = { readonly cwd?: ClientRequest__AbsolutePathBuf | null; readonly mode: ClientRequest__WindowsSandboxSetupMode; @@ -17936,9 +17863,11 @@ export type ServerNotification__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -17961,9 +17890,11 @@ export const ServerNotification__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -18666,6 +18597,26 @@ export const ServerNotification__AccountUpdatedNotification = Schema.Struct({ planType: Schema.optionalKey(Schema.Union([ServerNotification__PlanType, Schema.Null])), }).annotate({ identifier: "ServerNotification__AccountUpdatedNotification" }); +export type ServerNotification__GatewayOAuthChangedNotification = { + readonly authUrl?: string | null; + readonly error?: string | null; + readonly providerId: string; + readonly status: ServerNotification__GatewayOAuthStatus; +}; +export const ServerNotification__GatewayOAuthChangedNotification = Schema.Struct({ + authUrl: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Authorization handoff, sent only to the connection that started login.", + }), + Schema.Null, + ]), + ), + error: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + providerId: Schema.String, + status: ServerNotification__GatewayOAuthStatus, +}).annotate({ identifier: "ServerNotification__GatewayOAuthChangedNotification" }); + export type ServerNotification__RateLimitSnapshot = { readonly credits?: ServerNotification__CreditsSnapshot | null; readonly individualLimit?: ServerNotification__SpendControlLimitSnapshot | null; @@ -19998,9 +19949,11 @@ export type V2ErrorNotification__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -20023,9 +19976,11 @@ export const V2ErrorNotification__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -21472,6 +21427,7 @@ export const V2ItemStartedNotification__CollabAgentState = Schema.Struct({ export type V2ListMcpServerStatusResponse__McpServerStatus = { readonly authStatus: V2ListMcpServerStatusResponse__McpAuthStatus; + readonly httpOrigin?: string | null; readonly name: string; readonly pluginId?: string | null; readonly resourceTemplates: ReadonlyArray; @@ -21484,6 +21440,15 @@ export type V2ListMcpServerStatusResponse__McpServerStatus = { }; export const V2ListMcpServerStatusResponse__McpServerStatus = Schema.Struct({ authStatus: V2ListMcpServerStatusResponse__McpAuthStatus, + httpOrigin: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "HTTP origin of the effective configured endpoint, including plugin servers. Excludes credentials, path, query, and fragment; null for non-HTTP transports.", + }), + Schema.Null, + ]), + ), name: Schema.String, pluginId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), resourceTemplates: Schema.Array(V2ListMcpServerStatusResponse__ResourceTemplate), @@ -22440,9 +22405,11 @@ export type V2ReviewStartResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -22465,9 +22432,11 @@ export const V2ReviewStartResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -22974,9 +22943,11 @@ export type V2ThreadForkResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -22999,9 +22970,11 @@ export const V2ThreadForkResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -23358,6 +23331,17 @@ export const V2ThreadGoalUpdatedNotification__ThreadGoal = Schema.Struct({ ), }).annotate({ identifier: "V2ThreadGoalUpdatedNotification__ThreadGoal" }); +export type V2ThreadItemsListParams__ThreadItemsListCursor = + | string + | V2ThreadItemsListParams__ThreadItemsListAnchor; +export const V2ThreadItemsListParams__ThreadItemsListCursor = Schema.Union([ + Schema.String, + V2ThreadItemsListParams__ThreadItemsListAnchor, +]).annotate({ + description: "Starting position for an item-history page.", + identifier: "V2ThreadItemsListParams__ThreadItemsListCursor", +}); + export type V2ThreadItemsListResponse__TextElement = { readonly byteRange: V2ThreadItemsListResponse__ByteRange; readonly placeholder?: string | null; @@ -23609,9 +23593,11 @@ export type V2ThreadListResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -23634,9 +23620,11 @@ export const V2ThreadListResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -24014,9 +24002,11 @@ export type V2ThreadMetadataUpdateResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -24039,9 +24029,11 @@ export const V2ThreadMetadataUpdateResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -24424,9 +24416,11 @@ export type V2ThreadReadResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -24449,9 +24443,11 @@ export const V2ThreadReadResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -25188,9 +25184,11 @@ export type V2ThreadResumeResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -25213,9 +25211,11 @@ export const V2ThreadResumeResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -25555,9 +25555,11 @@ export type V2ThreadRevertResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -25580,9 +25582,11 @@ export const V2ThreadRevertResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -26091,9 +26095,11 @@ export type V2ThreadStartedNotification__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -26116,9 +26122,11 @@ export const V2ThreadStartedNotification__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -26592,9 +26600,11 @@ export type V2ThreadStartResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -26617,9 +26627,11 @@ export const V2ThreadStartResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -26918,9 +26930,11 @@ export type V2ThreadTurnsListResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -26943,9 +26957,11 @@ export const V2ThreadTurnsListResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -27327,9 +27343,11 @@ export type V2ThreadUnarchiveResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -27352,9 +27370,11 @@ export const V2ThreadUnarchiveResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -27654,9 +27674,11 @@ export type V2TurnCompletedNotification__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -27679,9 +27701,11 @@ export const V2TurnCompletedNotification__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -27991,9 +28015,11 @@ export type V2TurnStartedNotification__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -28016,9 +28042,11 @@ export const V2TurnStartedNotification__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -28455,9 +28483,11 @@ export type V2TurnStartResponse__CodexErrorInfo = | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" + | "flexUnavailable" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" + | "tooManyDenials" | "internalServerError" | "unauthorized" | "badRequest" @@ -28480,9 +28510,11 @@ export const V2TurnStartResponse__CodexErrorInfo = Schema.Union( "sessionBudgetExceeded", "usageLimitExceeded", "rateLimitExceeded", + "flexUnavailable", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", + "tooManyDenials", "internalServerError", "unauthorized", "badRequest", @@ -28858,6 +28890,49 @@ export const ApplyPatchApprovalResponse__ReviewDecision = Schema.Union( identifier: "ApplyPatchApprovalResponse__ReviewDecision", }); +export type ClientRequest__ThreadItemsListParams = { + readonly cursor?: ClientRequest__ThreadItemsListCursor | null; + readonly limit?: number | null; + readonly sortDirection?: ClientRequest__SortDirection | null; + readonly threadId: string; + readonly turnId?: string | null; +}; +export const ClientRequest__ThreadItemsListParams = Schema.Struct({ + cursor: Schema.optionalKey( + Schema.Union([ClientRequest__ThreadItemsListCursor, Schema.Null]).annotate({ + description: + "Opaque continuation cursor or an exclusive item anchor in the requested visible turn. An item anchor requires a non-empty `turnId`; ascending (the default) returns items after it, and descending returns items before it. Continue with the returned string cursor.", + }), + ), + limit: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ description: "Optional item page size.", format: "uint32" }) + .check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(0).annotate({ + expected: "a value greater than or equal to 0", + }), + ), + Schema.Null, + ]), + ), + sortDirection: Schema.optionalKey( + Schema.Union([ClientRequest__SortDirection, Schema.Null]).annotate({ + description: "Optional item pagination direction; defaults to ascending.", + }), + ), + threadId: Schema.String, + turnId: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Optional turn id to filter by. When omitted, returns items across the thread.", + }), + Schema.Null, + ]), + ), +}).annotate({ identifier: "ClientRequest__ThreadItemsListParams" }); + export type ClientRequest__PluginShareSaveParams = { readonly discoverability?: ClientRequest__PluginShareDiscoverability | null; readonly pluginPath: ClientRequest__AbsolutePathBuf; @@ -42790,7 +42865,7 @@ export const ServerNotification__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([ServerNotification__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43377,7 +43452,7 @@ export const V2ReviewStartResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ReviewStartResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43438,7 +43513,7 @@ export const V2ThreadForkResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ThreadForkResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43468,11 +43543,33 @@ export const V2ThreadForkResponse__Turn = Schema.Struct({ }).annotate({ identifier: "V2ThreadForkResponse__Turn" }); export type V2ThreadItemsListResponse__ThreadItemEntry = { + readonly completedAtMs?: number | null; readonly item: V2ThreadItemsListResponse__ThreadItem; + readonly startedAtMs?: number | null; readonly turnId: string; }; export const V2ThreadItemsListResponse__ThreadItemEntry = Schema.Struct({ + completedAtMs: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: + "Unix timestamp (milliseconds) when the item completed, if recorded by the producer.", + format: "int64", + }).check(Schema.isInt().annotate({ expected: "an integer" })), + Schema.Null, + ]), + ), item: V2ThreadItemsListResponse__ThreadItem, + startedAtMs: Schema.optionalKey( + Schema.Union([ + Schema.Number.annotate({ + description: + "Unix timestamp (milliseconds) when the item started, if recorded by the producer.", + format: "int64", + }).check(Schema.isInt().annotate({ expected: "an integer" })), + Schema.Null, + ]), + ), turnId: Schema.String.annotate({ description: "Turn containing this item." }), }).annotate({ identifier: "V2ThreadItemsListResponse__ThreadItemEntry" }); @@ -43507,7 +43604,7 @@ export const V2ThreadListResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ThreadListResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43567,7 +43664,7 @@ export const V2ThreadMetadataUpdateResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ThreadMetadataUpdateResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43628,7 +43725,7 @@ export const V2ThreadReadResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ThreadReadResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43688,7 +43785,7 @@ export const V2ThreadResumeResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ThreadResumeResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43749,7 +43846,7 @@ export const V2ThreadRevertResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ThreadRevertResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43810,7 +43907,7 @@ export const V2ThreadStartedNotification__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ThreadStartedNotification__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43871,7 +43968,7 @@ export const V2ThreadStartResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ThreadStartResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43932,7 +44029,7 @@ export const V2ThreadTurnsListResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ThreadTurnsListResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -43993,7 +44090,7 @@ export const V2ThreadUnarchiveResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2ThreadUnarchiveResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -44054,7 +44151,7 @@ export const V2TurnCompletedNotification__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2TurnCompletedNotification__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -44115,7 +44212,7 @@ export const V2TurnStartedNotification__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2TurnStartedNotification__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -44176,7 +44273,7 @@ export const V2TurnStartResponse__Turn = Schema.Struct({ ), error: Schema.optionalKey( Schema.Union([V2TurnStartResponse__TurnError, Schema.Null]).annotate({ - description: "Only populated when the Turn's status is failed.", + description: "Error associated with a failed or interrupted turn.", }), ), id: Schema.String.annotate({ @@ -47330,6 +47427,21 @@ export type ClientRequest = readonly method: "model/list"; readonly params: ClientRequest__ModelListParams; } + | { + readonly id: ClientRequest__RequestId; + readonly method: "account/gatewayOAuth/read"; + readonly params?: null; + } + | { + readonly id: ClientRequest__RequestId; + readonly method: "account/gatewayOAuth/login"; + readonly params?: null; + } + | { + readonly id: ClientRequest__RequestId; + readonly method: "account/gatewayOAuth/cancel"; + readonly params?: null; + } | { readonly id: ClientRequest__RequestId; readonly method: "modelProvider/capabilities/read"; @@ -47909,6 +48021,27 @@ export const ClientRequest = Schema.Union( method: Schema.Literal("model/list").annotate({ title: "Model/listRequestMethod" }), params: ClientRequest__ModelListParams, }).annotate({ title: "Model/listRequest" }), + Schema.Struct({ + id: ClientRequest__RequestId, + method: Schema.Literal("account/gatewayOAuth/read").annotate({ + title: "Account/gatewayOAuth/readRequestMethod", + }), + params: Schema.optionalKey(Schema.Null), + }).annotate({ title: "Account/gatewayOAuth/readRequest" }), + Schema.Struct({ + id: ClientRequest__RequestId, + method: Schema.Literal("account/gatewayOAuth/login").annotate({ + title: "Account/gatewayOAuth/loginRequestMethod", + }), + params: Schema.optionalKey(Schema.Null), + }).annotate({ title: "Account/gatewayOAuth/loginRequest" }), + Schema.Struct({ + id: ClientRequest__RequestId, + method: Schema.Literal("account/gatewayOAuth/cancel").annotate({ + title: "Account/gatewayOAuth/cancelRequestMethod", + }), + params: Schema.optionalKey(Schema.Null), + }).annotate({ title: "Account/gatewayOAuth/cancelRequest" }), Schema.Struct({ id: ClientRequest__RequestId, method: Schema.Literal("modelProvider/capabilities/read").annotate({ @@ -49660,6 +49793,11 @@ export type ServerNotification = readonly method: "account/updated"; readonly params: ServerNotification__AccountUpdatedNotification; } + | { + readonly emittedAtMs?: number; + readonly method: "account/gatewayOAuth/changed"; + readonly params: ServerNotification__GatewayOAuthChangedNotification; + } | { readonly emittedAtMs?: number; readonly method: "account/rateLimits/updated"; @@ -50449,6 +50587,19 @@ export const ServerNotification = Schema.Union( }), params: ServerNotification__AccountUpdatedNotification, }).annotate({ title: "Account/updatedNotification" }), + Schema.Struct({ + emittedAtMs: Schema.optionalKey( + Schema.Number.annotate({ + description: + "Unix timestamp (in milliseconds) when app-server emitted this notification.", + format: "int64", + }).check(Schema.isInt().annotate({ expected: "an integer" })), + ), + method: Schema.Literal("account/gatewayOAuth/changed").annotate({ + title: "Account/gatewayOAuth/changedNotificationMethod", + }), + params: ServerNotification__GatewayOAuthChangedNotification, + }).annotate({ title: "Account/gatewayOAuth/changedNotification" }), Schema.Struct({ emittedAtMs: Schema.optionalKey( Schema.Number.annotate({ @@ -52760,6 +52911,63 @@ export const V2FsWriteFileResponse = Schema.Record( description: "Successful response for `fs/writeFile`.", }); +export type V2GatewayOAuthCancelResponse = { readonly [x: string]: Schema.Json }; +export const V2GatewayOAuthCancelResponse = Schema.Record( + Schema.String, + Schema.Json.annotate({ expected: "JSON value" }), +).annotate({ title: "GatewayOAuthCancelResponse" }); + +export type V2GatewayOAuthChangedNotification = { + readonly authUrl?: string | null; + readonly error?: string | null; + readonly providerId: string; + readonly status: V2GatewayOAuthChangedNotification__GatewayOAuthStatus; +}; +export const V2GatewayOAuthChangedNotification = Schema.Struct({ + authUrl: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: "Authorization handoff, sent only to the connection that started login.", + }), + Schema.Null, + ]), + ), + error: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + providerId: Schema.String, + status: V2GatewayOAuthChangedNotification__GatewayOAuthStatus, +}).annotate({ title: "GatewayOAuthChangedNotification" }); + +export type V2GatewayOAuthLoginResponse = { readonly [x: string]: Schema.Json }; +export const V2GatewayOAuthLoginResponse = Schema.Record( + Schema.String, + Schema.Json.annotate({ expected: "JSON value" }), +).annotate({ title: "GatewayOAuthLoginResponse" }); + +export type V2GatewayOAuthReadResponse = { + readonly error?: string | null; + readonly providerId: string; + readonly providerName: string; + readonly required: boolean; + readonly status?: V2GatewayOAuthReadResponse__GatewayOAuthStatus | null; +}; +export const V2GatewayOAuthReadResponse = Schema.Struct({ + error: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + providerId: Schema.String, + providerName: Schema.String, + required: Schema.Boolean.annotate({ + description: "Whether the selected provider uses gateway OAuth, even when already signed in.", + }), + status: Schema.optionalKey( + Schema.Union([V2GatewayOAuthReadResponse__GatewayOAuthStatus, Schema.Null]).annotate({ + description: "Null when the effective provider does not use gateway OAuth.", + }), + ), +}).annotate({ + title: "GatewayOAuthReadResponse", + description: + "Current effective gateway policy and credential readiness; never contains credentials.", +}); + export type V2GetAccountParams = { readonly refreshToken?: boolean }; export const V2GetAccountParams = Schema.Struct({ refreshToken: Schema.optionalKey( @@ -53035,6 +53243,7 @@ export type V2ListMcpServerStatusParams = { readonly cursor?: string | null; readonly detail?: V2ListMcpServerStatusParams__McpServerStatusDetail | null; readonly limit?: number | null; + readonly serverName?: string | null; readonly threadId?: string | null; }; export const V2ListMcpServerStatusParams = Schema.Struct({ @@ -53067,6 +53276,15 @@ export const V2ListMcpServerStatusParams = Schema.Struct({ Schema.Null, ]), ), + serverName: Schema.optionalKey( + Schema.Union([ + Schema.String.annotate({ + description: + "Limit discovery to one server. With a thread ID, reuse that thread's MCP connection.", + }), + Schema.Null, + ]), + ), threadId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), }).annotate({ title: "ListMcpServerStatusParams" }); @@ -53294,6 +53512,7 @@ export type V2McpResourceReadParams = { readonly connectorId?: string | null; readonly originCallId?: string | null; readonly server: string; + readonly target?: V2McpResourceReadParams__McpResourceReadTarget | null; readonly threadId?: string | null; readonly uri: string; }; @@ -53308,6 +53527,11 @@ export const V2McpResourceReadParams = Schema.Struct({ ]), ), server: Schema.String, + target: Schema.optionalKey( + Schema.Union([V2McpResourceReadParams__McpResourceReadTarget, Schema.Null]).annotate({ + description: "Explicit hosted app/account. Omit to retain legacy resource discovery.", + }), + ), threadId: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), uri: Schema.String, }).annotate({ title: "McpResourceReadParams" }); @@ -54695,7 +54919,7 @@ export const V2ThreadInjectItemsResponse = Schema.Record( ).annotate({ title: "ThreadInjectItemsResponse" }); export type V2ThreadItemsListParams = { - readonly cursor?: string | null; + readonly cursor?: V2ThreadItemsListParams__ThreadItemsListCursor | null; readonly limit?: number | null; readonly sortDirection?: V2ThreadItemsListParams__SortDirection | null; readonly threadId: string; @@ -54703,12 +54927,10 @@ export type V2ThreadItemsListParams = { }; export const V2ThreadItemsListParams = Schema.Struct({ cursor: Schema.optionalKey( - Schema.Union([ - Schema.String.annotate({ - description: "Opaque cursor to pass to the next call to continue after the last item.", - }), - Schema.Null, - ]), + Schema.Union([V2ThreadItemsListParams__ThreadItemsListCursor, Schema.Null]).annotate({ + description: + "Opaque continuation cursor or an exclusive item anchor in the requested visible turn. An item anchor requires a non-empty `turnId`; ascending (the default) returns items after it, and descending returns items before it. Continue with the returned string cursor.", + }), ), limit: Schema.optionalKey( Schema.Union([ diff --git a/packages/effect-codex-app-server/src/replay.ts b/packages/effect-codex-app-server/src/replay.ts index ac8d05663417..ad4fd4be48a2 100644 --- a/packages/effect-codex-app-server/src/replay.ts +++ b/packages/effect-codex-app-server/src/replay.ts @@ -287,6 +287,26 @@ function normalizeLegacyInboundFrame(value: unknown): unknown { ) { normalized.sessionId = normalized.id; } + if ( + typeof normalized.id === "string" && + normalized.projectId === undefined && + "modelProvider" in normalized && + "status" in normalized + ) { + normalized.projectId = null; + } + if ( + normalized.method === "item/tool/requestUserInput" && + typeof normalized.params === "object" && + normalized.params !== null + ) { + const params = { ...(normalized.params as Record) }; + // Before `isBlocking`, a request without an auto-resolution deadline blocked the turn. + if (params.isBlocking === undefined) { + params.isBlocking = params.autoResolutionMs === undefined || params.autoResolutionMs === null; + } + normalized.params = params; + } if ( (normalized.method === "item/started" || normalized.method === "item/completed") && typeof normalized.params === "object" && diff --git a/packages/effect-codex-app-server/src/schema.test.ts b/packages/effect-codex-app-server/src/schema.test.ts index b12b33e02854..cc88b9e38363 100644 --- a/packages/effect-codex-app-server/src/schema.test.ts +++ b/packages/effect-codex-app-server/src/schema.test.ts @@ -201,13 +201,15 @@ it("accepts Codex misalignment policy errors for thread responses", () => { ); }); -it("accepts Codex 0.150 account plan values", () => { +it("accepts account plan slugs newer than the pinned protocol", () => { const planTypes = [ "self_serve_business_prolite", "ent26", "enterprise_cbp_automation", "edu_plus", "edu_pro", + "promax", + "some_future_plan", ]; for (const planType of planTypes) { diff --git a/packages/shared/package.json b/packages/shared/package.json index 6b2560d5ccd8..900f48d96c76 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -390,6 +390,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 !== "/settings" && + !url.pathname.startsWith("/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/threadPullRequests.test.ts b/packages/shared/src/threadPullRequests.test.ts index 4844df8f7786..a7042a7c8828 100644 --- a/packages/shared/src/threadPullRequests.test.ts +++ b/packages/shared/src/threadPullRequests.test.ts @@ -242,6 +242,18 @@ describe("legacyLinkedPullRequestOf", () => { it("does not guess when the project identity is unavailable", () => { expect(legacyLinkedPullRequestOf([link(7)], "project-1" as never, null)).toBeNull(); }); + it("does not route links for a local-path remote with no host or provider", () => { + const localIdentity = { + canonicalKey: "/tmp/r/remote", + displayName: "remote", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "/tmp/r/remote.git", + }, + }; + expect(legacyLinkedPullRequestOf([link(7)], "project-1" as never, localIdentity)).toBeNull(); + }); }); describe("resolveThreadPullRequestChains", () => { diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index 79a75d6978c4..d74927237534 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -157,7 +157,9 @@ export function legacyLinkedPullRequestOf( identity: RepositoryIdentity | null | undefined, ): ThreadLinkedPullRequest | null { if (!identity) return null; + // A local-path remote has no host segment and no provider, so there is no host to match. const host = pullRequestHostOf(identity, identity.provider as SourceControlProviderKind); + if (typeof host !== "string") return null; const repository = sourceControlRepositorySelector(identity); if (repository === null) return null; const azureKey = diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 0646953a5f08..2577d5439b68 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,9 @@ import { paceOf, providersWithLimits, remainingPercent, + CHATGPT_USAGE_LIMIT_MESSAGE, + isChatGptUsageLimitError, + usesChatGptSharing, } from "./usageLimits.ts"; const now = Date.parse("2026-09-03T12:00:00.000Z"); @@ -202,6 +208,74 @@ describe("pools", () => { expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(55); }); + it("merges OpenCode Go limits from machines with the same API key", () => { + const go = provider({ + driver: ProviderDriverKind.make("opencode"), + instanceId: ProviderInstanceId.make("opencode"), + auth: { status: "authenticated" }, + usageLimits: { + checkedAt, + credentialFingerprint: "shared-go-key", + windows: [{ ...window, id: "go_rolling", usedPercent: 3 }], + }, + }); + const input = new Map([ + [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [go] } }], + [ + EnvironmentId.make("env-b"), + { + entry: { target: { label: "Desktop" } }, + serverConfig: { + providers: [ + { + ...go, + usageLimits: { + ...go.usageLimits!, + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [{ ...window, id: "go_rolling", usedPercent: 4 }], + }, + }, + ], + }, + }, + ], + ]); + const accounts = collectLimitAccounts(input); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.environments).toEqual([ + { environmentId: "env-a", label: "Laptop" }, + { environmentId: "env-b", label: "Desktop" }, + ]); + expect(collectLimitPools(accounts, now)[0]?.windows[0]?.members).toHaveLength(1); + expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(4); + + const differentKey = { + ...go, + usageLimits: { ...go.usageLimits!, credentialFingerprint: "other-go-key" }, + }; + input.set(EnvironmentId.make("env-b"), { + entry: { target: { label: "Desktop" } }, + serverConfig: { providers: [differentKey] }, + }); + expect(collectLimitAccounts(input)).toHaveLength(2); + + input.set(EnvironmentId.make("env-a"), { + ...laptop, + serverConfig: { + providers: [{ ...go, auth: { status: "authenticated", email: "same@example.com" } }], + }, + }); + input.set(EnvironmentId.make("env-b"), { + entry: { target: { label: "Desktop" } }, + serverConfig: { + providers: [ + { ...differentKey, auth: { status: "authenticated", email: "SAME@example.com" } }, + ], + }, + }); + expect(collectLimitAccounts(input)).toHaveLength(1); + }); + it("takes windows from a fresher hub read but credits and redeem from the native instance", () => { const native = provider({ driver: claude, @@ -1056,3 +1130,87 @@ 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 ChatGPT usage limit failure a management action", () => { + expect(isChatGptUsageLimitError(CHATGPT_USAGE_LIMIT_MESSAGE)).toBe(true); + expect(isChatGptUsageLimitError("A different failure")).toBe(false); + expect(isChatGptUsageLimitError(null)).toBe(false); + }); +}); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index f2b5cfe53b84..373fd5b0a701 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -24,6 +24,23 @@ const MINUTE = 60_000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; +export const CHATGPT_USAGE_URL = "https://chatgpt.com/#settings/Usage"; + +export function usesChatGptSharing(provider: ServerProvider | null | undefined): boolean { + return provider?.auth.status === "authenticated" && provider.auth.subscriptionSharing === true; +} + +/** + * The message a ChatGPT-managed Codex turn fails with when shared usage runs out. Orchestration + * keeps only the failure message as the thread error, so clients match on it exactly. + */ +export const CHATGPT_USAGE_LIMIT_MESSAGE = + "Your ChatGPT usage limit was reached. Check ChatGPT Usage settings for your available allowance."; + +export function isChatGptUsageLimitError(error: string | null | undefined): boolean { + return error === CHATGPT_USAGE_LIMIT_MESSAGE; +} + export const CURSOR_USAGE_WINDOWS = [ { id: "totalPercentUsed", @@ -79,16 +96,51 @@ export type LimitPresentations = ReadonlyMap< } >; -function accountKey(driver: ServerProvider["driver"], email: string | undefined): string | null { +/** 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()]; +} + +/** Prefer the reported email; use an identical credential when no email is available. */ +function accountKey( + driver: ServerProvider["driver"], + email: string | undefined, + limits?: ServerProviderUsageLimits, +): string | null { const normalizedEmail = email?.trim().toLowerCase(); - return normalizedEmail ? `${driver}:${normalizedEmail}` : null; + if (normalizedEmail) return `${driver}:${normalizedEmail}`; + return limits?.credentialFingerprint + ? `${driver}:credential:${limits.credentialFingerprint}` + : null; } /** * One subscription account as the pooled views see it, whichever way it was - * reported. The same email signed in natively on two environments, or reported - * by a hub as well as natively, is one account: its quota is one bucket, so - * counting it twice would misstate what is left. + * reported. Matching emails or credentials across environments name + * one account. Its quota is one bucket, so counting it twice would misstate + * what is left. */ export interface LimitAccount { readonly key: string; @@ -186,7 +238,7 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue; merge( - accountKey(provider.driver, provider.auth.email) ?? + accountKey(provider.driver, provider.auth.email, provider.usageLimits) ?? `${environmentId}:${provider.instanceId}`, { key: `${environmentId}:${provider.instanceId}`, @@ -214,27 +266,31 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl : source.label; for (const account of source.accounts) { if (limitsNotice(account.usageLimits) !== null) continue; - merge(accountKey(account.driver, account.email) ?? `${source.id}:${account.id}`, { - key: `${source.id}:${account.id}`, - driver: account.driver, - displayName: account.email ? null : account.id.replace(/\.json$/i, ""), - email: account.email, - plan: account.plan, - accentColor: undefined, - environments: [], - sourceLabel, - redeem: account.usageLimits.resetCredits?.nextCreditId - ? { - environmentId, - input: { - sourceId: source.id, - accountId: account.id, - creditId: account.usageLimits.resetCredits.nextCreditId, - }, - } - : null, - limits: account.usageLimits, - }); + merge( + accountKey(account.driver, account.email, account.usageLimits) ?? + `${source.id}:${account.id}`, + { + key: `${source.id}:${account.id}`, + driver: account.driver, + displayName: account.email ? null : account.id.replace(/\.json$/i, ""), + email: account.email, + plan: account.plan, + accentColor: undefined, + environments: [], + sourceLabel, + redeem: account.usageLimits.resetCredits?.nextCreditId + ? { + environmentId, + input: { + sourceId: source.id, + accountId: account.id, + creditId: account.usageLimits.resetCredits.nextCreditId, + }, + } + : null, + limits: account.usageLimits, + }, + ); } } } @@ -590,7 +646,7 @@ export function collectProviderUsageLimits( ); const nativeAccounts = new Set( native.flatMap((provider) => { - const key = accountKey(provider.driver, provider.auth.email); + const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable ? [key] : []; @@ -600,13 +656,13 @@ export function collectProviderUsageLimits( const notices: string[] = []; for (const provider of native) { if (!provider.usageLimits) continue; - const key = accountKey(provider.driver, provider.auth.email); + const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); const hubCredits = sources .flatMap((source) => source.accounts.map((account) => ({ source, account }))) .filter( ({ account }) => key !== null && - accountKey(account.driver, account.email) === key && + accountKey(account.driver, account.email, account.usageLimits) === key && account.usageLimits.resetCredits && !limitsNotice(account.usageLimits), ) @@ -653,7 +709,7 @@ export function collectProviderUsageLimits( for (const source of sources) { const matching = source.accounts.filter((account) => account.driver === selected.driver); for (const account of matching) { - const key = accountKey(account.driver, account.email); + const key = accountKey(account.driver, account.email, account.usageLimits); if (key && nativeAccounts.has(key)) continue; accounts.push({ id: `${source.id}:${account.id}`, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 805ddb6938bc..cb5b34fcd223 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -541,9 +541,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 @@ -578,6 +584,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 @@ -5590,6 +5599,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==} @@ -5610,6 +5622,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==} @@ -16275,6 +16290,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': {} @@ -16295,6 +16314,8 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/retry@0.12.5': {} + '@types/send@1.2.1': dependencies: '@types/node': 24.12.4