diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index e14f0d6e0..0f98e84f9 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -19,6 +19,59 @@ The API route publishes 1,050,000 context / 922,000 max input metadata. Its `sol-pro`, `terra-pro`, and `luna-pro` virtual ids keep their selected public identity while the wire uses the base model plus `reasoning.mode: "pro"`. +## GPT-5.6 Pro through standard ChatGPT (experimental) + +`chatgpt-browser/gpt-5.6-pro` is an explicit opt-in route through a signed-in **standard ChatGPT** +conversation. It never calls the Codex/Work backend or OpenAI API and never falls back to another +model or account. OpenAI documents GPT-5.6 Pro under standard ChatGPT's existing Pro/model +allowance, separately from the agentic pool shared by Codex and Work. See OpenAI's +[GPT-5.6 in ChatGPT](https://help.openai.com/en/articles/20001354-gpt-56-in-chatgpt) and +[Codex plan limits](https://help.openai.com/en/articles/11369540-using-codex-with-your-chatgpt-plan). + +The adapter delegates visible browser login, strict Pro selection, submission, and final-response +capture to [Oracle](https://github.com/steipete/oracle) 0.16.1 or newer: + +```bash +npm install -g @steipete/oracle@^0.16.1 + +# One-time browser-profile setup. Sign in if Oracle opens an unauthenticated window, then rerun. +oracle --engine browser --browser-manual-login --browser-keep-browser \ + --model gpt-5.5-pro --browser-thinking-time extended --wait -p "Reply only OK." + +ocx provider add chatgpt-browser --sync +codex -m chatgpt-browser/gpt-5.6-pro +``` + +`gpt-5.5-pro` above is Oracle's stable alias for the current ChatGPT **Pro** picker entry; the +OpenCodex-facing selector remains `gpt-5.6-pro`. The adapter forces browser mode, the standard +`https://chatgpt.com/` surface, explicit model selection, and Pro effort confirmation before +submission. Missing login, ineligible access, allowance exhaustion, model-picker drift, and capture +failure are returned as distinct errors with no fallback or automatic retry. + +This is a `runTurn` adapter: it serializes the Responses conversation and client tools into a +nonce-bound JSON protocol, waits for Oracle's captured result, then emits either final Markdown or +one validated client tool call. Prose-only tool-schema annotations are compacted while callable +names, JSON types, properties, enums, and required fields remain available. OpenCodex also retains +`previous_response_id` state despite Codex's `store:false`, allowing tool results to continue in a +fresh one-shot browser chat. Hosted web search and the OpenAI vision sidecar are deliberately disabled +so this route cannot spend Codex/Work allowance. Image input is not supported. + +Browser failures are resolved before OpenCodex commits a streaming HTTP response. This prevents +Codex from treating a failed 200 stream as disconnected and automatically submitting another regular +Pro message. Because response headers wait for the first meaningful Oracle event, unusually long +browser turns depend on the invoking client's header-wait tolerance. + +Set `providers.chatgpt-browser.oracleCommand` or `OPENCODEX_ORACLE_COMMAND` when `oracle` is not on +`PATH`. The value is an executable path/name, not a shell command. `ocx provider test +chatgpt-browser` checks the executable and minimum version without spending a Pro message; browser +login, eligibility, and quota remain fail-closed checks on the first real turn. + +:::caution[Experimental browser automation] +This integration depends on ChatGPT's web UI and may break when that UI changes. Review OpenAI's +current terms and your organization policies before enabling it. Oracle stores its own local session +artifacts and browser-profile state; protect them like other authenticated application data. +::: + If the built-in `openai` provider is missing or disabled, the dashboard Accounts picker and Codex Auth page can restore it: absent rows are created from the canonical preset, disabled canonical rows are re-enabled without replacing saved mode or model settings, and noncanonical `openai` @@ -185,7 +238,7 @@ selectors, then retry. Signing in from a machine with no existing `kiro-cli` ses ## 3. API-key catalog -opencodex ships 53 built-in presets: 42 key-based, seven OAuth, three local, and the default +opencodex ships 61 built-in presets: 49 key-based, seven OAuth, four local, and the default ChatGPT-forward preset. The dashboard's **Add provider** picker opens a key provider's dashboard, validates the key, and stores it. Notable entries: diff --git a/docs-site/src/content/docs/ja/reference/adapters.md b/docs-site/src/content/docs/ja/reference/adapters.md index ea97ddbcf..036365d50 100644 --- a/docs-site/src/content/docs/ja/reference/adapters.md +++ b/docs-site/src/content/docs/ja/reference/adapters.md @@ -1,6 +1,6 @@ --- title: アダプター -description: 7つのプロバイダーアダプターの対象、リクエスト構成方式、固有の動作。 +description: 8つのプロバイダーアダプターの対象、リクエスト構成方式、固有の動作。 --- **アダプター**は opencodex の内部リクエスト/レスポンスモデルとプロバイダーの wire 形式の間を変換します。すべてのアダプターは `ProviderAdapter` インターフェース(`src/adapters/base.ts`)を実装します。 diff --git a/docs-site/src/content/docs/ja/reference/architecture.md b/docs-site/src/content/docs/ja/reference/architecture.md index d1f436e3c..79e77d4c9 100644 --- a/docs-site/src/content/docs/ja/reference/architecture.md +++ b/docs-site/src/content/docs/ja/reference/architecture.md @@ -13,7 +13,7 @@ src/ ├── server/ # Bun.serve, /v1/* proxy, /api/* management API, WS bridge ├── codex/ # Codex config injection, catalog sync, auth/account integration ├── providers/ # provider metadata, API-key pool, quota and labels -├── adapters/ # 7つの wire adapter, 共通 guard/util, Cursor protobuf transport +├── adapters/ # 8つの wire adapter, 共通 guard/util, runTurn transport ├── oauth/ # OAuth providers, API-key catalog, token store/refresh ├── usage/ # usage extraction, JSONL logs, summaries, totals ├── lib/ # runtime, process, retry, privacy, token estimate helpers @@ -51,8 +51,8 @@ HTTP の境界は `server/index.ts` が担い、Responses データプレーン 2. `server/responses/core.ts` が展開し JSON を読みます。覚えておいた `previous_response_id` 入力があれば展開したのち `responses/parser.ts` に渡します。 3. `router.ts` が通常のモデル id または `provider/model` id を解決します。続いて Codex アカウント affinity を決定し、必要ならプロバイダー OAuth を更新して選択された認証情報を route に適用します。 4. 本リクエストの前に `vision/` が `noVisionModels` モデル用の画像説明を作ります。安全なサイドカー経路がないときはテキスト専用の上流に画像を送らず取り除きます。 -5. `server/adapter-resolve.ts` がモデル別の wire override を適用し、7つのアダプターのいずれかを作ります。 - Responses passthrough は元の body を中継し、Cursor は双方向 `runTurn` transport を使い、 +5. `server/adapter-resolve.ts` がモデル別の wire override を適用し、8つのアダプターのいずれかを作ります。 + Responses passthrough は元の body を中継し、Cursor と ChatGPT Browser は `runTurn` transport を使い、 残りの変換型アダプターは上流リクエストを build/fetch/parse します。 6. ルーティングモデルがホステッド `web_search` を要求すると `web-search/` が合成関数を公開します。実際の検索は ChatGPT サイドカーで実行し、結果をルーティングモデルに戻し、設定された回数の中で繰り返します。 7. `bridge.ts` が Responses SSE または JSON を作ります。`server/request-log.ts` と `usage/` はレスポンスに触れずに終了ステータス、レイテンシー、プロバイダー/モデル、最善推定トークン使用量を記録します。 diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index ddfe86864..4dc1d0dca 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -1,6 +1,6 @@ --- title: 어댑터 -description: 7가지 프로바이더 어댑터의 대상, 요청 구성 방식, 고유 동작. +description: 8가지 프로바이더 어댑터의 대상, 요청 구성 방식, 고유 동작. --- **어댑터**는 opencodex의 내부 요청/응답 모델과 프로바이더 wire 형식 사이를 변환합니다. 모든 diff --git a/docs-site/src/content/docs/ko/reference/architecture.md b/docs-site/src/content/docs/ko/reference/architecture.md index 0166f4829..259484f87 100644 --- a/docs-site/src/content/docs/ko/reference/architecture.md +++ b/docs-site/src/content/docs/ko/reference/architecture.md @@ -15,7 +15,7 @@ src/ ├── server/ # Bun.serve, /v1/* proxy, /api/* management API, WS bridge ├── codex/ # Codex config injection, catalog sync, auth/account integration ├── providers/ # provider metadata, API-key pool, quota and labels -├── adapters/ # 7개 wire adapter, 공통 guard/util, Cursor protobuf transport +├── adapters/ # 8개 wire adapter, 공통 guard/util, runTurn transport ├── oauth/ # OAuth providers, API-key catalog, token store/refresh ├── usage/ # usage extraction, JSONL logs, summaries, totals ├── lib/ # runtime, process, retry, privacy, token estimate helpers @@ -56,8 +56,8 @@ HTTP 경계는 `server/index.ts`가 맡고, Responses 데이터 플레인은 `se 결정하고, 필요하면 프로바이더 OAuth를 갱신해 선택된 자격 증명을 route에 적용합니다. 4. 본 요청 전에 `vision/`이 `noVisionModels` 모델용 이미지 설명을 만듭니다. 안전한 사이드카 경로가 없으면 텍스트 전용 업스트림에 이미지를 보내지 않고 제거합니다. -5. `server/adapter-resolve.ts`가 모델별 wire override를 적용하고 7개 어댑터 중 하나를 만듭니다. - Responses passthrough는 원본 body를 중계하고, Cursor는 양방향 `runTurn` transport를 사용하며, +5. `server/adapter-resolve.ts`가 모델별 wire override를 적용하고 8개 어댑터 중 하나를 만듭니다. + Responses passthrough는 원본 body를 중계하고, Cursor와 ChatGPT Browser는 `runTurn` transport를 사용하며, 나머지 변환형 어댑터는 업스트림 요청을 build/fetch/parse합니다. 6. 라우팅 모델이 호스티드 `web_search`를 요청하면 `web-search/`가 합성 함수를 노출합니다. 실제 검색은 ChatGPT 사이드카로 실행하고 결과를 라우팅 모델에 다시 넣으며, 설정된 횟수 안에서 반복합니다. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 25cd3b962..7db12e278 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -1,6 +1,6 @@ --- title: Adapters -description: The seven provider adapters — what each targets, how it builds requests, and its quirks. +description: The eight provider adapters — what each targets, how it builds requests, and its quirks. --- An **adapter** translates between opencodex's internal request/response model and one provider wire @@ -173,6 +173,31 @@ advertised effort control on those models as proof of upstream-native reasoning broader built-in executor and bypasses Codex approval/sandbox semantics, and legacy `unsafeAllowNativeLocalExec: true` remains equivalent only when `nativeLocalExec` is unset. +## `chatgpt-browser` (experimental) + +**Targets:** the standard `https://chatgpt.com/` conversation UI with the visible Pro option. +**Auth:** a local signed-in Chrome session managed by Oracle; no API key or Codex bearer is sent. + +- Uses `runTurn` and requires `@steipete/oracle` 0.16.1 or newer. Oracle is spawned without a shell; + the request travels on stdin and the final answer is read from a fresh private temporary file. +- Invokes Oracle's stable current-Pro alias with explicit browser mode, standard ChatGPT URL, model + selection, and strict Pro effort confirmation. Wrong/unavailable model, login/session failure, + Pro allowance exhaustion, timeout, and missing/incompatible Oracle are distinct fail-closed errors. +- Wraps the full Responses conversation and callable client tools in a nonce-bound JSON protocol. + Tool names and validation-relevant JSON Schema are preserved while prose-only annotations are + compacted so large Codex plugin inventories remain within the browser context window. +- The visible ChatGPT composer receives that envelope as one user message. System and developer + labels inside it are advisory text, not transport-level roles, so this experimental adapter does + not provide the instruction-authority isolation of a native Responses API transport. Treat + untrusted repository or user content as prompt-injection-capable on this route. + A reply becomes either final Markdown or one validated function/custom/tool-search call; unknown + tools, bad nonces, malformed JSON, and invalid required-tool responses fail closed. +- Retains local `previous_response_id` replay even when Codex sends `store:false`. For streaming + requests, it waits for the first meaningful browser event before committing response headers so a + capture failure becomes one non-retryable HTTP error instead of an automatic duplicate submission. +- Advertises text-only input with no effort picker, hosted search, parallel tool calls, or vision + sidecar. This keeps the route isolated from the Codex/Work agentic allowance. + ## `azure-openai` (alias: `azure`) **Targets:** **Azure OpenAI**. Wraps `openai-responses` (so also `passthrough: true`). diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index c33c6047f..3bd1523fd 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -15,7 +15,7 @@ src/ ├── server/ # Bun.serve, /v1/* proxy, /api/* management API, WS bridge ├── codex/ # Codex config injection, catalog sync, auth/account integration ├── providers/ # provider metadata, API-key pool, quota and labels -├── adapters/ # seven wire adapters, shared guards/utilities, Cursor protobuf transport +├── adapters/ # eight wire adapters, shared guards/utilities, runTurn transports ├── oauth/ # OAuth providers, API-key catalog, token store/refresh ├── usage/ # request usage extraction, JSONL logs, summaries, totals ├── lib/ # runtime, process, retry, privacy, token estimate helpers @@ -59,8 +59,8 @@ the `server/responses.ts` facade and its `server/responses/*.ts` modules: 4. Before the main call, `vision/` describes images for models in `noVisionModels`; if no safe sidecar path exists, images are removed rather than sent to a text-only upstream. 5. `server/adapter-resolve.ts` applies any model-specific wire override and constructs one of the - seven adapters. Responses passthrough relays the native body, Cursor runs its bidirectional - `runTurn` transport, and translated adapters build/fetch/parse an upstream request. + eight adapters. Responses passthrough relays the native body, Cursor and ChatGPT Browser use + `runTurn` transports, and translated adapters build/fetch/parse an upstream request. 6. For routed models with a hosted `web_search` tool, `web-search/` exposes a synthetic function, executes the real search through the ChatGPT sidecar, feeds results back to the routed model, and repeats within the configured loop limit. diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 7c2de76b3..05e7c8be2 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -298,8 +298,9 @@ or bind the forward explicitly to loopback (`ssh -L 127.0.0.1:20100:localhost:10 | Field | Type | Meaning | | --- | --- | --- | -| `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (or alias `azure`). | +| `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `chatgpt-browser`, `azure-openai` (or alias `azure`). | | `baseUrl` | `string` | Upstream API base URL. Most built-in fixed endpoints ignore a mismatch; newly promoted collision-safe key presets preserve an older same-named custom destination. See [Fixed provider endpoints](#fixed-provider-endpoints). | +| `oracleCommand?` | `string` | **`chatgpt-browser` only.** Oracle executable path/name. Defaults to `OPENCODEX_ORACLE_COMMAND`, then `oracle`; it is executed directly without a shell. Requires Oracle 0.16.1+. | | `responsesPath?` | `string` | Optional relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no URL scheme, query, or fragment. When omitted, the adapter keeps its legacy `/v1/responses` URL construction. | | `disabled?` | `boolean` | Keep the provider on disk but exclude it from routing and model/catalog listings. | | `apiKey?` | `string` | API key, or an `${ENV_VAR}` / `$ENV_VAR` reference resolved at request time. | diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index 80c7ae647..956a71c40 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -1,6 +1,6 @@ --- title: Адаптеры -description: Семь адаптеров провайдеров — назначение каждого, способ построения запросов и особенности. +description: Восемь адаптеров провайдеров — назначение каждого, способ построения запросов и особенности. --- **Адаптер** выполняет преобразование между внутренней моделью запросов/ответов opencodex и diff --git a/docs-site/src/content/docs/ru/reference/architecture.md b/docs-site/src/content/docs/ru/reference/architecture.md index 569652c6a..b4996c99e 100644 --- a/docs-site/src/content/docs/ru/reference/architecture.md +++ b/docs-site/src/content/docs/ru/reference/architecture.md @@ -16,7 +16,7 @@ src/ ├── server/ # Bun.serve, /v1/* proxy, /api/* management API, WS bridge ├── codex/ # Codex config injection, catalog sync, auth/account integration ├── providers/ # provider metadata, API-key pool, quota and labels -├── adapters/ # seven wire adapters, shared guards/utilities, Cursor protobuf transport +├── adapters/ # eight wire adapters, shared guards/utilities, runTurn transports ├── oauth/ # OAuth providers, API-key catalog, token store/refresh ├── usage/ # request usage extraction, JSONL logs, summaries, totals ├── lib/ # runtime, process, retry, privacy, token estimate helpers @@ -63,8 +63,8 @@ src/ безопасного пути через сайдкар нет, изображения удаляются, а не отправляются текстовому вышестоящему провайдеру. 5. `server/adapter-resolve.ts` применяет переопределение wire-формата для конкретной модели и - конструирует один из семи адаптеров. Passthrough Responses ретранслирует нативное тело, Cursor - запускает свой двунаправленный транспорт `runTurn`, а транслирующие адаптеры выполняют + конструирует один из восьми адаптеров. Passthrough Responses ретранслирует нативное тело, Cursor + и ChatGPT Browser запускают транспорт `runTurn`, а транслирующие адаптеры выполняют build/fetch/parse запроса к вышестоящему провайдеру. 6. Для маршрутизируемых моделей с hosted-инструментом `web_search` модуль `web-search/` предоставляет синтетическую функцию, выполняет настоящий поиск через сайдкар ChatGPT, diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index 6a3d89a98..60659ec41 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -1,6 +1,6 @@ --- title: Adapters -description: 七个 provider adapter 的目标、请求构建方式与各自特性。 +description: 八个 provider adapter 的目标、请求构建方式与各自特性。 --- **adapter** 负责在 opencodex 的内部请求/响应模型与某个 provider 的 wire 格式之间转换。每个 diff --git a/docs-site/src/content/docs/zh-cn/reference/architecture.md b/docs-site/src/content/docs/zh-cn/reference/architecture.md index 806631086..d9fcdd201 100644 --- a/docs-site/src/content/docs/zh-cn/reference/architecture.md +++ b/docs-site/src/content/docs/zh-cn/reference/architecture.md @@ -15,7 +15,7 @@ src/ ├── server/ # Bun.serve, /v1/* proxy, /api/* management API, WS bridge ├── codex/ # Codex config injection, catalog sync, auth/account integration ├── providers/ # provider metadata, API-key pool, quota and labels -├── adapters/ # seven wire adapters, shared guards/utilities, Cursor protobuf transport +├── adapters/ # eight wire adapters, shared guards/utilities, runTurn transports ├── oauth/ # OAuth providers, API-key catalog, token store/refresh ├── usage/ # request usage extraction, JSONL logs, summaries, totals ├── lib/ # runtime, process, retry, privacy, token estimate helpers @@ -57,8 +57,8 @@ src/ 必要时刷新 provider OAuth,并把选中的 credential 应用到 route。 4. 主请求发出前,`vision/` 会为 `noVisionModels` 中的模型描述图像。如果没有安全的 sidecar 路径,则移除图像,而不是把它发送给纯文本上游。 -5. `server/adapter-resolve.ts` 应用模型级 wire override,并构造七个 adapter 之一。Responses - passthrough 直接转发原始 body;Cursor 运行双向 `runTurn` transport;其余转换型 adapter +5. `server/adapter-resolve.ts` 应用模型级 wire override,并构造八个 adapter 之一。Responses + passthrough 直接转发原始 body;Cursor 与 ChatGPT Browser 运行 `runTurn` transport;其余转换型 adapter 则构建、获取并解析上游请求。 6. 路由模型请求托管的 `web_search` 工具时,`web-search/` 会暴露一个合成函数,经 ChatGPT sidecar 执行真实搜索,把结果送回路由模型,并在配置的循环上限内重复。 diff --git a/gui/src/provider-workspace/catalog.ts b/gui/src/provider-workspace/catalog.ts index 08a8cde13..11ea4ed49 100644 --- a/gui/src/provider-workspace/catalog.ts +++ b/gui/src/provider-workspace/catalog.ts @@ -134,14 +134,13 @@ export function isAccountProvider(name: string, p: WorkspaceProvider): boolean { /** * Free pricing (badge / filter / sort): `freeTier`, keyless free (`keyOptional`), - * local runtimes, or loopback. Forward passthrough is NOT free — those are + * or loopback runtimes. Forward passthrough is NOT free — those are * account providers. Does **not** imply ready-without-key — use * `binProviderStatus` for readiness. */ export function isFreeProvider(p: WorkspaceProvider): boolean { return p.freeTier === true || p.keyOptional === true - || p.authMode === "local" || hasLoopbackBaseUrl(p.baseUrl); } diff --git a/gui/src/provider-workspace/kind.ts b/gui/src/provider-workspace/kind.ts index 212e4137a..d72b9236d 100644 --- a/gui/src/provider-workspace/kind.ts +++ b/gui/src/provider-workspace/kind.ts @@ -8,9 +8,9 @@ import { hasLoopbackBaseUrl, type WorkspaceProvider } from "./catalog"; export type ProviderKind = "cloud" | "local" | "selfHosted" | "login"; -/** Local runtime: explicit local auth mode or a loopback base URL. */ +/** Local runtime: a loopback endpoint. `authMode: local` only means no API credential. */ export function isLocalProvider(item: WorkspaceProvider): boolean { - return item.authMode === "local" || hasLoopbackBaseUrl(item.baseUrl); + return hasLoopbackBaseUrl(item.baseUrl); } export const SELF_HOSTED_HINTS = ["ollama", "vllm", "lm-studio", "lmstudio", "litellm", "localai"]; diff --git a/src/adapters/chatgpt-browser-oracle.ts b/src/adapters/chatgpt-browser-oracle.ts new file mode 100644 index 000000000..6ac4cf800 --- /dev/null +++ b/src/adapters/chatgpt-browser-oracle.ts @@ -0,0 +1,503 @@ +import { randomUUID } from "node:crypto"; +import { chmod, lstat, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fromJSONSchema, type ZodType } from "zod"; +import { terminateProcessTree } from "../lib/process-control"; +import { commandInvocation } from "../lib/win-exec"; +import { + isAllowedToolChoice, + namespacedToolName, + toolAllowedByChoice, + toolChoiceAliases, + type OcxContentPart, + type OcxParsedRequest, + type OcxTool, +} from "../types"; + +export const CHATGPT_BROWSER_MODEL_ID = "gpt-5.6-pro"; +/** Oracle's stable alias for the current ChatGPT Pro picker entry. */ +export const ORACLE_CHATGPT_PRO_MODEL = "gpt-5.5-pro"; + +export type ChatGptBrowserErrorCode = + | "aborted" + | "login_required" + | "model_unavailable" + | "quota_exhausted" + | "timeout" + | "oracle_missing" + | "oracle_incompatible" + | "empty_response" + | "response_too_large" + | "unsupported_content" + | "protocol_error" + | "browser_failed"; + +const ERROR_MESSAGES: Record = { + aborted: "The ChatGPT browser request was cancelled.", + login_required: "ChatGPT browser login is required or the saved browser session expired. Sign in through Oracle, then retry.", + model_unavailable: "GPT-5.6 Pro is not available for this ChatGPT account or workspace. No fallback model was used.", + quota_exhausted: "The ChatGPT Pro allowance is exhausted or temporarily unavailable. No fallback model was used.", + timeout: "The ChatGPT browser turn timed out before Oracle captured a final response.", + oracle_missing: "Oracle is not installed or could not be launched. Install @steipete/oracle and ensure the oracle executable is on PATH.", + oracle_incompatible: "Oracle 0.16.1 or newer is required for fail-closed GPT-5.6 Pro browser selection.", + empty_response: "Oracle completed without a captured ChatGPT response.", + response_too_large: "The captured ChatGPT response exceeded OpenCodex's safe browser-output limit.", + unsupported_content: "The ChatGPT browser provider currently supports text-only turns.", + protocol_error: "GPT-5.6 Pro returned an invalid browser response or an unavailable tool call. No fallback was used.", + browser_failed: "The ChatGPT browser turn failed before a final response was captured.", +}; + +export class ChatGptBrowserError extends Error { + constructor(public readonly code: ChatGptBrowserErrorCode) { + super(ERROR_MESSAGES[code]); + this.name = "ChatGptBrowserError"; + } +} + +type BrowserConversationMessage = { + role: "user" | "assistant" | "developer" | "tool"; + content: unknown; +}; + +type BrowserTool = { + name: string; + description: string; + parameters: Record; + freeform: boolean; + toolSearch: boolean; +}; + +const TOOL_DESCRIPTION_LIMIT = 240; +const SCHEMA_ANNOTATION_KEYS = new Set(["description", "title", "examples", "$comment", "markdownDescription"]); +const SCHEMA_MAP_KEYS = new Set(["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"]); +const toolSchemaCache = new WeakMap, ZodType>(); + +/** Keep validation-relevant JSON Schema while dropping prose that makes browser prompts enormous. */ +function compactToolSchema(value: unknown): unknown { + if (Array.isArray(value)) return value.map(compactToolSchema); + if (value === null || typeof value !== "object") return value; + const compact: Record = {}; + for (const [key, child] of Object.entries(value)) { + if (SCHEMA_ANNOTATION_KEYS.has(key)) continue; + if (SCHEMA_MAP_KEYS.has(key) && child !== null && typeof child === "object" && !Array.isArray(child)) { + compact[key] = Object.fromEntries( + Object.entries(child).map(([name, schema]) => [name, compactToolSchema(schema)]), + ); + continue; + } + // These values are data, not nested schemas; preserve them byte-for-byte. + if (key === "enum" || key === "const" || key === "default" || key === "required") { + compact[key] = child; + continue; + } + compact[key] = compactToolSchema(child); + } + return compact; +} + +export type ChatGptBrowserResponse = + | { type: "final"; text: string } + | { type: "tool_call"; id: string; name: string; arguments: Record }; + +function textContent(content: string | OcxContentPart[]): string | Array<{ type: "text"; text: string }> { + if (typeof content === "string") return content; + const parts: Array<{ type: "text"; text: string }> = []; + for (const part of content) { + if (part.type === "image") throw new ChatGptBrowserError("unsupported_content"); + parts.push({ type: "text", text: part.text }); + } + return parts; +} + +/** + * Convert the internal Responses conversation to one deterministic browser prompt. + * Oracle owns browser login/model selection/capture; OpenCodex owns only this text envelope. + */ +function browserTools(parsed: OcxParsedRequest): BrowserTool[] { + const choice = parsed.options.toolChoice; + if (choice === "none") return []; + const candidates = (parsed.context.tools ?? []).filter(tool => ( + tool.webSearch !== true && tool.imageGeneration !== true && tool.videoGeneration !== true + )); + const selected = candidates.filter(tool => { + if (!choice || choice === "auto" || choice === "required") return true; + if (typeof choice === "object" && "name" in choice) return toolChoiceAliases(tool).includes(choice.name); + if (isAllowedToolChoice(choice)) return toolAllowedByChoice(tool, new Set(choice.allowedTools)); + return true; + }); + const choiceRequiresTool = choice === "required" + || (typeof choice === "object" && "name" in choice) + || (isAllowedToolChoice(choice) && choice.mode === "required"); + if (choiceRequiresTool && selected.length === 0) { + throw new ChatGptBrowserError("unsupported_content"); + } + return selected.map(tool => ({ + name: namespacedToolName(tool.namespace, tool.name), + description: tool.description.slice(0, TOOL_DESCRIPTION_LIMIT), + parameters: compactToolSchema(tool.parameters) as Record, + freeform: tool.freeform === true, + toolSearch: tool.toolSearch === true, + })); +} + +function toolArgumentsMatchSchema(schema: Record, value: Record): boolean { + try { + let validate = toolSchemaCache.get(schema); + if (!validate) { + validate = fromJSONSchema(schema as never); + toolSchemaCache.set(schema, validate); + } + return validate.safeParse(value).success; + } catch { + return false; + } +} + +export function buildChatGptBrowserPrompt(parsed: OcxParsedRequest, nonce = randomUUID()): string { + const messages: BrowserConversationMessage[] = parsed.context.messages.map(message => { + if (message.role === "user" || message.role === "developer") { + return { role: message.role, content: textContent(message.content) }; + } + if (message.role === "toolResult") { + return { + role: "tool", + content: { + toolCallId: message.toolCallId, + toolName: message.toolName, + namespace: message.toolNamespace, + isError: message.isError, + output: textContent(message.content), + }, + }; + } + return { + role: "assistant", + content: message.content.map(part => { + if (part.type === "text") return { type: "text", text: part.text }; + if (part.type === "thinking") return { type: "reasoning_summary", text: part.thinking }; + return { + type: "tool_call", + id: part.id, + namespace: part.namespace, + name: part.name, + arguments: part.arguments, + }; + }), + }; + }); + + const conversation = { + model: CHATGPT_BROWSER_MODEL_ID, + system: parsed.context.systemPrompt ?? [], + messages, + tools: browserTools(parsed), + toolChoice: parsed.options.toolChoice ?? "auto", + responseProtocol: { + nonce, + allowed: [ + { nonce, type: "final", text: "non-empty Markdown response" }, + { nonce, type: "tool_call", name: "exact tools[].name", arguments: "JSON object" }, + ], + }, + }; + + return [ + "Continue the OpenAI Responses conversation encoded as JSON below.", + "Follow the system and developer instructions represented in the conversation.", + "Tools listed in conversation.tools are executed by the client, not by ChatGPT. You may request exactly one listed tool call when needed.", + "Return exactly one JSON object and no surrounding prose or Markdown fence. Copy responseProtocol.nonce exactly.", + "For a final response use {nonce,type:'final',text}. For a tool request use {nonce,type:'tool_call',name,arguments} with an exact listed tool name.", + "Do not discuss this transport envelope unless the conversation explicitly asks about it.", + "", + JSON.stringify(conversation), + ].join("\n"); +} + +function plainRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function parseResponseJson(answerText: string): unknown { + const trimmed = answerText.trim(); + const fenced = trimmed.match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/i); + const payload = fenced?.[1]?.trim() ?? trimmed; + try { return JSON.parse(payload); } + catch { throw new ChatGptBrowserError("protocol_error"); } +} + +export function parseChatGptBrowserResponse( + answerText: string, + parsed: OcxParsedRequest, + nonce: string, +): ChatGptBrowserResponse { + const value = parseResponseJson(answerText); + if (!plainRecord(value) || value.nonce !== nonce) throw new ChatGptBrowserError("protocol_error"); + const required = parsed.options.toolChoice === "required" + || (typeof parsed.options.toolChoice === "object" && "name" in parsed.options.toolChoice) + || (isAllowedToolChoice(parsed.options.toolChoice) && parsed.options.toolChoice.mode === "required"); + if (value.type === "final") { + if (required || typeof value.text !== "string" || !value.text.trim()) { + throw new ChatGptBrowserError("protocol_error"); + } + return { type: "final", text: value.text.trim() }; + } + if (value.type !== "tool_call" || typeof value.name !== "string" || !plainRecord(value.arguments)) { + throw new ChatGptBrowserError("protocol_error"); + } + const tools = browserTools(parsed); + const selected = tools.find(tool => tool.name === value.name); + if (!selected) throw new ChatGptBrowserError("protocol_error"); + if (selected.freeform && typeof value.arguments.input !== "string") { + throw new ChatGptBrowserError("protocol_error"); + } + if (!toolArgumentsMatchSchema(selected.parameters, value.arguments)) { + throw new ChatGptBrowserError("protocol_error"); + } + return { + type: "tool_call", + id: `call_${randomUUID().replaceAll("-", "")}`, + name: selected.name, + arguments: value.arguments, + }; +} + +export interface OracleBrowserTurnOptions { + signal?: AbortSignal; + command?: string; +} + +export interface OracleBrowserTurnResult { + answerText: string; +} + +export function buildOracleBrowserArgs(outputPath: string): string[] { + return [ + "--engine", "browser", + "--model", ORACLE_CHATGPT_PRO_MODEL, + "--browser-model-strategy", "select", + "--browser-thinking-time", "extended", + "--browser-timeout", "60m", + "--chatgpt-url", "https://chatgpt.com/", + "--browser-attachments", "never", + "--browser-archive", "auto", + "--heartbeat", "0", + "--no-notify", + "--render-plain", + "--write-output", outputPath, + "--wait", + "--prompt", "-", + ]; +} + +export function resolveOracleCommand(value: string | undefined): string { + const command = value?.trim() || process.env.OPENCODEX_ORACLE_COMMAND?.trim() || "oracle"; + if (!command || /[\0\r\n]/.test(command)) throw new ChatGptBrowserError("oracle_missing"); + return command; +} + +export function oracleVersionIsCompatible(output: string): boolean { + const match = output.match(/\b(\d+)\.(\d+)\.(\d+)\b/); + if (!match) return false; + const version = match.slice(1).map(Number); + const minimum = [0, 16, 1]; + for (let index = 0; index < minimum.length; index += 1) { + if (version[index]! > minimum[index]!) return true; + if (version[index]! < minimum[index]!) return false; + } + return true; +} + +const oracleCompatibilityCache = new Set(); +const ORACLE_VERSION_PROBE_TIMEOUT_MS = 10_000; + +export function resetOracleCompatibilityCacheForTests(): void { + oracleCompatibilityCache.clear(); +} + +export async function assertOracleCompatible( + commandValue?: string, + options: { signal?: AbortSignal; timeoutMs?: number } = {}, +): Promise { + const command = resolveOracleCommand(commandValue); + if (oracleCompatibilityCache.has(command)) return; + if (options.signal?.aborted) throw new ChatGptBrowserError("aborted"); + const invocation = commandInvocation(command, ["--version"]); + let child: ReturnType; + try { + child = Bun.spawn([invocation.file, ...invocation.args], { + stdout: "pipe", + stderr: "pipe", + ...invocation.options, + detached: process.platform !== "win32", + env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" }, + }); + } catch { + throw new ChatGptBrowserError("oracle_missing"); + } + let aborted = false; + let timedOut = false; + let termination: Promise | undefined; + const terminate = () => { + termination ??= terminateProcessTree(child.pid, "SIGTERM", 1_000).catch(() => { + try { child.kill("SIGKILL"); } catch { /* best-effort cleanup */ } + }); + }; + const onAbort = () => { aborted = true; terminate(); }; + options.signal?.addEventListener("abort", onAbort, { once: true }); + const timeout = setTimeout(() => { timedOut = true; terminate(); }, options.timeoutMs ?? ORACLE_VERSION_PROBE_TIMEOUT_MS); + try { + if (options.signal?.aborted) onAbort(); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + readProcessText(child.stdout), + readProcessText(child.stderr), + ]); + if (termination) await termination; + if (aborted || options.signal?.aborted) throw new ChatGptBrowserError("aborted"); + if (timedOut || exitCode !== 0) throw new ChatGptBrowserError("oracle_missing"); + if (!oracleVersionIsCompatible(`${stdout}\n${stderr}`)) { + throw new ChatGptBrowserError("oracle_incompatible"); + } + oracleCompatibilityCache.add(command); + } finally { + clearTimeout(timeout); + options.signal?.removeEventListener("abort", onAbort); + if (termination) await termination; + } +} + +function classifyOracleFailure(output: string): ChatGptBrowserErrorCode { + const normalized = output.toLowerCase(); + if (/rate[ -]?limit|too many requests|usage limit|reached.+limit|quota|allowance|temporarily limited access/.test(normalized)) { + return "quota_exhausted"; + } + if (/unable to find model|model option|model picker selected|requires gpt|refusing to submit without confirmed|model.+not available|not eligible/.test(normalized)) { + return "model_unavailable"; + } + if (/sign[ -]?in|log[ -]?in|login required|manual-login profile is not initialized|auth(?:entication)? session|session expired|session not detected|no chatgpt cookies|cookie.+(?:missing|failed|unavailable)/.test(normalized)) { + return "login_required"; + } + if (/timed? out|timeout/.test(normalized)) return "timeout"; + return "browser_failed"; +} + +async function readProcessText(stream: ReadableStream | number | null | undefined): Promise { + if (!stream || typeof stream === "number") return ""; + const reader = stream.getReader(); + const decoder = new TextDecoder(); + const limit = 64 * 1024; + let tail = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + tail += decoder.decode(value, { stream: true }); + if (tail.length > limit) tail = tail.slice(-limit); + } + tail += decoder.decode(); + return tail.length > limit ? tail.slice(-limit) : tail; + } catch { + return tail; + } +} + +/** Run Oracle without a shell. The prompt is piped over stdin so request text never enters argv. */ +export async function runOracleBrowserTurn( + prompt: string, + options: OracleBrowserTurnOptions = {}, +): Promise { + if (options.signal?.aborted) throw new ChatGptBrowserError("aborted"); + const command = resolveOracleCommand(options.command); + try { + await assertOracleCompatible(command, { signal: options.signal }); + } catch (error) { + if (options.signal?.aborted) throw new ChatGptBrowserError("aborted"); + throw error; + } + // Compatibility probing is deliberately allowed to finish so its child is reaped, but a + // cancellation during that probe must never progress to a real browser submission. + if (options.signal?.aborted) throw new ChatGptBrowserError("aborted"); + const tempDir = await mkdtemp(join(tmpdir(), "opencodex-chatgpt-browser-")); + await chmod(tempDir, 0o700); + const outputPath = join(tempDir, "answer.md"); + let child: ReturnType | undefined; + let aborted = false; + let termination: Promise | undefined; + const onAbort = () => { + aborted = true; + if (child) termination ??= terminateProcessTree(child.pid, "SIGINT", 5_000).catch(() => { + try { child?.kill("SIGKILL"); } catch { /* best-effort hard cancellation */ } + }); + }; + + try { + // Subscribe before spawning Oracle, then re-check. AbortSignal does not replay an abort that + // happened before addEventListener, so both steps are required to close the submission race. + options.signal?.addEventListener("abort", onAbort, { once: true }); + if (options.signal?.aborted) { + onAbort(); + throw new ChatGptBrowserError("aborted"); + } + const invocation = commandInvocation(command, buildOracleBrowserArgs(outputPath)); + try { + child = Bun.spawn([invocation.file, ...invocation.args], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + ...invocation.options, + detached: process.platform !== "win32", + env: { + ...process.env, + FORCE_COLOR: "0", + NO_COLOR: "1", + ORACLE_NO_DETACH: "1", + }, + }); + } catch { + throw new ChatGptBrowserError("oracle_missing"); + } + + if (aborted || options.signal?.aborted) { + onAbort(); + throw new ChatGptBrowserError("aborted"); + } + const stdoutPromise = readProcessText(child.stdout); + const stderrPromise = readProcessText(child.stderr); + const childStdin = child.stdin; + if (!childStdin || typeof childStdin === "number") { + throw new ChatGptBrowserError("browser_failed"); + } + childStdin.write(prompt); + childStdin.end(); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + stdoutPromise, + stderrPromise, + ]); + + if (aborted || options.signal?.aborted) throw new ChatGptBrowserError("aborted"); + if (exitCode !== 0) throw new ChatGptBrowserError(classifyOracleFailure(`${stdout}\n${stderr}`)); + + const outputStat = await lstat(outputPath).catch(() => undefined); + if (!outputStat?.isFile() || outputStat.isSymbolicLink()) { + throw new ChatGptBrowserError("empty_response"); + } + if (outputStat.size > 8 * 1024 * 1024) throw new ChatGptBrowserError("response_too_large"); + const answerText = (await readFile(outputPath, "utf8").catch(() => "")).trim(); + if (!answerText) throw new ChatGptBrowserError("empty_response"); + return { answerText }; + } finally { + options.signal?.removeEventListener("abort", onAbort); + // No-op after normal exit; guarantees early setup/write failures cannot orphan a browser + // process that may otherwise keep the private output directory alive for the full timeout. + if (child && child.exitCode === null) { + termination ??= terminateProcessTree(child.pid, "SIGTERM", 1_000).catch(() => { + try { child?.kill("SIGKILL"); } catch { /* best-effort process cleanup */ } + }); + } + if (termination) await termination; + if (child) await child.exited.catch(() => undefined); + await rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + } +} diff --git a/src/adapters/chatgpt-browser.ts b/src/adapters/chatgpt-browser.ts new file mode 100644 index 000000000..79a417b5d --- /dev/null +++ b/src/adapters/chatgpt-browser.ts @@ -0,0 +1,125 @@ +import { randomUUID } from "node:crypto"; +import { estimateTokens } from "../lib/token-estimate"; +import type { AdapterEvent, OcxProviderConfig } from "../types"; +import type { ProviderAdapter } from "./base"; +import { + buildChatGptBrowserPrompt, + CHATGPT_BROWSER_MODEL_ID, + ChatGptBrowserError, + parseChatGptBrowserResponse, + runOracleBrowserTurn, + type OracleBrowserTurnOptions, + type OracleBrowserTurnResult, +} from "./chatgpt-browser-oracle"; + +export interface ChatGptBrowserAdapterDeps { + runBrowserTurn?: (prompt: string, options?: OracleBrowserTurnOptions) => Promise; +} + +function errorStatus(code: ChatGptBrowserError["code"]): number { + switch (code) { + case "login_required": return 401; + case "model_unavailable": return 403; + // 402 is quota-signalling but non-retryable in Codex. A retry could consume another Pro turn. + case "quota_exhausted": return 402; + case "aborted": return 499; + // Oracle may have submitted before timing out, so surface a fail-fast client status. + case "timeout": return 400; + case "unsupported_content": return 400; + case "oracle_missing": + case "oracle_incompatible": return 503; + default: return 400; + } +} + +export function createChatGptBrowserAdapter( + provider: OcxProviderConfig, + deps: ChatGptBrowserAdapterDeps = {}, +): ProviderAdapter { + return { + name: "chatgpt-browser", + + buildRequest() { + return { + url: provider.baseUrl, + method: "POST", + headers: {}, + body: "", + }; + }, + + async *parseStream(): AsyncGenerator { + yield { + type: "error", + message: "The ChatGPT browser adapter uses runTurn; the HTTP stream path is disabled.", + }; + }, + + async runTurn(parsed, incoming, emit) { + if (parsed.modelId !== CHATGPT_BROWSER_MODEL_ID) { + emit({ + type: "error", + message: `ChatGPT browser supports only ${CHATGPT_BROWSER_MODEL_ID}; no fallback model was used.`, + status: 400, + errorType: "invalid_request_error", + code: "model_not_supported", + }); + return; + } + + try { + const responseNonce = randomUUID(); + const prompt = buildChatGptBrowserPrompt(parsed, responseNonce); + const run = deps.runBrowserTurn ?? runOracleBrowserTurn; + emit({ type: "heartbeat" }); + const heartbeat = setInterval(() => emit({ type: "heartbeat" }), 10_000); + let result: OracleBrowserTurnResult; + try { + result = await run(prompt, { + signal: incoming.abortSignal, + command: provider.oracleCommand, + }); + } finally { + clearInterval(heartbeat); + } + const inputTokens = estimateTokens(prompt, CHATGPT_BROWSER_MODEL_ID); + const outputTokens = estimateTokens(result.answerText, CHATGPT_BROWSER_MODEL_ID); + const response = parseChatGptBrowserResponse(result.answerText, parsed, responseNonce); + if (response.type === "final") { + emit({ type: "text_delta", text: response.text, phase: "final_answer" }); + } else { + emit({ type: "tool_call_start", id: response.id, name: response.name }); + emit({ type: "tool_call_delta", arguments: JSON.stringify(response.arguments) }); + emit({ type: "tool_call_end" }); + } + emit({ + type: "done", + usage: { + inputTokens, + outputTokens, + totalTokens: inputTokens + outputTokens, + estimated: true, + }, + ...(response.type === "tool_call" ? { stopReason: "tool_use", endTurn: false } : { endTurn: true }), + }); + } catch (error) { + if (!(error instanceof ChatGptBrowserError)) { + console.error("[chatgpt-browser] unexpected runTurn failure:", error); + } + const safe = error instanceof ChatGptBrowserError + ? error + : new ChatGptBrowserError("browser_failed"); + emit({ + type: "error", + message: safe.message, + status: errorStatus(safe.code), + errorType: "chatgpt_browser_error", + code: safe.code, + // Oracle may have submitted before a capture failure. Never invite an automatic retry + // that could duplicate a ChatGPT turn and consume the user's regular Pro allowance. + retryable: false, + }); + } + }, + }; +} diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 432ace821..f75e9550e 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -326,11 +326,13 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls = // Per-model routed opt-ins can be added once provider metadata exposes this capability. delete entry.supports_reasoning_summaries; const isCursorEntry = typeof entry.slug === "string" && entry.slug.startsWith("cursor/"); + const isChatGptBrowserEntry = typeof entry.slug === "string" && entry.slug.startsWith("chatgpt-browser/"); // Routed providers use opencodex sidecars and client-executed tool discovery. The sidecar // runs through native gpt-5.4-mini, so image search is available and verbalized for text-only - // models. EXCEPT cursor: its runTurn transport bypasses the web-search plan entirely and - // rejects server search queries — advertising the tool would make models call into a void. - if (isCursorEntry) { + // models. EXCEPT runTurn-only transports: they bypass the web-search plan entirely, so + // advertising the tool would make models call into a void. ChatGPT Browser also must not + // resolve the native OpenAI search sidecar because that would consume Codex/Work allowance. + if (isCursorEntry || isChatGptBrowserEntry) { delete entry.web_search_tool_type; entry.supports_search_tool = false; } else { diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 404441ae2..0f1573375 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -187,7 +187,7 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, : {}), ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), - ...(prov.adapter === "kiro" ? { supportsVerbosity: false } : {}), + ...(prov.adapter === "kiro" || prov.adapter === "chatgpt-browser" ? { supportsVerbosity: false } : {}), // Default-on for openai-chat providers (explicit false opts out); other adapters // advertise only on explicit opt-in. ...(prov.parallelToolCalls === true || (prov.adapter === "openai-chat" && prov.parallelToolCalls !== false) diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index d7724439f..757f38000 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -208,10 +208,13 @@ export function deriveEntry( slug, display_name: slug, description: desc, shell_type: "shell_command", visibility: "list", supported_in_api: true, priority, base_instructions: "You are a helpful coding assistant.", - ...(isRouted ? { web_search_tool_type: "text_and_image", supports_search_tool: true } : {}), + ...(isRouted && !slug.startsWith("chatgpt-browser/") + ? { web_search_tool_type: "text_and_image", supports_search_tool: true } + : isRouted ? { supports_search_tool: false } : {}), }; if (isRouted) { applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact); + if (slug.startsWith("chatgpt-browser/")) entry.supports_parallel_tool_calls = false; } else { applyReasoningLevels(entry, isGpt56NativeSlug(slug) ? undefined : ["low", "medium", "high", "xhigh"]); diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index 6e936ec22..129986f19 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -381,6 +381,8 @@ export function applySubagentModelFallback( nativeFallbackOnly = false, ): { from?: string; to?: string; skipped?: string[] } | null { if (!isThreadSpawnRequest(headers)) return null; + const primaryRoute = tryRouteFallbackModel(config, parsed.modelId); + if (primaryRoute?.provider.adapter === "chatgpt-browser") return null; const roleFallback = resolveAgentModelFallbackForPrimary(parsed.modelId, getCodexHome()); const globalFallback = config.subagentModelFallback ?? []; if (globalFallback.length === 0 && roleFallback.length === 0) return null; diff --git a/src/config.ts b/src/config.ts index 9127897c1..0f54ae297 100644 --- a/src/config.ts +++ b/src/config.ts @@ -430,9 +430,35 @@ function resolveRuntimePortPath(): string { const warnedConfigFallbacks = new Set(); +type OracleCommandNormalization = + | { ok: true; value: string | undefined } + | { ok: false; error: string }; + +/** Validate and normalize oracleCommand identically at schema and management boundaries. */ +export function normalizeOracleCommandConfig(value: unknown): OracleCommandNormalization { + if (value === undefined) return { ok: true, value: undefined }; + if (typeof value !== "string" || !value.trim()) { + return { ok: false, error: "oracleCommand must be a nonblank string" }; + } + if (/[\0\r\n]/.test(value)) { + return { ok: false, error: "oracleCommand must not contain NUL or line breaks" }; + } + return { ok: true, value: value.trim() }; +} + +const oracleCommandSchema = z.unknown().transform((value, ctx): string | undefined => { + const normalized = normalizeOracleCommandConfig(value); + if (!normalized.ok) { + ctx.addIssue({ code: "custom", message: normalized.error }); + return z.NEVER; + } + return normalized.value; +}); + const providerConfigSchema = z.object({ adapter: z.string().min(1), baseUrl: z.string().min(1), + oracleCommand: oracleCommandSchema.optional(), apiKeyTransport: z.enum(["x-api-key", "bearer"]).optional(), responsesPath: z.string().min(1).optional(), allowPrivateNetwork: z.boolean().optional(), @@ -480,6 +506,11 @@ export function providerBaseUrlConfigError(baseUrl: string): string | null { return null; } +export function oracleCommandConfigError(value: unknown): string | null { + const normalized = normalizeOracleCommandConfig(value); + return normalized.ok ? null : normalized.error; +} + function providerResponsesPathConfigError(responsesPath: string | undefined): string | null { if (responsesPath === undefined) return null; if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(responsesPath) || responsesPath.includes("://")) { diff --git a/src/lib/process-control.ts b/src/lib/process-control.ts index f55028460..96a770fe0 100644 --- a/src/lib/process-control.ts +++ b/src/lib/process-control.ts @@ -21,6 +21,46 @@ export function waitForExit(pid: number, timeoutMs: number): boolean { return !isProcessAlive(pid); } +async function waitForExitAsync(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!isProcessAlive(pid)) return true; + await Bun.sleep(50); + } + return !isProcessAlive(pid); +} + +/** Terminate an isolated spawned process group/tree, then wait until the root exits. */ +export async function terminateProcessTree( + pid: number, + gracefulSignal: NodeJS.Signals = "SIGTERM", + graceMs = 5_000, +): Promise { + if (!Number.isInteger(pid) || pid <= 0 || !isProcessAlive(pid)) return; + if (process.platform === "win32") { + const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`; + const killer = Bun.spawn([taskkill, "/PID", String(pid), "/T", "/F"], { + stdout: "ignore", + stderr: "ignore", + windowsHide: true, + }); + const exitCode = await killer.exited; + if (exitCode !== 0 && isProcessAlive(pid)) { + throw new Error(`failed to terminate process tree ${pid}`); + } + } else { + try { process.kill(-pid, gracefulSignal); } catch { + try { process.kill(pid, gracefulSignal); } catch { /* process already exited */ } + } + if (!(await waitForExitAsync(pid, graceMs))) { + try { process.kill(-pid, "SIGKILL"); } catch { + try { process.kill(pid, "SIGKILL"); } catch { /* process already exited */ } + } + } + } + if (!(await waitForExitAsync(pid, 5_000))) throw new Error(`process ${pid} did not exit`); +} + /** Injectable seams so the graceful-stop flow is unit-testable without a live proxy. */ export interface GracefulStopIo { fetchFn?: typeof fetch; diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 7ca8fa5cc..8b768d51c 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -105,7 +105,9 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon adapter: entry.adapter, baseUrl: entry.baseUrl, ...(entry.apiKeyTransport !== undefined ? { apiKeyTransport: entry.apiKeyTransport } : {}), - authMode: entry.authKind === "local" ? undefined : entry.authKind, + // Keep local authentication explicit. The GUI and management API distinguish + // credential-free local helpers (including browser automation) from missing keys. + authMode: entry.authKind, ...(entry.codexAccountMode ? { codexAccountMode: entry.codexAccountMode } : {}), ...(entry.keyOptional !== undefined ? { keyOptional: entry.keyOptional } : {}), ...(entry.freeTier !== undefined ? { freeTier: entry.freeTier } : {}), diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 663e8c8ec..ea3970d73 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -442,6 +442,21 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ featured: true, note: "Codex login account pool (default) or Direct main-account mode via codexAccountMode", }, + { + id: "chatgpt-browser", + label: "ChatGPT Browser (experimental)", + adapter: "chatgpt-browser", + baseUrl: "https://chatgpt.com", + authKind: "local", + dashboardPreset: true, + models: ["gpt-5.6-pro"], + defaultModel: "gpt-5.6-pro", + liveModels: false, + modelInputModalities: { "gpt-5.6-pro": ["text"] }, + modelReasoningEfforts: { "gpt-5.6-pro": [] }, + parallelToolCalls: false, + note: "Experimental, opt-in standard-ChatGPT browser route. Requires @steipete/oracle and an eligible signed-in ChatGPT account. Routes only gpt-5.6-pro, verifies Pro selection, and never falls back to Codex, Work, API billing, another model, or another account.", + }, { id: "cursor", label: "Cursor (experimental)", diff --git a/src/server/adapter-resolve.ts b/src/server/adapter-resolve.ts index 3f74d87b2..f70e47b29 100644 --- a/src/server/adapter-resolve.ts +++ b/src/server/adapter-resolve.ts @@ -1,5 +1,6 @@ import { createAnthropicAdapter } from "../adapters/anthropic"; import { createAzureAdapter } from "../adapters/azure"; +import { createChatGptBrowserAdapter } from "../adapters/chatgpt-browser"; import { createCursorAdapter } from "../adapters/cursor"; import { createGoogleAdapter } from "../adapters/google"; import { createKiroAdapter } from "../adapters/kiro"; @@ -55,6 +56,8 @@ export function resolveAdapter(providerConfig: OcxProviderConfig, cacheRetention return createAzureAdapter(providerConfig); case "cursor": return createCursorAdapter(providerConfig); + case "chatgpt-browser": + return createChatGptBrowserAdapter(providerConfig); case "mimo-free": return createMimoFreeAdapter(providerConfig); default: diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 5b4d48c21..ccb3d6719 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -4,6 +4,7 @@ import { apiKeyTransportConfigError, booleanRecordConfigError, modelAdapterRecordConfigError, + oracleCommandConfigError, codexAutoStartEnabled, positiveIntegerConfigError, positiveIntegerRecordConfigError, @@ -311,6 +312,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown): return `provider ${name} must not include codexAccountMode`; } const typed = provider as unknown as OcxProviderConfig; + const oracleCommandError = oracleCommandConfigError(raw.oracleCommand); + if (oracleCommandError) return `provider ${name} ${oracleCommandError}`; const baseUrlError = providerBaseUrlConfigError(typed.baseUrl); if (baseUrlError) return `provider ${name} ${baseUrlError}`; const destinationError = providerDestinationConfigError(name, typed); diff --git a/src/server/management/context.ts b/src/server/management/context.ts index a646d68ee..cf0ab88eb 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -11,6 +11,7 @@ export interface ManagementApiDeps { action: StartupInstallAction, options?: { repair?: boolean }, ) => Promise<{ message: string }>; + assertOracleCompatible?: (command?: string) => Promise; } diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index b792505cd..189274a04 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -335,6 +335,25 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise = queue.stream(); - if (options.comboAttempt) { + if (options.comboAttempt || adapterNeedsErrorPreflight(adapter.name)) { const preflight = await preflightAdapterEvents(eventSource); if (preflight.error || preflight.empty) { runTurnAbort.abort(); queue.close(); const message = preflight.error?.message ?? "Adapter ended before producing a response"; - return formatErrorResponse(502, "upstream_error", redactSecretString(message)); + if (options.comboAttempt) { + return formatErrorResponse(502, "upstream_error", redactSecretString(message)); + } + return formatErrorResponse( + preflight.error?.status ?? 502, + preflight.error?.errorType ?? "upstream_error", + redactSecretString(message), + { code: preflight.error?.code }, + ); } eventSource = preflight.stream; } diff --git a/src/types.ts b/src/types.ts index 983b483dd..d6ca87dd1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -874,6 +874,11 @@ export interface ResponsesItemIdRepairConfig { export interface OcxProviderConfig { adapter: string; + /** + * Executable used by the experimental chatgpt-browser adapter. Defaults to + * OPENCODEX_ORACLE_COMMAND, then `oracle`. Passed directly to Bun.spawn without a shell. + */ + oracleCommand?: string; /** * Per-model wire override, keyed by the upstream native model id (after namespace * and combo resolution). A single gateway can front models that speak different diff --git a/structure/01_runtime.md b/structure/01_runtime.md index dde66fd1e..cee05ec10 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -70,6 +70,8 @@ The server exposes `POST /api/stop` which restores native Codex config, stops an | `src/adapters/anthropic.ts` | Anthropic Messages bridge. | | `src/adapters/google.ts` | Gemini bridge. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | +| `src/adapters/chatgpt-browser.ts` | Experimental standard-ChatGPT Pro `runTurn` adapter and strict client-tool response protocol. | +| `src/adapters/chatgpt-browser-oracle.ts` | Oracle version gate, browser invocation, bounded capture, cancellation, and fixed error classification. | Adapter output must stay in internal `AdapterEvent` form until `bridge.ts` converts it back to Responses SSE or WebSocket frames. diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index fec2c1380..98941b879 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -33,6 +33,36 @@ within their route; neither route falls through to the other. See and before the `/v1/*` guard. Unknown `/v1/*` paths return JSON 404 errors instead of falling through to GUI static serving. +## Standard ChatGPT browser transport + +The opt-in `chatgpt-browser/gpt-5.6-pro` route is a `runTurn` adapter backed by Oracle 0.16.1+. +OpenCodex launches the configured executable without a shell, forces browser mode against +`https://chatgpt.com/`, selects Oracle's stable current-Pro alias with strict Pro confirmation, pipes +the request on stdin, and accepts output only from a fresh private bounded file. The public model id +is intentionally namespaced; bare `gpt-*` ids remain owned by the canonical `openai` Codex route. + +The adapter uses a nonce-bound JSON protocol for final text or one client-executed tool call. It +compacts prose-only tool-schema annotations, forces local continuation replay under Codex's +`store:false`, and preflights the first meaningful event so a browser failure is returned as one HTTP +error rather than a retried 200 stream. Unknown tools, malformed output, missing login, ineligible +access, quota exhaustion, model drift, timeout, and missing/incompatible Oracle all fail closed. +Because the visible composer accepts only a user message, encoded system/developer labels do not +retain native Responses role authority. This experimental route must not be treated as an +instruction-isolated transport when repository or user content is untrusted. + +This provider advertises text-only input, no hosted search, no parallel calls, and no reasoning +picker. It is deliberately absent from `noVisionModels`: that flag would invoke the native OpenAI +vision sidecar and spend Codex/Work allowance before the browser turn. The same isolation rule keeps +the native web-search sidecar disabled. + +[Decision Log] +- 목적과 의도: Expose GPT-5.6 Pro only through the standard ChatGPT allowance while preserving explicit user opt-in and fail-closed routing. +- 기존 구현 및 제약 조건: The canonical ChatGPT endpoint is `/backend-api/codex` and consumes Codex/Work usage; Pro is available only in standard ChatGPT and browser turns can take longer than the bridge watchdog. +- 검토한 주요 대안: Relabel the Codex endpoint; call an undocumented backend API; use the separately billed OpenAI API; automate the visible standard ChatGPT UI through an isolated helper. +- 선택한 방식: Delegate visible browser login/model confirmation/capture to audited Oracle semantics, require 0.16.1+, pin the standard ChatGPT URL, and validate a nonce-bound tool/final protocol. +- 다른 대안 대신 이 방식을 선택한 이유: The other routes either violate the requested usage boundary, rely on undocumented direct calls, or silently change billing; Oracle already fails closed on Pro selection and session state. +- 장점, 단점 및 영향: No Codex/Work or API fallback and actionable failures; in exchange, the route is experimental, UI-dependent, non-streaming until capture, text-only, and requires a local Oracle/Chrome installation. + ### Passthrough SSE stream shapes (#314) Native passthrough SSE has TWO shapes, selected per request in diff --git a/tests/chatgpt-browser-adapter.test.ts b/tests/chatgpt-browser-adapter.test.ts new file mode 100644 index 000000000..5a1b500cc --- /dev/null +++ b/tests/chatgpt-browser-adapter.test.ts @@ -0,0 +1,438 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { access, chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createChatGptBrowserAdapter } from "../src/adapters/chatgpt-browser"; +import { buildCatalogEntries } from "../src/codex/catalog"; +import { + buildChatGptBrowserPrompt, + buildOracleBrowserArgs, + CHATGPT_BROWSER_MODEL_ID, + ChatGptBrowserError, + assertOracleCompatible, + oracleVersionIsCompatible, + parseChatGptBrowserResponse, + ORACLE_CHATGPT_PRO_MODEL, + resetOracleCompatibilityCacheForTests, + runOracleBrowserTurn, +} from "../src/adapters/chatgpt-browser-oracle"; +import { providerConfigSeed } from "../src/providers/derive"; +import { PROVIDER_REGISTRY } from "../src/providers/registry"; +import { routeModel } from "../src/router"; +import { resolveAdapter } from "../src/server/adapter-resolve"; +import { buildProviderWorkspace, isFreeProvider } from "../gui/src/provider-workspace/catalog"; +import { providerAuthSurface } from "../gui/src/provider-workspace/auth"; +import { isLocalProvider } from "../gui/src/provider-workspace/kind"; +import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +function request(overrides: Partial = {}): OcxParsedRequest { + return { + modelId: CHATGPT_BROWSER_MODEL_ID, + stream: true, + context: { + systemPrompt: ["Be precise."], + tools: [{ + name: "read_file", + description: "Read one file", + parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] }, + }], + messages: [ + { role: "user", content: "Review this change.", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "What constraint matters?" }], timestamp: 2 }, + { role: "developer", content: "Prefer the smallest safe change.", timestamp: 3 }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "read_file", + content: "const answer = 42;", + isError: false, + timestamp: 4, + }, + ], + }, + options: {}, + ...overrides, + }; +} + +function provider(): OcxProviderConfig { + return { + adapter: "chatgpt-browser", + baseUrl: "https://chatgpt.com", + models: [CHATGPT_BROWSER_MODEL_ID], + defaultModel: CHATGPT_BROWSER_MODEL_ID, + liveModels: false, + }; +} + +async function runAdapter( + runBrowserTurn: (prompt: string) => Promise<{ answerText: string }>, + parsed = request(), +): Promise { + const events: AdapterEvent[] = []; + const adapter = createChatGptBrowserAdapter(provider(), { runBrowserTurn }); + await adapter.runTurn!(parsed, { headers: new Headers() }, event => events.push(event)); + return events; +} + +function withoutHeartbeats(events: AdapterEvent[]): AdapterEvent[] { + return events.filter(event => event.type !== "heartbeat"); +} + +function protocolAnswer(prompt: string, payload: Record): string { + const conversation = JSON.parse(prompt.slice(prompt.indexOf("\n\n") + 2)) as { + responseProtocol: { nonce: string }; + }; + return JSON.stringify({ nonce: conversation.responseProtocol.nonce, ...payload }); +} + +describe("ChatGPT browser prompt", () => { + test("serializes the Responses conversation and client tool contract", () => { + const prompt = buildChatGptBrowserPrompt(request()); + expect(prompt).toContain("Tools listed in conversation.tools are executed by the client"); + const payload = JSON.parse(prompt.slice(prompt.indexOf("\n\n") + 2)) as { + model: string; + system: string[]; + messages: Array<{ role: string; content: unknown }>; + tools: Array<{ name: string }>; + responseProtocol: { nonce: string }; + }; + expect(payload.model).toBe(CHATGPT_BROWSER_MODEL_ID); + expect(payload.system).toEqual(["Be precise."]); + expect(payload.messages.map(message => message.role)).toEqual(["user", "assistant", "developer", "tool"]); + expect(payload.messages[3]!.content).toMatchObject({ + toolCallId: "call_1", + toolName: "read_file", + output: "const answer = 42;", + }); + expect(payload.tools).toEqual([expect.objectContaining({ name: "read_file" })]); + expect(payload.responseProtocol.nonce).toBeString(); + }); + + test("compacts verbose tool prose while preserving the callable JSON shape", () => { + const verbose = "schema guidance ".repeat(1_000); + const parsed = request({ + context: { + messages: [{ role: "user", content: "Use the tool.", timestamp: 1 }], + tools: [{ + name: "verbose_tool", + description: verbose, + parameters: { + type: "object", + description: verbose, + properties: { + path: { type: "string", description: verbose }, + }, + required: ["path"], + }, + }], + }, + }); + const prompt = buildChatGptBrowserPrompt(parsed); + const payload = JSON.parse(prompt.slice(prompt.indexOf("\n\n") + 2)) as { + tools: Array<{ description: string; parameters: Record }>; + }; + expect(payload.tools[0]!.description.length).toBeLessThanOrEqual(240); + expect(JSON.stringify(payload.tools[0]!.parameters)).not.toContain("schema guidance"); + expect(payload.tools[0]!.parameters).toEqual({ + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }); + expect(prompt.length).toBeLessThan(2_000); + }); + + test("fails closed for image input", () => { + const parsed = request({ + context: { + messages: [{ + role: "user", + content: [{ type: "image", imageUrl: "data:image/png;base64,abc" }], + timestamp: 1, + }], + }, + }); + expect(() => buildChatGptBrowserPrompt(parsed)).toThrow(ChatGptBrowserError); + try { + buildChatGptBrowserPrompt(parsed); + } catch (error) { + expect((error as ChatGptBrowserError).code).toBe("unsupported_content"); + } + }); +}); + +describe("Oracle browser contract", () => { + test("pins standard ChatGPT, current Pro selection, stdin, and foreground output capture", () => { + const args = buildOracleBrowserArgs("/tmp/answer.md"); + expect(ORACLE_CHATGPT_PRO_MODEL).toBe("gpt-5.5-pro"); + expect(args).toEqual(expect.arrayContaining([ + "--engine", "browser", + "--model", ORACLE_CHATGPT_PRO_MODEL, + "--browser-model-strategy", "select", + "--browser-thinking-time", "extended", + "--browser-timeout", "60m", + "--chatgpt-url", "https://chatgpt.com/", + "--no-notify", + "--wait", + "--prompt", "-", + "--write-output", "/tmp/answer.md", + ])); + expect(args).not.toContain("codex"); + expect(args).not.toContain("api"); + }); + + test("requires the audited Oracle model-selection contract", () => { + expect(oracleVersionIsCompatible("Oracle 0.16.1")).toBe(true); + expect(oracleVersionIsCompatible("0.17.0")).toBe(true); + expect(oracleVersionIsCompatible("oracle/1.0.0")).toBe(true); + expect(oracleVersionIsCompatible("0.16.0")).toBe(false); + expect(oracleVersionIsCompatible("unknown")).toBe(false); + }); + + test.skipIf(process.platform === "win32")("cancellation during version probing never starts a browser submission", async () => { + const dir = await mkdtemp(join(tmpdir(), "ocx-oracle-abort-test-")); + const command = join(dir, "oracle"); + const submitted = join(dir, "submitted"); + const previousMarker = process.env.OPENCODEX_TEST_ORACLE_SUBMITTED; + try { + await writeFile(command, [ + "#!/bin/sh", + "if [ \"$1\" = \"--version\" ]; then", + " sleep 0.25", + " printf 'Oracle 0.16.1\\n'", + " exit 0", + "fi", + ": > \"$OPENCODEX_TEST_ORACLE_SUBMITTED\"", + "exit 99", + "", + ].join("\n")); + await chmod(command, 0o700); + process.env.OPENCODEX_TEST_ORACLE_SUBMITTED = submitted; + const controller = new AbortController(); + const pending = runOracleBrowserTurn("must not be submitted", { + command, + signal: controller.signal, + }); + setTimeout(() => controller.abort(), 20); + await expect(pending).rejects.toMatchObject({ code: "aborted" }); + await expect(access(submitted)).rejects.toThrow(); + } finally { + if (previousMarker === undefined) delete process.env.OPENCODEX_TEST_ORACLE_SUBMITTED; + else process.env.OPENCODEX_TEST_ORACLE_SUBMITTED = previousMarker; + await rm(dir, { recursive: true, force: true }); + } + }); + + test.skipIf(process.platform === "win32")("bounds and reaps a hung Oracle version probe", async () => { + const dir = await mkdtemp(join(tmpdir(), "ocx-oracle-probe-timeout-")); + const command = join(dir, "oracle"); + try { + await writeFile(command, ["#!/bin/sh", "sleep 10", ""].join("\n")); + await chmod(command, 0o700); + resetOracleCompatibilityCacheForTests(); + const started = performance.now(); + await expect(assertOracleCompatible(command, { timeoutMs: 50 })) + .rejects.toMatchObject({ code: "oracle_missing" }); + expect(performance.now() - started).toBeLessThan(2_000); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("accepts only nonce-bound final answers or known tool calls", () => { + const parsed = request(); + expect(parseChatGptBrowserResponse( + '{"nonce":"n","type":"final","text":"Done"}', parsed, "n", + )).toEqual({ type: "final", text: "Done" }); + expect(parseChatGptBrowserResponse( + '{"nonce":"n","type":"tool_call","name":"read_file","arguments":{"path":"a.ts"}}', parsed, "n", + )).toMatchObject({ type: "tool_call", name: "read_file", arguments: { path: "a.ts" } }); + expect(() => parseChatGptBrowserResponse( + '{"nonce":"wrong","type":"final","text":"Done"}', parsed, "n", + )).toThrow(ChatGptBrowserError); + expect(() => parseChatGptBrowserResponse( + '{"nonce":"n","type":"tool_call","name":"shell","arguments":{}}', parsed, "n", + )).toThrow(ChatGptBrowserError); + expect(() => parseChatGptBrowserResponse( + '{"nonce":"n","type":"tool_call","name":"read_file","arguments":{"path":42}}', parsed, "n", + )).toThrow(ChatGptBrowserError); + expect(() => parseChatGptBrowserResponse( + '{"nonce":"n","type":"tool_call","name":"read_file","arguments":{}}', parsed, "n", + )).toThrow(ChatGptBrowserError); + }); + + test("enforces named tool choices and fails when the named tool is unavailable", () => { + const named = request({ options: { toolChoice: { name: "read_file" } } }); + expect(() => parseChatGptBrowserResponse( + '{"nonce":"n","type":"final","text":"Done"}', named, "n", + )).toThrow(ChatGptBrowserError); + expect(() => buildChatGptBrowserPrompt(request({ + context: { messages: [], tools: [] }, + options: { toolChoice: { name: "read_file" } }, + }))).toThrow(ChatGptBrowserError); + }); +}); + +describe("ChatGPT browser adapter", () => { + test("emits a final answer and estimated usage", async () => { + let captured = ""; + const events = await runAdapter(async prompt => { + captured = prompt; + return { answerText: protocolAnswer(prompt, { type: "final", text: "Use a narrower interface." }) }; + }); + expect(events[0]).toEqual({ type: "heartbeat" }); + const terminalEvents = withoutHeartbeats(events); + expect(captured).toContain("Review this change."); + expect(terminalEvents[0]).toEqual({ + type: "text_delta", + text: "Use a narrower interface.", + phase: "final_answer", + }); + expect(terminalEvents[1]).toMatchObject({ + type: "done", + endTurn: true, + usage: { estimated: true }, + }); + }); + + test("emits a validated client tool call", async () => { + const events = withoutHeartbeats(await runAdapter(async prompt => ({ + answerText: protocolAnswer(prompt, { + type: "tool_call", + name: "read_file", + arguments: { path: "src/router.ts" }, + }), + }))); + expect(events.slice(0, 3)).toMatchObject([ + { type: "tool_call_start", name: "read_file" }, + { type: "tool_call_delta", arguments: '{"path":"src/router.ts"}' }, + { type: "tool_call_end" }, + ]); + expect(events[3]).toMatchObject({ type: "done", stopReason: "tool_use", endTurn: false }); + }); + + test("preserves actionable fail-closed error categories", async () => { + const cases = [ + ["login_required", 401, "browser login is required"], + ["model_unavailable", 403, "no fallback model was used"], + ["quota_exhausted", 402, "no fallback model was used"], + ["timeout", 400, "timed out"], + ["oracle_missing", 503, "oracle is not installed"], + ["oracle_incompatible", 503, "oracle 0.16.1 or newer"], + ] as const; + for (const [code, status, messageFragment] of cases) { + const events = await runAdapter(async () => { throw new ChatGptBrowserError(code); }); + const terminalEvents = withoutHeartbeats(events); + expect(terminalEvents).toHaveLength(1); + expect(terminalEvents[0]).toMatchObject({ + type: "error", + code, + status, + errorType: "chatgpt_browser_error", + }); + expect((terminalEvents[0] as Extract).message.toLowerCase()) + .toContain(messageFragment); + } + }); + + test("logs unexpected exceptions while keeping the client error generic", async () => { + const log = spyOn(console, "error").mockImplementation(() => {}); + const original = new Error("diagnostic sentinel"); + try { + const events = withoutHeartbeats(await runAdapter(async () => { throw original; })); + expect(events[0]).toMatchObject({ code: "browser_failed", retryable: false }); + expect(log).toHaveBeenCalledWith( + "[chatgpt-browser] unexpected runTurn failure:", + original, + ); + } finally { + log.mockRestore(); + } + }); + + test("rejects every other model without invoking Oracle", async () => { + let called = false; + const events = await runAdapter(async () => { + called = true; + return { answerText: "unexpected" }; + }, request({ modelId: "gpt-5.6-sol" })); + expect(called).toBe(false); + expect(events[0]).toMatchObject({ type: "error", code: "model_not_supported", status: 400 }); + }); +}); + +describe("ChatGPT browser provider registry", () => { + test("is an explicit static experimental preset with no API or Codex route", () => { + const entry = PROVIDER_REGISTRY.find(provider => provider.id === "chatgpt-browser")!; + expect(entry).toMatchObject({ + adapter: "chatgpt-browser", + baseUrl: "https://chatgpt.com", + authKind: "local", + dashboardPreset: true, + models: [CHATGPT_BROWSER_MODEL_ID], + defaultModel: CHATGPT_BROWSER_MODEL_ID, + liveModels: false, + }); + const seed = providerConfigSeed(entry); + expect(seed.authMode).toBe("local"); + expect(seed.modelInputModalities).toEqual({ [CHATGPT_BROWSER_MODEL_ID]: ["text"] }); + expect(seed.modelReasoningEfforts).toEqual({ [CHATGPT_BROWSER_MODEL_ID]: [] }); + expect(seed.parallelToolCalls).toBe(false); + expect(seed.noVisionModels).toBeUndefined(); + expect(resolveAdapter(seed).name).toBe("chatgpt-browser"); + + const [catalog] = buildCatalogEntries(null, [], [{ + id: CHATGPT_BROWSER_MODEL_ID, + provider: "chatgpt-browser", + inputModalities: ["text"], + reasoningEfforts: [], + supportsVerbosity: false, + }]); + expect(catalog).toMatchObject({ + slug: "chatgpt-browser/gpt-5.6-pro", + supports_search_tool: false, + supports_parallel_tool_calls: false, + supported_reasoning_levels: [], + input_modalities: ["text"], + support_verbosity: false, + }); + expect(catalog).not.toHaveProperty("web_search_tool_type"); + expect(catalog).not.toHaveProperty("supports_websockets"); + }); + + test("appears ready in the provider GUI without a fake API key or free/local-runtime badge", () => { + const entry = PROVIDER_REGISTRY.find(provider => provider.id === "chatgpt-browser")!; + const seed = providerConfigSeed(entry); + const sections = buildProviderWorkspace({ + "chatgpt-browser": { ...seed, hasApiKey: false }, + }); + expect(sections.ready.map(provider => provider.name)).toEqual(["chatgpt-browser"]); + expect(sections.needsSetup).toEqual([]); + expect(isFreeProvider(sections.ready[0]!)).toBe(false); + expect(isLocalProvider(sections.ready[0]!)).toBe(false); + expect(providerAuthSurface(sections.ready[0]!)).toBeNull(); + }); + + test("routes only the explicit namespace and never claims bare GPT ids", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + "chatgpt-browser": provider(), + }, + }; + expect(routeModel(config, "gpt-5.6-pro").providerName).toBe("openai"); + expect(routeModel(config, "chatgpt-browser/gpt-5.6-pro")).toMatchObject({ + providerName: "chatgpt-browser", + modelId: "gpt-5.6-pro", + provider: { adapter: "chatgpt-browser", baseUrl: "https://chatgpt.com" }, + }); + config.providers["chatgpt-browser"]!.disabled = true; + expect(() => routeModel(config, "chatgpt-browser/gpt-5.6-pro")).toThrow("Provider is disabled"); + }); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts index 5057bc565..5cb8e7f87 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -14,6 +14,7 @@ import { isOcxStartCommandLine, loadConfig, multiAgentGuidanceEnabled, + normalizeOracleCommandConfig, parsePidFile, positiveIntegerConfigError, positiveIntegerRecordConfigError, @@ -142,6 +143,36 @@ describe("opencodex config defaults", () => { }); }); + test("oracleCommand validation trims valid values and rejects line breaks and NUL consistently", () => { + const base = getDefaultConfig(); + const candidate = { + ...base, + providers: { + ...base.providers, + browser: { + adapter: "chatgpt-browser", + baseUrl: "https://chatgpt.com", + oracleCommand: " /opt/oracle/bin/oracle ", + }, + }, + }; + expect(normalizeOracleCommandConfig(candidate.providers.browser.oracleCommand)).toEqual({ + ok: true, + value: "/opt/oracle/bin/oracle", + }); + expect(validateConfigCandidate(candidate)).toMatchObject({ + ok: true, + config: { providers: { browser: { oracleCommand: "/opt/oracle/bin/oracle" } } }, + }); + for (const invalid of ["oracle\n--flag", "oracle\r--flag", "oracle\0--flag"]) { + expect(normalizeOracleCommandConfig(invalid)).toMatchObject({ ok: false }); + expect(validateConfigCandidate({ + ...candidate, + providers: { browser: { ...candidate.providers.browser, oracleCommand: invalid } }, + })).toMatchObject({ ok: false, error: expect.stringContaining("oracleCommand") }); + } + }); + test("config candidates validate Claude Code subagent effort levels", () => { const base = getDefaultConfig(); for (const subagentEffort of ["low", "medium", "high", "xhigh", "max"]) { diff --git a/tests/images/z-handler-activation.test.ts b/tests/images/z-handler-activation.test.ts index 324b87732..95753bd9c 100644 --- a/tests/images/z-handler-activation.test.ts +++ b/tests/images/z-handler-activation.test.ts @@ -35,6 +35,8 @@ let useRunTurnAdapter = false; let runTurnCalled = false; /** Controlled return value for the stubbed planWebSearch (truthy ⇒ web-search plan active). */ let mockWsPlan: unknown = undefined; +/** Spy: ChatGPT Browser must never resolve a native OpenAI sidecar. */ +let openAiSidecarResolveCalls = 0; let handleResponses: typeof import("../../src/server/responses")["handleResponses"]; @@ -101,6 +103,15 @@ beforeAll(async () => { shouldResolveOpenAiWebSearchSidecar: () => false, })); + const actualOpenAiSidecar = await import("../../src/providers/openai-sidecar"); + mock.module("../../src/providers/openai-sidecar", () => ({ + ...actualOpenAiSidecar, + resolveFirstUsableOpenAiSidecar: async () => { + openAiSidecarResolveCalls += 1; + throw new Error("unexpected OpenAI sidecar resolution"); + }, + })); + ({ handleResponses } = await import("../../src/server/responses")); }); @@ -136,7 +147,48 @@ function post(stream: boolean, tools: unknown[]): Promise { ); } +function postChatGptBrowser( + input: unknown, + tools: unknown[], + bodyOverrides: Record = {}, +): Promise { + const config = makeConfig(); + config.defaultProvider = "chatgpt-browser"; + config.providers["chatgpt-browser"] = { + adapter: "chatgpt-browser", + baseUrl: "https://chatgpt.com", + models: ["gpt-5.6-pro"], + liveModels: false, + // Adversarial user override: this must not re-enable the native vision sidecar. + noVisionModels: ["gpt-5.6-pro"], + }; + return handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "chatgpt-browser/gpt-5.6-pro", + input, + stream: true, + tools, + ...bodyOverrides, + }), + }), + config, + { model: "", provider: "" } as never, + {}, + ); +} + describe("image bridge dispatch priority (handler activation)", () => { + test("ChatGPT Browser fails closed when local continuation replay state is missing", async () => { + const res = await postChatGptBrowser("continue", [], { + previous_response_id: "resp_evicted_browser_state", + }); + expect(res.status).toBe(400); + expect(await res.text()).toMatch(/ChatGPT Browser continuation state is missing/i); + }); + test("stream=true + image_generation tool → image bridge activates and returns SSE", async () => { imageBridgeRun = false; webSearchRun = false; mockWsPlan = undefined; const res = await post(true, [{ type: "image_generation" }]); @@ -211,4 +263,30 @@ describe("image bridge dispatch priority (handler activation)", () => { useRunTurnAdapter = false; } }); + + test("ChatGPT Browser bypasses native vision/search and paid media bridges even under adversarial config", async () => { + imageBridgeRun = false; webSearchRun = false; runTurnCalled = false; + openAiSidecarResolveCalls = 0; + useRunTurnAdapter = true; + mockWsPlan = { backend: "openai" }; + try { + const res = await postChatGptBrowser([ + { + role: "user", + content: [ + { type: "input_text", text: "describe this" }, + { type: "input_image", image_url: "data:image/png;base64,YQ==" }, + ], + }, + ], [{ type: "web_search" }, { type: "image_generation" }]); + expect(res.status).toBe(200); + expect(openAiSidecarResolveCalls).toBe(0); + expect(webSearchRun).toBe(false); + expect(imageBridgeRun).toBe(false); + expect(runTurnCalled).toBe(true); + } finally { + useRunTurnAdapter = false; + mockWsPlan = undefined; + } + }); }); diff --git a/tests/provider-connection-test.test.ts b/tests/provider-connection-test.test.ts index 99d613157..0b2d62d1c 100644 --- a/tests/provider-connection-test.test.ts +++ b/tests/provider-connection-test.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { handleManagementAPI } from "../src/server/management-api"; import { saveConfig } from "../src/config"; import { PROVIDER_REGISTRY } from "../src/providers/registry"; +import type { ManagementApiDeps } from "../src/server/management-api"; import type { OcxConfig } from "../src/types"; const TEST_DIR = join(tmpdir(), "ocx-conn-test"); @@ -40,9 +41,9 @@ function baseConfig(providers: OcxConfig["providers"]): OcxConfig { return config; } -async function probe(config: OcxConfig, name: string): Promise<{ status: number; body: Record }> { +async function probe(config: OcxConfig, name: string, deps: ManagementApiDeps = {}): Promise<{ status: number; body: Record }> { const req = new Request(`http://127.0.0.1/api/providers/test?name=${name}`, { method: "POST" }); - const res = await handleManagementAPI(req, new URL(req.url), config, {}); + const res = await handleManagementAPI(req, new URL(req.url), config, deps); if (!res) throw new Error("handler returned no response"); return { status: res.status, body: await res.json() as Record }; } @@ -96,6 +97,25 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { expect(String(body.error)).toContain("static catalog only"); }); + test("ChatGPT browser reports Oracle readiness without spending a Pro turn", async () => { + let checkedCommand: string | undefined; + const config = baseConfig({ + "chatgpt-browser": { + adapter: "chatgpt-browser", + baseUrl: "https://chatgpt.com", + liveModels: false, + models: ["gpt-5.6-pro"], + oracleCommand: "/opt/oracle/bin/oracle", + }, + }); + const { body } = await probe(config, "chatgpt-browser", { + assertOracleCompatible: async command => { checkedCommand = command; }, + }); + expect(checkedCommand).toBe("/opt/oracle/bin/oracle"); + expect(body.ok).toBe(true); + expect(String(body.message)).toContain("without spending quota"); + }); + test("a fake key gets the upstream rejection, not a catalog-presence pass", async () => { globalThis.fetch = (async () => new Response("unauthorized", { status: 401 })) as typeof fetch; const config = baseConfig({ diff --git a/tests/provider-workspace-data.test.ts b/tests/provider-workspace-data.test.ts index 7cfddfb84..21d713ff5 100644 --- a/tests/provider-workspace-data.test.ts +++ b/tests/provider-workspace-data.test.ts @@ -114,7 +114,7 @@ describe("catalog: section membership", () => { keyless: prov({ keyOptional: true }), oauth: prov({ authMode: "oauth" }), forward: forwardProv(), - // Defensive-only: dev wire never emits authMode "local"; pair with loopback. + // Local auth is explicit; loopback determines the local-runtime badge/tier. local: prov({ authMode: "local", baseUrl: "http://localhost:11434/v1" }), loopback: prov({ baseUrl: "http://127.0.0.1:8000/v1" }), keyed: prov({ authMode: "key", hasApiKey: true }), @@ -522,8 +522,9 @@ describe("provider kind classification (WP080a)", () => { expect(providerTier("myproxy", customForward)).toBe("paid"); }); - test("local: explicit authMode local or loopback base URL", () => { - expect(isLocalProvider(prov({ authMode: "local" }))).toBe(true); + test("local runtime kind comes from loopback, not credential-free local auth", () => { + expect(isLocalProvider(prov({ authMode: "local" }))).toBe(false); + expect(providerKind({ ...prov({ authMode: "local" }), name: "browser-helper" })).toBe("cloud"); expect(isLocalProvider(prov({ baseUrl: "http://localhost:11434/v1" }))).toBe(true); expect(isLocalProvider(prov({ baseUrl: "http://[::1]:8000/v1" }))).toBe(true); expect(isLocalProvider(prov())).toBe(false); diff --git a/tests/responses-state.test.ts b/tests/responses-state.test.ts index 705b71121..0297e84f0 100644 --- a/tests/responses-state.test.ts +++ b/tests/responses-state.test.ts @@ -20,7 +20,11 @@ import { setResponseStateByteCapForTests, getStoredResponseBytesForTests, } from "../src/responses/state"; -import { adapterNeedsForcedContinuation, injectDeveloperMessage } from "../src/server/responses"; +import { + adapterNeedsErrorPreflight, + adapterNeedsForcedContinuation, + injectDeveloperMessage, +} from "../src/server/responses"; function feedInspector( inspector: ReturnType, @@ -492,14 +496,20 @@ describe("Responses previous_response_id state", () => { .toBe("cursor_conv_force_1"); }); - test("adapterNeedsForcedContinuation covers exactly kiro and cursor", () => { + test("adapterNeedsForcedContinuation covers adapters that require local replay state", () => { expect(adapterNeedsForcedContinuation("kiro")).toBe(true); expect(adapterNeedsForcedContinuation("cursor")).toBe(true); + expect(adapterNeedsForcedContinuation("chatgpt-browser")).toBe(true); expect(adapterNeedsForcedContinuation("openai")).toBe(false); expect(adapterNeedsForcedContinuation("claude")).toBe(false); expect(adapterNeedsForcedContinuation("")).toBe(false); }); + test("browser turns preflight terminal errors before committing a streaming response", () => { + expect(adapterNeedsErrorPreflight("chatgpt-browser")).toBe(true); + expect(adapterNeedsErrorPreflight("cursor")).toBe(false); + }); + test("byte cap evicts oldest entries while the newest chain link survives", () => { setResponseStateByteCapForTests(4_000); try { diff --git a/tests/subagent-model-fallback.test.ts b/tests/subagent-model-fallback.test.ts index 3e13782d0..04fea6e1b 100644 --- a/tests/subagent-model-fallback.test.ts +++ b/tests/subagent-model-fallback.test.ts @@ -682,6 +682,34 @@ describe("subagent model fallback chain", () => { expect(parsed.modelId).toBe("gpt-5.6-sol"); }); + test("applySubagentModelFallback never rewrites ChatGPT Browser thread spawns", () => { + const config = cfg({ + providers: { + ...cfg().providers, + "chatgpt-browser": { + adapter: "chatgpt-browser", + baseUrl: "https://chatgpt.com", + models: ["gpt-5.6-pro"], + defaultModel: "gpt-5.6-pro", + }, + }, + }); + const parsed = { + modelId: "chatgpt-browser/gpt-5.6-pro", + options: {}, + context: { messages: [] }, + _rawBody: { model: "chatgpt-browser/gpt-5.6-pro" }, + }; + noteSubagentModelFailure(parsed.modelId, "quota exhausted", config); + expect(applySubagentModelFallback( + parsed as never, + new Headers({ "x-openai-subagent": "collab_spawn" }), + config, + )).toBeNull(); + expect(parsed.modelId).toBe("chatgpt-browser/gpt-5.6-pro"); + expect(parsed._rawBody.model).toBe("chatgpt-browser/gpt-5.6-pro"); + }); + test("applySubagentModelFallback can use per-agent model_fallback without global config", () => { const dir = codexHomeFixture(); writeFileSync(join(dir, "agents", "executor.toml"), [