diff --git a/bun.lock b/bun.lock index f36aea209a4..cdf403165f7 100644 --- a/bun.lock +++ b/bun.lock @@ -377,6 +377,7 @@ "https-proxy-agent": "7.0.6", "ignore": "7.0.5", "immer": "11.1.4", + "jose": "6.0.11", "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "tree-sitter-bash": "0.25.0", diff --git a/packages/app/src/providers/connect/chatgpt-welcome.stories.tsx b/packages/app/src/providers/connect/chatgpt-welcome.stories.tsx new file mode 100644 index 00000000000..a33cd10bf65 --- /dev/null +++ b/packages/app/src/providers/connect/chatgpt-welcome.stories.tsx @@ -0,0 +1,23 @@ +import { Button } from "@opencode/ui/button" +import { useDialog } from "@opencode/ui/context/dialog" +import { onMount } from "solid-js" +import { DialogChatGPTPlanWelcome } from "./chatgpt-welcome" + +function WelcomeStory() { + const dialog = useDialog() + const open = () => dialog.show(() => ) + onMount(open) + + return ( + + ) +} + +export default { + title: "App/Dialogs/Connect Provider", + id: "app-dialog-chatgpt-welcome", +} + +export const ChatGPTPlanWelcome = { render: () => } diff --git a/packages/app/src/providers/connect/chatgpt-welcome.tsx b/packages/app/src/providers/connect/chatgpt-welcome.tsx new file mode 100644 index 00000000000..14d751eb42b --- /dev/null +++ b/packages/app/src/providers/connect/chatgpt-welcome.tsx @@ -0,0 +1,37 @@ +import { Button } from "@opencode/ui/button" +import { useDialog } from "@opencode/ui/context/dialog" +import { Dialog, DialogTitle } from "@opencode/ui/dialog" +import { ProviderIcon } from "@opencode/ui/provider-icon" +import { useLanguage } from "@/runtime/i18n/language" +import { ExternalLink } from "@/runtime/platform/external-link" + +export function DialogChatGPTPlanWelcome() { + const dialog = useDialog() + const language = useLanguage() + + return ( + +
+
+
+ ) +} diff --git a/packages/app/src/providers/connect/dialog.stories.tsx b/packages/app/src/providers/connect/dialog.stories.tsx index 15553d1ba6e..1619a1262b4 100644 --- a/packages/app/src/providers/connect/dialog.stories.tsx +++ b/packages/app/src/providers/connect/dialog.stories.tsx @@ -71,3 +71,12 @@ export const LoginMethods = { { type: "key", label: "API key" }, ]), } + +export const ChatGPTSignIn = { + render: renderConnection("openai", [ + { type: "oauth", id: "chatgpt-token-sharing", label: "Sign in with ChatGPT" }, + { type: "oauth", id: "chatgpt-browser", label: "ChatGPT Pro/Plus (browser)" }, + { type: "oauth", id: "chatgpt-headless", label: "ChatGPT Pro/Plus (headless)" }, + { type: "key", label: "API key" }, + ]), +} diff --git a/packages/app/src/providers/connect/dialog.tsx b/packages/app/src/providers/connect/dialog.tsx index c7bbaf28d54..3aa501cad7f 100644 --- a/packages/app/src/providers/connect/dialog.tsx +++ b/packages/app/src/providers/connect/dialog.tsx @@ -37,8 +37,11 @@ import { ProviderModelGroup, ProviderModelIcon } from "@/providers/models/provid import type { ModelSelection } from "@/providers/models/selection" import { OpenCodeLogo } from "@/providers/opencode-logo" import { decode64 } from "@/runtime/persistence/base64" +import { Persistence } from "@/runtime/persistence/schema" +import { Persist, persisted } from "@/runtime/persistence/storage" import { SettingsList } from "@/settings/list" import { useTabs } from "@/shell/tabs/tabs" +import { Schema } from "effect" import { CONSOLE_INTEGRATION, CONSOLE_PROVIDERS, @@ -48,6 +51,7 @@ import { type ProviderConnectMethod, } from "./controller" import { ConsoleAuthorization } from "./console" +import { DialogChatGPTPlanWelcome } from "./chatgpt-welcome" import { authServerName, RemoteAuthNotice } from "./remote" import "./models.css" @@ -81,8 +85,16 @@ export const DialogConnectProvider: Component<{ completed: false, modelProvider: undefined as { id: string; name: string } | undefined, authorization: false, + chatgptWelcome: false, }) const language = useLanguage() + const platform = usePlatform() + const dialog = useDialog() + const [welcome, setWelcome, , welcomeReady] = persisted( + Persist.global("chatgpt-plan-welcome.v1"), + Persistence.struct({ seen: Schema.Boolean }), + { seen: false }, + ) const reset = controller.reset const back = { current: reset } const consoleSelected = () => CONSOLE_PROVIDERS.has(controller.selected() ?? "") @@ -112,7 +124,11 @@ export const DialogConnectProvider: Component<{ setBack={(handler) => (back.current = handler)} selection={props.selection} onDone={props.onDone ? () => setState("completed", true) : undefined} - onConnected={() => props.onConnected?.(provider)} + onConnected={(methodID) => { + props.onConnected?.(provider) + if (platform.platform === "desktop" && provider === "openai" && methodID === "chatgpt-token-sharing") + setState("chatgptWelcome", true) + }} onFirstConnection={(provider) => setState("modelProvider", provider)} onAuthorization={(authorization) => setState("authorization", authorization)} /> @@ -136,13 +152,21 @@ export const DialogConnectProvider: Component<{ : "!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]" } onCloseAutoFocus={(event) => { - if (!state.completed || !props.onDone) return - event.preventDefault() - props.onDone() + if (state.completed && props.onDone) { + event.preventDefault() + props.onDone() + } + if (!state.chatgptWelcome) return + void Promise.resolve(welcomeReady.promise).then(() => { + if (welcome.seen) return + setWelcome("seen", true) + void dialog.show(() => ) + }) }} class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]" classList={{ - "[&_[data-slot=dialog-header]]:!pt-4 [&_[data-slot=dialog-header]]:!pb-3": consoleSelected() && !state.modelProvider, + "[&_[data-slot=dialog-header]]:!pt-4 [&_[data-slot=dialog-header]]:!pb-3": + consoleSelected() && !state.modelProvider, "[&_[data-slot=dialog-header]]:!pt-5": !!state.modelProvider, }} > @@ -354,7 +378,7 @@ function ProviderConnection(props: { setBack: (handler: () => void) => void selection?: ModelSelection onDone?: () => void - onConnected?: () => void + onConnected?: (methodID?: string) => void onFirstConnection: (provider: { id: string; name: string }) => void onAuthorization: (authorization: boolean) => void }) { @@ -401,12 +425,11 @@ function ProviderConnection(props: { prepare: isConsole ? prepareConsoleCatalog : undefined, pollInterval: isConsole ? 500 : undefined, onComplete: () => { - props.onConnected?.() + const method = controller.currentMethod() + props.onConnected?.(method?.type === "oauth" ? method.id : undefined) // The picker only lists the newest model per family by default, which hides most of // what a new connection just unlocked. Show everything the connected integration offers. - global.models.show( - connectionModels().map((model) => ({ providerID: model.providerID, modelID: model.id })), - ) + global.models.show(connectionModels().map((model) => ({ providerID: model.providerID, modelID: model.id }))) if (state.catalogPending) { setState("noModels", true) return @@ -500,7 +523,9 @@ function ProviderConnection(props: { }) createEffect(() => { const current = controller.auth.state() - props.onAuthorization(controller.authorization() !== undefined && (current === "waiting" || current === "refreshing")) + props.onAuthorization( + controller.authorization() !== undefined && (current === "waiting" || current === "refreshing"), + ) }) const provider = createMemo(() => ({ id: props.provider, @@ -541,6 +566,10 @@ function ProviderConnection(props: { }) const keyIndex = () => controller.methods().findIndex((method) => method.type === "key") const oauthIndex = () => controller.methods().findIndex((method) => method.type === "oauth") + const chatgptIndex = () => + props.provider === "openai" + ? controller.methods().findIndex((method) => method.type === "oauth" && method.id === "chatgpt-token-sharing") + : -1 // The Console device flow owns the dialog from the first frame until the catalogs are loaded. const consoleSignIn = () => isConsole && @@ -691,33 +720,96 @@ function ProviderConnection(props: { props.setBack(goBack) function MethodSelection() { + const [selection, setSelection] = createStore({ codex: false }) + const codex = () => + controller + .methods() + .flatMap((method, index) => + method.type === "oauth" && (method.id === "chatgpt-browser" || method.id === "chatgpt-headless") + ? [{ method, index }] + : [], + ) + const other = () => + controller + .methods() + .flatMap((method, index) => + chatgptIndex() === -1 || + (method.type === "oauth" && + !["chatgpt-token-sharing", "chatgpt-browser", "chatgpt-headless"].includes(method.id)) + ? [{ method, index }] + : [], + ) return (
-
- {language.t("provider.connect.selectMethod", { provider: provider().name })} -
-
- - {(item, index) => { - const details = () => methodDetails(item) - return ( - - ) - }} - -
+ +
+ + + + + 0}> + + +
+ + {({ method, index }) => ( + + )} + +
+
+
+
+
+ 0}> +
+ {language.t("provider.connect.selectMethod", { provider: provider().name })} +
+
+ + {({ method: item, index }) => { + const details = () => methodDetails(item) + return ( + + ) + }} + +
+
) } diff --git a/packages/app/src/providers/models/select-dialog.stories.tsx b/packages/app/src/providers/models/select-dialog.stories.tsx new file mode 100644 index 00000000000..e7de12fb052 --- /dev/null +++ b/packages/app/src/providers/models/select-dialog.stories.tsx @@ -0,0 +1,79 @@ +import { Button } from "@opencode/ui/button" +import { createSignal } from "solid-js" +import type { ModelSelection } from "./selection" +import { ModelSelectorPopoverView } from "./select-dialog" + +const chatgpt = { + id: "gpt-5.6-sol", + providerID: "openai", + api: { id: "gpt-5.6-sol", url: "https://api.openai.com/v1", npm: "@opencode/ai/providers/openai" }, + name: "GPT-5.6 Sol", + family: "gpt", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: true, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 400_000, output: 64_000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-09-01", + variants: {}, + provider: { + id: "openai", + name: "OpenAI", + source: "custom", + env: [], + options: {}, + models: {}, + }, + latest: true, +} satisfies NonNullable> + +const models = [ + chatgpt, + { + ...chatgpt, + id: "gpt-5.6-terra", + api: { ...chatgpt.api, id: "gpt-5.6-terra" }, + name: "GPT-5.6 Terra", + latest: false, + }, +] + +function SelectorStory(props: { plan: boolean }) { + const [current, setCurrent] = createSignal(models[0].id) + return ( +
+ ( + + )} + models={(search) => models.filter((model) => model.name.toLowerCase().includes(search.toLowerCase()))} + groups={(items) => [{ category: "openai", items }]} + current={`openai:${current()}`} + chatgptPlan={props.plan} + select={(item) => setCurrent(item.id)} + onManage={() => undefined} + onClose={() => undefined} + /> +
+ ) +} + +export default { + title: "App/Dialogs/Model Selector", + id: "app-dialog-model-selector", +} + +export const ChatGPTPlan = { render: () => } +export const CodexOAuth = { render: () => } +export const ApiKey = { render: () => } diff --git a/packages/app/src/providers/models/select-dialog.tsx b/packages/app/src/providers/models/select-dialog.tsx index 524d37f9600..f269eec21c3 100644 --- a/packages/app/src/providers/models/select-dialog.tsx +++ b/packages/app/src/providers/models/select-dialog.tsx @@ -15,13 +15,14 @@ import { Menu } from "@opencode/ui/menu" import { TextInput } from "@opencode/ui/text-input" import { ModelTooltip } from "./tooltip" import { useLanguage } from "@/runtime/i18n/language" +import { ExternalLink } from "@/runtime/platform/external-link" import { decode64 } from "@/runtime/persistence/base64" import { handleDocumentSearchKeydown } from "@/shell/commands/search-keydown" import { createMenuDismissController } from "@/shell/commands/menu-dismiss" import { createEventListener } from "@solid-primitives/event-listener" import { matchesModelSearch } from "./search" import { SettingsList } from "@/settings/list" -import { CONSOLE_GROUP_KEY, consoleModelGroup, ProviderModelSections } from "@/providers/models/provider-group" +import { CONSOLE_GROUP_KEY, consoleModelGroup, ProviderModelIcon, ProviderModelSections } from "@/providers/models/provider-group" import "@/settings/settings.css" const isFree = (provider: string, cost: { input: number } | undefined) => @@ -230,6 +231,8 @@ export function ModelSelectorPopover(props: { models={controller.models} groups={controller.groups} current={controller.current()} + // ConnectionInfo reports only "oauth", not the active method ID. Never infer SIWC from Codex OAuth. + chatgptPlan={false} select={controller.select} onManage={() => { void import("./manage").then((module) => { @@ -281,11 +284,12 @@ function createModelSelectorController(input: { } } -function ModelSelectorPopoverView(props: { +export function ModelSelectorPopoverView(props: { trigger: ModelSelectorTrigger models: (search: string) => ModelItem[] groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[] current: string | undefined + chatgptPlan?: boolean select: (item: ModelItem) => void onManage: () => void onClose: () => void @@ -368,7 +372,8 @@ function ModelSelectorPopoverView(props: { (contentRef = element)} - class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none" + class="w-[284px] max-w-[calc(100vw-16px)] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none" + classList={{ "!w-[320px]": props.chatgptPlan }} onPointerDownOutside={dismiss.preventTriggerRestore} onFocusOutside={dismiss.preventTriggerRestore} onCloseAutoFocus={dismiss.onCloseAutoFocus} @@ -505,6 +510,20 @@ function ModelSelectorPopoverView(props: { {language.t("dialog.model.manage")} + +
+
+ + {language.t("dialog.model.chatgptPlan")} + + {language.t("dialog.model.chatgptManageUsage")} + + +
+ diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 0c44e05dad1..3e054c9bcaa 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -186,6 +186,8 @@ export const dict = { "dialog.model.empty": "No model results", "dialog.model.manage": "Manage models", "dialog.model.manage.description": "Customize which models appear in the model selector.", + "dialog.model.chatgptPlan": "Using ChatGPT plan", + "dialog.model.chatgptManageUsage": "Manage usage", "dialog.model.manage.provider.toggle": "Toggle all {{provider}} models", "dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode", @@ -217,6 +219,13 @@ export const dict = { "provider.connect.models.title": "Connected to {{provider}}", "provider.connect.models.description": "Choose a model to start with. You can switch models anytime.", "provider.connect.models.available": "Available models", + "provider.connect.chatgpt.signIn": "Sign in with ChatGPT", + "provider.connect.chatgpt.codexSignIn": "Sign in with Codex instead", + "provider.connect.chatgpt.useApiKey": "Use API key", + "provider.connect.chatgptWelcome.title": "You're using your ChatGPT plan", + "provider.connect.chatgptWelcome.description": "Eligible requests in OpenCode use your ChatGPT plan.", + "provider.connect.chatgptWelcome.usage": "Manage usage in ChatGPT settings", + "provider.connect.chatgptWelcome.confirm": "Got it", "provider.connect.models.list": "Models available from {{provider}}", "provider.connect.console.refreshFailed": "Your account is connected, but we couldn't load your models. Try again to refresh them.", diff --git a/packages/cli/src/commands/handlers/auth/shared.ts b/packages/cli/src/commands/handlers/auth/shared.ts index b3d91dd7889..e8d14adbb09 100644 --- a/packages/cli/src/commands/handlers/auth/shared.ts +++ b/packages/cli/src/commands/handlers/auth/shared.ts @@ -48,7 +48,17 @@ export type ConnectMethod = Exclude export function connectMethods(integration: IntegrationInfo) { return integration.methods .filter((method): method is ConnectMethod => method.type !== "env") - .toSorted((a, b) => Number(a.type === "key") - Number(b.type === "key")) + .toSorted((a, b) => { + if (integration.id !== "openai") return Number(a.type === "key") - Number(b.type === "key") + const rank = (method: ConnectMethod) => { + if (method.type === "key") return 1 + if (method.id === "chatgpt-token-sharing") return 0 + if (method.id === "chatgpt-browser") return 2 + if (method.id === "chatgpt-headless") return 3 + return 4 + } + return rank(a) - rank(b) + }) } export const resolveMethod = Effect.fn("cli.auth.resolve-method")(function* (methods: ConnectMethod[], target: string) { diff --git a/packages/cli/test/auth-login-choices.test.ts b/packages/cli/test/auth-login-choices.test.ts index fc1f071861f..6b21f76e8ab 100644 --- a/packages/cli/test/auth-login-choices.test.ts +++ b/packages/cli/test/auth-login-choices.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test" import type { IntegrationInfo } from "@opencode/client" import { loginChoices } from "../src/commands/handlers/auth/login" +import { connectMethods } from "../src/commands/handlers/auth/shared" const integration = (value: Partial & Pick): IntegrationInfo => ({ methods: [{ type: "key" }], @@ -8,6 +9,23 @@ const integration = (value: Partial & Pick { + const methods: IntegrationInfo["methods"] = [ + { type: "oauth", id: "chatgpt-headless", label: "Codex headless" }, + { type: "key", label: "API key" }, + { type: "oauth", id: "chatgpt-browser", label: "Codex browser" }, + { type: "oauth", id: "chatgpt-token-sharing", label: "Sign in with ChatGPT" }, + ] + expect( + connectMethods(integration({ id: "openai", name: "OpenAI", methods })).map((method) => + method.type === "key" ? "key" : method.id, + ), + ).toEqual(["chatgpt-token-sharing", "key", "chatgpt-browser", "chatgpt-headless"]) + expect(connectMethods(integration({ id: "example", name: "Example", methods })).map((method) => method.type)).toEqual( + ["oauth", "oauth", "oauth", "key"], + ) +}) + test("groups the CLI choices like /connect while keeping stable login IDs", () => { expect( loginChoices([ diff --git a/packages/core/package.json b/packages/core/package.json index e7544467e41..73987283aec 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -129,6 +129,7 @@ "https-proxy-agent": "7.0.6", "ignore": "7.0.5", "immer": "11.1.4", + "jose": "6.0.11", "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "tree-sitter-bash": "0.25.0", diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index 1f02ae29dc5..249152333da 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -5,6 +5,8 @@ import { sqliteLayer, supportsForeignKeyToggle, supportsTuningPragmas } from "#s import { Context, Effect, Layer, Schema, Semaphore } from "effect" import type { SqlClient } from "effect/unstable/sql" import { Global } from "@opencode/util/global" +import { closeSync, existsSync, openSync } from "node:fs" +import { chmod } from "node:fs/promises" import { isAbsolute, join } from "path" import { DatabaseMigration } from "./migration.js" import { makeGlobalNode } from "@opencode/util/effect/app-node" @@ -70,13 +72,31 @@ export function layer(options: Options = { path: ":memory:" }) { Layer.provide(sqliteLayer({ filename })), ) const filename = options.path ?? ":memory:" - if (filename === ":memory:" || isAbsolute(filename)) return provide(filename) - const global = yield* Global.Service - return provide(join(global.data, filename)) + if (filename === ":memory:") return provide(filename) + const file = isAbsolute(filename) ? filename : join((yield* Global.Service).data, filename) + yield* Effect.promise(() => restrictToOwner(file)) + return provide(file) }), ) } +// SQLite creates new sidecars with the database's mode, but does not tighten existing sidecars. +// Windows relies on the user profile directory's inherited ACLs instead of POSIX modes. +async function restrictToOwner(filename: string) { + if (process.platform === "win32") return + // Opening and closing an existing database can release another connection's POSIX locks. + // Create missing files synchronously so another fiber cannot open one before it is restricted. + if (!existsSync(filename)) closeSync(openSync(filename, "a", 0o600)) + await chmod(filename, 0o600) + await Promise.all( + [`${filename}-wal`, `${filename}-shm`].map((file) => + chmod(file, 0o600).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error + }), + ), + ) +} + // The database service over an injected SqlClient, for runtimes that receive // database storage instead of opening a filesystem path. Any client provided // here still goes through the pragma guards and migrations; Global is required diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 092758cbd1d..747bbacfc29 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -1,12 +1,15 @@ import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration" +import type { Context } from "@opencode/plugin/effect/plugin" import { define } from "@opencode/plugin/effect/plugin" import type { SessionRequest } from "@opencode/plugin/effect/session" -import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect" -import type { Server } from "node:http" +import { Deferred, Duration, Effect, Option, Schema, Semaphore, Stream } from "effect" +import type { Server, ServerResponse } from "node:http" import { App } from "../../app.js" import { Credential } from "../../credential.js" import { Bus } from "../../bus.js" import { Integration } from "../../integration.js" +import { IntegrationConnection } from "../../integration/connection.js" +import { Model } from "../../model.js" import { OauthCallbackPage } from "../../oauth/page.js" import { Provider } from "../../provider.js" import { SessionAffinity } from "../../session/affinity.js" @@ -20,6 +23,11 @@ const callbackBindAttempts = 10 const callbackBindRetryDelay = 200 const pollingSafetyMargin = 3000 const codexBaseURL = "https://chatgpt.com/backend-api/codex" +const sharingClientID = "dynamic_agent_client" +const sharingTokenURL = `${issuer}/api/accounts/oauth/token` +const sharingResource = "https://api.openai.com/v1" +const sharingScope = "chatgpt.tokens.use.direct" +const sharingMethodID = Integration.MethodID.make("chatgpt-token-sharing") const browserMethodID = Integration.MethodID.make("chatgpt-browser") const headlessMethodID = Integration.MethodID.make("chatgpt-headless") // ChatGPT accounts lost gpt-5.4 and gpt-5.4-mini in Codex on 2026-08-31 (replacements: gpt-5.6-terra, gpt-5.6-luna). @@ -38,6 +46,33 @@ type TokenResponse = { expires_in?: number } +type SharingTokenResponse = { + access_token: string + refresh_token: string + id_token?: string + expires_in?: number + scope?: string +} + +const RemoteModel = Schema.Struct({ + slug: Schema.String, + display_name: Schema.String, + visibility: Schema.String, + supported_in_api: Schema.Boolean, + context_window: Schema.Int.check(Schema.isGreaterThan(1)), + input_modalities: Schema.Array(Schema.String), + supported_reasoning_levels: Schema.optional(Schema.Array(Schema.Struct({ effort: Schema.String }))), +}) +type RemoteModel = typeof RemoteModel.Type +const decodeModels = Schema.decodeUnknownEffect(Schema.Struct({ models: Schema.Array(RemoteModel) })) +const decodeSharingMetadata = Schema.decodeUnknownOption( + Schema.Struct({ + clientID: Schema.String, + scopes: Schema.optional(Schema.Array(Schema.String)), + models: Schema.optional(Schema.Array(RemoteModel)), + }), +) + const Claims = Schema.fromJsonString( Schema.Struct({ chatgpt_account_id: Schema.optional(Schema.String), @@ -229,14 +264,122 @@ const headless = (app: App.Info) => refresh: (value) => refresh(headlessMethodID, value, app), }) satisfies IntegrationOAuthMethodRegistration +const signIn = (app: App.Info, savedClientID: () => string | undefined, storage: Context["storage"]) => + ({ + integrationID: Integration.ID.make("openai"), + method: { id: sharingMethodID, type: "oauth", label: "Sign in with ChatGPT" }, + authorize: () => + Effect.gen(function* () { + const storedHostID = yield* storage.get("chatgpt-agent-host-id") + const hostID = typeof storedHostID === "string" ? storedHostID : `urn:uuid:${crypto.randomUUID()}` + if (typeof storedHostID !== "string") yield* storage.set("chatgpt-agent-host-id", hostID) + const pkce = yield* Effect.promise(generatePKCE) + const state = randomValue() + const nonce = randomValue() + const savedID = savedClientID() + const received = yield* Deferred.make<{ code: string; clientID?: string; response: ServerResponse }, Error>() + const { createServer } = yield* Effect.promise(() => import("node:http")) + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1") + if (url.pathname !== "/auth/callback") { + response.writeHead(404).end("Not found") + return + } + const error = url.searchParams.get("error_description") ?? url.searchParams.get("error") + const code = url.searchParams.get("code") + if (error) { + Effect.runFork(Deferred.fail(received, new Error(error))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(error, { provider: "ChatGPT" })) + return + } + if (!code || url.searchParams.get("state") !== state) { + const message = code ? "Invalid OAuth state" : "Missing authorization code" + Effect.runFork(Deferred.fail(received, new Error(message))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(message, { provider: "ChatGPT" })) + return + } + if ( + !Effect.runSync( + Deferred.succeed(received, { code, clientID: url.searchParams.get("client_id") ?? undefined, response }), + ) + ) + response.writeHead(409).end("OAuth callback already received") + }) + const port = yield* Effect.callback((resume) => { + const onError = (error: Error) => resume(Effect.fail(error)) + server.once("error", onError) + server.listen(0, "127.0.0.1", () => { + server.off("error", onError) + const address = server.address() + if (!address || typeof address === "string") + return resume(Effect.fail(new Error("Missing OAuth callback port"))) + resume(Effect.succeed(address.port)) + }) + }) + yield* Effect.addFinalizer(() => Effect.sync(() => server.close())) + const redirect = `http://127.0.0.1:${port}/auth/callback` + return { + mode: "auto" as const, + url: sharingAuthorizeURL(redirect, pkce, state, nonce, savedID, hostID), + instructions: "Complete authorization in your browser. This window will close automatically.", + callback: Effect.gen(function* () { + const result = yield* Deferred.await(received) + const respond = (error?: string) => + Effect.sync(() => + result.response + .writeHead(error ? 400 : 200, { "Content-Type": "text/html" }) + .end( + error + ? OauthCallbackPage.error(error, { provider: "ChatGPT" }) + : OauthCallbackPage.success({ provider: "ChatGPT" }), + ), + ) + return yield* Effect.gen(function* () { + const clientID = result.clientID ?? savedID + if (!clientID) + return yield* Effect.fail(new Error("ChatGPT sign-in did not return a client ID. Connect again.")) + const tokens = yield* sharingExchange(result.code, clientID, redirect, pkce, app) + if (!tokens.scope?.split(" ").includes(sharingScope)) + return yield* Effect.fail( + new Error( + "ChatGPT sign-in finished without token sharing. Sign in again and allow token sharing, or connect OpenAI with an API key.", + ), + ) + if (!tokens.id_token) return yield* Effect.fail(new Error("ChatGPT sign-in did not return an ID token.")) + yield* verifySharingIDToken(tokens.id_token, clientID, nonce) + return sharingCredential( + tokens, + clientID, + undefined, + yield* fetchSharingModels(tokens.access_token, app).pipe(Effect.timeout(15_000)), + ) + }).pipe( + Effect.tap(() => respond()), + Effect.tapError((error) => respond(error instanceof Error ? error.message : "ChatGPT sign-in failed")), + Effect.onInterrupt(() => Effect.sync(() => result.response.destroy())), + ) + }), + } + }), + refresh: (value) => sharingRefresh(value, app), + }) satisfies IntegrationOAuthMethodRegistration + export const OpenAIPlugin = define({ id: "opencode.provider.openai", effect: Effect.fn(function* (ctx) { const bus = yield* Bus.Service const loading = Semaphore.makeUnsafe(1) let chatgpt: Credential.OAuth | undefined + let sharing: Credential.OAuth | undefined + let available: ReadonlyArray | undefined + let source: Effect.Success> const load = Effect.fn("OpenAIPlugin.load")(function* () { + const previous = IntegrationConnection.key(source) const connection = yield* ctx.integration.connection.active("openai") const credential = connection ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined)) @@ -246,9 +389,16 @@ export const OpenAIPlugin = define({ (credential.methodID === browserMethodID || credential.methodID === headlessMethodID) ? credential : undefined + sharing = credential?.type === "oauth" && credential.methodID === sharingMethodID ? credential : undefined + source = sharing ? connection : undefined + if (previous !== IntegrationConnection.key(source)) + available = Option.getOrUndefined(decodeSharingMetadata(sharing?.metadata))?.models }) yield* ctx.integration.transform((editor) => { + editor.method.update( + signIn(ctx.app, () => Option.getOrUndefined(decodeSharingMetadata(sharing?.metadata))?.clientID, ctx.storage), + ) editor.method.update(browser(ctx.app)) editor.method.update(headless(ctx.app)) }) @@ -259,8 +409,9 @@ export const OpenAIPlugin = define({ const account = chatgpt?.metadata?.accountID providers.update(item.provider.id, (provider) => { provider.settings = Provider.mergeOverlay(provider.settings, { - transport: provider.settings?.transport ?? "websocket", + transport: sharing ? "http" : (provider.settings?.transport ?? "websocket"), ...(chatgpt ? { baseURL: codexBaseURL } : {}), + ...(sharing ? { compaction: { type: "summary" } } : {}), }) if (!chatgpt) return provider.headers = Provider.mergeHeaders(provider.headers, { @@ -269,12 +420,50 @@ export const OpenAIPlugin = define({ ...(typeof account === "string" ? { "chatgpt-account-id": account } : {}), }) }) + if (!sharing || !available || !source) return + const updated = providers.get(Provider.ID.openai) + if (!updated) return + providers.add({ + info: updated.provider, + models: deriveSharingModels(available, Array.from(updated.models.values())), + sourceConnection: source, + }) }) yield* ctx.model.transform((models) => { for (const model of models.list(Provider.ID.openai)) { // ChatGPT-plan tokens only authorize codex-eligible models, and the // subscription covers usage, so hide the rest and zero the cost. models.update(model.providerID, model.id, (draft) => { + if (sharing) { + draft.settings = { ...draft.settings, compaction: { type: "summary" } } + if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) { + draft.enabled = false + return + } + const apiID = draft.modelID ?? draft.id + if (available) { + if (!available.some((remote) => remote.slug === apiID)) { + draft.enabled = false + return + } + draft.cost = [] + return + } + // A connection saved before model discovery has a catalog fallback until reauthorization. + const match = apiID.match(/^gpt-(\d+)(?:\.(\d+))?/) + const major = Number(match?.[1]) + const minor = Number(match?.[2] ?? 0) + if ( + !codexAllowed.has(apiID) && + (codexDisallowed.has(apiID) || !match || !(major > 5 || (major === 5 && minor > 4))) + ) { + draft.enabled = false + return + } + draft.cost = [] + draft.limit = { ...draft.limit, context: 400_000, input: 272_000 } + return + } if (!chatgpt) return if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) { draft.enabled = false @@ -326,6 +515,29 @@ export const OpenAIPlugin = define({ Stream.runForEach(refresh), Effect.forkScoped({ startImmediately: true }), ) + yield* Effect.sleep(Duration.minutes(30)).pipe( + Effect.andThen( + loading.withPermit( + Effect.gen(function* () { + if (!sharing || !source || sharing.expires <= Date.now() + 60_000) return + const models = yield* fetchSharingModels(sharing.access, ctx.app).pipe( + Effect.timeout(15_000), + Effect.catch(() => Effect.logWarning("failed to refresh ChatGPT models").pipe(Effect.as(undefined))), + ) + if (!models || JSON.stringify(models) === JSON.stringify(available)) return + if ( + IntegrationConnection.key(source) !== + IntegrationConnection.key(yield* ctx.integration.connection.active("openai")) + ) + return + available = models + yield* ctx.provider.reload() + }), + ), + ), + Effect.forever, + Effect.forkScoped, + ) }), } satisfies PluginInternal.InternalPlugin) @@ -428,3 +640,149 @@ function claim(token: string) { claims.organizations?.[0]?.id ) } + +function sharingExchange(code: string, clientID: string, redirect: string, pkce: Pkce, app: App.Info) { + return request(sharingTokenURL, { + method: "POST", + headers: headers("application/x-www-form-urlencoded", app), + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: clientID, + code, + code_verifier: pkce.verifier, + redirect_uri: redirect, + resource: sharingResource, + }).toString(), + }) +} + +function sharingRefresh(value: Credential.OAuth, app: App.Info) { + return Effect.gen(function* () { + const metadata = Option.getOrUndefined(decodeSharingMetadata(value.metadata)) + if (!metadata) + return yield* Effect.fail(new Error("This ChatGPT connection has no registered client ID. Connect again.")) + const tokens = yield* request(sharingTokenURL, { + method: "POST", + headers: headers("application/x-www-form-urlencoded", app), + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: metadata.clientID, + refresh_token: value.refresh, + resource: sharingResource, + }).toString(), + }) + return sharingCredential(tokens, metadata.clientID, metadata.scopes, metadata.models) + }) +} + +export function fetchSharingModels(token: string, app: App.Info, baseURL = sharingResource) { + return request(`${baseURL}/models`, { + headers: { Authorization: `Bearer ${token}`, "User-Agent": App.useragent(app) }, + }).pipe( + Effect.flatMap(decodeModels), + Effect.flatMap((response) => { + const models = response.models.filter((model) => model.visibility === "list" && model.supported_in_api) + return models.length + ? Effect.succeed(models) + : Effect.fail(new Error("No ChatGPT models are available for this account.")) + }), + ) +} + +export function deriveSharingModels(remote: ReadonlyArray, existing: ReadonlyArray) { + const byID = new Map(remote.map((model) => [model.slug, model])) + const known = new Set(existing.map((model) => model.id)) + const derive = (item: RemoteModel, previous: Model.Info): Model.Info => ({ + ...previous, + name: previous.id === previous.modelID ? item.display_name : previous.name, + package: previous.package ?? "@opencode/ai/providers/openai", + capabilities: { + ...previous.capabilities, + input: item.input_modalities.filter((modality) => modality === "text" || modality === "image"), + }, + variants: [ + ...previous.variants.filter((variant) => typeof variant.settings?.reasoningEffort !== "string"), + ...(item.supported_reasoning_levels ?? []).map( + ({ effort }) => + previous.variants.find( + (variant) => variant.id === effort && variant.settings?.reasoningEffort === effort, + ) ?? { id: Model.VariantID.make(effort), settings: { reasoningEffort: effort } }, + ), + ], + limit: { context: item.context_window, output: previous.limit.output }, + }) + return [ + ...existing.flatMap((model) => { + const found = byID.get(model.modelID) + return found ? [derive(found, model)] : [] + }), + ...remote + .filter((model) => !known.has(Model.ID.make(model.slug))) + .map((model) => derive(model, Model.Info.default(Provider.ID.openai, Model.ID.make(model.slug)))), + ] +} + +export function verifySharingIDToken( + token: string, + clientID: string, + nonce: string, + jwksURL = new URL(`${issuer}/.well-known/jwks.json`), +) { + return Effect.tryPromise({ + try: async () => { + const { createRemoteJWKSet, jwtVerify } = await import("jose") + const { payload } = await jwtVerify(token, createRemoteJWKSet(jwksURL), { + issuer, + audience: clientID, + algorithms: ["RS256"], + requiredClaims: ["exp", "nonce", "sub"], + }) + if (typeof payload.sub !== "string" || !payload.sub.trim()) throw new Error("ID token subject is missing") + if (payload.nonce !== nonce) throw new Error("ID token nonce does not match this sign-in attempt") + }, + catch: (cause) => new Error("ChatGPT sign-in returned an invalid ID token.", { cause }), + }) +} + +function sharingCredential( + tokens: SharingTokenResponse, + clientID: string, + scopes?: ReadonlyArray, + models?: ReadonlyArray, +) { + return Credential.OAuth.make({ + type: "oauth", + methodID: sharingMethodID, + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + metadata: { clientID, scopes: tokens.scope?.split(" ").filter(Boolean) ?? scopes ?? [], models }, + }) +} + +function randomValue() { + return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) +} + +function sharingAuthorizeURL( + redirect: string, + pkce: Pkce, + state: string, + nonce: string, + savedID: string | undefined, + hostID: string, +) { + return `${issuer}/api/accounts/authorize?${new URLSearchParams({ + client_id: savedID ?? sharingClientID, + ...(savedID ? {} : { agent_name_hint: "OpenCode" }), + ext_agent_host_id: hostID, + response_type: "code", + redirect_uri: redirect, + scope: `openid profile email offline_access resource.invoke ${sharingScope}`, + resource: sharingResource, + state, + nonce, + code_challenge_method: "S256", + code_challenge: pkce.challenge, + })}` +} diff --git a/packages/core/test/database.test.ts b/packages/core/test/database.test.ts new file mode 100644 index 00000000000..7a8df62493f --- /dev/null +++ b/packages/core/test/database.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import { chmod, stat, writeFile } from "node:fs/promises" +import path from "path" +import { Effect, Layer } from "effect" +import { Database } from "@opencode/core/database/database" +import { Global } from "@opencode/util/global" +import { tmpdir } from "./fixture/tmpdir" + +// Read while the connection is open: SQLite may remove the sidecars on close. +const openModes = (filename: string) => + Effect.runPromise( + Effect.gen(function* () { + yield* Layer.build(Database.layer({ path: filename })) + return yield* Effect.promise(() => + Promise.all(["", "-wal", "-shm"].map(async (suffix) => (await stat(filename + suffix)).mode & 0o777)), + ) + }).pipe(Effect.scoped, Effect.provideService(Global.Service, Global.make({ data: path.dirname(filename) }))), + ) + +describe.skipIf(process.platform === "win32")("Database file permissions", () => { + test("creates the database and sidecars readable only by the owner", async () => { + await using tmp = await tmpdir() + + expect(await openModes(path.join(tmp.path, "opencode.db"))).toEqual([0o600, 0o600, 0o600]) + }) + + test("tightens an existing database and sidecars", async () => { + await using tmp = await tmpdir() + const filename = path.join(tmp.path, "opencode.db") + await Promise.all( + ["", "-wal", "-shm"].map(async (suffix) => { + await writeFile(filename + suffix, "") + await chmod(filename + suffix, 0o644) + }), + ) + + expect(await openModes(filename)).toEqual([0o600, 0o600, 0o600]) + }) +}) diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 1374d6b4e8e..46fa5f9f747 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -4,7 +4,9 @@ import { Session } from "@opencode/core/session" import { OpenAIResponses } from "@opencode/ai/protocols/openai-responses" import { describe, expect } from "bun:test" import { ConfigProvider, DateTime, Effect } from "effect" +import { exportJWK, generateKeyPair, SignJWT } from "jose" import { Credential } from "@opencode/core/credential" +import { App } from "@opencode/core/app" import { Integration } from "@opencode/core/integration" import { Location } from "@opencode/core/location" import { Model } from "@opencode/core/model" @@ -12,7 +14,7 @@ import { Plugin } from "@opencode/core/plugin" import { PluginHost } from "@opencode/core/plugin/host" import { PluginHooks } from "@opencode/core/plugin/hooks" import { GithubCopilotPlugin } from "@opencode/core/plugin/provider/github-copilot" -import { OpenAIPlugin } from "@opencode/core/plugin/provider/openai" +import { OpenAIPlugin, fetchSharingModels, verifySharingIDToken } from "@opencode/core/plugin/provider/openai" import { Project } from "@opencode/core/project" import { Provider } from "@opencode/core/provider" import { AbsolutePath } from "@opencode/core/schema" @@ -65,11 +67,16 @@ const request = Effect.fn(function* ( }) describe("OpenAIPlugin", () => { - it.effect("registers browser and headless ChatGPT OAuth methods", () => + it.effect("registers ChatGPT token sharing without replacing either Codex OAuth method", () => Effect.gen(function* () { yield* addPlugin() const integrations = yield* Integration.Service expect((yield* integrations.get(Integration.ID.make("openai")))?.methods).toEqual([ + { + id: Integration.MethodID.make("chatgpt-token-sharing"), + type: "oauth", + label: "Sign in with ChatGPT", + }, { id: Integration.MethodID.make("chatgpt-browser"), type: "oauth", @@ -84,6 +91,153 @@ describe("OpenAIPlugin", () => { }), ) + it.live("registers a user-owned ChatGPT agent with a separate IPv4 callback", () => + Effect.gen(function* () { + yield* addPlugin() + const integrations = yield* Integration.Service + const signIn = yield* integrations.oauth.connect({ + integrationID: Integration.ID.make("openai"), + methodID: Integration.MethodID.make("chatgpt-token-sharing"), + }) + const url = new URL(signIn.url) + const callback = new URL(url.searchParams.get("redirect_uri") ?? "") + expect(`${url.origin}${url.pathname}`).toBe("https://auth.openai.com/api/accounts/authorize") + expect(url.searchParams.get("client_id")).toBe("dynamic_agent_client") + expect(url.searchParams.get("scope")).toContain("chatgpt.tokens.use.direct") + expect(url.searchParams.get("ext_agent_host_id")).toMatch(/^urn:uuid:/) + expect(url.searchParams.get("nonce")).toBeTruthy() + expect(callback.hostname).toBe("127.0.0.1") + expect(Number(callback.port)).toBeGreaterThan(0) + yield* integrations.oauth.cancel({ integrationID: Integration.ID.make("openai"), attemptID: signIn.attemptID }) + + const codex = yield* integrations.oauth.connect({ + integrationID: Integration.ID.make("openai"), + methodID: Integration.MethodID.make("chatgpt-browser"), + }) + expect(new URL(codex.url).pathname).toBe("/oauth/authorize") + yield* integrations.oauth.cancel({ integrationID: Integration.ID.make("openai"), attemptID: codex.attemptID }) + }), + ) + + it.live("discovers only visible ChatGPT Responses models", () => + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.sync(() => + Bun.serve({ + port: 0, + fetch: () => + Response.json({ + models: [ + { + slug: "gpt-visible", + display_name: "Visible", + visibility: "list", + supported_in_api: true, + context_window: 272_000, + input_modalities: ["text"], + }, + { + slug: "gpt-hidden", + display_name: "Hidden", + visibility: "hide", + supported_in_api: true, + context_window: 272_000, + input_modalities: ["text"], + }, + ], + }), + }), + ), + (server) => Effect.sync(() => server.stop()), + ) + expect( + (yield* fetchSharingModels("test-token", App.make(), `http://127.0.0.1:${server.port}`)).map( + (model) => model.slug, + ), + ).toEqual(["gpt-visible"]) + }), + ) + + it.live("verifies the ChatGPT ID token signature, client ID, and sign-in nonce", () => + Effect.gen(function* () { + const keys = yield* Effect.promise(() => generateKeyPair("RS256")) + const publicKey = yield* Effect.promise(() => exportJWK(keys.publicKey)) + const server = yield* Effect.acquireRelease( + Effect.sync(() => + Bun.serve({ + port: 0, + fetch: () => Response.json({ keys: [{ ...publicKey, kid: "sign-in", alg: "RS256", use: "sig" }] }), + }), + ), + (server) => Effect.sync(() => server.stop()), + ) + const jwksURL = new URL(`http://127.0.0.1:${server.port}/jwks`) + const sign = (nonce: string, audience: string) => + new SignJWT({ nonce }) + .setProtectedHeader({ alg: "RS256", kid: "sign-in" }) + .setIssuer("https://auth.openai.com") + .setAudience(audience) + .setSubject("user_123") + .setExpirationTime(Math.floor(Date.now() / 1000) + 60) + .sign(keys.privateKey) + const valid = yield* Effect.promise(() => sign("expected", "oaiapp_issued")) + yield* verifySharingIDToken(valid, "oaiapp_issued", "expected", jwksURL) + expect(yield* Effect.flip(verifySharingIDToken(valid, "wrong-client", "expected", jwksURL))).toBeInstanceOf(Error) + expect(yield* Effect.flip(verifySharingIDToken(valid, "oaiapp_issued", "wrong-nonce", jwksURL))).toBeInstanceOf( + Error, + ) + }), + ) + + it.effect("uses account-visible models and HTTP without changing Codex routing", () => + Effect.gen(function* () { + const catalog = yield* Provider.Service + const models = yield* Model.Service + const credentials = yield* Credential.Service + yield* catalog.transform((editor) => { + editor.models.update(Provider.ID.openai, Model.ID.make("gpt-5.5"), () => {}) + editor.models.update(Provider.ID.openai, Model.ID.make("gpt-4.1"), () => {}) + }) + yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-token-sharing"), + access: "sharing-token", + refresh: "sharing-refresh", + expires: Date.now() + 60 * 60_000, + metadata: { + clientID: "oaiapp_issued", + models: [ + { + slug: "gpt-4.1", + display_name: "GPT-4.1", + visibility: "list", + supported_in_api: true, + context_window: 128_000, + input_modalities: ["text", "image"], + }, + ], + }, + }), + }) + yield* addPlugin() + const provider = required(yield* catalog.get(Provider.ID.openai)) + expect(provider.settings?.transport).toBe("http") + expect(provider.settings?.baseURL).toBeUndefined() + expect(provider.headers).not.toHaveProperty("x-codex-beta-features") + expect((yield* request(Provider.ID.openai, "https://api.openai.com/v1")).baseURL).toBe( + "https://api.openai.com/v1", + ) + expect(required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-4.1")))).toMatchObject({ + enabled: true, + cost: [], + limit: { context: 128_000 }, + }) + expect(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))).toBeUndefined() + }), + ) + it.effect("filters the OpenAI catalog to codex-eligible models under a ChatGPT connection", () => Effect.gen(function* () { const catalog = yield* Provider.Service diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index b21882eb8dd..442bb437aae 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -58,7 +58,17 @@ export function integrationOptions(list: IntegrationInfo[]) { export function connectMethods(integration: IntegrationInfo): ConnectMethod[] { return integration.methods .filter((method): method is ConnectMethod => method.type !== "env") - .toSorted((a, b) => Number(a.type === "key") - Number(b.type === "key")) + .toSorted((a, b) => { + if (integration.id !== "openai") return Number(a.type === "key") - Number(b.type === "key") + const rank = (method: ConnectMethod) => { + if (method.type === "key") return 1 + if (method.id === "chatgpt-token-sharing") return 0 + if (method.id === "chatgpt-browser") return 2 + if (method.id === "chatgpt-headless") return 3 + return 4 + } + return rank(a) - rank(b) + }) } export function credentialConnections(integration: IntegrationInfo) { diff --git a/packages/tui/test/cli/cmd/tui/integration-options.test.ts b/packages/tui/test/cli/cmd/tui/integration-options.test.ts index 28ca51443fb..10f2e8244ff 100644 --- a/packages/tui/test/cli/cmd/tui/integration-options.test.ts +++ b/packages/tui/test/cli/cmd/tui/integration-options.test.ts @@ -41,6 +41,23 @@ describe("integrationOptions", () => { }) describe("connectMethods", () => { + test("orders OpenAI sign-in, API key, and both Codex methods", () => { + expect( + connectMethods( + integration({ + id: "openai", + name: "OpenAI", + methods: [ + { type: "oauth", id: "chatgpt-headless", label: "Codex headless" }, + { type: "key", label: "API key" }, + { type: "oauth", id: "chatgpt-browser", label: "Codex browser" }, + { type: "oauth", id: "chatgpt-token-sharing", label: "Sign in with ChatGPT" }, + ], + }), + ).map((method) => (method.type === "key" ? "key" : method.id)), + ).toEqual(["chatgpt-token-sharing", "key", "chatgpt-browser", "chatgpt-headless"]) + }) + test("offers key and OAuth methods but not environment discovery", () => { expect( connectMethods(