diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37eeef494..479884b97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,18 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 + - name: Validate launcher installer with Windows PowerShell 5.1 + if: runner.os == 'Windows' + shell: powershell + run: | + if ($PSVersionTable.PSVersion.Major -ne 5) { + throw "Expected Windows PowerShell 5.1, got $($PSVersionTable.PSVersion)" + } + $source = Get-Content scripts/install-launcher.ps1 -Raw + [void][scriptblock]::Create($source) + if (-not [Environment]::Is64BitOperatingSystem) { + throw "The Windows CI runner must be 64-bit" + } - uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.11 diff --git a/README.ko-KR.md b/README.ko-KR.md index ce34cacac..d6144a0f2 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -1,25 +1,39 @@ # Codex용 ChatGPT Web -Codex의 기본 모델 선택기에서 ChatGPT Web의 Instant, Medium, High, Extra High, Pro를 선택해 +Codex의 기본 모델 선택기에서 ChatGPT Web의 임시대화·저장 대화·즉시·중간·높음·매우 높음·Pro를 선택해 사용하는 로컬 Responses 브리지입니다. 이 포크는 런처의 기본 언어를 한국어로 바꾸고, 한국어 UI를 추가했으며, GitHub/X 페이지를 반드시 열어야 했던 시작 절차를 선택 사항으로 바꿨습니다. 이 포크의 런타임은 upstream -[`miuuyy/codex-chatgpt-web`](https://github.com/miuuyy/codex-chatgpt-web) v1.0.1의 -커밋 `4ad7abe428fe2226fa05038a36e1682de1670fcc`를 기반으로 합니다. 원 저작자 표시와 MIT +[`miuuyy/codex-chatgpt-web`](https://github.com/miuuyy/codex-chatgpt-web) v1.1.2의 +커밋 `2e79cb15a6266916ef2298bda7cc94ead4f32af3`까지 반영합니다. 원 저작자 표시와 MIT 라이선스를 유지합니다. ## 권장 사용 방식 - 기본값은 브라우저 전용 모드입니다. 로컬 도구 없이 ChatGPT Web 모델로 계획·검토·조사를 수행할 때 적합합니다. -- 전체 하네스 모드는 명시적으로 필요한 경우에만 사용하세요. MCP 커넥터와 OpenAI Tunnel - 설정이 필요하며, 쓰기 작업은 워크스페이스 정책과 수동 승인을 따라야 합니다. +- 전체 하네스 모드는 명시적으로 필요한 경우에만 사용하세요. MCP 커넥터와 OpenAI Secure MCP + Tunnel 설정이 필요하며, 쓰기 작업은 워크스페이스 정책과 수동 승인을 따라야 합니다. +- Web GPT가 전체 하네스를 사용하더라도 현재 Codex 작업의 프로젝트 루트만 전달됩니다. 외부 경로, + 심볼릭 링크 탈출, 범용 도구 우회와 네트워크 접근은 별도 프로젝트 경계에서 차단됩니다. - Pro는 깊은 계획과 검토에 적합하지만 현재 이 브리지의 로컬 MCP 도구를 직접 호출하지 못합니다. - 공유 계정, 브라우저 프로필 공유, 과도한 동시 요청, 제한 우회는 사용하지 마세요. +## 모델과 라우팅 + +- 모델 선택은 `Web / 임시|저장 / 낮음|중간|높음|매우 높음`처럼 대화 보존 방식과 추론 수준을 함께 표시합니다. +- `Web / 임시 / ...`는 대화 기록에 남지 않는 Temporary Chat으로 실행되고, `Web / 저장 / ...`는 새 일반 대화로 저장됩니다. +- `ChatGPT Web — 저장 대화 (매우 높음)`은 일반 ChatGPT 대화를 만들어 ChatGPT 기록에 남깁니다. +- Web GPT 두 항목은 선택한 노력 수준이 낮게 들어와도 브리지에서 `매우 높음`으로 고정합니다. +- `gpt-5.6-sol`은 Codex 기본 설정에서 `높음`으로 두며, Web GPT 브리지가 브라우저를 열지 않고 + 공식 Codex 엔드포인트로 직접 전달합니다. +- 순수 네이티브 Codex는 `codex --profile codex-native`로 별도 실행할 수 있고, OCX는 + `127.0.0.1:10100`에서 별도 프로세스로 유지됩니다. 한 경로가 내려가도 다른 경로의 인증·라우팅·런타임을 + 덮어쓰지 않습니다. + ## 설치 및 시작 macOS 또는 Linux: @@ -44,6 +58,20 @@ irm https://github.com/AgenticLab-SH/codex-chatgpt-web/releases/latest/download/ 브라우저 창은 닫아도 됩니다. 설정의 백그라운드 실행이 켜져 있으면 런처와 내장 브라우저 프로세스는 계속 동작합니다. 다만 런처를 완전히 종료하면 브리지도 멈춥니다. +## MCP와 자동 시작 + +이 포크는 로컬 MCP를 외부에 공개하는 임의의 공개 터널 대신 OpenAI Secure MCP Tunnel을 +사용합니다. 런처가 macOS 로그인 항목으로 실행되면 다음 순서를 자동으로 관리합니다. + +1. 로컬 MCP 서버와 Responses 브리지를 시작합니다. +2. 저장된 최소 권한 런타임 키로 `tunnel-client`를 실행합니다. +3. Tunnel과 로컬 MCP가 준비된 뒤 Web GPT 요청을 받습니다. + +따라서 매 작업마다 Tunnel이나 ChatGPT 앱을 다시 만들 필요는 없습니다. 다만 Mac이 꺼져 있거나 +런처를 완전히 종료하면 로컬 파일 도구는 사용할 수 없습니다. ChatGPT Business에서 게시한 앱의 +도구 이름·입력 스키마를 바꾼 경우에는 Business 정책상 기존 앱을 수정하는 대신 다시 게시해야 할 +수 있으므로, 현재의 범용 7개 도구 표면을 안정적으로 유지합니다. + ## 소스에서 실행 ```bash diff --git a/README.md b/README.md index 1d53a444b..1e592f527 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ connects ChatGPT back to the tools of that same Codex task. - **A polished cross-platform launcher.** One command installs the native macOS, Windows, or Linux app. It keeps sign-in, setup, smoke testing, MCP guidance, runtime health, and local logs in one - place, while the embedded browser lets you watch every ChatGPT turn as it happens. + place, while the embedded browser lets you watch every ChatGPT turn as it happens. Up to five + task-bound browser tabs can run in parallel; the cap avoids excessive parallel account traffic. - **ChatGPT is the selected model.** It runs as a native Codex model, not as a tool called by another host model. The original model picker, task lifecycle, streaming, tracing, and tool UI remain intact. @@ -113,8 +114,8 @@ This source path requires Bun 1.3.11. The command installs locked dependencies a | Mode | Models | Local Codex tools | Extra setup | | --- | --- | --- | --- | -| **Browser-only** | Instant through Pro | No; Codex shows a warning | None | -| **Full harness** | Instant through Pro | Instant–Extra High: yes; Pro: read-only | OpenAI tunnel + ChatGPT connector | +| **Browser-only** | Plus: Instant–High; Pro: adds Extra High and Pro | No; Codex shows a warning | None | +| **Full harness** | Plus: Instant–High; Pro: adds Extra High and Pro | Instant–Extra High: yes; Pro: read-only | OpenAI tunnel + ChatGPT connector | Every picker entry has one fixed ChatGPT mode. Codex still displays its built-in Effort and Speed rows, but changing them cannot silently change the selected browser model. Pro receives the full diff --git a/README.zh-CN.md b/README.zh-CN.md index c36439128..19842efdd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -41,7 +41,8 @@ Codex 任务的工具。 - **精致的跨平台启动器。** 一条命令即可安装原生 macOS、Windows 或 Linux 应用。登录、设置、 冒烟测试、MCP 指南、运行状态和本地日志都集中在同一处;内置浏览器还能让你实时看到每个 - ChatGPT 轮次的执行过程。 + ChatGPT 轮次的执行过程。最多可同时运行五个与 Codex 任务绑定的浏览器标签页;此上限用于避免 + 对 ChatGPT 账户产生过多并行流量。 - **ChatGPT 就是所选模型。** 它作为 Codex 原生模型运行,而不是由另一个宿主模型调用的工具。 原有的模型选择器、任务生命周期、流式输出、追踪和工具界面保持不变。 - **本地优先的任务会话。** Codex 仍然是电脑上任务历史的真实来源。每个浏览器轮次都会从一个 diff --git a/bun.lock b/bun.lock index 577ced4dd..a545b9cd3 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "codex-chatgpt-web", "dependencies": { - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/sdk": "^1.30.0", "chromium-bidi": "12.1.0", "fflate": "^0.8.2", "playwright-core": "^1.62.0", @@ -22,6 +22,8 @@ }, "overrides": { "@hono/node-server": "2.0.12", + "fast-uri": "3.1.5", + "hono": "4.12.34", "zod-to-json-schema": "3.25.1", }, "packages": { @@ -29,7 +31,7 @@ "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], @@ -99,7 +101,7 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="], + "fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="], "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], @@ -121,7 +123,7 @@ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], - "hono": ["hono@4.12.32", "", {}, "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg=="], + "hono": ["hono@4.12.34", "", {}, "sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], diff --git a/docs/architecture.md b/docs/architecture.md index 9450278b6..88b98eb70 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,7 +7,7 @@ Codex app / CLI launcher-owned codex-chatgpt-web daemon ├─ official /models passthrough + fixed ChatGPT Web models ├─ native Responses passthrough or ChatGPT Responses/SSE bridge - ├─ ChatGPT browser worker (embedded Electron surface, one turn at a time) + ├─ ChatGPT browser worker (up to five task-bound Electron tabs) ├─ capability broker (full mode only) └─ stdio MCP server ▲ @@ -20,9 +20,9 @@ launcher-owned codex-chatgpt-web daemon ### `browser-only` -- Exposes Instant (`chatgpt-web/light`), Medium, High, and Extra High; each model advertises exactly one - immutable Codex effort matching its ChatGPT browser mode. `chatgpt-web/pro` is appended only when - the authenticated account exposes Pro. +- Exposes the conversation mode and effort as independent axes: temporary or saved, each combined with + Low, Medium, High, and Extra High. Pro is likewise exposed per conversation mode only when the + authenticated account exposes it. Legacy route ids remain accepted as hidden compatibility aliases. - Sends the complete Codex context and image attachments to a fresh ChatGPT Temporary Chat. - Never starts the broker, tunnel, or MCP server. - Emits a nonfatal Codex commentary warning that local tools are unavailable for the selected model. @@ -37,11 +37,14 @@ launcher-owned codex-chatgpt-web daemon ## Browser lifecycle -The desktop launcher owns one persistent Electron partition and one visible browser surface. -Playwright attaches to that exact surface through a launcher-owned loopback CDP endpoint; it does -not launch another browser or copy authentication state. A Codex turn navigates the owned surface -to a fresh Temporary Chat, and the surface returns to an inert local page after completion. The -login persists locally while browser conversations are not reused between tasks. +The desktop launcher owns one persistent Electron partition and up to five task-bound browser +tabs. Each Codex task is leased an independent `WebContentsView` and surface ID; Playwright attaches +to that exact surface through a launcher-owned loopback CDP endpoint. It does not launch another +browser or copy authentication state. Each tab opens a fresh Temporary Chat, shares only the local +login partition, and keeps its own document and lifecycle. Completed tabs remain inspectable until +closed. Closing a running tab destroys its page and terminates that browser turn. A sixth concurrent +turn fails explicitly; the cap avoids excessive parallel traffic that could trigger account abuse +controls. The complete serialized Codex task is inserted as one inline JSON envelope. Image bytes stay out of the JSON and are attached natively with stable references. The runtime does not create a context @@ -104,8 +107,8 @@ launcher error. - Store browser state and tunnel credentials under the application home with mode `0600`. - Protect lifecycle control endpoints with a random application-owned bearer token. - Never place secret values in command-line arguments, logs, generated profiles, or Git. -- Serialize browser turns and reject unsupported models explicitly. The selected routed model fixes - the adapter effort; a conflicting request effort cannot change it. +- Limit browser turns to five independent task-bound tabs and reject unsupported models explicitly. + The selected routed model fixes the adapter effort; a conflicting request effort cannot change it. - Do not retry or switch modes to evade product usage limits. See the complete [security model](security-model.md). diff --git a/docs/security-model.md b/docs/security-model.md index cc5cfed41..188106114 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -66,9 +66,11 @@ transport, or returns a fabricated success. ### Cross-turn data leakage -Browser turns are serialized. Every outer Codex turn navigates to a fresh Temporary Chat page and -closes the prior page. Tool calls for that turn remain in the same ChatGPT response. The bounded -local continuation cache is private, expires, and exists only to implement Codex +Browser turns use at most five independent task-bound tabs in one private login partition. Every +outer Codex task owns a fresh Temporary Chat document and an exact launcher surface lease; chats are +never reused across tasks. Closing a running tab destroys its page and terminates that turn. The +five-tab limit bounds parallel account traffic. Tool calls remain in the same ChatGPT response. The +bounded local continuation cache is private, expires, and exists only to implement Codex `previous_response_id` replay. ChatGPT Web context compaction remains inside the active browser response; the bridge does not fabricate or install a Codex history checkpoint. diff --git a/launcher/electron/browser-host.cjs b/launcher/electron/browser-host.cjs index 9ebabecff..c366dcec1 100644 --- a/launcher/electron/browser-host.cjs +++ b/launcher/electron/browser-host.cjs @@ -4,7 +4,6 @@ const { randomBytes } = require("node:crypto"); const { WebContentsView, shell } = require("electron"); const { writePrivateFileAtomic } = require("./atomic-file.cjs"); const { verifyConnectorWithBrowserHelper } = require("./browser-helper-verifier.cjs"); -const { processRunning } = require("./process-tree.cjs"); const { dispatchTrustedKey, evaluatePage, @@ -25,6 +24,7 @@ const SMOKE_SUBMISSION_TIMEOUT_MS = 15_000; const SMOKE_RESPONSE_TIMEOUT_MS = 120_000; const SMOKE_COMPLETION_SETTLE_MS = 1_500; const MAX_BROWSER_VIEW_DIMENSION = 16_384; +const MAX_BROWSER_TABS = 5; const CHATGPT_PARTITION = "persist:codex-web-gpt-chatgpt"; const COMPOSER_SELECTOR = [ '[data-testid="prompt-textarea"]', @@ -114,6 +114,14 @@ function isTemporaryChatUrl(value) { && parsed.searchParams.get("temporary-chat") === "true"; } +function initializationNavigationWasSuperseded(error, expectedUrl, currentUrl) { + const code = error && typeof error === "object" ? error.code : undefined; + const message = error instanceof Error ? error.message : String(error); + return (code === "ERR_ABORTED" || /\bERR_ABORTED\s*\(-3\)/.test(message)) + && currentUrl !== expectedUrl + && isTemporaryChatUrl(currentUrl); +} + class BrowserHost { constructor({ window, descriptorPath, cdpPort, control, helper, logger, publishState }) { this.window = window; @@ -129,8 +137,9 @@ class BrowserHost { this.surfaceId = randomBytes(24).toString("base64url"); this.visible = false; this.surfaceActive = true; - this.activeTraceId = null; - this.activeHelperPid = null; + this.turnTabs = new Map(); + this.closedTurnOwners = new Map(); + this.selectedTabId = "home"; this.manualOperation = null; this.loginOperation = null; this.viewportCssKey = null; @@ -164,12 +173,153 @@ class BrowserHost { this.view.setVisible(false); this.bindWebContents(); void this.view.webContents.loadURL(IDLE_BROWSER_URL).catch((error) => { + const currentUrl = this.view.webContents.getURL(); + if (initializationNavigationWasSuperseded(error, IDLE_BROWSER_URL, currentUrl)) { + this.logger.info("browser.initialization_superseded", { url: currentUrl }); + return; + } this.logger.error("browser.initialization_failed", { message: error instanceof Error ? error.message : String(error) }); this.setState({ status: "error", message: "Embedded browser failed to initialize" }); }); this.writeDescriptor(); } + get activeTraceId() { + return [...this.turnTabs.values()].find((tab) => tab.status === "running")?.traceId || null; + } + + tabSnapshot(tab) { + return { + id: tab.id, + traceId: tab.traceId, + title: tab.label, + status: tab.status, + loading: tab.loading === true, + active: this.selectedTabId === tab.id, + closable: true, + }; + } + + selectedTurnTab() { + return this.turnTabs.get(this.selectedTabId) || null; + } + + createTurnTab(traceId, helperPid) { + if (this.turnTabs.size >= MAX_BROWSER_TABS) { + throw new Error( + `ChatGPT Web already has ${MAX_BROWSER_TABS} browser tabs; close one before starting another turn to avoid excessive parallel traffic on the ChatGPT account`, + ); + } + const id = randomBytes(12).toString("base64url"); + const surfaceId = randomBytes(24).toString("base64url"); + const ordinal = Array.from({ length: MAX_BROWSER_TABS }, (_unused, index) => index + 1) + .find(candidate => ![...this.turnTabs.values()].some(tab => tab.ordinal === candidate)); + if (!ordinal) throw new Error("ChatGPT Web browser tab allocation is inconsistent"); + const view = new WebContentsView({ + webPreferences: { + partition: CHATGPT_PARTITION, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + spellcheck: true, + backgroundThrottling: false, + }, + }); + const tab = { + id, + surfaceId, + traceId, + helperPid, + view, + status: "running", + ordinal, + label: `ChatGPT ${ordinal}`, + pageTitle: "ChatGPT", + url: IDLE_BROWSER_URL, + loading: true, + message: "ChatGPT is working", + }; + this.turnTabs.set(id, tab); + this.window.contentView.addChildView(view); + view.setBounds(this.bounds); + view.setVisible(false); + this.bindTurnContents(tab); + void view.webContents.loadURL(IDLE_BROWSER_URL).catch((error) => { + tab.status = "error"; + tab.loading = false; + tab.message = error instanceof Error ? error.message : String(error); + this.publishState?.(this.snapshot()); + }); + return tab; + } + + bindTurnContents(tab) { + const contents = tab.view.webContents; + contents.setWindowOpenHandler(({ url }) => { + let parsed; + try { parsed = new URL(url); } catch { return { action: "deny" }; } + if (parsed.protocol === "https:" || parsed.protocol === "http:") void shell.openExternal(parsed.toString()); + return { action: "deny" }; + }); + contents.on("did-start-loading", () => { + tab.loading = true; + this.publishState?.(this.snapshot()); + }); + contents.on("did-stop-loading", () => { + tab.loading = false; + tab.url = contents.getURL(); + this.publishState?.(this.snapshot()); + }); + contents.on("did-finish-load", () => { + tab.url = contents.getURL(); + tab.loading = false; + void contents.insertCSS(CHATGPT_VIEWPORT_CSS).catch(() => {}); + const encoded = JSON.stringify(tab.surfaceId); + void contents.executeJavaScript(`(() => { + Object.defineProperty(globalThis, "__CODEX_WEB_GPT_SURFACE_ID__", { + value: ${encoded}, configurable: true, enumerable: false, writable: false, + }); + document.documentElement.dataset.codexWebGptSurface = ${encoded}; + })()`, true).then( + () => this.publishState?.(this.snapshot()), + (error) => { + tab.status = "error"; + tab.message = `Browser ownership failed: ${error instanceof Error ? error.message : String(error)}`; + this.publishState?.(this.snapshot()); + }, + ); + }); + contents.on("page-title-updated", (_event, title) => { + if (typeof title === "string" && title.trim()) tab.pageTitle = title.trim(); + this.publishState?.(this.snapshot()); + }); + contents.on("did-navigate-in-page", (_event, url, mainFrame) => { + if (mainFrame) tab.url = url; + this.publishState?.(this.snapshot()); + }); + contents.on("did-fail-load", (_event, errorCode, errorDescription, url, mainFrame) => { + if (!mainFrame || errorCode === -3) return; + tab.status = "error"; + tab.loading = false; + tab.url = url; + tab.message = errorDescription; + this.logger.error("browser.tab_navigation_failed", { + tabId: tab.id, + traceId: tab.traceId, + errorCode, + errorDescription, + url, + }); + this.publishState?.(this.snapshot()); + }); + contents.on("render-process-gone", (_event, details) => { + tab.status = "error"; + tab.loading = false; + tab.message = `Browser renderer stopped: ${details.reason}`; + this.publishState?.(this.snapshot()); + }); + } + bindWebContents() { const contents = this.view.webContents; contents.setWindowOpenHandler(({ url }) => { @@ -227,11 +377,41 @@ class BrowserHost { snapshot() { const contents = this.activeView()?.webContents; - return readBrowserNavigationState(contents, { - ...this.state, + const selected = this.selectedTurnTab(); + const homeTab = { + id: "home", + traceId: null, + title: this.state.title || "ChatGPT", + status: this.state.status, + loading: this.state.loading === true, + active: this.selectedTabId === "home", + closable: false, + }; + const state = selected + ? { + ...this.state, + status: selected.status, + message: selected.message, + url: selected.url, + title: selected.pageTitle, + loading: selected.loading, + } + : this.state; + return { + ...readBrowserNavigationState(contents, { + ...state, visible: this.visible, surfaceActive: this.surfaceActive, - }); + }), + activeTabId: this.selectedTabId, + tabs: this.turnTabs.size > 0 + ? [ + ...(this.selectedTabId === "home" ? [homeTab] : []), + ...[...this.turnTabs.values()].map((tab) => this.tabSnapshot(tab)), + ] + : [homeTab], + maxTabs: MAX_BROWSER_TABS, + }; } setState(patch) { @@ -249,6 +429,7 @@ class BrowserHost { this.bounds = constrainBrowserBounds(normalizeBounds(bounds), { width, height }); this.boundsReady = true; this.view.setBounds(this.bounds); + for (const tab of this.turnTabs.values()) tab.view.setBounds(this.bounds); this.authView?.setBounds(this.bounds); this.syncViewVisibility(); void this.view.webContents.executeJavaScript("window.dispatchEvent(new Event('resize'))", true).catch(() => {}); @@ -258,15 +439,62 @@ class BrowserHost { } activeView() { - return this.authView || this.view; + return this.authView || this.selectedTurnTab()?.view || this.view; + } + + activateHomeSurface() { + this.selectedTabId = "home"; + this.syncViewVisibility(); + if (this.visible && this.surfaceActive) this.activeView().webContents.focus(); + this.publishState?.(this.snapshot()); + this.writeDescriptor(); } syncViewVisibility() { const visible = browserViewVisible(this.visible, this.surfaceActive, this.boundsReady); - this.view.setVisible(visible && !this.authView); + const selected = this.selectedTurnTab(); + this.view.setVisible(visible && !this.authView && !selected); + for (const tab of this.turnTabs.values()) { + tab.view.setVisible(visible && !this.authView && selected?.id === tab.id); + } this.authView?.setVisible(visible); } + selectTab(tabId) { + if (tabId !== "home" && !this.turnTabs.has(tabId)) throw new Error("Browser tab does not exist"); + if (this.authView) this.closeAuthView(this.authView, true); + this.selectedTabId = tabId; + this.syncViewVisibility(); + if (this.visible && this.surfaceActive) this.activeView().webContents.focus(); + this.publishState?.(this.snapshot()); + this.writeDescriptor(); + return this.snapshot(); + } + + removeTurnTab(tab, abortRunning) { + this.turnTabs.delete(tab.id); + if (abortRunning && tab.status === "running") { + this.closedTurnOwners.set(tab.traceId, tab.helperPid); + tab.status = "aborted"; + } + try { this.window.contentView.removeChildView(tab.view); } catch {} + if (!tab.view.webContents.isDestroyed()) tab.view.webContents.close(); + if (this.selectedTabId === tab.id) { + this.selectedTabId = [...this.turnTabs.keys()].at(-1) || "home"; + } + this.syncViewVisibility(); + this.publishState?.(this.snapshot()); + this.writeDescriptor(); + } + + closeTab(tabId) { + const tab = this.turnTabs.get(tabId); + if (!tab) throw new Error("Browser tab does not exist"); + this.removeTurnTab(tab, true); + this.logger.info("browser.tab_closed", { tabId, traceId: tab.traceId, status: tab.status }); + return this.snapshot(); + } + createAuthView(options = {}) { this.closeAuthView(this.authView, true); const authView = new WebContentsView({ @@ -370,7 +598,7 @@ class BrowserHost { async reveal() { this.show(); - if (this.view.webContents.getURL() === IDLE_BROWSER_URL) { + if (!this.selectedTurnTab() && this.view.webContents.getURL() === IDLE_BROWSER_URL) { await this.view.webContents.loadURL(TEMPORARY_CHAT_URL); await this.probeAuthentication(); } @@ -417,52 +645,64 @@ class BrowserHost { if (this.manualOperation) { throw new Error(`ChatGPT browser is busy with ${this.manualOperation}`); } - if (this.activeTraceId) { - const sameSerializedHelper = this.activeHelperPid === helperPid; - const previousHelperExited = !processRunning(this.activeHelperPid); - if (!sameSerializedHelper && !previousHelperExited) { - throw new Error(`ChatGPT browser already owns Codex turn ${this.activeTraceId}`); + const existing = [...this.turnTabs.values()].find((tab) => tab.traceId === traceId); + if (existing) { + if (existing.status === "running" && existing.helperPid !== helperPid) { + throw new Error(`ChatGPT browser turn ${traceId} is owned by another helper process`); } - this.logger.warn("browser.stale_turn_replaced", { - previousTraceId: this.activeTraceId, - previousHelperPid: this.activeHelperPid, - traceId, - helperPid, - evidence: sameSerializedHelper ? "same serialized helper" : "previous helper exited", - }); + existing.helperPid = helperPid; + existing.status = "running"; + existing.loading = true; + existing.message = "ChatGPT is working"; + if (!existing.view.webContents.isDestroyed()) { + existing.view.webContents.setBackgroundThrottling(false); + } + this.selectedTabId = existing.id; + if (reveal) this.show(); + else this.syncViewVisibility(); + this.publishState?.(this.snapshot()); + this.writeDescriptor(); + this.logger.info("browser.tab_reused", { tabId: existing.id, traceId }); + return { surfaceId: existing.surfaceId, tabId: existing.id }; } - this.activeTraceId = traceId; - this.activeHelperPid = helperPid; - this.view.webContents.setBackgroundThrottling(false); + const tab = this.createTurnTab(traceId, helperPid); + this.selectedTabId = tab.id; if (reveal) this.show(); - this.setState({ status: "running", message: "ChatGPT is working", authenticated: true }); + else this.syncViewVisibility(); + this.publishState?.(this.snapshot()); + this.logger.info("browser.tab_created", { tabId: tab.id, traceId, tabCount: this.turnTabs.size }); + return { surfaceId: tab.surfaceId, tabId: tab.id }; } async endTurn(traceId, helperPid, status, hideAfterTurn, message) { - if (this.activeTraceId !== traceId) { - throw new Error(`Browser turn ownership mismatch: expected ${this.activeTraceId || "none"}, received ${traceId}`); + const tab = [...this.turnTabs.values()].find((candidate) => candidate.traceId === traceId); + if (!tab) { + const closedOwner = this.closedTurnOwners.get(traceId); + if (closedOwner === helperPid) { + this.closedTurnOwners.delete(traceId); + return; + } + throw new Error(`Browser turn ownership mismatch: no browser tab owns ${traceId}`); } - if (this.activeHelperPid !== helperPid) { + if (tab.helperPid !== helperPid) { throw new Error( - `Browser helper ownership mismatch: expected ${this.activeHelperPid || "none"}, received ${helperPid}`, + `Browser helper ownership mismatch: expected ${tab.helperPid}, received ${helperPid}`, ); } - this.activeTraceId = null; - this.activeHelperPid = null; - this.view.webContents.setBackgroundThrottling(true); - if (hideAfterTurn) this.hide(); + tab.status = status === "completed" ? "ready" : status === "aborted" ? "aborted" : "error"; + tab.message = status === "completed" ? "Task completed" : message || `ChatGPT turn ${status}`; + tab.loading = false; + if (!tab.view.webContents.isDestroyed()) tab.view.webContents.setBackgroundThrottling(true); if (status === "completed") { - this.setState({ status: "ready", message: "No active task", authenticated: true }); - try { - await this.returnToIdle(); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - this.logger.error("browser.idle_cleanup_failed", { message: detail }); - this.setState({ status: "error", message: `Browser cleanup failed: ${detail}`, authenticated: true }); - } - } else { - this.setState({ status: "error", message: message || `ChatGPT turn ${status}`, authenticated: true }); + this.logger.info("browser.tab_completed", { tabId: tab.id, traceId }); } + // A browser tab represents an active Codex turn, not durable task history. Retaining terminal + // tabs leaked one slot per response/compaction until the five-tab safety limit made later + // turns fail. The result already lives in Codex; release the browser document on every + // terminal path while leaving other concurrently running tabs untouched. + this.removeTurnTab(tab, false); + if (hideAfterTurn && !this.activeTraceId) this.hide(); + this.logger.info("browser.tab_released", { tabId: tab.id, traceId, status: tab.status }); } async returnToIdle() { @@ -479,10 +719,12 @@ class BrowserHost { openLogin() { if (this.state.authenticated) { + this.activateHomeSurface(); this.show(); return Promise.resolve(this.snapshot()); } if (this.loginOperation) { + this.activateHomeSurface(); this.show(); return this.loginOperation; } @@ -1116,6 +1358,7 @@ class BrowserHost { if (this.manualOperation) { throw new Error(`ChatGPT browser is already busy with ${this.manualOperation}`); } + this.activateHomeSurface(); this.manualOperation = name; const contents = this.view?.webContents; if (contents && !contents.isDestroyed()) contents.setBackgroundThrottling(false); @@ -1160,6 +1403,11 @@ class BrowserHost { if (current.pid === process.pid) fs.rmSync(this.descriptorPath, { force: true }); } catch {} this.closeAuthView(this.authView, true); + for (const tab of this.turnTabs.values()) { + try { this.window.contentView.removeChildView(tab.view); } catch {} + if (!tab.view.webContents.isDestroyed()) tab.view.webContents.close(); + } + this.turnTabs.clear(); if (this.view && !this.view.webContents.isDestroyed()) this.view.webContents.close(); } } @@ -1169,6 +1417,7 @@ module.exports = { BrowserHost, CHATGPT_VIEWPORT_CSS, IDLE_BROWSER_URL, + initializationNavigationWasSuperseded, isTemporaryChatUrl, TEMPORARY_CHAT_URL, }; diff --git a/launcher/electron/control-server.cjs b/launcher/electron/control-server.cjs index 8aaae46b9..d4c29635e 100644 --- a/launcher/electron/control-server.cjs +++ b/launcher/electron/control-server.cjs @@ -113,8 +113,10 @@ class BrowserControlServer { } const preferences = this.getPreferences(); if (request.url === "/v1/turn/start") { - host.beginTurn(body.traceId, preferences.showBrowserDuringTurns === true, body.helperPid); + const lease = host.beginTurn(body.traceId, preferences.showBrowserDuringTurns === true, body.helperPid); this.logger.info("browser.turn_started", { traceId: body.traceId }); + writeJson(response, 200, { ok: true, ...lease }); + return; } else { if (!['completed', 'failed', 'aborted'].includes(body.status)) throw new Error("turn status is invalid"); await host.endTurn( diff --git a/launcher/electron/main.cjs b/launcher/electron/main.cjs index a0f30ba1d..0ae54782d 100644 --- a/launcher/electron/main.cjs +++ b/launcher/electron/main.cjs @@ -28,6 +28,10 @@ const { RuntimeHost, launcherRuntimeAllowedForRoute } = require("./runtime.cjs") const { ensurePackagedRuntime } = require("./runtime-install.cjs"); const { RuntimeSupervisor } = require("./runtime-supervisor.cjs"); const { createStateStore, validateSidebarState } = require("./state.cjs"); +const { + UPSTREAM_REPOSITORY_URL, + checkForUpstreamUpdate, +} = require("./upstream-update.cjs"); const { MIN_WINDOW_BOUNDS, readWindowState, @@ -205,6 +209,20 @@ async function openWebUrl(url) { await shell.openExternal(parsed.toString()); } +function externalUrlAllowed(value) { + if (ALLOWED_EXTERNAL_URLS.has(value)) return true; + try { + const parsed = new URL(value); + return parsed.protocol === "https:" + && parsed.hostname === "github.com" + && parsed.pathname.startsWith("/miuuyy/codex-chatgpt-web/releases/") + && parsed.search === "" + && parsed.hash === ""; + } catch { + return false; + } +} + function rendererNavigationAllowed(value) { let target; try { @@ -377,10 +395,38 @@ function registerIpc({ logger, stateStore }) { }); handle("launcher:open-external", async (_event, url) => { - if (!ALLOWED_EXTERNAL_URLS.has(url)) throw new Error("External URL is not allowlisted"); + if (!externalUrlAllowed(url)) throw new Error("External URL is not allowlisted"); await openWebUrl(url); return true; }); + handle("launcher:check-upstream-update", async () => { + try { + const result = await checkForUpstreamUpdate(app.getVersion()); + logger.info("launcher.upstream_update_checked", { + status: result.status, + currentVersion: result.currentVersion, + latestTag: result.latest?.tag ?? null, + }); + return result; + } catch (error) { + logger.debug("launcher.upstream_update_check_unavailable", { + message: error instanceof Error ? error.message : String(error), + }); + return { + status: "unavailable", + currentVersion: app.getVersion(), + latest: null, + }; + } + }); + handle("launcher:dismiss-upstream-update", (_event, tag) => { + if (typeof tag !== "string" || tag.trim().length === 0 || tag.length > 128) { + throw new Error("Invalid upstream release tag"); + } + const state = stateStore.update({ upstreamUpdateDismissedTag: tag.trim() }); + send("launcher:state-changed", state); + return state; + }); handle("launcher:browser-bounds", (_event, bounds) => { browserHost?.setBounds(validateBounds(bounds)); @@ -390,6 +436,8 @@ function registerIpc({ logger, stateStore }) { handle("launcher:browser-show", () => browserHost.reveal()); handle("launcher:browser-hide", () => { browserHost?.hide(); return browserHost?.snapshot(); }); handle("launcher:browser-navigate", (_event, action) => browserHost.navigate(action)); + handle("launcher:browser-tab-select", (_event, tabId) => browserHost.selectTab(tabId)); + handle("launcher:browser-tab-close", (_event, tabId) => browserHost.closeTab(tabId)); handle("launcher:browser-login", () => browserHost.openLogin()); handle("launcher:browser-smoke", async () => { const result = await browserHost.smokeTest(); diff --git a/launcher/electron/preload.cjs b/launcher/electron/preload.cjs index a2af47ac1..225a3a2ee 100644 --- a/launcher/electron/preload.cjs +++ b/launcher/electron/preload.cjs @@ -12,11 +12,15 @@ contextBridge.exposeInMainWorld("codexWebLauncher", { openSocial: (target) => ipcRenderer.invoke("launcher:open-social", target), completeOnboarding: (language) => ipcRenderer.invoke("launcher:complete-onboarding", language), openExternal: (url) => ipcRenderer.invoke("launcher:open-external", url), + checkForUpstreamUpdate: () => ipcRenderer.invoke("launcher:check-upstream-update"), + dismissUpstreamUpdate: (tag) => ipcRenderer.invoke("launcher:dismiss-upstream-update", tag), setBrowserBounds: (bounds) => ipcRenderer.invoke("launcher:browser-bounds", bounds), setBrowserSurfaceActive: (active) => ipcRenderer.invoke("launcher:browser-surface-active", active), showBrowser: () => ipcRenderer.invoke("launcher:browser-show"), hideBrowser: () => ipcRenderer.invoke("launcher:browser-hide"), navigateBrowser: (action) => ipcRenderer.invoke("launcher:browser-navigate", action), + selectBrowserTab: (tabId) => ipcRenderer.invoke("launcher:browser-tab-select", tabId), + closeBrowserTab: (tabId) => ipcRenderer.invoke("launcher:browser-tab-close", tabId), openLogin: () => ipcRenderer.invoke("launcher:browser-login"), smokeTest: () => ipcRenderer.invoke("launcher:browser-smoke"), verifyMcp: () => ipcRenderer.invoke("launcher:mcp-verify"), diff --git a/launcher/electron/runtime-supervisor.cjs b/launcher/electron/runtime-supervisor.cjs index 0b35a56f9..706692ff4 100644 --- a/launcher/electron/runtime-supervisor.cjs +++ b/launcher/electron/runtime-supervisor.cjs @@ -241,6 +241,20 @@ function validateConfig(config, descriptorPath, platform = process.platform) { return config; } +function migrateConfigForLauncher(config, appVersion, invocation) { + if (!config || typeof config !== "object") throw new Error("Runtime configuration is not an object"); + if (typeof appVersion !== "string" || !appVersion.trim()) throw new Error("Launcher version is missing"); + if (config.releaseVersion === appVersion) return config; + if (!invocation || typeof invocation.executable !== "string" || !Array.isArray(invocation.args)) { + throw new Error("Current launcher runtime invocation is invalid"); + } + return { + ...config, + releaseVersion: appVersion, + runtimeCommand: [invocation.executable, ...invocation.args], + }; +} + class RuntimeSupervisor { constructor({ app, @@ -972,10 +986,25 @@ class RuntimeSupervisor { return { status: "external", detail }; } } - const detail = `Config requires ${config.releaseVersion}; launcher is ${this.app.getVersion()}`; - this.writeState("needs-setup", detail); - this.logger.warn("runtime.setup_required", { detail }); - return { status: "needs-setup", detail }; + const previousVersion = config.releaseVersion; + try { + const migrated = migrateConfigForLauncher( + config, + this.app.getVersion(), + this.runtimeCommand([]), + ); + writePrivateFileAtomic(this.configPath, `${JSON.stringify(migrated, null, 2)}\n`); + config = this.readConfig(); + this.logger.info("runtime.config_migrated", { + fromVersion: previousVersion, + toVersion: this.app.getVersion(), + }); + } catch (error) { + const detail = `Config requires ${previousVersion}; launcher is ${this.app.getVersion()}`; + this.writeState("needs-setup", `${detail}; migration failed: ${errorMessage(error)}`); + this.logger.warn("runtime.setup_required", { detail, migrationError: errorMessage(error) }); + return { status: "needs-setup", detail }; + } } if (!this.daemon && !this.tunnel) { const healthyRuntime = await this.proxyHealth(config); @@ -1717,5 +1746,6 @@ module.exports = { TUNNEL_START_TIMEOUT_MS, RuntimeSupervisor, managedTunnelConnectArgs, + migrateConfigForLauncher, validateConfig, }; diff --git a/launcher/electron/state.cjs b/launcher/electron/state.cjs index 785af5767..6e87e0cd1 100644 --- a/launcher/electron/state.cjs +++ b/launcher/electron/state.cjs @@ -13,6 +13,7 @@ const DEFAULT_STATE = Object.freeze({ bridgeEnabled: true, keepRunningOnClose: true, showBrowserDuringTurns: true, + upstreamUpdateDismissedTag: null, browserSmokePassed: false, browserSmokeVersion: null, sidebarOpen: true, @@ -41,6 +42,10 @@ function readState(filePath) { ]) { if (typeof state[key] !== "boolean") state[key] = DEFAULT_STATE[key]; } + if (state.upstreamUpdateDismissedTag !== null + && (typeof state.upstreamUpdateDismissedTag !== "string" || state.upstreamUpdateDismissedTag.length > 128)) { + state.upstreamUpdateDismissedTag = DEFAULT_STATE.upstreamUpdateDismissedTag; + } if (state.browserSmokeVersion !== null && (typeof state.browserSmokeVersion !== "string" || state.browserSmokeVersion.length > 128)) { state.browserSmokeVersion = DEFAULT_STATE.browserSmokeVersion; diff --git a/launcher/electron/upstream-update.cjs b/launcher/electron/upstream-update.cjs new file mode 100644 index 000000000..f7500005a --- /dev/null +++ b/launcher/electron/upstream-update.cjs @@ -0,0 +1,89 @@ +const UPSTREAM_OWNER = "miuuyy"; +const UPSTREAM_REPOSITORY = "codex-chatgpt-web"; +const UPSTREAM_REPOSITORY_URL = `https://github.com/${UPSTREAM_OWNER}/${UPSTREAM_REPOSITORY}`; +const UPSTREAM_LATEST_RELEASE_URL = `https://api.github.com/repos/${UPSTREAM_OWNER}/${UPSTREAM_REPOSITORY}/releases/latest`; +const REQUEST_TIMEOUT_MS = 5_000; + +function versionParts(value) { + if (typeof value !== "string") return null; + const match = value.trim().replace(/^v/i, "").match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/); + if (!match) return null; + return match.slice(1, 4).map((part) => Number(part)); +} + +function compareReleaseVersion(left, right) { + const leftParts = versionParts(left); + const rightParts = versionParts(right); + if (!leftParts || !rightParts) return 0; + for (let index = 0; index < leftParts.length; index += 1) { + if (leftParts[index] !== rightParts[index]) return leftParts[index] > rightParts[index] ? 1 : -1; + } + return 0; +} + +function releaseUrlIsSafe(value) { + if (typeof value !== "string") return false; + try { + const url = new URL(value); + return url.protocol === "https:" + && url.hostname === "github.com" + && url.pathname.startsWith(`/${UPSTREAM_OWNER}/${UPSTREAM_REPOSITORY}/releases/`) + && url.search === "" + && url.hash === ""; + } catch { + return false; + } +} + +function parseReleasePayload(payload) { + if (!payload || typeof payload !== "object") throw new Error("GitHub release response is not an object"); + const tag = typeof payload.tag_name === "string" ? payload.tag_name.trim() : ""; + const url = typeof payload.html_url === "string" ? payload.html_url.trim() : ""; + if (!versionParts(tag)) throw new Error("GitHub release has no supported version tag"); + if (!releaseUrlIsSafe(url)) throw new Error("GitHub release URL did not match the upstream repository"); + const name = typeof payload.name === "string" && payload.name.trim() + ? payload.name.trim().slice(0, 200) + : tag; + const publishedAt = typeof payload.published_at === "string" ? payload.published_at : null; + return { tag, name, url, publishedAt }; +} + +async function fetchLatestRelease({ fetchImpl = globalThis.fetch, timeoutMs = REQUEST_TIMEOUT_MS } = {}) { + if (typeof fetchImpl !== "function") throw new Error("Fetch is unavailable"); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(UPSTREAM_LATEST_RELEASE_URL, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "codex-web-gpt-launcher", + }, + signal: controller.signal, + }); + if (!response || !response.ok) { + throw new Error(`GitHub release check returned HTTP ${response?.status ?? "unknown"}`); + } + return parseReleasePayload(await response.json()); + } finally { + clearTimeout(timeout); + } +} + +async function checkForUpstreamUpdate(currentVersion, options = {}) { + const latest = await fetchLatestRelease(options); + return { + status: compareReleaseVersion(latest.tag, currentVersion) > 0 ? "available" : "current", + currentVersion, + latest, + }; +} + +module.exports = { + UPSTREAM_LATEST_RELEASE_URL, + UPSTREAM_REPOSITORY_URL, + checkForUpstreamUpdate, + compareReleaseVersion, + fetchLatestRelease, + parseReleasePayload, + releaseUrlIsSafe, +}; diff --git a/launcher/package.json b/launcher/package.json index a938cc97a..036efd802 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -1,6 +1,6 @@ { "name": "codex-web-gpt-launcher", - "version": "1.0.1-ko.4", + "version": "1.1.2-ko.4", "private": true, "description": "Desktop control center for Codex ChatGPT Web", "author": "miuuyy; Korean localization by AgenticLab-SH", diff --git a/launcher/src/App.tsx b/launcher/src/App.tsx index cbabb27bf..439507d84 100644 --- a/launcher/src/App.tsx +++ b/launcher/src/App.tsx @@ -19,6 +19,7 @@ import type { LogRecord, OperationState, Surface, + UpstreamUpdateResult, } from "./types"; const api = window.codexWebLauncher; @@ -35,6 +36,7 @@ export function App() { const [browser, setBrowser] = useState(null); const [operation, setOperation] = useState(null); const [logs, setLogs] = useState([]); + const [upstreamUpdate, setUpstreamUpdate] = useState(null); const [error, setError] = useState(null); useEffect(() => { @@ -47,6 +49,11 @@ export function App() { setLogs(next.logs); setOperation(next.operation); if (next.operation?.status === "failed") setError(next.operation.message); + void api.checkForUpstreamUpdate().then((result) => { + if (!cancelled) setUpstreamUpdate(result); + }).catch(() => { + // Update checks are advisory and must never prevent the launcher from opening. + }); }).catch((cause) => setError(messageOf(cause))); const unsubscribeState = api.onStateChanged((state) => { setSnapshot((current) => current @@ -110,12 +117,24 @@ export function App() { language={language} logs={logs} operation={operation} + setUpstreamUpdate={setUpstreamUpdate} setError={setError} snapshot={snapshot} + upstreamUpdate={upstreamUpdate} updateState={updateState} /> )} + {snapshot.state.onboardingComplete && upstreamUpdate?.status === "available" && upstreamUpdate.latest + && upstreamUpdate.latest.tag !== snapshot.state.upstreamUpdateDismissedTag ? ( + + ) : null} {error ? setError(null)} /> : null} @@ -282,8 +301,10 @@ function LauncherShell({ language, logs, operation, + setUpstreamUpdate, setError, snapshot, + upstreamUpdate, updateState, }: { browser: BrowserState | null; @@ -291,8 +312,10 @@ function LauncherShell({ language: Language; logs: LogRecord[]; operation: OperationState | null; + setUpstreamUpdate: (update: UpstreamUpdateResult) => void; setError: (error: string | null) => void; snapshot: LauncherSnapshot; + upstreamUpdate: UpstreamUpdateResult | null; updateState: (state: LauncherState) => void; }) { const [surface, setSurface] = useState( @@ -520,8 +543,10 @@ function LauncherShell({ ) : null} @@ -603,7 +628,6 @@ function BrowserSurface({ }) { const visible = browser?.visible === true; const navigationLocked = browser?.status === "running" || browser?.status === "testing"; - const title = browserTabTitle(browser, copy); const navigate = async (action: "back" | "forward" | "reload") => { try { await api!.navigateBrowser(action); @@ -619,20 +643,52 @@ function BrowserSurface({ setError(messageOf(cause)); } }; + const selectTab = async (tabId: string) => { + try { + await api!.selectBrowserTab(tabId); + } catch (cause) { + setError(messageOf(cause)); + } + }; + const closeTab = async (tabId: string) => { + try { + await api!.closeBrowserTab(tabId); + } catch (cause) { + setError(messageOf(cause)); + } + }; return (
-
-
- - {title} - {browser?.loading ? : } - {visible ? ( - - ) : null} -
+
+ {(browser?.tabs ?? []).map((tab) => ( +
void selectTab(tab.id)} + role="tab" + aria-selected={tab.active} + > + + + {browserTabTitleFromTitle(tab.title, copy)} + + {tab.loading ? : } + {tab.closable ? ( + + ) : null} +
+ ))}
@@ -1102,20 +1158,25 @@ function ActivitySurface({ function SettingsSurface({ copy, language, + setUpstreamUpdate, setError, snapshot, + upstreamUpdate, updateState, }: { copy: Copy; language: Language; + setUpstreamUpdate: (update: UpstreamUpdateResult) => void; setError: (error: string | null) => void; snapshot: LauncherSnapshot; + upstreamUpdate: UpstreamUpdateResult | null; updateState: (state: LauncherState) => void; }) { const [doctor, setDoctor] = useState(null); const [busy, setBusy] = useState(false); const [turnsCancelled, setTurnsCancelled] = useState(false); const [integrationRemoved, setIntegrationRemoved] = useState(false); + const [updateBusy, setUpdateBusy] = useState(false); const updateLanguage = async (next: Language) => { try { @@ -1172,6 +1233,17 @@ function SettingsSurface({ setBusy(false); } }; + const checkForUpdates = async () => { + setUpdateBusy(true); + setError(null); + try { + setUpstreamUpdate(await api!.checkForUpstreamUpdate()); + } catch (cause) { + setError(messageOf(cause)); + } finally { + setUpdateBusy(false); + } + }; return ( @@ -1214,6 +1286,24 @@ function SettingsSurface({
+ + +
+
+ + + ); +} + function ContentSurface({ children, eyebrow, @@ -1641,12 +1784,19 @@ function browserTone(browser: BrowserState | null): "idle" | "ready" | "busy" | return "idle"; } -function browserTabTitle(browser: BrowserState | null, copy: Copy): string { - const title = browser?.title?.trim(); +function browserTabTitleFromTitle(value: string | undefined, copy: Copy): string { + const title = value?.trim(); if (!title || title === "about:blank" || title.includes("codex-web-gpt-browser-host")) return copy.temporaryChat; return title.replace(/\s*[|–-]\s*ChatGPT\s*$/i, "") || copy.temporaryChat; } +function browserTabTone(status: BrowserState["tabs"][number]["status"]): "idle" | "ready" | "busy" | "error" { + if (status === "error" || status === "aborted") return "error"; + if (status === "loading" || status === "running" || status === "testing") return "busy"; + if (status === "ready") return "ready"; + return "idle"; +} + function formatBrowserAddress(url: string | undefined, copy: Copy): string { if (!url || url.startsWith("about:blank")) return copy.browserAddress; try { diff --git a/launcher/src/i18n.ts b/launcher/src/i18n.ts index 26e22ec4e..838ce8c88 100644 --- a/launcher/src/i18n.ts +++ b/launcher/src/i18n.ts @@ -30,7 +30,8 @@ const en = { hideSidebar: "Hide sidebar", showSidebar: "Show sidebar", resizeSidebar: "Resize sidebar", - hideTab: "Hide browser", + hideTab: "Close tab", + browserTabLimit: "Up to five simultaneous ChatGPT Web tabs. The limit avoids excessive parallel traffic on your ChatGPT account.", browserAddress: "ChatGPT browser", noActiveTask: "No active task", noActiveTaskBody: "ChatGPT will appear here when Codex starts a Web model turn.", @@ -69,7 +70,7 @@ const en = { done: "Done", guideVideo: "Guide video", mcpStepOne: "Create a tunnel and API key", - mcpStepOneBody: "Create an OpenAI tunnel, copy its Tunnel ID, and create a regular API key with Tunnels Read + Use (free; the key is required only to run the tunnel).", + mcpStepOneBody: "Create an OpenAI tunnel, copy its Tunnel ID, and create a regular API key with Tunnels Read + Use (free; the key is required only to run the tunnel). (Don't forget to create a ChatGPT workspace.)", openTunnels: "Open Tunnels", openKeys: "Create API key", mcpStepTwo: "Connect the local harness", @@ -123,6 +124,15 @@ const en = { notConfigured: "Not configured", error: "Something went wrong", dismiss: "Dismiss", + updateAvailable: "Upstream update available", + updateAvailableBody: "A newer release is available in the original repository:", + openRelease: "Open release", + dismissUpdate: "Later", + checkUpdates: "Check upstream updates", + checkingUpdates: "Checking for updates", + latestRelease: "Latest", + upToDate: "Already up to date", + updateCheckUnavailable: "Could not check right now", } as const; const zh: Record = { @@ -155,7 +165,8 @@ const zh: Record = { hideSidebar: "隐藏侧边栏", showSidebar: "显示侧边栏", resizeSidebar: "调整侧边栏宽度", - hideTab: "隐藏浏览器", + hideTab: "关闭标签页", + browserTabLimit: "最多可同时运行五个 ChatGPT Web 标签页,以避免向您的 ChatGPT 帐户发送过多并行流量。", browserAddress: "ChatGPT 浏览器", noActiveTask: "当前没有任务", noActiveTaskBody: "当 Codex 启动 Web 模型任务时,ChatGPT 会显示在这里。", @@ -194,7 +205,7 @@ const zh: Record = { done: "完成", guideVideo: "指导视频", mcpStepOne: "创建 Tunnel 和 API key", - mcpStepOneBody: "创建 OpenAI Tunnel,复制 Tunnel ID,然后创建一个拥有 Tunnels Read + Use 权限的普通 API key(免费;此密钥仅用于运行 Tunnel)。", + mcpStepOneBody: "创建 OpenAI Tunnel,复制 Tunnel ID,然后创建一个拥有 Tunnels Read + Use 权限的普通 API key(免费;此密钥仅用于运行 Tunnel)。(别忘了创建 ChatGPT 工作区。)", openTunnels: "打开 Tunnels", openKeys: "创建 API key", mcpStepTwo: "连接本地 Harness", @@ -248,6 +259,15 @@ const zh: Record = { notConfigured: "未配置", error: "出现错误", dismiss: "关闭", + updateAvailable: "上游仓库有新版本", + updateAvailableBody: "原始仓库发布了新版本:", + openRelease: "打开发布页面", + dismissUpdate: "稍后提醒", + checkUpdates: "检查上游更新", + checkingUpdates: "正在检查更新", + latestRelease: "最新", + upToDate: "已经是最新版本", + updateCheckUnavailable: "暂时无法检查", }; const ko: Record = { @@ -281,6 +301,7 @@ const ko: Record = { showSidebar: "사이드바 표시", resizeSidebar: "사이드바 크기 조절", hideTab: "브라우저 숨기기", + browserTabLimit: "ChatGPT Web 탭은 최대 5개까지 사용할 수 있습니다. 계정에 과도한 병렬 요청이 발생하지 않도록 제한됩니다.", browserAddress: "ChatGPT 브라우저", noActiveTask: "실행 중인 작업 없음", noActiveTaskBody: "Codex에서 Web 모델 작업을 시작하면 여기에 ChatGPT가 표시됩니다.", @@ -318,14 +339,14 @@ const ko: Record = { next: "다음", done: "완료", guideVideo: "안내 영상", - mcpStepOne: "Tunnel과 API 키 만들기", - mcpStepOneBody: "OpenAI Tunnel을 만들고 Tunnel ID를 복사한 다음, Tunnels Read + Use 권한이 있는 일반 API 키를 만드세요. 키는 Tunnel 실행에만 필요하며 모델 API 사용료를 발생시키지 않습니다.", + mcpStepOne: "Tunnel과 런타임 키 만들기", + mcpStepOneBody: "OpenAI Tunnel을 만들고 Tunnel ID를 확인하세요. 기존 Tunnel이 있으면 새로 만들지 마세요. 새로 연결할 때만 Tunnels Read + Use 권한이 있는 일반 런타임 키가 필요합니다. 이 키는 Tunnel 실행에만 사용되며 모델 API 사용료를 발생시키지 않습니다.", openTunnels: "Tunnels 열기", - openKeys: "API 키 만들기", + openKeys: "런타임 키 만들기", mcpStepTwo: "로컬 하네스 연결", - mcpStepTwoBody: "Tunnel ID와 API 키를 입력하세요. Tunnel은 ChatGPT 플러그인을 사용할 OpenAI 계정과 같아야 합니다. 키는 비공개 로컬 저장소에만 보관되고 런처 로그에는 기록되지 않습니다.", + mcpStepTwoBody: "Tunnel ID와 런타임 키는 Tunnel을 관리하는 Platform 조직의 자격증명이어야 합니다. 사용할 ChatGPT Business 워크스페이스는 이 Tunnel에 별도로 연결합니다. 기존 자격 증명이 있으면 새로 만들지 말고 재사용하세요. 키는 비공개 로컬 저장소에만 보관되고 런처 로그에는 기록되지 않습니다.", tunnelId: "Tunnel ID", - runtimeKey: "API 키(Admin 키 아님)", + runtimeKey: "런타임 API 키(Admin 키 아님)", connect: "하네스 연결", reconnect: "하네스 다시 연결", credentialsConfigured: "Tunnel 자격 증명 저장됨", @@ -333,8 +354,8 @@ const ko: Record = { replaceCredentials: "자격 증명 교체", keepCredentials: "저장된 자격 증명 유지", mcpStepThree: "ChatGPT 커넥터 연결", - mcpStepThreeBody: "커넥터를 만들기 전에 ChatGPT 설정에서 개발자 모드를 켜세요. ChatGPT 플러그인에서 커넥터를 만들고 Tunnel을 선택한 뒤, 만든 Tunnel을 지정하고 인증을 없음으로 설정하세요. 이름은 정확히 Codex Native로 지정한 다음 런타임을 검증합니다.", - openConnectors: "ChatGPT 플러그인 열기", + mcpStepThreeBody: "ChatGPT의 Apps/Plugins에서 개발자 모드 Custom App을 만들고, 연결 방식에서 Tunnel, 기존 Codex Native Tunnel, 인증 없음(None)을 선택하세요. 도구를 스캔한 뒤 이름을 정확히 Codex Native로 지정하고 런타임을 검증합니다.", + openConnectors: "ChatGPT Apps/Plugins 열기", connectorName: "커넥터 이름", verifyRuntime: "런타임 검증", activityTitle: "런타임 활동", @@ -373,6 +394,15 @@ const ko: Record = { notConfigured: "구성되지 않음", error: "문제가 발생했습니다", dismiss: "닫기", + updateAvailable: "원본 레포 업데이트 있음", + updateAvailableBody: "원본 저장소에 새 릴리스가 있습니다:", + openRelease: "릴리스 페이지 열기", + dismissUpdate: "나중에", + checkUpdates: "원본 레포 업데이트 확인", + checkingUpdates: "업데이트 확인 중", + latestRelease: "최신", + upToDate: "최신 상태입니다", + updateCheckUnavailable: "지금은 확인할 수 없습니다", }; export type Copy = typeof en; diff --git a/launcher/src/styles.css b/launcher/src/styles.css index 8aea0f49a..410232cd2 100644 --- a/launcher/src/styles.css +++ b/launcher/src/styles.css @@ -507,7 +507,9 @@ code { position: relative; display: flex; width: min(220px, 32vw); - min-width: 120px; + min-width: 96px; + max-width: 220px; + flex: 1 1 120px; height: 28px; align-items: center; gap: 7px; @@ -1927,6 +1929,78 @@ code { color: var(--color-text-primary); } +.upstream-update-toast { + position: fixed; + z-index: 450; + top: 58px; + right: 18px; + display: grid; + width: min(420px, calc(100vw - 36px)); + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: 11px; + padding: 14px 14px 13px; + border: 1px solid color-mix(in srgb, var(--color-text-success) 34%, var(--color-border-heavy)); + border-radius: var(--radius-md); + background: var(--color-background-elevated); + box-shadow: 0 16px 38px -18px rgb(0 0 0 / 82%); +} + +.upstream-update-toast > svg { + width: 18px; + height: 18px; + margin-top: 1px; + color: var(--color-text-success); +} + +.upstream-update-toast strong { + font-size: var(--text-sm); + font-weight: 600; +} + +.upstream-update-toast p { + margin: 4px 0 9px; + color: var(--color-text-secondary); + font-size: var(--text-xs); + line-height: 1.45; +} + +.upstream-update-toast p b { + color: var(--color-text-primary); + font-weight: 600; +} + +.upstream-update-actions { + display: flex; + gap: 10px; +} + +.upstream-update-actions button, +.upstream-update-close { + border: 0; + background: transparent; + color: var(--color-text-secondary); + font-size: var(--text-xs); +} + +.upstream-update-actions button:first-child { + color: var(--color-text-success); +} + +.upstream-update-actions button:hover, +.upstream-update-close:hover { + color: var(--color-text-primary); +} + +.upstream-update-close { + padding: 1px; +} + +.upstream-update-close svg { + width: 15px; + height: 15px; +} + .launch-loading, .fatal-message { display: flex; diff --git a/launcher/src/types.ts b/launcher/src/types.ts index b30270549..eb2db436c 100644 --- a/launcher/src/types.ts +++ b/launcher/src/types.ts @@ -11,6 +11,7 @@ export interface LauncherState { bridgeEnabled: boolean; keepRunningOnClose: boolean; showBrowserDuringTurns: boolean; + upstreamUpdateDismissedTag?: string | null; sidebarOpen: boolean; sidebarWidth: number; browserSmokePassed?: boolean; @@ -34,6 +35,19 @@ export interface BrowserState { loading: boolean; canGoBack: boolean; canGoForward: boolean; + activeTabId: string; + maxTabs: number; + tabs: BrowserTabState[]; +} + +export interface BrowserTabState { + id: string; + traceId: string | null; + title: string; + status: "idle" | "loading" | "signed-out" | "ready" | "testing" | "running" | "error" | "aborted"; + loading: boolean; + active: boolean; + closable: boolean; } export interface LogRecord { @@ -62,6 +76,20 @@ export interface OperationState { message: string; } +export interface UpstreamRelease { + tag: string; + name: string; + url: string; + publishedAt: string | null; +} + +export interface UpstreamUpdateResult { + status: "available" | "current" | "unavailable"; + currentVersion: string; + latest: UpstreamRelease | null; + error?: string; +} + export interface LauncherSnapshot { state: LauncherState; browser: BrowserState | null; @@ -87,11 +115,15 @@ export interface LauncherApi { openSocial(target: "github" | "x"): Promise; completeOnboarding(language: Language): Promise; openExternal(url: string): Promise; + checkForUpstreamUpdate(): Promise; + dismissUpstreamUpdate(tag: string): Promise; setBrowserBounds(bounds: { x: number; y: number; width: number; height: number }): Promise; setBrowserSurfaceActive(active: boolean): Promise; showBrowser(): Promise; hideBrowser(): Promise; navigateBrowser(action: "back" | "forward" | "reload"): Promise; + selectBrowserTab(tabId: string): Promise; + closeBrowserTab(tabId: string): Promise; openLogin(): Promise; smokeTest(): Promise<{ ok: boolean; effort: string; response: string }>; verifyMcp(): Promise; diff --git a/launcher/tests/browser-host.test.cjs b/launcher/tests/browser-host.test.cjs index 9d00c9bbc..bd7744492 100644 --- a/launcher/tests/browser-host.test.cjs +++ b/launcher/tests/browser-host.test.cjs @@ -13,8 +13,33 @@ const { BrowserHost, CHATGPT_VIEWPORT_CSS, isTemporaryChatUrl, + initializationNavigationWasSuperseded, } = require("../electron/browser-host.cjs"); +test("only a proven replacement navigation suppresses initial ERR_ABORTED noise", () => { + const aborted = Object.assign(new Error("ERR_ABORTED (-3) loading 'about:blank'"), { code: "ERR_ABORTED" }); + assert.equal(initializationNavigationWasSuperseded( + aborted, + "about:blank#codex-web-gpt-browser-host", + "https://chatgpt.com/?temporary-chat=true", + ), true); + assert.equal(initializationNavigationWasSuperseded( + aborted, + "about:blank#codex-web-gpt-browser-host", + "about:blank#codex-web-gpt-browser-host", + ), false); + assert.equal(initializationNavigationWasSuperseded( + aborted, + "about:blank#codex-web-gpt-browser-host", + "about:blank", + ), false); + assert.equal(initializationNavigationWasSuperseded( + new Error("net::ERR_FAILED"), + "about:blank#codex-web-gpt-browser-host", + "https://chatgpt.com/?temporary-chat=true", + ), false); +}); + function createContents() { const calls = []; const history = { @@ -161,6 +186,7 @@ test("concurrent login requests share one authentication operation", async () => waits += 1; return await new Promise((resolve) => { resolveLogin = resolve; }); }, + activateHomeSurface() {}, withManualOperation: async (_name, action) => await action(), }; const first = BrowserHost.prototype.openLogin.call(fixture); @@ -678,11 +704,13 @@ test("launcher session refresh resolves persisted authentication before setup ac test("manual browser operations disable background throttling until completion", async () => { const throttling = []; + const surfaces = []; const states = []; const fixture = { activeTraceId: null, manualOperation: null, state: { authenticated: true, status: "loading" }, + activateHomeSurface: () => surfaces.push("home"), setState(patch) { states.push(patch); this.state = { ...this.state, ...patch }; @@ -698,6 +726,7 @@ test("manual browser operations disable background throttling until completion", const result = await BrowserHost.prototype.withManualOperation.call(fixture, "hidden check", async () => "ok"); assert.equal(result, "ok"); + assert.deepEqual(surfaces, ["home"]); assert.deepEqual(throttling, [false, true]); assert.deepEqual(states, [{ status: "ready", message: "ChatGPT is ready" }]); assert.equal(fixture.manualOperation, null); @@ -709,6 +738,7 @@ test("failed manual browser operations preserve their error state", async () => activeTraceId: null, manualOperation: null, state: { authenticated: true, status: "loading" }, + activateHomeSurface() {}, setState(patch) { states.push(patch); this.state = { ...this.state, ...patch }; @@ -732,10 +762,98 @@ test("failed manual browser operations preserve their error state", async () => assert.equal(fixture.manualOperation, null); }); +test("manual operations show the home surface without discarding retained task tabs", () => { + const events = []; + const taskTab = { id: "tab-ready", status: "ready" }; + const fixture = { + selectedTabId: taskTab.id, + turnTabs: new Map([[taskTab.id, taskTab]]), + visible: true, + surfaceActive: true, + activeView: () => ({ webContents: { focus: () => events.push("focus") } }), + syncViewVisibility: () => events.push("visibility"), + snapshot: () => ({ activeTabId: "home" }), + publishState: () => events.push("publish"), + writeDescriptor: () => events.push("descriptor"), + }; + + BrowserHost.prototype.activateHomeSurface.call(fixture); + + assert.equal(fixture.selectedTabId, "home"); + assert.equal(fixture.turnTabs.size, 1); + assert.deepEqual(events, ["visibility", "focus", "publish", "descriptor"]); +}); + +test("selected home surface remains represented while task tabs are retained", () => { + const { webContents } = createContents(); + const taskTab = { id: "tab-ready", traceId: "trace_ready" }; + const fixture = { + selectedTabId: "home", + turnTabs: new Map([[taskTab.id, taskTab]]), + state: { + title: "ChatGPT", + status: "signed-out", + loading: false, + visible: true, + surfaceActive: true, + }, + visible: true, + surfaceActive: true, + activeView: () => ({ webContents }), + selectedTurnTab: () => null, + tabSnapshot: (tab) => ({ id: tab.id, traceId: tab.traceId, active: false }), + }; + + const snapshot = BrowserHost.prototype.snapshot.call(fixture); + + assert.equal(snapshot.activeTabId, "home"); + assert.deepEqual(snapshot.tabs.map((tab) => tab.id), ["home", "tab-ready"]); + assert.equal(snapshot.tabs[0].active, true); +}); + +test("selecting a task tab shows and focuses its owned Playwright surface", () => { + const visibility = []; + const focused = []; + const makeView = (id) => ({ + setVisible: (visible) => visibility.push([id, visible]), + webContents: { focus: () => focused.push(id) }, + }); + const first = { id: "tab-first", view: makeView("first") }; + const second = { id: "tab-second", view: makeView("second") }; + const fixture = Object.assign(Object.create(BrowserHost.prototype), { + view: makeView("home"), + authView: null, + turnTabs: new Map([[first.id, first], [second.id, second]]), + selectedTabId: first.id, + visible: true, + surfaceActive: true, + boundsReady: true, + snapshot: () => ({ activeTabId: fixture.selectedTabId }), + publishState() {}, + writeDescriptor() {}, + }); + + const state = BrowserHost.prototype.selectTab.call(fixture, second.id); + + assert.equal(fixture.selectedTabId, second.id); + assert.deepEqual(visibility, [ + ["home", false], + ["first", false], + ["second", true], + ]); + assert.deepEqual(focused, ["second"]); + assert.equal(state.activeTabId, second.id); +}); + test("a stale helper cannot end a replacement turn with the same trace id", async () => { + const turnTabs = new Map([["tab-1", { + id: "tab-1", + traceId: "trace_same_retry", + helperPid: 222, + }]]); await assert.rejects( BrowserHost.prototype.endTurn.call( - { activeTraceId: "trace_same_retry", activeHelperPid: 222 }, + { turnTabs, closedTurnOwners: new Map() }, "trace_same_retry", 111, "failed", @@ -745,3 +863,183 @@ test("a stale helper cannot end a replacement turn with the same trace id", asyn /Browser helper ownership mismatch: expected 222, received 111/, ); }); + +test("closing a running browser tab preserves ownership until its helper reports termination", () => { + const closed = []; + const tab = { + id: "tab-running", + traceId: "trace_running", + helperPid: 333, + status: "running", + view: { + webContents: { isDestroyed: () => false, close: () => closed.push("contents") }, + }, + }; + const fixture = Object.assign(Object.create(BrowserHost.prototype), { + turnTabs: new Map([[tab.id, tab]]), + closedTurnOwners: new Map(), + selectedTabId: tab.id, + window: { contentView: { removeChildView: () => closed.push("view") } }, + syncViewVisibility() {}, + snapshot: () => ({ tabs: [] }), + publishState() {}, + writeDescriptor() {}, + logger: { info() {} }, + }); + + BrowserHost.prototype.closeTab.call(fixture, tab.id); + + assert.deepEqual(closed, ["view", "contents"]); + assert.equal(fixture.closedTurnOwners.get("trace_running"), 333); + assert.equal(fixture.selectedTabId, "home"); +}); + +test("a later provider round reuses its task tab and restores active ownership", () => { + const throttling = []; + const tab = { + id: "tab-reused", + surfaceId: "surface-reused", + traceId: "trace_reused", + helperPid: 111, + status: "ready", + loading: false, + message: "Task completed", + view: { + webContents: { + isDestroyed: () => false, + setBackgroundThrottling: (enabled) => throttling.push(enabled), + }, + }, + }; + const events = []; + const fixture = Object.assign(Object.create(BrowserHost.prototype), { + manualOperation: null, + turnTabs: new Map([[tab.id, tab]]), + selectedTabId: "home", + syncViewVisibility: () => events.push("visible"), + snapshot: () => ({ tabs: [] }), + publishState: () => events.push("published"), + writeDescriptor: () => events.push("descriptor"), + logger: { info: (event) => events.push(event) }, + }); + + const lease = BrowserHost.prototype.beginTurn.call(fixture, "trace_reused", false, 222); + + assert.deepEqual(lease, { surfaceId: "surface-reused", tabId: "tab-reused" }); + assert.equal(tab.helperPid, 222); + assert.equal(tab.status, "running"); + assert.equal(tab.loading, true); + assert.equal(tab.message, "ChatGPT is working"); + assert.equal(fixture.selectedTabId, tab.id); + assert.deepEqual(throttling, [false]); + assert.deepEqual(events, ["visible", "published", "descriptor", "browser.tab_reused"]); +}); + +test("five browser tabs are a hard account-safety limit", () => { + const turnTabs = new Map(Array.from({ length: 5 }, (_unused, index) => [ + `tab-${index + 1}`, + { ordinal: index + 1 }, + ])); + + assert.throws( + () => BrowserHost.prototype.createTurnTab.call({ turnTabs }, "trace_six", 444), + /already has 5 browser tabs.*avoid excessive parallel traffic/, + ); +}); + +test("ending one browser turn does not stop another running tab", async () => { + let closedViews = 0; + let removedViews = 0; + const ended = { + id: "tab-ended", + traceId: "trace_ended", + helperPid: 555, + status: "running", + loading: true, + view: { webContents: { isDestroyed: () => false, setBackgroundThrottling() {}, close: () => { closedViews += 1; } } }, + }; + const active = { + id: "tab-active", + traceId: "trace_active", + helperPid: 666, + status: "running", + loading: true, + view: { webContents: { isDestroyed: () => false, setBackgroundThrottling() {} } }, + }; + const fixture = Object.assign(Object.create(BrowserHost.prototype), { + turnTabs: new Map([[ended.id, ended], [active.id, active]]), + closedTurnOwners: new Map(), + selectedTabId: ended.id, + window: { contentView: { removeChildView: (view) => { + assert.equal(view, ended.view); + removedViews += 1; + } } }, + syncViewVisibility() {}, + writeDescriptor() {}, + publishState() {}, + snapshot: () => ({ tabs: [] }), + hide: () => assert.fail("a second running tab must keep the browser host active"), + logger: { info() {} }, + }); + + await BrowserHost.prototype.endTurn.call( + fixture, + ended.traceId, + ended.helperPid, + "completed", + true, + ); + + assert.equal(ended.status, "ready"); + assert.equal(fixture.turnTabs.has(ended.id), false); + assert.equal(fixture.turnTabs.has(active.id), true); + assert.equal(fixture.selectedTabId, active.id); + assert.equal(closedViews, 1); + assert.equal(removedViews, 1); + assert.equal(active.status, "running"); + assert.equal(fixture.activeTraceId, active.traceId); +}); + +test("failed and aborted browser turns release their tab slots", async () => { + for (const status of ["failed", "aborted"]) { + let closed = false; + const tab = { + id: `tab-${status}`, + traceId: `trace_${status}`, + helperPid: 777, + status: "running", + loading: true, + view: { webContents: { + isDestroyed: () => false, + setBackgroundThrottling() {}, + close: () => { closed = true; }, + } }, + }; + const fixture = Object.assign(Object.create(BrowserHost.prototype), { + turnTabs: new Map([[tab.id, tab]]), + closedTurnOwners: new Map(), + selectedTabId: tab.id, + window: { contentView: { removeChildView() {} } }, + syncViewVisibility() {}, + writeDescriptor() {}, + publishState() {}, + snapshot: () => ({ tabs: [] }), + hide() {}, + logger: { info() {} }, + }); + + await BrowserHost.prototype.endTurn.call( + fixture, + tab.traceId, + tab.helperPid, + status, + true, + `turn ${status}`, + ); + + assert.equal(fixture.turnTabs.size, 0); + assert.equal(fixture.selectedTabId, "home"); + assert.equal(tab.status, status === "aborted" ? "aborted" : "error"); + assert.equal(closed, true); + } +}); diff --git a/launcher/tests/control-server.test.cjs b/launcher/tests/control-server.test.cjs index 83e677850..fd61f5646 100644 --- a/launcher/tests/control-server.test.cjs +++ b/launcher/tests/control-server.test.cjs @@ -6,7 +6,10 @@ test("browser control server authenticates and owns turn visibility", async () = const calls = []; const logs = []; const host = { - beginTurn: (...args) => calls.push(["start", ...args]), + beginTurn: (...args) => { + calls.push(["start", ...args]); + return { surfaceId: "launcher_surface_id_0123456789AB", tabId: "tab-1" }; + }, endTurn: (...args) => calls.push(["end", ...args]), }; const server = await new BrowserControlServer({ diff --git a/launcher/tests/design-contract.test.cjs b/launcher/tests/design-contract.test.cjs index f6f0a87f3..63a0987f4 100644 --- a/launcher/tests/design-contract.test.cjs +++ b/launcher/tests/design-contract.test.cjs @@ -144,6 +144,8 @@ test("MCP guide uses the two optimized recordings in the requested step order", test("MCP copy includes every required account, key, and connector instruction", () => { assert.match(i18nSource, /regular API key with Tunnels Read \+ Use \(free;/); + assert.match(i18nSource, /Don't forget to create a ChatGPT workspace\./); + assert.match(i18nSource, /别忘了创建 ChatGPT 工作区。/); assert.match(i18nSource, /same OpenAI account that will use the ChatGPT plugin/); assert.match(i18nSource, /enable Developer Mode[\s\S]*?choose Tunnel[\s\S]*?set Authentication to None/); assert.match(appSource, //); diff --git a/launcher/tests/packaging-contract.test.cjs b/launcher/tests/packaging-contract.test.cjs index c22b1de78..02f5aa428 100644 --- a/launcher/tests/packaging-contract.test.cjs +++ b/launcher/tests/packaging-contract.test.cjs @@ -54,6 +54,8 @@ test("release installers resolve checksummed native launcher assets", () => { "the downloaded AppImage must be executable before it is inspected", ); assert.match(windowsInstaller, /codex-web-gpt-\$Version-win-\$Arch\.exe/); + assert.match(windowsInstaller, /\[Environment\]::Is64BitOperatingSystem/); + assert.doesNotMatch(windowsInstaller, /RuntimeInformation/); }); test("CI packages and smoke-launches on macOS, Windows, and Linux", () => { diff --git a/launcher/tests/runtime-supervisor.test.cjs b/launcher/tests/runtime-supervisor.test.cjs index 00a743492..f96ee83a9 100644 --- a/launcher/tests/runtime-supervisor.test.cjs +++ b/launcher/tests/runtime-supervisor.test.cjs @@ -16,6 +16,7 @@ const { TUNNEL_START_TIMEOUT_MS, RuntimeSupervisor, managedTunnelConnectArgs, + migrateConfigForLauncher, validateConfig, } = require("../electron/runtime-supervisor.cjs"); @@ -135,6 +136,24 @@ test("launcher runtime ownership rejects a different browser descriptor", () => ); }); +test("launcher migrates an older owned config to the current durable runtime", () => { + const config = launcherConfig("/tmp/launcher.json", { + releaseVersion: "1.0.1-ko.4", + runtimeCommand: ["/old/runtime/bun", "/old/runtime/app/cli.js"], + }); + const migrated = migrateConfigForLauncher(config, "1.1.0-ko.1", { + executable: "/new/runtime/bun", + args: ["/new/runtime/app/cli.js"], + }); + assert.equal(migrated.releaseVersion, "1.1.0-ko.1"); + assert.deepEqual(migrated.runtimeCommand, ["/new/runtime/bun", "/new/runtime/app/cli.js"]); + assert.equal(migrated.tunnel, config.tunnel); + assert.equal(migrateConfigForLauncher(config, "1.0.1-ko.4", { + executable: "/unused", + args: [], + }), config); +}); + test("launcher runtime validation rejects a relative full-mode executable before spawn", () => { const descriptorPath = path.join(os.tmpdir(), "launcher.json"); assert.throws(() => validateConfig(launcherConfig(descriptorPath, { diff --git a/launcher/tests/state.test.cjs b/launcher/tests/state.test.cjs index 3f650a962..3c7d60604 100644 --- a/launcher/tests/state.test.cjs +++ b/launcher/tests/state.test.cjs @@ -20,6 +20,7 @@ test("launcher state persists onboarding, language, and autostart atomically", ( bridgeEnabled: true, keepRunningOnClose: true, showBrowserDuringTurns: true, + upstreamUpdateDismissedTag: null, browserSmokePassed: false, browserSmokeVersion: null, sidebarOpen: true, @@ -43,6 +44,7 @@ test("launcher state persists onboarding, language, and autostart atomically", ( bridgeEnabled: true, keepRunningOnClose: false, showBrowserDuringTurns: true, + upstreamUpdateDismissedTag: null, browserSmokePassed: true, browserSmokeVersion: "0.2.0", sidebarOpen: true, @@ -104,6 +106,7 @@ test("persisted sidebar corruption is repaired without changing the rest of laun bridgeEnabled: true, keepRunningOnClose: true, showBrowserDuringTurns: true, + upstreamUpdateDismissedTag: null, browserSmokePassed: false, browserSmokeVersion: null, sidebarOpen: true, diff --git a/launcher/tests/upstream-update.test.cjs b/launcher/tests/upstream-update.test.cjs new file mode 100644 index 000000000..9b9cb48b7 --- /dev/null +++ b/launcher/tests/upstream-update.test.cjs @@ -0,0 +1,70 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + UPSTREAM_LATEST_RELEASE_URL, + UPSTREAM_REPOSITORY_URL, + checkForUpstreamUpdate, + compareReleaseVersion, + parseReleasePayload, + releaseUrlIsSafe, +} = require("../electron/upstream-update.cjs"); + +test("upstream release versions compare numeric base versions", () => { + assert.equal(compareReleaseVersion("v1.1.0", "1.0.1-ko.4"), 1); + assert.equal(compareReleaseVersion("1.0.1", "v1.0.1-ko.4"), 0); + assert.equal(compareReleaseVersion("v1.0.0", "1.0.1"), -1); + assert.equal(compareReleaseVersion("not-a-version", "1.0.0"), 0); +}); + +test("release payload is restricted to the upstream GitHub repository", () => { + const release = parseReleasePayload({ + tag_name: "v1.1.0", + name: "Parallel browser tasks", + html_url: `${UPSTREAM_REPOSITORY_URL}/releases/tag/v1.1.0`, + published_at: "2026-08-02T00:00:00Z", + }); + assert.deepEqual(release, { + tag: "v1.1.0", + name: "Parallel browser tasks", + url: `${UPSTREAM_REPOSITORY_URL}/releases/tag/v1.1.0`, + publishedAt: "2026-08-02T00:00:00Z", + }); + assert.equal(releaseUrlIsSafe(`${UPSTREAM_REPOSITORY_URL}/releases/tag/v1.1.0`), true); + assert.equal(releaseUrlIsSafe("https://github.com/other/repo/releases/tag/v1.1.0"), false); + assert.throws(() => parseReleasePayload({ + tag_name: "v1.1.0", + html_url: "https://evil.example/release", + }), /did not match/); +}); + +test("update check reports an available upstream release without credentials", async () => { + let request; + const result = await checkForUpstreamUpdate("1.0.1-ko.4", { + fetchImpl: async (url, options) => { + request = { url, options }; + return { + ok: true, + async json() { + return { + tag_name: "v1.1.0", + name: "Parallel browser tasks", + html_url: `${UPSTREAM_REPOSITORY_URL}/releases/tag/v1.1.0`, + published_at: "2026-08-02T00:00:00Z", + }; + }, + }; + }, + }); + assert.equal(request.url, UPSTREAM_LATEST_RELEASE_URL); + assert.equal(request.options.headers.Authorization, undefined); + assert.deepEqual(result, { + status: "available", + currentVersion: "1.0.1-ko.4", + latest: { + tag: "v1.1.0", + name: "Parallel browser tasks", + url: `${UPSTREAM_REPOSITORY_URL}/releases/tag/v1.1.0`, + publishedAt: "2026-08-02T00:00:00Z", + }, + }); +}); diff --git a/package.json b/package.json index c48927b95..ab3554fb3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-chatgpt-web", - "version": "1.0.1-ko.4", + "version": "1.1.2-ko.4", "private": true, "description": "A focused local Responses bridge that runs Codex tasks through a user-authenticated ChatGPT web session.", "repository": { @@ -46,7 +46,7 @@ "verify": "bun run scripts/verify.ts" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/sdk": "^1.30.0", "chromium-bidi": "12.1.0", "fflate": "^0.8.2", "playwright-core": "^1.62.0", @@ -64,6 +64,8 @@ }, "overrides": { "@hono/node-server": "2.0.12", + "fast-uri": "3.1.5", + "hono": "4.12.34", "zod-to-json-schema": "3.25.1" }, "keywords": [ diff --git a/scripts/install-launcher.ps1 b/scripts/install-launcher.ps1 index 3024a711a..c80aebb1d 100644 --- a/scripts/install-launcher.ps1 +++ b/scripts/install-launcher.ps1 @@ -37,11 +37,10 @@ if ($Version -and $Version.StartsWith("v")) { $Version = $Version.Substring(1) } if (-not $Version) { throw "Could not resolve the latest Codex Web GPT release" } if ($Version -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$') { throw "Invalid release version: $Version" } -$Architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() -$Arch = switch ($Architecture) { - "x64" { "x64" } - default { throw "The packaged Windows launcher currently supports x64; detected $Architecture" } +if (-not [Environment]::Is64BitOperatingSystem) { + throw "The packaged Windows launcher requires 64-bit Windows" } +$Arch = "x64" $Asset = "codex-web-gpt-$Version-win-$Arch.exe" $BaseUrl = "https://github.com/$Repository/releases/download/v$Version" diff --git a/scripts/install.sh b/scripts/install.sh index 3f4bb5d78..5323be5f8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ set -eu REPOSITORY="${CODEX_CHATGPT_WEB_REPOSITORY:-AgenticLab-SH/codex-chatgpt-web}" -VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.0.1-ko.4}" +VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.1.2-ko.4}" BIN_DIR="${CODEX_CHATGPT_WEB_BIN_DIR:-$HOME/.local/bin}" LIB_DIR="${CODEX_CHATGPT_WEB_LIB_DIR:-$HOME/.local/lib/codex-chatgpt-web}" DOC_DIR="${CODEX_CHATGPT_WEB_DOC_DIR:-$HOME/.local/share/doc/codex-chatgpt-web}" diff --git a/scripts/smoke-release.ts b/scripts/smoke-release.ts index d13f6db57..fbccf0a68 100644 --- a/scripts/smoke-release.ts +++ b/scripts/smoke-release.ts @@ -119,7 +119,7 @@ try { const rejectedWhileDraining = await fetch(`http://127.0.0.1:${port}/v1/responses`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "chatgpt-web/high", reasoning: { effort: "high" }, input: "test", stream: false }), + body: JSON.stringify({ model: "chatgpt-web/temporary/high", reasoning: { effort: "high" }, input: "test", stream: false }), }); if (rejectedWhileDraining.status !== 503) { throw new Error(`daemon accepted a new turn while draining: HTTP ${rejectedWhileDraining.status}`); diff --git a/src/adapters/chatgpt-web/adapter-error.ts b/src/adapters/chatgpt-web/adapter-error.ts new file mode 100644 index 000000000..6ef6e0c44 --- /dev/null +++ b/src/adapters/chatgpt-web/adapter-error.ts @@ -0,0 +1,22 @@ +export interface ChatGptWebAdapterErrorOptions { + status: number; + errorType: string; + code: string; + retryable: boolean; +} + +export class ChatGptWebAdapterError extends Error { + readonly status: number; + readonly errorType: string; + readonly code: string; + readonly retryable: boolean; + + constructor(message: string, options: ChatGptWebAdapterErrorOptions) { + super(message); + this.name = "ChatGptWebAdapterError"; + this.status = options.status; + this.errorType = options.errorType; + this.code = options.code; + this.retryable = options.retryable; + } +} diff --git a/src/adapters/chatgpt-web/browser-helper-main.ts b/src/adapters/chatgpt-web/browser-helper-main.ts index 6d80deb8f..fd10b9b2d 100644 --- a/src/adapters/chatgpt-web/browser-helper-main.ts +++ b/src/adapters/chatgpt-web/browser-helper-main.ts @@ -2,7 +2,9 @@ import { createInterface } from "node:readline"; import { stdin, stderr, stdout } from "node:process"; import type { CodexProviderConfig } from "../../types"; import { ChatGptBrowserWorker, closeChatGptBrowserWorkers, type BrowserTurn } from "./browser-worker"; +import { ChatGptWebAdapterError } from "./adapter-error"; import type { ChatGptWebCapabilities } from "./model"; +import type { ChatGptWebConversationMode } from "../../chatgpt-web-models"; import { createProcessLineWriter } from "./process-line-writer"; import type { CompiledChatGptWebPrompt } from "./prompt"; @@ -19,6 +21,7 @@ interface RunMessage { traceId: string; modelId: string; reasoning?: string; + conversationMode?: ChatGptWebConversationMode; capabilities: ChatGptWebCapabilities; prepared: CompiledChatGptWebPrompt; }; @@ -109,6 +112,7 @@ async function run(message: RunMessage): Promise { traceId: message.turn.traceId, modelId: message.turn.modelId, reasoning: message.turn.reasoning, + conversationMode: message.turn.conversationMode, capabilities: message.turn.capabilities, prepare: async () => ({ ...message.turn.prepared, release: () => {} }), abortSignal: abortController.signal, @@ -132,6 +136,12 @@ async function run(message: RunMessage): Promise { id: message.id, name: error instanceof Error ? error.name : "Error", message: error instanceof Error ? error.message : String(error), + ...(error instanceof ChatGptWebAdapterError ? { + status: error.status, + errorType: error.errorType, + code: error.code, + retryable: error.retryable, + } : {}), }); } finally { abortControllers.delete(message.id); diff --git a/src/adapters/chatgpt-web/browser-worker.ts b/src/adapters/chatgpt-web/browser-worker.ts index 1f3ac4019..cdf27564b 100644 --- a/src/adapters/chatgpt-web/browser-worker.ts +++ b/src/adapters/chatgpt-web/browser-worker.ts @@ -4,12 +4,14 @@ import { chromium, type Browser, type BrowserContext, type Locator, type Page } import { atomicWriteFile, expandUserPath, getConfigDir } from "../../config"; import type { CodexProviderConfig } from "../../types"; import { parseDataUrl } from "../image"; -import { ChatGptMarkdownStream } from "./markdown"; +import { ChatGptMarkdownBuffer } from "./markdown"; import { resolveChatGptWebModelMode, type ChatGptWebCapabilities, type ChatGptWebModelMode } from "./model"; +import type { ChatGptWebConversationMode } from "../../chatgpt-web-models"; import { CHATGPT_INTERNAL_COMPACTION_MARKER, CHATGPT_MAX_INPUT_IMAGES, containsChatGptCompactionMarker, stripChatGptTransportMarkers, type CompiledChatGptWebPrompt, type ChatGptWebPromptImage } from "./prompt"; import { estimateCompiledChatGptWebInputTokens } from "./usage"; import { assertAuthenticatedChatGptPage, + assertChatGptConversationPage, assertTemporaryChatPage, CHATGPT_ASSISTANT_TURN_SELECTOR, CHATGPT_COMPLETION_ACTION_SELECTOR, @@ -20,10 +22,16 @@ import { CHATGPT_STOP_BUTTON_SELECTOR, CHATGPT_TEMPORARY_CHAT_URL, CHATGPT_USER_TURN_SELECTOR, + chatGptConversationUrl, } from "../../chatgpt-session"; import { loginVerificationMarkerPath } from "../../browser-login"; import { connectLauncherBrowserHost, notifyLauncherTurn } from "../../launcher-browser-host"; +import { resolveChatGptWebContextLimits } from "../../chatgpt-web-models"; import { LauncherBrowserHelperClient } from "./launcher-helper-client"; +import { MAX_CHATGPT_BROWSER_TABS } from "./concurrency"; +import { ChatGptWebAdapterError } from "./adapter-error"; + +export { MAX_CHATGPT_BROWSER_TABS } from "./concurrency"; const workers = new Map(); @@ -39,11 +47,10 @@ export async function closeChatGptBrowserWorkers(): Promise { } } -export const DEFAULT_CHATGPT_TURN_TIMEOUT_MS = 40 * 60_000; export const CHATGPT_RESPONSE_DOM_GRACE_MS = 60_000; export const CHATGPT_EMPTY_RESPONSE_GRACE_MS = 10_000; export const CHATGPT_COMPLETION_SETTLE_MS = 2_000; - +export const CHATGPT_TOOL_CONFIRMATION_TIMEOUT_MS = 60_000; /** * ChatGPT applies composer state asynchronously, and a fast host can reach the next step before the * editor has taken the previous one. This is headroom for that, not a readiness check. @@ -54,6 +61,91 @@ const settleChatGptUi = (): Promise => ( new Promise(resolveSettle => setTimeout(resolveSettle, CHATGPT_UI_SETTLE_MS)) ); +const chatGptRateLimitDialog = (page: Page): Locator => page.locator('[role="dialog"]') + .filter({ hasText: /Too many requests/i }) + .filter({ hasText: /making requests too quickly/i }) + .last(); + +export async function throwIfChatGptRateLimitDialog(page: Page): Promise { + const dialog = chatGptRateLimitDialog(page); + if (!await dialog.isVisible().catch(() => false)) return; + + const acknowledge = dialog.getByRole("button", { name: "Got it", exact: true }).last(); + if (await acknowledge.isVisible().catch(() => false)) { + try { + await acknowledge.press("Enter"); + } catch (error) { + throw new ChatGptWebAdapterError( + `ChatGPT rate-limit dialog is open, but its acknowledgement failed: ${error instanceof Error ? error.message : String(error)}`, + { status: 429, errorType: "rate_limit_error", code: "rate_limit_exceeded", retryable: true }, + ); + } + } + throw new ChatGptWebAdapterError( + "ChatGPT rate limit: too many requests are being made too quickly. Wait before retrying.", + { status: 429, errorType: "rate_limit_error", code: "rate_limit_exceeded", retryable: true }, + ); +} + +const chatGptTerminalErrorAlert = (page: Page): Locator => page.locator('[role="alert"]') + .filter({ hasText: /Something went wrong/i }) + .filter({ hasText: /help\.openai\.com/i }) + .last(); + +export async function throwIfChatGptTerminalErrorAlert(page: Page): Promise { + if (!await chatGptTerminalErrorAlert(page).isVisible().catch(() => false)) return; + throw new ChatGptWebAdapterError( + "ChatGPT ended the turn with 'Something went wrong'. Retry the turn.", + { status: 502, errorType: "server_error", code: "upstream_server_error", retryable: true }, + ); +} + +export async function resolveChatGptToolConfirmation( + page: Page, + appName: string, + autoApprove: boolean, + signal?: AbortSignal, + timeoutMs = CHATGPT_TOOL_CONFIRMATION_TIMEOUT_MS, +): Promise { + const dialog = page.locator('[role="dialog"]') + .filter({ hasText: `Allow ChatGPT to use ${appName}?` }) + .last(); + if (!await dialog.isVisible().catch(() => false)) return false; + + if (autoApprove) { + const allowOnce = dialog.getByRole("button", { name: "Allow once", exact: true }).last(); + await allowOnce.waitFor({ state: "visible", timeout: 10_000 }); + await allowOnce.press("Enter"); + return true; + } + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (signal?.aborted) throw new DOMException("ChatGPT web turn aborted", "AbortError"); + if (!await dialog.isVisible().catch(() => false)) return true; + await new Promise(resolveSleep => setTimeout(resolveSleep, Math.min(100, Math.max(1, deadline - Date.now())))); + } + + if (!await dialog.isVisible().catch(() => false)) return true; + const deny = dialog.getByRole("button", { name: "Deny", exact: true }).last(); + await deny.waitFor({ state: "visible", timeout: 5_000 }); + await deny.press("Enter"); + await dialog.waitFor({ state: "hidden", timeout: 10_000 }); + return true; +} + +export function assertChatGptWebInputWithinContextWindow( + estimatedInputTokens: number, + proAvailable: boolean, +): void { + const { contextWindow } = resolveChatGptWebContextLimits(proAvailable); + if (estimatedInputTokens < contextWindow) return; + throw new ChatGptWebAdapterError( + `This task is estimated at ${estimatedInputTokens.toLocaleString("en-US")} input tokens, which exceeds the ${contextWindow.toLocaleString("en-US")}-token context window for this ChatGPT Web model. Switch to a model with a larger context window, run /compact, then retry this Web model.`, + { status: 400, errorType: "invalid_request_error", code: "context_length_exceeded", retryable: false }, + ); +} + const browserStageTimeouts = { browserPage: 60_000, navigation: 70_000, @@ -69,6 +161,7 @@ export interface BrowserTurn { traceId: string; modelId: string; reasoning?: string; + conversationMode?: ChatGptWebConversationMode; capabilities: ChatGptWebCapabilities; prepare: () => Promise void }>; abortSignal?: AbortSignal; @@ -87,7 +180,7 @@ export interface ResolvedBrowserConfig { browserHostDescriptorPath?: string; storageStatePath: string; chromeExecutablePath: string; - turnTimeoutMs: number; + turnTimeoutMs?: number; headed: boolean; autoApproveToolCalls: boolean; } @@ -198,7 +291,6 @@ interface ChatGptResponseDomSnapshot { responsePresent: boolean; visibleText: string; fullHtml: string; - stableHtml: string; completionActionVisible: boolean; traceBlocks: ChatGptVisibleTraceBlock[]; } @@ -207,7 +299,6 @@ const absentResponseDomSnapshot = (): ChatGptResponseDomSnapshot => ({ responsePresent: false, visibleText: "", fullHtml: "", - stableHtml: "", completionActionVisible: false, traceBlocks: [], }); @@ -215,18 +306,12 @@ const absentResponseDomSnapshot = (): ChatGptResponseDomSnapshot => ({ /** Convert the public ChatGPT turn DOM into append-only Codex reasoning summaries. */ export class ChatGptVisibleTraceTracker { private readonly seen = new Set(); - private readonly emittedCommentary = new Map(); - private readonly commentaryChangedAt = new Map(); private readonly emittedReasoning = new Map(); private readonly reasoningCandidates = new Map(); constructor(private readonly traceStabilityMs = 1_000) {} - observe(blocks: ChatGptVisibleTraceBlock[], completionActionVisible: boolean, now = Date.now()): ChatGptVisibleTraceEvent[] { - let lastMarkdown = -1; - for (let index = 0; index < blocks.length; index++) { - if (blocks[index]!.kind === "markdown") lastMarkdown = index; - } + observe(blocks: ChatGptVisibleTraceBlock[], _completionActionVisible: boolean, now = Date.now()): ChatGptVisibleTraceEvent[] { const output: ChatGptVisibleTraceEvent[] = []; for (let index = 0; index < blocks.length; index++) { const block = blocks[index]!; @@ -235,6 +320,11 @@ export class ChatGptVisibleTraceTracker { this.seen.add(CHATGPT_INTERNAL_COMPACTION_MARKER); output.push({ kind: "reasoning", text: "Context automatically compacted" }); } + // Every visible Markdown root belongs to the final assistant answer. ChatGPT may split one + // answer into several roots around status/tool UI; emitting the earlier roots as commentary + // moves most of the answer under Codex's `Working` disclosure and leaves a truncated final. + // ChatGptMarkdownBuffer owns all Markdown roots; this tracker owns status/reasoning only. + if (block.kind === "markdown") continue; const text = stripChatGptTransportMarkers(block.text) .replace(/\r\n/g, "\n") .split("\n") @@ -243,53 +333,25 @@ export class ChatGptVisibleTraceTracker { .replace(/\n{3,}/g, "\n\n") .trim(); if (!text) continue; - // The trailing Markdown root is ambiguous while running and becomes the final answer once - // complete. It stays owned by ChatGptMarkdownStream; earlier roots are stable commentary. - if (block.kind === "markdown" - && (completionActionVisible ? index === lastMarkdown : index === blocks.length - 1)) { + const candidate = this.reasoningCandidates.get(index); + if (!candidate || candidate.text !== text) { + this.reasoningCandidates.set(index, { text, changedAt: now }); continue; } - if (block.kind === "markdown") { - const previous = this.emittedCommentary.get(index); - if (previous === text) { - const changedAt = this.commentaryChangedAt.get(index) ?? now; - if (now - changedAt < this.traceStabilityMs) break; - continue; - } - this.commentaryChangedAt.set(index, now); - if (previous && text.startsWith(previous)) { - this.emittedCommentary.set(index, text); - output.push({ kind: "commentary", text: text.slice(previous.length), continuation: true }); - break; - } - this.emittedCommentary.set(index, text); - } else { - const candidate = this.reasoningCandidates.get(index); - if (!candidate || candidate.text !== text) { - this.reasoningCandidates.set(index, { text, changedAt: now }); - continue; - } - if (now - candidate.changedAt < this.traceStabilityMs) continue; + if (now - candidate.changedAt < this.traceStabilityMs) continue; - const previous = this.emittedReasoning.get(index); - if (previous === text) continue; - this.emittedReasoning.set(index, text); + const previous = this.emittedReasoning.get(index); + if (previous === text) continue; + this.emittedReasoning.set(index, text); - const key = `${block.kind}\0${text}`; - if (this.seen.has(key)) continue; - this.seen.add(key); - if (previous && text.startsWith(previous)) { - output.push({ kind: "reasoning", text: text.slice(previous.length), continuation: true }); - } else { - output.push({ kind: "reasoning", text }); - } - continue; - } const key = `${block.kind}\0${text}`; if (this.seen.has(key)) continue; this.seen.add(key); - output.push({ kind: block.kind === "markdown" ? "commentary" : "reasoning", text }); - if (block.kind === "markdown") break; + if (previous && text.startsWith(previous)) { + output.push({ kind: "reasoning", text: text.slice(previous.length), continuation: true }); + } else { + output.push({ kind: "reasoning", text }); + } } return output; } @@ -305,20 +367,25 @@ export function redactChatGptUiDiagnostic(value: string): string { .replace(/\b(turn|binding|call)_[A-Za-z0-9_-]{12,}\b/g, "$1_[redacted]"); } -function resolveBrowserConfig(provider: CodexProviderConfig): ResolvedBrowserConfig { +export function resolveBrowserConfig(provider: CodexProviderConfig): ResolvedBrowserConfig { const configured = provider.chatgptWeb ?? {}; const browserHost = configured.browserHost ?? "managed-chrome"; const browserHostDescriptorPath = configured.browserHostDescriptorPath?.trim(); + const turnTimeoutMs = configured.turnTimeoutMs; if (browserHost === "launcher" && !browserHostDescriptorPath) { throw new Error("Launcher browser host requires chatgptWeb.browserHostDescriptorPath"); } + if (turnTimeoutMs !== undefined + && (!Number.isFinite(turnTimeoutMs) || turnTimeoutMs <= 0)) { + throw new Error("ChatGPT Web turnTimeoutMs must be a positive finite number"); + } return { appName: configured.appName?.trim() || "Codex Native", browserHost, ...(browserHostDescriptorPath ? { browserHostDescriptorPath: resolve(expandUserPath(browserHostDescriptorPath)) } : {}), storageStatePath: resolve(expandUserPath(configured.storageStatePath?.trim() || join(getConfigDir(), "browser", "storage-state.json"))), chromeExecutablePath: resolve(expandUserPath(configured.chromeExecutablePath?.trim() || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")), - turnTimeoutMs: configured.turnTimeoutMs ?? DEFAULT_CHATGPT_TURN_TIMEOUT_MS, + ...(turnTimeoutMs !== undefined ? { turnTimeoutMs } : {}), headed: configured.headed !== false, autoApproveToolCalls: configured.autoApproveToolCalls === true, }; @@ -374,24 +441,42 @@ export class ChatGptBrowserWorker { private browser?: Browser; private context?: BrowserContext; private page?: Page; + private managedBrowserReady?: Promise<{ browser: Browser; context: BrowserContext }>; private launcherHelper?: LauncherBrowserHelperClient; - private tail: Promise = Promise.resolve(); + private verificationTail: Promise = Promise.resolve(); + private readonly activeRuns = new Map>(); private constructor(private readonly config: ResolvedBrowserConfig) {} run(turn: BrowserTurn): Promise { + if (this.activeRuns.has(turn.traceId)) { + return Promise.reject(new Error(`Duplicate ChatGPT web browser turn: ${turn.traceId}`)); + } + if (this.activeRuns.size >= MAX_CHATGPT_BROWSER_TABS) { + return Promise.reject(new Error( + `ChatGPT Web supports at most ${MAX_CHATGPT_BROWSER_TABS} simultaneous browser turns; close or finish a browser tab before starting another`, + )); + } const useHelper = this.config.browserHost === "launcher" && process.env.CODEX_CHATGPT_WEB_BROWSER_HELPER_PROCESS !== "1"; if (useHelper) { this.launcherHelper ??= new LauncherBrowserHelperClient(this.config); } - const run = this.tail.then(() => useHelper ? this.launcherHelper!.run(turn) : this.runExclusive(turn)); - this.tail = run.then(() => undefined, () => undefined); + const run = Promise.resolve().then(() => useHelper ? this.launcherHelper!.run(turn) : this.runExclusive(turn)); + this.activeRuns.set(turn.traceId, run); + void run.finally(() => { + if (this.activeRuns.get(turn.traceId) === run) this.activeRuns.delete(turn.traceId); + }).catch(() => {}); return run; } verifyConnector(): Promise { - const verification = this.tail.then(() => this.verifyConnectorExclusive()); - this.tail = verification.then(() => undefined, () => undefined); + const verification = this.verificationTail.then(() => { + if (this.activeRuns.size > 0) { + throw new Error("ChatGPT connector verification requires all browser turns to finish"); + } + return this.verifyConnectorExclusive(); + }); + this.verificationTail = verification.then(() => undefined, () => undefined); return verification; } @@ -401,49 +486,41 @@ export class ChatGptBrowserWorker { this.launcherHelper = undefined; await helper.close(); } - await this.tail; + await Promise.allSettled([...this.activeRuns.values()]); + await this.verificationTail; const browser = this.browser; this.browser = undefined; this.context = undefined; this.page = undefined; + this.managedBrowserReady = undefined; // For connectOverCDP, Playwright implements Browser.close as a transport disconnect; it does // not close the launcher-owned Electron process. Always release that connection and its // artifact directory instead of leaking one per timeout/helper lifecycle. if (browser) await browser.close(); } - private discardBrowser(): void { - const browser = this.browser; - this.browser = undefined; - this.context = undefined; - this.page = undefined; - if (browser) { - void browser.close().catch(error => { - console.error( - `[chatgpt-web] failed to discard browser connection: ${error instanceof Error ? error.message : String(error)}`, - ); - }); - } - } - - private async runStage(traceId: string, stage: string, timeoutMs: number, action: () => Promise): Promise { + private async runStage( + traceId: string, + stage: string, + timeoutMs: number, + action: (abortSignal: AbortSignal) => Promise, + ): Promise { const startedAt = performance.now(); console.info(`[chatgpt-web] browser turn ${traceId} stage=${stage} started`); + const controller = new AbortController(); let timer: ReturnType | undefined; - let timedOut = false; try { const timeout = new Promise((_, rejectTimeout) => { timer = setTimeout(() => { - timedOut = true; rejectTimeout(new Error(`ChatGPT browser stage timed out: ${stage}`)); + controller.abort(); }, timeoutMs); }); - const value = await Promise.race([action(), timeout]); + const value = await Promise.race([action(controller.signal), timeout]); console.info(`[chatgpt-web] browser turn ${traceId} stage=${stage} completed durationMs=${Math.round(performance.now() - startedAt)}`); return value; } catch (error) { console.error(`[chatgpt-web] browser turn ${traceId} stage=${stage} failed durationMs=${Math.round(performance.now() - startedAt)}: ${error instanceof Error ? error.message : String(error)}`); - if (timedOut) this.discardBrowser(); throw error; } finally { if (timer) clearTimeout(timer); @@ -474,25 +551,44 @@ export class ChatGptBrowserWorker { return this.page; } + private async ensureManagedBrowser(): Promise<{ browser: Browser; context: BrowserContext }> { + if (this.managedBrowserReady) return this.managedBrowserReady; + const opening = (async () => { + if (!existsSync(this.config.storageStatePath) || !existsSync(loginVerificationMarkerPath(this.config.storageStatePath))) { + throw new Error(`ChatGPT web login state is missing: ${this.config.storageStatePath}`); + } + if (!existsSync(this.config.chromeExecutablePath)) { + throw new Error(`Configured Chrome executable does not exist: ${this.config.chromeExecutablePath}`); + } + const browser = await chromium.launch({ + executablePath: this.config.chromeExecutablePath, + headless: !this.config.headed, + }); + const context = await browser.newContext({ storageState: this.config.storageStatePath }); + this.browser = browser; + this.context = context; + return { browser, context }; + })(); + this.managedBrowserReady = opening; + try { + return await opening; + } catch (error) { + if (this.managedBrowserReady === opening) this.managedBrowserReady = undefined; + throw error; + } + } + /** * A Codex turn owns one isolated Temporary Chat document. Reusing the same * ChatGPT SPA page can retain the previous transcript and autocomplete DOM, * so an @app lookup may select stale UI from the preceding turn. */ private async pageForNewTurn(): Promise { - const previous = await this.ensurePage(); - if (this.config.browserHost === "launcher") return previous; - if (previous.url() === "about:blank") return previous; - const context = this.context; - if (!context) throw new Error("ChatGPT web browser context is unavailable"); - const page = await context.newPage(); - this.page = page; - await previous.close().catch(error => { - console.error( - `[chatgpt-web] failed to close previous browser page: ${error instanceof Error ? error.message : String(error)}`, - ); - }); - return page; + if (this.config.browserHost === "launcher") { + throw new Error("Launcher turns require an explicitly leased browser surface"); + } + const { context } = await this.ensureManagedBrowser(); + return await context.newPage(); } private async selectModelAndEffort( @@ -511,19 +607,33 @@ export class ChatGptBrowserWorker { throw new Error("ChatGPT rendered the composer but its model/effort control did not become ready"); } await settleChatGptUi(); + await throwIfChatGptRateLimitDialog(page); const effortMenu = page.locator(CHATGPT_EFFORT_MENU_SELECTOR).last(); const menuVisible = await effortMenu.isVisible().catch(() => false); const menuExpanded = await currentEffort.getAttribute("aria-expanded").catch(() => null); - if (!menuVisible && menuExpanded !== "true") await currentEffort.press("Enter"); + if (!menuVisible && menuExpanded !== "true") { + await throwIfChatGptRateLimitDialog(page); + await currentEffort.press("Enter"); + } const effortChoices = effortMenu.locator(CHATGPT_EFFORT_ITEM_SELECTOR); const effortChoice = effortChoices.nth(mode.uiEffortIndex); + const waitAbort = new AbortController(); try { - await effortChoice.waitFor({ state: "visible", timeout: 70_000 }); - } catch { - throw new Error( - `ChatGPT effort item index ${mode.uiEffortIndex} is unavailable` - + `; available item count: ${await effortChoices.count().catch(() => 0)}`, + const ready = await Promise.race([ + effortChoice.waitFor({ state: "visible", timeout: 70_000, signal: waitAbort.signal }).then(() => "effort" as const), + chatGptRateLimitDialog(page).waitFor({ state: "visible", timeout: 70_000, signal: waitAbort.signal }).then(() => "rate-limit" as const), + ]); + if (ready === "rate-limit") await throwIfChatGptRateLimitDialog(page); + } catch (error) { + if (error instanceof ChatGptWebAdapterError) throw error; + await throwIfChatGptRateLimitDialog(page); + throw new ChatGptWebAdapterError( + `ChatGPT effort menu did not expose item index ${mode.uiEffortIndex}` + + `; item count: ${await effortChoices.count().catch(() => 0)}`, + { status: 502, errorType: "server_error", code: "upstream_server_error", retryable: false }, ); + } finally { + waitAbort.abort(); } const selected = await effortChoice.getAttribute("aria-checked"); if (selected !== "true" && selected !== "false") { @@ -533,6 +643,7 @@ export class ChatGptBrowserWorker { await page.keyboard.press("Escape"); return mode; } + await throwIfChatGptRateLimitDialog(page); await effortChoice.press("Enter"); const deadline = Date.now() + 40_000; @@ -540,7 +651,10 @@ export class ChatGptBrowserWorker { while (Date.now() < deadline) { if (!await effortMenu.isVisible().catch(() => false)) { const expanded = await currentEffort.getAttribute("aria-expanded").catch(() => null); - if (expanded !== "true") await currentEffort.press("Enter"); + if (expanded !== "true") { + await throwIfChatGptRateLimitDialog(page); + await currentEffort.press("Enter"); + } await effortChoice.waitFor({ state: "visible", timeout: Math.max(1, Math.min(5_000, deadline - Date.now())), @@ -583,6 +697,7 @@ export class ChatGptBrowserWorker { ): Promise { const visibleStopButtons = page.locator(CHATGPT_STOP_BUTTON_SELECTOR).filter({ visible: true }); for (;;) { + await throwIfChatGptTerminalErrorAlert(page); const [userTurnCount, assistantTurnCount, visibleStopButtonCount] = await Promise.all([ userTurns.count(), responseTurns.count(), @@ -786,20 +901,6 @@ export class ChatGptBrowserWorker { throw new Error("ChatGPT accepted the prompt attachments but did not make the message ready to send"); } - private async handleToolConfirmation(page: Page): Promise { - const heading = page.getByText(`Allow ChatGPT to use ${this.config.appName}?`, { exact: true }).last(); - if (!await heading.isVisible().catch(() => false)) return false; - if (!this.config.autoApproveToolCalls) { - throw new Error( - `ChatGPT is waiting for confirmation to use ${this.config.appName}; set chatgptWeb.autoApproveToolCalls=true to authorize per-call "Allow once" clicks`, - ); - } - const allowOnce = page.getByRole("button", { name: "Allow once", exact: true }).last(); - await allowOnce.waitFor({ state: "visible", timeout: 10_000 }); - await allowOnce.press("Enter"); - return true; - } - private async responseDomSnapshot(responseTurn: Locator): Promise { const snapshot = await responseTurn.evaluate((element, completionActionSelector) => { const root = element as HTMLElement; @@ -813,8 +914,13 @@ export class ChatGptBrowserWorker { && rect.height > 0; }; - const rendered = [...root.querySelectorAll(".markdown")].at(-1); - const renderedChildren = rendered ? [...rendered.children] : []; + // ChatGPT can split one assistant answer into several sibling Markdown roots around + // reasoning/status UI. Keep only top-level visible roots, then aggregate all of them in DOM + // order so Codex receives the complete answer instead of only the trailing fragment. + const renderedRoots = [...root.querySelectorAll(".markdown")] + .filter(candidate => !candidate.parentElement?.closest(".markdown")) + .filter(visible); + const rendered = renderedRoots.at(-1); const completionAction = rendered ? [...root.querySelectorAll(completionActionSelector)] .filter(visible) @@ -823,16 +929,25 @@ export class ChatGptBrowserWorker { : undefined; const completionActionSet = new Set(completionAction ? [completionAction] : []); const candidates = new Map(); - root.querySelectorAll(".markdown").forEach(candidate => candidates.set(candidate, "markdown")); + renderedRoots.forEach(candidate => candidates.set(candidate, "markdown")); + const overlapsRenderedAnswer = (candidate: HTMLElement): boolean => renderedRoots.some(rendered => ( + candidate.contains(rendered) || rendered.contains(candidate) + )); root.querySelectorAll( 'button, [role="status"], [aria-busy="true"], [data-testid*="cot"], [data-testid*="reason"], [data-testid*="thought"]', ).forEach(candidate => { if (completionActionSet.has(candidate)) return; const semantic = candidate.closest("button") ?? candidate; - if (!candidates.has(semantic)) candidates.set(semantic, "status"); + // A renderer may wrap the final Markdown in a reason/status container. That wrapper and + // its descendants still belong exclusively to the final-answer stream; assigning either + // side to the trace stream duplicates or truncates the answer under Codex's `Working` UI. + if (!overlapsRenderedAnswer(semantic) && !candidates.has(semantic)) { + candidates.set(semantic, "status"); + } }); root.querySelectorAll("[data-streaming-response-status]").forEach(container => { - if (![...candidates.keys()].some(candidate => container.contains(candidate))) { + if (!overlapsRenderedAnswer(container) + && ![...candidates.keys()].some(candidate => container.contains(candidate))) { candidates.set(container, "status"); } }); @@ -848,13 +963,17 @@ export class ChatGptBrowserWorker { )); return { responsePresent: true, - visibleText: rendered?.innerText.trim() ?? "", - fullHtml: rendered?.innerHTML ?? "", - stableHtml: renderedChildren.slice(0, -1).map(child => child.outerHTML).join(""), + visibleText: renderedRoots.map(candidate => candidate.innerText.trim()).filter(Boolean).join("\n\n"), + fullHtml: renderedRoots.map(candidate => candidate.innerHTML).join(""), completionActionVisible: completionAction !== undefined, traceBlocks, }; - }, CHATGPT_COMPLETION_ACTION_SELECTOR, { timeout: 2_000 }).catch(() => absentResponseDomSnapshot()); + }, CHATGPT_COMPLETION_ACTION_SELECTOR, { timeout: 2_000 }).catch(() => { + if (responseTurn.page().isClosed()) { + throw new Error("ChatGPT browser tab was closed; the Codex turn was terminated"); + } + return absentResponseDomSnapshot(); + }); snapshot.traceBlocks = snapshot.traceBlocks.filter(block => !isChatGptTraceControl(block)); return snapshot; } @@ -908,16 +1027,18 @@ export class ChatGptBrowserWorker { if (turn.abortSignal?.aborted) throw new DOMException("ChatGPT web turn aborted", "AbortError"); if (this.config.browserHost !== "launcher") return this.runBrowserTurn(turn); - await notifyLauncherTurn(this.config.browserHostDescriptorPath!, { + const lease = await notifyLauncherTurn(this.config.browserHostDescriptorPath!, { phase: "start", traceId: turn.traceId, helperPid: process.pid, }); + const surfaceId = lease.surfaceId; + if (!surfaceId) throw new Error("Launcher did not lease a browser tab for the ChatGPT turn"); let terminal: "completed" | "failed" | "aborted" = "completed"; let terminalMessage: string | undefined; let originalError: unknown; try { - return await this.runBrowserTurn(turn); + return await this.runBrowserTurn(turn, surfaceId); } catch (error) { originalError = error; terminal = error instanceof DOMException && error.name === "AbortError" ? "aborted" : "failed"; @@ -941,19 +1062,50 @@ export class ChatGptBrowserWorker { } } - private async runBrowserTurn(turn: BrowserTurn): Promise { + private async runBrowserTurn(turn: BrowserTurn, launcherSurfaceId?: string): Promise { if (turn.abortSignal?.aborted) throw new DOMException("ChatGPT web turn aborted", "AbortError"); const prepared = await turn.prepare(); + let turnConnection: Browser | undefined; + let managedPage: Page | undefined; try { if (turn.abortSignal?.aborted) throw new DOMException("ChatGPT web turn aborted", "AbortError"); const estimatedInputTokens = estimateCompiledChatGptWebInputTokens(prepared, turn.modelId); - const deadline = Date.now() + this.config.turnTimeoutMs; - const page = await this.runStage(turn.traceId, "browser_page", browserStageTimeouts.browserPage, () => this.pageForNewTurn()); + assertChatGptWebInputWithinContextWindow( + estimatedInputTokens, + turn.capabilities.proAvailable, + ); + const deadline = this.config.turnTimeoutMs === undefined + ? undefined + : Date.now() + this.config.turnTimeoutMs; + const page = await this.runStage(turn.traceId, "browser_page", browserStageTimeouts.browserPage, async (abortSignal) => { + if (!launcherSurfaceId) { + const managed = await this.pageForNewTurn(); + if (abortSignal.aborted) { + await managed.close().catch(() => {}); + throw new DOMException("ChatGPT browser page acquisition aborted", "AbortError"); + } + return managed; + } + const connection = await connectLauncherBrowserHost( + this.config.browserHostDescriptorPath!, + browserStageTimeouts.browserPage, + launcherSurfaceId, + abortSignal, + ); + if (abortSignal.aborted) { + await connection.browser.close().catch(() => {}); + throw new DOMException("ChatGPT browser page acquisition aborted", "AbortError"); + } + turnConnection = connection.browser; + return connection.page; + }); + if (!launcherSurfaceId) managedPage = page; console.info( `[chatgpt-web] browser turn ${turn.traceId} opened (transport=inline, promptChars=${prepared.text.length}, estimatedInputTokens=${estimatedInputTokens}, images=${prepared.images.length})`, ); - await this.runStage(turn.traceId, "temporary_chat_navigation", browserStageTimeouts.navigation, () => ( - page.goto(CHATGPT_TEMPORARY_CHAT_URL, { waitUntil: "domcontentloaded", timeout: 60_000 }).then(() => undefined) + const conversationMode = turn.conversationMode ?? "temporary"; + await this.runStage(turn.traceId, "conversation_navigation", browserStageTimeouts.navigation, () => ( + page.goto(chatGptConversationUrl(conversationMode), { waitUntil: "domcontentloaded", timeout: 60_000 }).then(() => undefined) )); try { await this.runStage(turn.traceId, "composer_ready", browserStageTimeouts.composerReady, () => ( @@ -964,7 +1116,7 @@ export class ChatGptBrowserWorker { } await this.runStage(turn.traceId, "session_verification", browserStageTimeouts.sessionVerification, async () => { await assertAuthenticatedChatGptPage(page); - await assertTemporaryChatPage(page); + await assertChatGptConversationPage(page, conversationMode); }); const mode = await this.runStage(turn.traceId, "effort_selection", browserStageTimeouts.effortSelection, () => ( this.selectModelAndEffort(page, turn.modelId, turn.reasoning, turn.capabilities) @@ -1007,22 +1159,34 @@ export class ChatGptBrowserWorker { let loggedCompletionWait = false; const sentAt = Date.now(); const visibleTrace = new ChatGptVisibleTraceTracker(); - const markdownStream = new ChatGptMarkdownStream(stripChatGptTransportMarkers); + const markdownBuffer = new ChatGptMarkdownBuffer(stripChatGptTransportMarkers); const completionTracker = new ChatGptCompletionTracker(); const domHealthTracker = new ChatGptTurnDomHealthTracker(); for (;;) { + if (page.isClosed()) { + throw new Error("ChatGPT browser tab was closed; the Codex turn was terminated"); + } if (turn.abortSignal?.aborted) { const stop = page.locator(CHATGPT_STOP_BUTTON_SELECTOR).last(); if (await stop.isVisible().catch(() => false)) await stop.press("Enter").catch(() => {}); throw new DOMException("ChatGPT web turn aborted", "AbortError"); } - if (Date.now() >= deadline) throw new Error("ChatGPT web turn timed out"); + if (deadline !== undefined && Date.now() >= deadline) { + throw new Error("ChatGPT web turn timed out"); + } if (Date.now() - lastHeartbeat >= 10_000) { turn.onHeartbeat?.(); lastHeartbeat = Date.now(); } - if (mode.localTools && await this.handleToolConfirmation(page)) { + await throwIfChatGptTerminalErrorAlert(page); + + if (mode.localTools && await resolveChatGptToolConfirmation( + page, + this.config.appName, + this.config.autoApproveToolCalls, + turn.abortSignal, + )) { await new Promise(resolveSleep => setTimeout(resolveSleep, 250)); continue; } @@ -1032,6 +1196,7 @@ export class ChatGptBrowserWorker { const running = await stop.isVisible().catch(() => false); if (running) sawRunning = true; if (snapshot.responsePresent) { + markdownBuffer.observe(snapshot.fullHtml); for (const trace of visibleTrace.observe(snapshot.traceBlocks, snapshot.completionActionVisible)) { if (trace.kind === "commentary") turn.onCommentary?.(trace.text, trace.continuation === true); else turn.onReasoningSummary?.(trace.text, trace.continuation === true); @@ -1043,12 +1208,6 @@ export class ChatGptBrowserWorker { completionActionVisible: snapshot.completionActionVisible, }); if (domError) throw new Error(domError); - // ChatGPT can render visible commentary Markdown between tool-status rows. Only a - // Markdown root accompanied by the response action belongs to the final answer stream. - if (snapshot.completionActionVisible) { - const stableDelta = markdownStream.observeStableHtml(snapshot.stableHtml); - if (stableDelta) turn.onTextDelta(stableDelta); - } if (completionTracker.update({ responsePresent: snapshot.responsePresent, running, @@ -1059,7 +1218,7 @@ export class ChatGptBrowserWorker { if (snapshot.visibleText === "api_tool unavailable") { throw new Error("ChatGPT selected mode rejected the Codex Native MCP tool (api_tool unavailable)"); } - const final = markdownStream.finish(snapshot.fullHtml); + const final = markdownBuffer.finish(); if (!final.markdown && snapshot.visibleText) { throw new Error("ChatGPT completed with visible text that could not be serialized as Markdown"); } @@ -1096,6 +1255,19 @@ export class ChatGptBrowserWorker { return finalText; } finally { prepared.release(); + if (turnConnection) { + await turnConnection.close().catch(error => { + console.error( + `[chatgpt-web] failed to release launcher browser connection for ${turn.traceId}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + } else if (managedPage && !managedPage.isClosed()) { + await managedPage.close().catch(error => { + console.error( + `[chatgpt-web] failed to close managed browser tab for ${turn.traceId}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + } } } } diff --git a/src/adapters/chatgpt-web/concurrency.ts b/src/adapters/chatgpt-web/concurrency.ts new file mode 100644 index 000000000..d52e99fd7 --- /dev/null +++ b/src/adapters/chatgpt-web/concurrency.ts @@ -0,0 +1,6 @@ +/** + * ChatGPT Web concurrency is deliberately bounded. Every active Codex turn owns a real + * browser document in the signed-in account, so unbounded fan-out would create account-level + * traffic that is indistinguishable from spam. + */ +export const MAX_CHATGPT_BROWSER_TABS = 5; diff --git a/src/adapters/chatgpt-web/environment.ts b/src/adapters/chatgpt-web/environment.ts index ac382245d..d2fbdc3e1 100644 --- a/src/adapters/chatgpt-web/environment.ts +++ b/src/adapters/chatgpt-web/environment.ts @@ -12,6 +12,8 @@ export interface ChatGptTurnEnvironment { writableRoots: string[]; sandboxPolicy: ChatGptSandboxPolicy; tools: CodexTool[]; + /** Enforce a second project-only boundary because the surrounding native harness is broader. */ + enforceProjectBoundary?: true; } export interface ChatGptTurnIdentity { @@ -84,8 +86,13 @@ function environmentBeforeUser(input: unknown[], userIndex: number, expectedTurn function sandboxTypeFromEnvironment(text: string): ChatGptSandboxPolicy["type"] | undefined { const unrestricted = /]*>[\s\S]*?]*\/?\s*>/i.test(text) || /danger-full-access<\/sandbox_mode>/i.test(text); - const workspaceWrite = /workspace-write<\/sandbox_mode>/i.test(text); - const readOnly = /read-only<\/sandbox_mode>/i.test(text); + const restrictedFileSystem = /]*>[\s\S]*?]*>([\s\S]*?)<\/file_system>/i.exec(text); + const restrictedHasWriteEntry = restrictedFileSystem !== null + && /]*>/i.test(restrictedFileSystem[1]!); + const workspaceWrite = /workspace-write<\/sandbox_mode>/i.test(text) + || restrictedHasWriteEntry; + const readOnly = /read-only<\/sandbox_mode>/i.test(text) + || (restrictedFileSystem !== null && !restrictedHasWriteEntry); if (Number(unrestricted) + Number(workspaceWrite) + Number(readOnly) !== 1) return undefined; return unrestricted ? "dangerFullAccess" : workspaceWrite ? "workspaceWrite" : "readOnly"; } @@ -131,7 +138,12 @@ function workspaceMetadataEnvironmentBeforeUser( const trimmed = text.trim(); if (!/^[\s\S]*<\/environment_context>$/.test(trimmed)) continue; - const cwdMatches = [...trimmed.matchAll(/([^<]+)<\/cwd>/g)].map(match => decodeXmlText(match[1]!.trim())); + let cwdMatches: string[]; + try { + cwdMatches = selectedEnvironmentCwdMatches(trimmed).map(value => decodeXmlText(value.trim())); + } catch { + continue; + } if (cwdMatches.length !== 1 || !isAbsolute(cwdMatches[0]!)) continue; const rootMatches = [...trimmed.matchAll(/[\s\S]*?<\/workspace_roots>/g)] .flatMap(section => [...section[0].matchAll(/([^<]+)<\/root>/g)].map(match => decodeXmlText(match[1]!.trim()))); @@ -216,6 +228,22 @@ function decodeXmlText(value: string): string { .replaceAll("'", "'"); } +function selectedEnvironmentCwdMatches(text: string): string[] { + const environments = [...text.matchAll(/]*)>([\s\S]*?)<\/environment>/gi)]; + if (environments.length === 0) { + return [...text.matchAll(/([^<]+)<\/cwd>/gi)].map(match => match[1] ?? ""); + } + const primary = environments.filter(match => /\bprimary=["']true["']/i.test(match[1] ?? "")); + if (primary.length !== 1) { + throw new Error("ChatGPT web turn requires exactly one primary Codex environment"); + } + const cwdMatches = [...primary[0]![2]!.matchAll(/([^<]+)<\/cwd>/gi)].map(match => match[1] ?? ""); + if (cwdMatches.length !== 1) { + throw new Error("ChatGPT web primary Codex environment requires exactly one cwd"); + } + return cwdMatches; +} + function uniqueAbsolutePaths(values: string[], field: string): string[] { const decoded = values.map(value => decodeXmlText(value.trim())); if (decoded.length === 0) throw new MissingTrustedCodexEnvironmentError(field); @@ -234,7 +262,7 @@ function matchesPath(root: string, path: string): boolean { export function extractChatGptTurnEnvironment(parsed: CodexParsedRequest): ChatGptTurnEnvironment { const text = trustedEnvironmentText(parsed); - const cwdMatches = [...text.matchAll(/([^<]+)<\/cwd>/g)].map(match => match[1] ?? ""); + const cwdMatches = selectedEnvironmentCwdMatches(text); const cwdCandidates = uniqueAbsolutePaths(cwdMatches, "cwd"); if (cwdCandidates.length !== 1) throw new Error("ChatGPT web turn has conflicting trusted Codex cwd values"); const cwd = cwdCandidates[0]!; diff --git a/src/adapters/chatgpt-web/index.ts b/src/adapters/chatgpt-web/index.ts index 6e26dc268..8e2e640bb 100644 --- a/src/adapters/chatgpt-web/index.ts +++ b/src/adapters/chatgpt-web/index.ts @@ -4,9 +4,11 @@ import { defaultBrokerEndpoint, expandUserPath, resolveBrokerEndpoint } from ".. import { namespacedToolName, type AdapterEvent, type CodexContentPart, type CodexParsedRequest, type CodexProviderConfig, type CodexToolResultMessage, type CodexUsage } from "../../types"; import type { ProviderAdapter } from "../base"; import { parseDataUrl } from "../image"; -import { ChatGptBrowserWorker, DEFAULT_CHATGPT_TURN_TIMEOUT_MS } from "./browser-worker"; +import { ChatGptWebAdapterError } from "./adapter-error"; +import { ChatGptBrowserWorker } from "./browser-worker"; import { extractChatGptTurnEnvironment, extractChatGptTurnIdentity } from "./environment"; -import { resolveChatGptWebModelMode, type ChatGptWebCapabilities } from "./model"; +import { chatGptWebLocalToolsAllowed, resolveChatGptWebModelMode, type ChatGptWebCapabilities } from "./model"; +import { scopeChatGptTurnEnvironment } from "./project-boundary"; import { chatGptReadOnlyContextWarning, compileChatGptWebPrompt } from "./prompt"; import { TurnBroker, type BrokerToolRequest, type BrokerToolResult } from "./turn-broker"; import { ChatGptTextFeed, ChatGptTraceFeed, chatGptTurnExecutionKey, chatGptTurnSessions, type ChatGptBrowserOutcome, type ChatGptTraceEvent, type ChatGptTurnRuntime, type ChatGptTurnSession } from "./turn-execution"; @@ -152,10 +154,19 @@ function validateBatchTools(parsed: CodexParsedRequest, requests: BrokerToolRequ } } +function capabilitiesForRuntime( + capabilities: ChatGptWebCapabilities, + runtime: ChatGptTurnRuntime, +): ChatGptWebCapabilities { + return runtime.mode === "tools" + ? capabilities + : { ...capabilities, localToolsEnabled: false }; +} + export function createChatGptWebAdapter(provider: CodexProviderConfig): ProviderAdapter { const worker = ChatGptBrowserWorker.forProvider(provider); const broker = TurnBroker.forSocket(brokerSocketPath(provider)); - const timeoutMs = provider.chatgptWeb?.turnTimeoutMs ?? DEFAULT_CHATGPT_TURN_TIMEOUT_MS; + const timeoutMs = provider.chatgptWeb?.turnTimeoutMs; const capabilities: ChatGptWebCapabilities = { localToolsEnabled: provider.chatgptWeb?.localToolsEnabled === true, proAvailable: provider.chatgptWeb?.proAvailable === true, @@ -175,7 +186,11 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider environment: ReturnType | undefined, traceId: string, ): ChatGptTurnRuntime => { - const mode = resolveChatGptWebModelMode(parsed.modelId, parsed.options.reasoning, capabilities); + const runtimeCapabilities = { + ...capabilities, + localToolsEnabled: chatGptWebLocalToolsAllowed(capabilities, environment?.sandboxPolicy.type), + } satisfies ChatGptWebCapabilities; + const mode = resolveChatGptWebModelMode(parsed.modelId, parsed.options.reasoning, runtimeCapabilities); const browserAbort = new AbortController(); const trace = new ChatGptTraceFeed(); const text = new ChatGptTextFeed(); @@ -184,8 +199,9 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider traceId, modelId: parsed.modelId, reasoning: parsed.options.reasoning, - capabilities, - prepare: async () => ({ ...compileChatGptWebPrompt(parsed, capabilities), release: () => {} }), + conversationMode: parsed._chatGptWebConversationMode, + capabilities: runtimeCapabilities, + prepare: async () => ({ ...compileChatGptWebPrompt(parsed, runtimeCapabilities), release: () => {} }), abortSignal: browserAbort.signal, onReasoningSummary: (text, continuation) => trace.push({ kind: "reasoning", text, ...(continuation ? { continuation: true } : {}) }), onCommentary: (text, continuation) => trace.push({ kind: "commentary", text, ...(continuation ? { continuation: true } : {}) }), @@ -207,9 +223,14 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider traceId, modelId: parsed.modelId, reasoning: parsed.options.reasoning, - capabilities, + conversationMode: parsed._chatGptWebConversationMode, + capabilities: runtimeCapabilities, prepare: async () => { - const turnToken = await broker.register(environment, timeoutMs + 60_000, traceId); + const turnToken = await broker.register( + environment, + timeoutMs === undefined ? undefined : timeoutMs + 60_000, + traceId, + ); activeToken = turnToken; tokenSettled = true; token.resolve(turnToken); @@ -248,11 +269,18 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider return { name: "chatgpt-web", async runTurn(parsed, incoming, emit) { + if (parsed._opaqueMultiAgentV2Payload) { + throw new Error( + "ChatGPT Web subagents currently require a V1-rooted task. " + + "Start a new task with a ChatGPT Web model before spawning ChatGPT Web Pro. " + + "Codex MultiAgent V2 currently encrypts cross-backend task payloads.", + ); + } const mode = resolveChatGptWebModelMode(parsed.modelId, parsed.options.reasoning, capabilities); let environment: ReturnType | undefined; if (mode.localTools) { try { - environment = environmentStore.resolve(parsed); + environment = scopeChatGptTurnEnvironment(environmentStore.resolve(parsed)); } catch (error) { const identity = extractChatGptTurnIdentity(parsed); console.warn( @@ -268,6 +296,7 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider try { emit({ type: "heartbeat" }); await session.runExclusive(async () => { + const sessionCapabilities = capabilitiesForRuntime(capabilities, session.runtime); const settled = session.settledOutcome(); if (settled) { if (settled.type === "error") throw settled.error; @@ -281,7 +310,7 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider events.push(event); emit(event); }; - if (!parsed._compactionRequest) emitProContextWarning(parsed, capabilities, emitCaptured); + if (!parsed._compactionRequest) emitProContextWarning(parsed, sessionCapabilities, emitCaptured); const trace = session.runtime.trace.drain(); reasoning = trace.map(event => event.text); emitTraceEvents(trace, emitCaptured); @@ -292,7 +321,7 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider session.setFinalReasoning(reasoning); session.setFinalEvents(events); } - emitBrowserCompletion(settled, estimateChatGptWebUsage(parsed, { answer: settled.answer, reasoning }, capabilities), emit); + emitBrowserCompletion(settled, estimateChatGptWebUsage(parsed, { answer: settled.answer, reasoning }, sessionCapabilities), emit); return; } @@ -308,7 +337,7 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider if (results.length === 0) { const reasoning = session.reasoningForOutstandingReplay(); replayEvents(session.eventsForOutstandingReplay(), emit); - emitToolBatch(outstanding, estimateChatGptWebUsage(parsed, { reasoning, toolRequests: outstanding }, capabilities), emit); + emitToolBatch(outstanding, estimateChatGptWebUsage(parsed, { reasoning, toolRequests: outstanding }, sessionCapabilities), emit); return; } if (results.length !== outstanding.length) { @@ -336,7 +365,7 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider emitTraceEvents(trace, emitRound); }; const emitNewText = (deltas: string[]) => emitTextDeltas(deltas, emitRound); - if (!parsed._compactionRequest) emitProContextWarning(parsed, capabilities, emitRound); + if (!parsed._compactionRequest) emitProContextWarning(parsed, sessionCapabilities, emitRound); emitNewTrace(session.runtime.trace.drain()); emitNewText(session.runtime.text.drain()); const nextTools = turnToken @@ -377,7 +406,7 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider } emitBrowserCompletion( next.outcome, - estimateChatGptWebUsage(parsed, { answer: next.outcome.answer, reasoning: roundReasoning }, capabilities), + estimateChatGptWebUsage(parsed, { answer: next.outcome.answer, reasoning: roundReasoning }, sessionCapabilities), emit, ); return; @@ -390,7 +419,7 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider session.setOutstanding(next.requests, roundReasoning, roundEvents); emitToolBatch( next.requests, - estimateChatGptWebUsage(parsed, { reasoning: roundReasoning, toolRequests: next.requests }, capabilities), + estimateChatGptWebUsage(parsed, { reasoning: roundReasoning, toolRequests: next.requests }, sessionCapabilities), emit, ); return; @@ -404,6 +433,17 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider if (session.runtime.mode === "tools") { void session.runtime.token.then(turnToken => broker.revoke(turnToken)).catch(() => {}); } + if (error instanceof ChatGptWebAdapterError) { + emit({ + type: "error", + message: error.message, + status: error.status, + errorType: error.errorType, + code: error.code, + retryable: error.retryable, + }); + return; + } throw error; } finally { clearInterval(heartbeat); diff --git a/src/adapters/chatgpt-web/launcher-helper-client.ts b/src/adapters/chatgpt-web/launcher-helper-client.ts index 80d1affdd..decd87c94 100644 --- a/src/adapters/chatgpt-web/launcher-helper-client.ts +++ b/src/adapters/chatgpt-web/launcher-helper-client.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { createInterface } from "node:readline"; import { notifyLauncherTurn, readLauncherBrowserHostDescriptor } from "../../launcher-browser-host"; +import { ChatGptWebAdapterError } from "./adapter-error"; import type { CompiledChatGptWebPrompt } from "./prompt"; import type { BrowserTurn, ResolvedBrowserConfig } from "./browser-worker"; @@ -16,7 +17,16 @@ type HelperMessage = | { type: "ready" } | { type: "event"; id: string; event: "heartbeat" | "reasoning" | "commentary" | "text"; text?: string; continuation?: boolean } | { type: "result"; id: string; text: string } - | { type: "error"; id: string; name?: string; message: string }; + | { + type: "error"; + id: string; + name?: string; + message: string; + status?: number; + errorType?: string; + code?: string; + retryable?: boolean; + }; function parseHelperMessage(line: string): HelperMessage { const value = JSON.parse(line) as unknown; @@ -59,8 +69,26 @@ function parseHelperMessage(line: string): HelperMessage { if (message.type === "error") { const errorMessage = message.message; const errorName = message.name; + const status = message.status; + const errorType = message.errorType; + const code = message.code; + const retryable = message.retryable; + const structured = status !== undefined + || errorType !== undefined + || code !== undefined + || retryable !== undefined; if (typeof errorMessage !== "string" - || (errorName !== undefined && typeof errorName !== "string")) { + || (errorName !== undefined && typeof errorName !== "string") + || (structured && ( + !Number.isInteger(status) + || (status as number) < 400 + || (status as number) > 599 + || typeof errorType !== "string" + || !errorType + || typeof code !== "string" + || !code + || typeof retryable !== "boolean" + ))) { throw new Error("Launcher browser helper error payload is invalid"); } return { @@ -68,6 +96,12 @@ function parseHelperMessage(line: string): HelperMessage { id: message.id, message: errorMessage, ...(errorName !== undefined ? { name: errorName as string } : {}), + ...(structured ? { + status: status as number, + errorType: errorType as string, + code: code as string, + retryable: retryable as boolean, + } : {}), }; } throw new Error("Launcher browser helper emitted an unknown message type"); @@ -134,6 +168,7 @@ export class LauncherBrowserHelperClient { traceId: turn.traceId, modelId: turn.modelId, reasoning: turn.reasoning, + conversationMode: turn.conversationMode, capabilities: turn.capabilities, prepared: { text: prepared.text, images: prepared.images } satisfies CompiledChatGptWebPrompt, }, @@ -261,9 +296,16 @@ export class LauncherBrowserHelperClient { this.finish(message.id); pending.resolve(message.text); } else if (message.type === "error") { - const error = message.name === "AbortError" - ? new DOMException(message.message, "AbortError") - : new Error(message.message); + const error = message.status !== undefined + ? new ChatGptWebAdapterError(message.message, { + status: message.status, + errorType: message.errorType!, + code: message.code!, + retryable: message.retryable!, + }) + : message.name === "AbortError" + ? new DOMException(message.message, "AbortError") + : new Error(message.message); this.finish(message.id); pending.reject(error); } diff --git a/src/adapters/chatgpt-web/markdown.ts b/src/adapters/chatgpt-web/markdown.ts index 3e144a3f2..4c9940be6 100644 --- a/src/adapters/chatgpt-web/markdown.ts +++ b/src/adapters/chatgpt-web/markdown.ts @@ -42,38 +42,23 @@ export function chatGptHtmlToMarkdown(html: string): string { } /** - * Converts append-only rendered ChatGPT blocks into Responses text deltas. - * A stable prefix must be observed twice before it is committed. The final unstable block is - * emitted only by `finish`, so already-streamed Markdown never needs a retraction. + * Buffers ChatGPT's mutable rendered answer until terminal DOM evidence is stable. + * + * The Web UI may rewrite an already-visible paragraph while hydrating citations, links, lists, or + * a replacement renderer. Responses text deltas cannot be retracted, so no final-answer Markdown + * is emitted before `finish`; live reasoning/status events remain a separate append-only stream. */ -export class ChatGptMarkdownStream { - private candidate = ""; - private committed = ""; +export class ChatGptMarkdownBuffer { + private html = ""; constructor(private readonly transform: (markdown: string) => string = markdown => markdown) {} - observeStableHtml(html: string): string { - const next = this.transform(chatGptHtmlToMarkdown(html)); - if (!next.startsWith(this.committed)) { - throw new Error("ChatGPT changed Markdown that was already streamed to Codex"); - } - if (next !== this.candidate) { - this.candidate = next; - return ""; - } - const delta = next.slice(this.committed.length); - this.committed = next; - return delta; + observe(html: string): void { + this.html = html; } - finish(html: string): { markdown: string; delta: string } { - const markdown = this.transform(chatGptHtmlToMarkdown(html)); - if (!markdown.startsWith(this.committed)) { - throw new Error("ChatGPT final Markdown does not extend the streamed stable prefix"); - } - const delta = markdown.slice(this.committed.length); - this.committed = markdown; - this.candidate = markdown; - return { markdown, delta }; + finish(): { markdown: string; delta: string } { + const markdown = this.transform(chatGptHtmlToMarkdown(this.html)); + return { markdown, delta: markdown }; } } diff --git a/src/adapters/chatgpt-web/mcp-server.ts b/src/adapters/chatgpt-web/mcp-server.ts index c8afdaedc..baadbbf29 100644 --- a/src/adapters/chatgpt-web/mcp-server.ts +++ b/src/adapters/chatgpt-web/mcp-server.ts @@ -4,15 +4,16 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import * as z from "zod/v4"; import { namespacedToolName, type CodexTool } from "../../types"; import type { ChatGptTurnEnvironment } from "./environment"; +import { assertProjectPatch, assertProjectPath, projectBoundCommand } from "./project-boundary"; import { callTurnBroker, type BrokerToolResult } from "./turn-broker"; interface ClaimedTurn { bindingId: string; - environment: ChatGptTurnEnvironment & { expiresAt: number }; + environment: ChatGptTurnEnvironment & { expiresAt?: number }; } interface ResolvedTurn { - environment: ChatGptTurnEnvironment & { expiresAt: number }; + environment: ChatGptTurnEnvironment & { expiresAt?: number }; } const bindingSchema = z.string().min(20).max(256).describe("Opaque binding_id returned by codex_bind_turn."); @@ -70,8 +71,13 @@ function namedTool(environment: ChatGptTurnEnvironment, requestedWireName: strin return tool; } -function invocationTimeout(environment: ChatGptTurnEnvironment & { expiresAt: number }): number { - return Math.max(1, environment.expiresAt - Date.now()); +function invocationTimeout(environment: ChatGptTurnEnvironment & { expiresAt?: number }): number { + // An omitted Web turn timeout means the binding follows the outer Codex turn lifetime. The + // broker revokes it when that turn ends; keep one generous transport guard so a broken socket + // still fails instead of manufacturing an Invalid Date or a five-second tool timeout. + return environment.expiresAt === undefined + ? 30 * 60_000 + : Math.max(1, environment.expiresAt - Date.now()); } function asMcpResult(value: BrokerToolResult) { @@ -124,15 +130,17 @@ function execGatewayProgram( export async function runChatGptMcpServer(options: { brokerSocketPath: string }): Promise { const server = new McpServer({ name: "codex-native", version: "3.0.0" }); - const environment = async (bindingId: string): Promise => { + const environment = async (bindingId: string): Promise => { const resolved = await callTurnBroker(options.brokerSocketPath, { method: "resolve", bindingId }); - if (resolved.environment.expiresAt <= Date.now()) throw new Error("Codex turn binding expired"); + if (resolved.environment.expiresAt !== undefined && resolved.environment.expiresAt <= Date.now()) { + throw new Error("Codex turn binding expired"); + } return resolved.environment; }; const invoke = async ( bindingId: string, - bound: ChatGptTurnEnvironment & { expiresAt: number }, + bound: ChatGptTurnEnvironment & { expiresAt?: number }, tool: CodexTool, payload: { arguments?: Record; input?: string }, ) => { @@ -148,7 +156,7 @@ export async function runChatGptMcpServer(options: { brokerSocketPath: string }) const invokeNative = ( bindingId: string, - bound: ChatGptTurnEnvironment & { expiresAt: number }, + bound: ChatGptTurnEnvironment & { expiresAt?: number }, tool: CodexTool, payload: { arguments?: Record; input?: string }, ) => { @@ -160,7 +168,7 @@ export async function runChatGptMcpServer(options: { brokerSocketPath: string }) const invokeNestedNative = ( bindingId: string, - bound: ChatGptTurnEnvironment & { expiresAt: number }, + bound: ChatGptTurnEnvironment & { expiresAt?: number }, nestedToolName: string, freeform: boolean, payload: { arguments?: Record; input?: string }, @@ -195,7 +203,10 @@ export async function runChatGptMcpServer(options: { brokerSocketPath: string }) roots: claimed.environment.roots, writable_roots: claimed.environment.writableRoots, sandbox: claimed.environment.sandboxPolicy.type, - expires_at: new Date(claimed.environment.expiresAt).toISOString(), + project_boundary: claimed.environment.enforceProjectBoundary === true, + expires_at: claimed.environment.expiresAt === undefined + ? null + : new Date(claimed.environment.expiresAt).toISOString(), tool_count: claimed.environment.tools.length, command_tool: commandTool ? wireName(commandTool) : gateway ? "exec_command" : null, outer_tool_gateway: gateway ? wireName(gateway) : null, @@ -224,17 +235,18 @@ export async function runChatGptMcpServer(options: { brokerSocketPath: string }) const bound = await environment(binding_id); const tool = exactTool(bound, "exec_command") ?? exactTool(bound, "shell_command"); const commandName = tool?.name ?? "exec_command"; + const bounded = await projectBoundCommand(bound, cmd, workdir); const args = commandName === "exec_command" ? { - cmd, - ...(workdir ? { workdir } : {}), + cmd: bounded.command, + workdir: bounded.workdir, ...(yield_time_ms !== undefined ? { yield_time_ms } : {}), ...(max_output_tokens !== undefined ? { max_output_tokens } : {}), ...(tty !== undefined ? { tty } : {}), } : { - command: cmd, - ...(workdir ? { workdir } : {}), + command: bounded.command, + workdir: bounded.workdir, ...(yield_time_ms !== undefined ? { timeout_ms: yield_time_ms } : {}), }; return tool @@ -282,6 +294,7 @@ export async function runChatGptMcpServer(options: { brokerSocketPath: string }) }, async ({ binding_id, patch }) => { const bound = await environment(binding_id); + if (bound.enforceProjectBoundary) await assertProjectPatch(bound, patch); const tool = exactTool(bound, "apply_patch"); if (!tool) return invokeNestedNative(binding_id, bound, "apply_patch", true, { input: patch }); return tool.freeform @@ -304,8 +317,11 @@ export async function runChatGptMcpServer(options: { brokerSocketPath: string }) }, async ({ binding_id, path, detail }) => { const bound = await environment(binding_id); + const boundedPath = bound.enforceProjectBoundary + ? await assertProjectPath(bound, path, "read") + : path; const tool = exactTool(bound, "view_image"); - const payload = { arguments: { path, ...(detail ? { detail } : {}) } }; + const payload = { arguments: { path: boundedPath, ...(detail ? { detail } : {}) } }; return tool ? invokeNative(binding_id, bound, tool, payload) : invokeNestedNative(binding_id, bound, "view_image", false, payload); @@ -328,6 +344,15 @@ export async function runChatGptMcpServer(options: { brokerSocketPath: string }) }, async ({ binding_id, query, offset, limit, include_schema }) => { const bound = await environment(binding_id); + if (bound.enforceProjectBoundary) { + return result({ + tools: [], + total: 0, + next_offset: null, + project_boundary: true, + message: "Use the dedicated project-bound Codex Native tools in this turn.", + }); + } const needle = query?.trim().toLowerCase(); const matches = bound.tools.filter(tool => !needle || [ wireName(tool), @@ -366,6 +391,9 @@ export async function runChatGptMcpServer(options: { brokerSocketPath: string }) }, async ({ binding_id, wire_name, arguments: args, input }) => { const bound = await environment(binding_id); + if (bound.enforceProjectBoundary) { + throw new Error("Generic outer-tool calls are disabled by the Web GPT project boundary"); + } const tool = namedTool(bound, wire_name); if (tool.freeform) { if (input === undefined) throw new Error(`Freeform Codex tool ${wire_name} requires input`); diff --git a/src/adapters/chatgpt-web/model.ts b/src/adapters/chatgpt-web/model.ts index 30706b3f8..359d262c6 100644 --- a/src/adapters/chatgpt-web/model.ts +++ b/src/adapters/chatgpt-web/model.ts @@ -5,6 +5,22 @@ export interface ChatGptWebCapabilities { proAvailable: boolean; } +export type ChatGptWebSandboxType = "dangerFullAccess" | "readOnly" | "workspaceWrite"; + +/** + * Web GPT gets a narrower capability boundary than the surrounding Codex process. A native + * Codex thread may intentionally use danger-full-access, but that authority must not silently + * become ChatGPT Web's authority. Web GPT local tools are therefore available only when the + * active project thread advertises workspace-write, including the separately guarded project + * boundary derived from a broader native task. + */ +export function chatGptWebLocalToolsAllowed( + capabilities: ChatGptWebCapabilities, + sandboxType: ChatGptWebSandboxType | undefined, +): boolean { + return capabilities.localToolsEnabled && sandboxType === "workspaceWrite"; +} + export interface ChatGptWebModelMode { modelId: string; effort: "low" | "medium" | "high" | "xhigh" | "max"; @@ -21,7 +37,7 @@ export function resolveChatGptWebModelMode( if (modelId !== CHATGPT_WEB_MODEL_ID) { throw new Error(`ChatGPT web model is not supported: ${modelId}`); } - const effort = reasoning ?? "high"; + const effort = reasoning ?? "xhigh"; switch (effort) { case "low": return { modelId, effort, displayLabel: "Instant", uiEffortIndex: 0, localTools: capabilities.localToolsEnabled }; @@ -30,6 +46,7 @@ export function resolveChatGptWebModelMode( case "high": return { modelId, effort, displayLabel: "High", uiEffortIndex: 2, localTools: capabilities.localToolsEnabled }; case "xhigh": + if (!capabilities.proAvailable) throw new Error("ChatGPT Extra High effort is not available for this account"); return { modelId, effort, displayLabel: "Extra High", uiEffortIndex: 3, localTools: capabilities.localToolsEnabled }; case "max": if (!capabilities.proAvailable) throw new Error("ChatGPT Pro effort is not available for this account"); diff --git a/src/adapters/chatgpt-web/project-boundary.ts b/src/adapters/chatgpt-web/project-boundary.ts new file mode 100644 index 000000000..e71055d32 --- /dev/null +++ b/src/adapters/chatgpt-web/project-boundary.ts @@ -0,0 +1,165 @@ +import { realpath } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; +import type { ChatGptTurnEnvironment } from "./environment"; + +function pathIdentity(value: string): string { + const normalized = resolve(value); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + +function contains(root: string, path: string): boolean { + const relationship = relative(pathIdentity(root), pathIdentity(path)); + return relationship === "" || (!relationship.startsWith("..") && !isAbsolute(relationship)); +} + +function uniquePaths(values: string[]): string[] { + const unique = new Map(); + for (const value of values.map(path => resolve(path))) { + if (!unique.has(pathIdentity(value))) unique.set(pathIdentity(value), value); + } + return [...unique.values()]; +} + +/** + * Native Codex and OCX may intentionally run without a filesystem sandbox. Web GPT does not + * inherit that authority: its trusted task roots become a separate project boundary while the + * surrounding harness remains unchanged. + */ +export function scopeChatGptTurnEnvironment(environment: ChatGptTurnEnvironment): ChatGptTurnEnvironment { + if (environment.sandboxPolicy.type !== "dangerFullAccess") return environment; + const roots = uniquePaths(environment.roots); + return { + ...environment, + roots, + writableRoots: roots, + sandboxPolicy: { type: "workspaceWrite", writableRoots: roots, networkAccess: false }, + enforceProjectBoundary: true, + }; +} + +function requestedPath(environment: ChatGptTurnEnvironment, value: string): string { + const candidate = resolve(isAbsolute(value) ? value : resolve(environment.cwd, value)); + if (!environment.roots.some(root => contains(root, candidate))) { + throw new Error(`Path is outside the active Codex project: ${value}`); + } + return candidate; +} + +async function canonicalCandidate(path: string): Promise { + const suffix: string[] = []; + let current = path; + for (;;) { + try { + const base = await realpath(current); + return resolve(base, ...suffix); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") throw error; + const parent = dirname(current); + if (parent === current) throw error; + suffix.unshift(basename(current)); + current = parent; + } + } +} + +export async function assertProjectPath( + environment: ChatGptTurnEnvironment, + value: string, + access: "read" | "write", +): Promise { + const candidate = requestedPath(environment, value); + const permittedRoots = access === "write" ? environment.writableRoots : environment.roots; + if (!permittedRoots.some(root => contains(root, candidate))) { + throw new Error(`Path is outside the writable Codex project: ${value}`); + } + const [canonical, ...canonicalRoots] = await Promise.all([ + canonicalCandidate(candidate), + ...permittedRoots.map(root => realpath(root)), + ]); + if (!canonicalRoots.some(root => contains(root, canonical))) { + throw new Error(`Path escapes the active Codex project through a symbolic link: ${value}`); + } + return candidate; +} + +function patchPaths(patch: string): string[] { + const paths: string[] = []; + for (const line of patch.split(/\r?\n/)) { + const match = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/.exec(line) + ?? /^\*\*\* Move to: (.+)$/.exec(line); + if (match) paths.push(match[1]!.trim()); + } + return paths; +} + +export async function assertProjectPatch(environment: ChatGptTurnEnvironment, patch: string): Promise { + const paths = patchPaths(patch); + if (paths.length === 0) throw new Error("Patch contains no recognized project file path"); + await Promise.all(paths.map(path => assertProjectPath(environment, path, "write"))); +} + +function seatbeltString(value: string): string { + return JSON.stringify(resolve(value)); +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function readOnlyToolRoots(): string[] { + const home = homedir(); + return uniquePaths([ + "/Applications", + "/Library", + "/System", + "/bin", + "/opt/homebrew", + "/sbin", + "/usr", + "/usr/local", + resolve(home, "Applications", "Codex Web GPT.app"), + resolve(home, ".codex-chatgpt-web", "versions"), + ]); +} + +export function projectSandboxProfile(environment: ChatGptTurnEnvironment): string { + const readRoots = uniquePaths([...readOnlyToolRoots(), ...environment.roots, tmpdir(), "/private/tmp"]); + const writeRoots = uniquePaths([...environment.writableRoots, tmpdir(), "/private/tmp"]); + return [ + "(version 1)", + "(deny default)", + "(import \"system.sb\")", + "(allow process*)", + "(allow signal (target same-sandbox))", + "(allow sysctl-read)", + "(allow mach-lookup)", + "(allow ipc-posix*)", + "(allow file-read-metadata)", + `(allow file-read* ${readRoots.map(root => `(subpath ${seatbeltString(root)})`).join(" ")})`, + `(allow file-write* ${writeRoots.map(root => `(subpath ${seatbeltString(root)})`).join(" ")})`, + ...(environment.sandboxPolicy.type === "workspaceWrite" && environment.sandboxPolicy.networkAccess + ? ["(allow network-outbound)"] + : []), + ].join(" "); +} + +export async function projectBoundCommand( + environment: ChatGptTurnEnvironment, + command: string, + workdir: string | undefined, + platform = process.platform, +): Promise<{ command: string; workdir: string }> { + const requestedWorkdir = workdir && workdir.trim() ? workdir : environment.cwd; + const boundedWorkdir = await assertProjectPath(environment, requestedWorkdir, "read"); + if (!environment.enforceProjectBoundary) return { command, workdir: boundedWorkdir }; + if (platform !== "darwin") { + throw new Error("Project-bound Web GPT shell execution is currently supported on macOS only"); + } + const profile = projectSandboxProfile(environment); + return { + command: `/usr/bin/sandbox-exec -p ${shellQuote(profile)} /bin/zsh -lc ${shellQuote(command)}`, + workdir: boundedWorkdir, + }; +} diff --git a/src/adapters/chatgpt-web/turn-broker.ts b/src/adapters/chatgpt-web/turn-broker.ts index 5cebb929e..7b7a26425 100644 --- a/src/adapters/chatgpt-web/turn-broker.ts +++ b/src/adapters/chatgpt-web/turn-broker.ts @@ -6,7 +6,7 @@ import { isWindowsPipeEndpoint } from "../../config"; import type { ChatGptTurnEnvironment } from "./environment"; interface PendingTurn extends ChatGptTurnEnvironment { - expiresAt: number; + expiresAt?: number; } export interface BrokerToolRequest { @@ -97,6 +97,7 @@ function environmentIdentity(environment: ChatGptTurnEnvironment): string { roots: environment.roots, writableRoots: environment.writableRoots, sandboxPolicy: environment.sandboxPolicy, + enforceProjectBoundary: environment.enforceProjectBoundary === true, }); } @@ -133,13 +134,19 @@ export class TurnBroker { await this.start(); } - async register(environment: ChatGptTurnEnvironment, ttlMs: number, traceId = "unknown"): Promise { + async register(environment: ChatGptTurnEnvironment, ttlMs?: number, traceId = "unknown"): Promise { await this.start(); this.prune(); + if (ttlMs !== undefined && (!Number.isFinite(ttlMs) || ttlMs <= 0)) { + throw new Error("ChatGPT web turn broker TTL must be a positive finite number"); + } const token = opaqueId("turn"); const channel: TurnChannel = { traceId, - environment: { ...environment, expiresAt: Date.now() + ttlMs }, + environment: { + ...environment, + ...(ttlMs !== undefined ? { expiresAt: Date.now() + ttlMs } : {}), + }, queuedCallIds: [], invocations: new Map(), waiters: new Set(), @@ -156,7 +163,12 @@ export class TurnBroker { if (environmentIdentity(channel.environment) !== environmentIdentity(environment)) { throw new Error("Codex turn environment changed during an active ChatGPT tool loop"); } - channel.environment = { ...environment, expiresAt: channel.environment.expiresAt }; + channel.environment = { + ...environment, + ...(channel.environment.expiresAt !== undefined + ? { expiresAt: channel.environment.expiresAt } + : {}), + }; } async nextToolBatch(token: string, signal?: AbortSignal): Promise { @@ -481,7 +493,7 @@ export class TurnBroker { private prune(): void { const now = Date.now(); for (const [token, channel] of this.channels) { - if (channel.environment.expiresAt > now) continue; + if (channel.environment.expiresAt === undefined || channel.environment.expiresAt > now) continue; this.revoke(token); } } diff --git a/src/adapters/chatgpt-web/turn-execution.ts b/src/adapters/chatgpt-web/turn-execution.ts index e9d66571a..9f8d613ab 100644 --- a/src/adapters/chatgpt-web/turn-execution.ts +++ b/src/adapters/chatgpt-web/turn-execution.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import type { AdapterEvent, CodexParsedRequest } from "../../types"; import type { BrokerToolRequest } from "./turn-broker"; import { extractChatGptTurnIdentity } from "./environment"; +import { MAX_CHATGPT_BROWSER_TABS } from "./concurrency"; export type ChatGptBrowserOutcome = | { type: "final"; answer: string } @@ -127,6 +128,10 @@ export function chatGptTurnExecutionKey(parsed: CodexParsedRequest): string { threadId: identity.threadId, turnId: identity.turnId, purpose: parsed._compactionRequest ? "compaction" : "response", + conversationMode: parsed._chatGptWebConversationMode ?? "temporary", + userInputs: parsed.context.messages + .filter(message => message.role === "user") + .map(message => message.content), }; return createHash("sha256").update(JSON.stringify({ modelId: parsed.modelId, @@ -254,27 +259,18 @@ export class ChatGptTurnSessions { existing.touch(); return existing; } - this.reclaimSupersededTurns(); + const active = [...this.entries.values()].filter(session => session.isActive()).length; + if (active >= MAX_CHATGPT_BROWSER_TABS) { + throw new Error( + `ChatGPT Web supports at most ${MAX_CHATGPT_BROWSER_TABS} simultaneous browser turns; close or finish a browser tab before starting another`, + ); + } if (this.entries.size >= this.maxEntries) throw new Error(`ChatGPT web session registry is full (${this.maxEntries} entries)`); const session = new ChatGptTurnSession(start()); this.entries.set(key, session); return session; } - /** - * The launcher owns a single ChatGPT surface, and a starting turn navigates it to its own - * Temporary Chat. Any turn still open there is destroyed by that navigation, so a turn parked - * between tool batches - with no request left to abort when the user stops the task - can never - * produce an outcome again. - */ - private reclaimSupersededTurns(): void { - for (const [key, session] of this.entries) { - if (!session.isActive()) continue; - session.cancel(); - this.entries.delete(key); - } - } - clear(): number { const cancelled = this.entries.size; for (const session of this.entries.values()) session.cancel(); diff --git a/src/chatgpt-session.ts b/src/chatgpt-session.ts index 20c415cc8..0bbaecd5c 100644 --- a/src/chatgpt-session.ts +++ b/src/chatgpt-session.ts @@ -1,6 +1,8 @@ import type { Locator, Page } from "playwright-core"; +import type { ChatGptWebConversationMode } from "./chatgpt-web-models"; export const CHATGPT_TEMPORARY_CHAT_URL = "https://chatgpt.com/?temporary-chat=true"; +export const CHATGPT_SAVED_CHAT_URL = "https://chatgpt.com/"; export const CHATGPT_COMPOSER_SELECTOR = [ '[data-testid="prompt-textarea"]', "#prompt-textarea", @@ -51,6 +53,24 @@ export async function assertTemporaryChatPage(page: Page): Promise { } } +export function chatGptConversationUrl(mode: ChatGptWebConversationMode): string { + return mode === "saved" ? CHATGPT_SAVED_CHAT_URL : CHATGPT_TEMPORARY_CHAT_URL; +} + +export async function assertChatGptConversationPage( + page: Page, + mode: ChatGptWebConversationMode, +): Promise { + if (mode === "temporary") { + await assertTemporaryChatPage(page); + return; + } + const url = new URL(page.url()); + if (url.origin !== new URL(CHATGPT_SAVED_CHAT_URL).origin || url.searchParams.get("temporary-chat") === "true") { + throw new Error(`ChatGPT left the saved conversation surface (${page.url()})`); + } +} + export async function detectChatGptProCapability(page: Page): Promise { const composer = page.locator(CHATGPT_COMPOSER_SELECTOR).last(); const composerForm = composer.locator("xpath=ancestor::form[1]"); diff --git a/src/chatgpt-web-models.ts b/src/chatgpt-web-models.ts index 366ad7bae..449f6f994 100644 --- a/src/chatgpt-web-models.ts +++ b/src/chatgpt-web-models.ts @@ -3,14 +3,38 @@ export const CHATGPT_WEB_BACKEND_MODEL = "gpt-5.6-sol"; export type ChatGptWebCodexEffort = "low" | "medium" | "high" | "xhigh" | "ultra"; export type ChatGptWebAdapterEffort = "low" | "medium" | "high" | "xhigh" | "max"; +export type ChatGptWebConversationMode = "temporary" | "saved"; + +export const CHATGPT_WEB_PLUS_CONTEXT_WINDOW = 225_000; +export const CHATGPT_WEB_PRO_CONTEXT_WINDOW = 256_000; + +export interface ChatGptWebContextLimits { + contextWindow: number; + autoCompactTokenLimit: number; +} + +/** Resolve the authenticated account's product limit for one visible ChatGPT mode. */ +export function resolveChatGptWebContextLimits( + proAvailable: boolean, +): ChatGptWebContextLimits { + const contextWindow = proAvailable ? CHATGPT_WEB_PRO_CONTEXT_WINDOW : CHATGPT_WEB_PLUS_CONTEXT_WINDOW; + return { + contextWindow, + // Leave ten percent for Codex to submit and receive the compact checkpoint before the hard cap. + autoCompactTokenLimit: Math.floor(contextWindow * 0.9), + }; +} export interface ChatGptWebModelRoute { slug: string; + /** Bare model id used when OpenCodex routes this model through its dedicated local provider. */ + ocxModelId: string; displayName: string; description: string; codexEffort: ChatGptWebCodexEffort; adapterEffort: ChatGptWebAdapterEffort; requiresPro: boolean; + conversationMode: ChatGptWebConversationMode; } /** @@ -21,51 +45,128 @@ export interface ChatGptWebModelRoute { */ export const CHATGPT_WEB_MODEL_ROUTES: readonly ChatGptWebModelRoute[] = [ { - slug: "chatgpt-web/light", - displayName: "ChatGPT Web — Instant", - description: "ChatGPT Web Instant through the native Codex harness.", + slug: "chatgpt-web/temporary/low", + ocxModelId: "webgpt-temporary-low", + displayName: "Web / 임시 / 낮음", + description: "기록에 남지 않는 임시대화를 낮은 추론 수준으로 실행합니다.", codexEffort: "low", adapterEffort: "low", requiresPro: false, + conversationMode: "temporary", }, { - slug: "chatgpt-web/medium", - displayName: "ChatGPT Web — Medium", - description: "ChatGPT Web Medium through the native Codex harness.", + slug: "chatgpt-web/temporary/medium", + ocxModelId: "webgpt-temporary-medium", + displayName: "Web / 임시 / 중간", + description: "기록에 남지 않는 임시대화를 중간 추론 수준으로 실행합니다.", codexEffort: "medium", adapterEffort: "medium", requiresPro: false, + conversationMode: "temporary", }, { - slug: "chatgpt-web/high", - displayName: "ChatGPT Web — High", - description: "ChatGPT Web High through the native Codex harness.", + slug: "chatgpt-web/temporary/high", + ocxModelId: "webgpt-temporary-high", + displayName: "Web / 임시 / 높음", + description: "기록에 남지 않는 임시대화를 높은 추론 수준으로 실행합니다.", codexEffort: "high", adapterEffort: "high", requiresPro: false, + conversationMode: "temporary", }, { - slug: "chatgpt-web/extra-high", - displayName: "ChatGPT Web — Extra High", - description: "ChatGPT Web Extra High through the native Codex harness.", + slug: "chatgpt-web/temporary/extra-high", + ocxModelId: "webgpt-temporary-extra-high", + displayName: "Web / 임시 / 매우 높음", + description: "기록에 남지 않는 임시대화를 매우 높은 추론 수준으로 실행합니다.", codexEffort: "xhigh", adapterEffort: "xhigh", + requiresPro: true, + conversationMode: "temporary", + }, + { + slug: "chatgpt-web/saved/low", + ocxModelId: "webgpt-saved-low", + displayName: "Web / 저장 / 낮음", + description: "기록에 남는 새 대화를 낮은 추론 수준으로 실행합니다.", + codexEffort: "low", + adapterEffort: "low", + requiresPro: false, + conversationMode: "saved", + }, + { + slug: "chatgpt-web/saved/medium", + ocxModelId: "webgpt-saved-medium", + displayName: "Web / 저장 / 중간", + description: "기록에 남는 새 대화를 중간 추론 수준으로 실행합니다.", + codexEffort: "medium", + adapterEffort: "medium", requiresPro: false, + conversationMode: "saved", + }, + { + slug: "chatgpt-web/saved/high", + ocxModelId: "webgpt-saved-high", + displayName: "Web / 저장 / 높음", + description: "기록에 남는 새 대화를 높은 추론 수준으로 실행합니다.", + codexEffort: "high", + adapterEffort: "high", + requiresPro: false, + conversationMode: "saved", + }, + { + slug: "chatgpt-web/saved/extra-high", + ocxModelId: "webgpt-saved-extra-high", + displayName: "Web / 저장 / 매우 높음", + description: "기록에 남는 새 대화를 매우 높은 추론 수준으로 실행합니다.", + codexEffort: "xhigh", + adapterEffort: "xhigh", + requiresPro: true, + conversationMode: "saved", + }, + { + slug: "chatgpt-web/temporary/pro", + ocxModelId: "webgpt-temporary-pro", + displayName: "Web / 임시 / Pro", + description: "계정에 Pro 권한이 있을 때만 사용할 수 있는 임시대화 모드입니다. 이 모드에서는 로컬 도구를 사용할 수 없습니다.", + codexEffort: "ultra", + adapterEffort: "max", + requiresPro: true, + conversationMode: "temporary", }, { - slug: "chatgpt-web/pro", - displayName: "ChatGPT Web — Pro", - description: "Account-gated ChatGPT Pro through the native Codex harness. Local tool calls are unavailable in this mode.", + slug: "chatgpt-web/saved/pro", + ocxModelId: "webgpt-saved-pro", + displayName: "Web / 저장 / Pro", + description: "계정에 Pro 권한이 있을 때만 사용할 수 있는 저장 대화 모드입니다. 이 모드에서는 로컬 도구를 사용할 수 없습니다.", codexEffort: "ultra", adapterEffort: "max", requiresPro: true, + conversationMode: "saved", }, ]; const routesBySlug = new Map(CHATGPT_WEB_MODEL_ROUTES.map(route => [route.slug, route])); +const routesByOcxModelId = new Map(CHATGPT_WEB_MODEL_ROUTES.map(route => [route.ocxModelId, route])); +const legacyRouteAliases = new Map([ + ["chatgpt-web/temporary", "chatgpt-web/temporary/extra-high"], + ["webgpt-temporary", "chatgpt-web/temporary/extra-high"], + ["chatgpt-web/saved", "chatgpt-web/saved/extra-high"], + ["webgpt-saved", "chatgpt-web/saved/extra-high"], + ["chatgpt-web/light", "chatgpt-web/temporary/low"], + ["webgpt-light", "chatgpt-web/temporary/low"], + ["chatgpt-web/medium", "chatgpt-web/temporary/medium"], + ["webgpt-medium", "chatgpt-web/temporary/medium"], + ["chatgpt-web/high", "chatgpt-web/temporary/high"], + ["webgpt-high", "chatgpt-web/temporary/high"], + ["chatgpt-web/extra-high", "chatgpt-web/temporary/extra-high"], + ["webgpt-extra-high", "chatgpt-web/temporary/extra-high"], + ["chatgpt-web/pro", "chatgpt-web/temporary/pro"], + ["webgpt-pro", "chatgpt-web/temporary/pro"], +]); export function isChatGptWebModelSlug(modelId: string): boolean { - return modelId.startsWith(CHATGPT_WEB_MODEL_PREFIX); + return modelId.startsWith(CHATGPT_WEB_MODEL_PREFIX) || routesByOcxModelId.has(modelId) || legacyRouteAliases.has(modelId); } export function availableChatGptWebModelRoutes(proAvailable: boolean): readonly ChatGptWebModelRoute[] { @@ -75,10 +176,12 @@ export function availableChatGptWebModelRoutes(proAvailable: boolean): readonly } export function requireChatGptWebModelRoute(modelId: string, proAvailable: boolean): ChatGptWebModelRoute { - const route = routesBySlug.get(modelId); + const canonicalSlug = legacyRouteAliases.get(modelId); + const route = routesBySlug.get(modelId) ?? routesByOcxModelId.get(modelId) + ?? (canonicalSlug ? routesBySlug.get(canonicalSlug) : undefined); if (!route) throw new Error(`ChatGPT web model is not enabled: ${modelId}`); if (route.requiresPro && !proAvailable) { - throw new Error("ChatGPT Web Pro is not available for this account"); + throw new Error(`${route.displayName} is not available for this account`); } return route; } diff --git a/src/cli.ts b/src/cli.ts index fb3478277..eacfba870 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -42,8 +42,8 @@ Usage: codex-chatgpt-web uninstall --yes Setup options: - --browser-only Fixed Instant–Pro models, full context/images, no local tools or tunnel - --full Fixed Instant–Extra High tool models plus read-only Pro + --browser-only Account-eligible Web models, full context/images, no local tools or tunnel + --full Account-eligible Web models with tools; Pro remains read-only --port NUMBER Loopback Responses port (default: 17841) --chrome PATH Google Chrome executable --browser-host-descriptor PATH diff --git a/src/codex-integration.ts b/src/codex-integration.ts index 1590e1021..c436c981e 100644 --- a/src/codex-integration.ts +++ b/src/codex-integration.ts @@ -6,6 +6,14 @@ import type { AppConfig } from "./config"; import { atomicWriteFile, expandUserPath, getConfigDir } from "./config"; const MANAGED_COMMENT = "# Managed by codex-chatgpt-web; `codex-chatgpt-web uninstall` restores prior values."; +const MANAGED_REMOTE_COMPACTION_LINE = + "remote_compaction_v2 = false # Managed by codex-chatgpt-web: bounds retained Web image history."; +const MANAGED_MULTI_AGENT_LINE = + "multi_agent = true # Managed by codex-chatgpt-web: enables routed Web subagents."; +const MANAGED_MULTI_AGENT_V2_LINE = + "multi_agent_v2 = false # Managed by codex-chatgpt-web: keeps routed Web subagent payloads readable."; +const MANAGED_MULTI_AGENT_V2_TABLE_LINE = + "enabled = false # Managed by codex-chatgpt-web: keeps routed Web subagent payloads readable."; interface PreviousAssignment { present: boolean; @@ -16,7 +24,50 @@ interface PreviousAssignment { type ManagedAssignmentKey = "openai_base_url" | "model_provider" | "model_catalog_json"; +interface PreviousFeatureAssignment extends PreviousAssignment { + tablePresent: boolean; + tableName?: "features" | "features.multi_agent_v2"; +} + export interface CodexIntegrationJournal { + version: 6; + active: boolean; + configPath: string; + installed: { + openai_base_url: string; + remote_compaction_v2: false; + multi_agent: true; + multi_agent_v2: false; + }; + previous: Record; + previousRemoteCompactionV2: PreviousFeatureAssignment; + previousMultiAgent: PreviousFeatureAssignment; + previousMultiAgentV2: PreviousFeatureAssignment; + format?: { + lineEnding: "\n" | "\r\n"; + trailingNewline: boolean; + }; +} + +interface LegacyCodexIntegrationJournalV5 { + version: 5; + active: boolean; + configPath: string; + installed: { + openai_base_url: string; + remote_compaction_v2: false; + multi_agent: true; + }; + previous: Record; + previousRemoteCompactionV2: PreviousFeatureAssignment; + previousMultiAgent: PreviousFeatureAssignment; + format?: { + lineEnding: "\n" | "\r\n"; + trailingNewline: boolean; + }; +} + +interface LegacyCodexIntegrationJournalV4 { version: 4; active: boolean; configPath: string; @@ -59,7 +110,11 @@ interface LegacyCodexIntegrationJournal { }; } -type ManagedRouteJournal = CodexIntegrationJournal | LegacyCodexIntegrationJournalV3; +type ManagedRouteJournal = + | CodexIntegrationJournal + | LegacyCodexIntegrationJournalV5 + | LegacyCodexIntegrationJournalV4 + | LegacyCodexIntegrationJournalV3; type AnyCodexIntegrationJournal = ManagedRouteJournal | LegacyCodexIntegrationJournal; interface FileSnapshot { @@ -327,6 +382,311 @@ function removeManagedComment(document: CodexConfigDocument): void { } } +interface TomlTableRange { + headerIndex: number; + endIndex: number; +} + +function findTomlTable(lines: string[], tableName: string): TomlTableRange | undefined { + const escaped = tableName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const header = new RegExp(`^\\s*\\[${escaped}\\]\\s*(?:#.*)?$`); + const matches = lines + .map((line, index) => header.test(line) ? index : -1) + .filter(index => index >= 0); + if (matches.length > 1) throw new Error(`Codex config contains duplicate [${tableName}] tables`); + const headerIndex = matches[0]; + if (headerIndex === undefined) return undefined; + const relativeEnd = lines + .slice(headerIndex + 1) + .findIndex(line => /^\s*\[\[?[^\]]+\]\]?\s*(?:#.*)?$/.test(line)); + return { + headerIndex, + endIndex: relativeEnd < 0 ? lines.length : headerIndex + 1 + relativeEnd, + }; +} + +function findBooleanAssignmentInTable( + lines: string[], + tableName: "features" | "features.multi_agent_v2", + key: string, +): PreviousFeatureAssignment { + const table = findTomlTable(lines, tableName); + if (!table) return { present: false, tablePresent: false, tableName }; + const regex = assignmentRegex(key); + const matches: PreviousAssignment[] = []; + for (let index = table.headerIndex + 1; index < table.endIndex; index += 1) { + const line = lines[index]!; + if (/^\s*#/.test(line)) continue; + const match = regex.exec(line); + if (!match) continue; + const value = stripTomlComment(match[1]!).trim(); + if (value !== "true" && value !== "false") { + throw new Error(`${key} in Codex [${tableName}] must be a boolean`); + } + matches.push({ present: true, rawLine: line, value, index }); + } + if (matches.length > 1) { + throw new Error(`Codex config contains duplicate [${tableName}].${key} assignments`); + } + return { ...(matches[0] ?? { present: false }), tablePresent: true, tableName }; +} + +function findFeatureAssignment(lines: string[], key: string): PreviousFeatureAssignment { + return findBooleanAssignmentInTable(lines, "features", key); +} + +function findMultiAgentV2Assignment(lines: string[]): PreviousFeatureAssignment { + const scalar = findFeatureAssignment(lines, "multi_agent_v2"); + const table = findTomlTable(lines, "features.multi_agent_v2"); + if (scalar.present && table) { + throw new Error( + "Codex config defines multi_agent_v2 as both [features] scalar and [features.multi_agent_v2] table", + ); + } + return table + ? findBooleanAssignmentInTable(lines, "features.multi_agent_v2", "enabled") + : scalar; +} + +function installBooleanFeature( + text: string, + key: string, + managedLine: string, +): { + text: string; + previous: PreviousFeatureAssignment; +} { + const document = parseDocument(text); + const previous = findFeatureAssignment(document.lines, key); + let table = findTomlTable(document.lines, "features"); + if (!table) { + insertDocumentLine(document, document.lines.length, "[features]"); + table = findTomlTable(document.lines, "features")!; + } + const current = findFeatureAssignment(document.lines, key); + if (current.index !== undefined) { + document.lines[current.index] = managedLine; + } else { + insertDocumentLine(document, table.endIndex, managedLine); + } + return { text: renderDocument(document), previous }; +} + +function installMultiAgentV2Feature(text: string): { + text: string; + previous: PreviousFeatureAssignment; +} { + const document = parseDocument(text); + const previous = findMultiAgentV2Assignment(document.lines); + if (previous.tableName !== "features.multi_agent_v2") { + return installBooleanFeature(text, "multi_agent_v2", MANAGED_MULTI_AGENT_V2_LINE); + } + const table = findTomlTable(document.lines, "features.multi_agent_v2"); + if (!table) throw new Error("Codex [features.multi_agent_v2] table disappeared during setup"); + const current = findBooleanAssignmentInTable( + document.lines, + "features.multi_agent_v2", + "enabled", + ); + if (current.index !== undefined) { + document.lines[current.index] = MANAGED_MULTI_AGENT_V2_TABLE_LINE; + } else { + insertDocumentLine(document, table.endIndex, MANAGED_MULTI_AGENT_V2_TABLE_LINE); + } + return { text: renderDocument(document), previous }; +} + +function verifyInstalledBooleanFeature( + text: string, + key: string, + expectedValue: "true" | "false", + managedLine: string, +): void { + const current = findFeatureAssignment(splitLines(text), key); + if (current.value !== expectedValue || current.rawLine !== managedLine) { + throw new Error( + `Codex [features].${key} changed after setup; refusing to overwrite the user's newer value`, + ); + } +} + +function verifyInstalledMultiAgentV2Feature( + text: string, + previous: PreviousFeatureAssignment, +): void { + if (previous.tableName !== "features.multi_agent_v2") { + const current = findMultiAgentV2Assignment(splitLines(text)); + if (current.tableName !== "features" + || current.value !== "false" + || current.rawLine !== MANAGED_MULTI_AGENT_V2_LINE) { + throw new Error( + "Codex [features].multi_agent_v2 changed after setup; refusing to overwrite the user's newer value", + ); + } + return; + } + const lines = splitLines(text); + if (findFeatureAssignment(lines, "multi_agent_v2").present) { + throw new Error( + "Codex [features].multi_agent_v2 changed after setup; refusing to overwrite the user's newer value", + ); + } + const current = findBooleanAssignmentInTable(lines, "features.multi_agent_v2", "enabled"); + if (current.value !== "false" || current.rawLine !== MANAGED_MULTI_AGENT_V2_TABLE_LINE) { + throw new Error( + "Codex [features.multi_agent_v2].enabled changed after setup; refusing to overwrite the user's newer value", + ); + } +} + +function restoreBooleanFeature( + text: string, + key: string, + expectedValue: "true" | "false", + managedLine: string, + previous: PreviousFeatureAssignment, +): string { + verifyInstalledBooleanFeature(text, key, expectedValue, managedLine); + const document = parseDocument(text); + const current = findFeatureAssignment(document.lines, key); + if (current.index === undefined) throw new Error(`Managed Codex ${key} is missing`); + if (previous.present) { + if (!previous.rawLine) { + throw new Error(`Codex integration journal is missing the prior ${key} line`); + } + document.lines[current.index] = previous.rawLine; + } else { + removeDocumentLine(document, current.index); + if (!previous.tablePresent) { + const table = findTomlTable(document.lines, "features"); + if (!table) throw new Error("Managed Codex [features] table is missing"); + const remaining = document.lines + .slice(table.headerIndex + 1, table.endIndex) + .filter(line => line.trim().length > 0); + if (remaining.length === 0) removeDocumentLine(document, table.headerIndex); + } + } + return renderDocument(document); +} + +function restoreMultiAgentV2Feature( + text: string, + previous: PreviousFeatureAssignment, +): string { + if (previous.tableName !== "features.multi_agent_v2") { + return restoreBooleanFeature( + text, + "multi_agent_v2", + "false", + MANAGED_MULTI_AGENT_V2_LINE, + previous, + ); + } + verifyInstalledMultiAgentV2Feature(text, previous); + const document = parseDocument(text); + const current = findBooleanAssignmentInTable( + document.lines, + "features.multi_agent_v2", + "enabled", + ); + if (current.index === undefined) throw new Error("Managed Codex multi_agent_v2.enabled is missing"); + if (previous.present) { + if (!previous.rawLine) { + throw new Error("Codex integration journal is missing the prior multi_agent_v2.enabled line"); + } + document.lines[current.index] = previous.rawLine; + } else { + removeDocumentLine(document, current.index); + } + return renderDocument(document); +} + +function installManagedFeatures(text: string): { + text: string; + previousRemoteCompactionV2: PreviousFeatureAssignment; + previousMultiAgent: PreviousFeatureAssignment; + previousMultiAgentV2: PreviousFeatureAssignment; +} { + const compaction = installBooleanFeature( + text, + "remote_compaction_v2", + MANAGED_REMOTE_COMPACTION_LINE, + ); + const multiAgent = installBooleanFeature( + compaction.text, + "multi_agent", + MANAGED_MULTI_AGENT_LINE, + ); + const multiAgentV2 = installMultiAgentV2Feature(multiAgent.text); + return { + text: multiAgentV2.text, + previousRemoteCompactionV2: compaction.previous, + previousMultiAgent: multiAgent.previous, + previousMultiAgentV2: multiAgentV2.previous, + }; +} + +function verifyInstalledFeatures( + text: string, + journal: CodexIntegrationJournal | LegacyCodexIntegrationJournalV5, +): void { + verifyInstalledBooleanFeature( + text, + "remote_compaction_v2", + "false", + MANAGED_REMOTE_COMPACTION_LINE, + ); + verifyInstalledBooleanFeature(text, "multi_agent", "true", MANAGED_MULTI_AGENT_LINE); + if (journal.version === 6) { + verifyInstalledMultiAgentV2Feature(text, journal.previousMultiAgentV2); + } +} + +function restoreManagedFeatures( + text: string, + journal: CodexIntegrationJournal | LegacyCodexIntegrationJournalV5, +): string { + const withoutMultiAgentV2 = journal.version === 6 + ? restoreMultiAgentV2Feature(text, journal.previousMultiAgentV2) + : text; + const withoutMultiAgent = restoreBooleanFeature( + withoutMultiAgentV2, + "multi_agent", + "true", + MANAGED_MULTI_AGENT_LINE, + journal.previousMultiAgent, + ); + return restoreBooleanFeature( + withoutMultiAgent, + "remote_compaction_v2", + "false", + MANAGED_REMOTE_COMPACTION_LINE, + journal.previousRemoteCompactionV2, + ); +} + +function installIntegrationConfig( + text: string, + installedUrl: string, + replaceExistingRoute: boolean, +): { + text: string; + previous: CodexIntegrationJournal["previous"]; + previousRemoteCompactionV2: PreviousFeatureAssignment; + previousMultiAgent: PreviousFeatureAssignment; + previousMultiAgentV2: PreviousFeatureAssignment; +} { + const route = installRoute(text, installedUrl, replaceExistingRoute); + const features = installManagedFeatures(route.text); + return { + text: features.text, + previous: route.previous, + previousRemoteCompactionV2: features.previousRemoteCompactionV2, + previousMultiAgent: features.previousMultiAgent, + previousMultiAgentV2: features.previousMultiAgentV2, + }; +} + function installRoute( text: string, installedUrl: string, @@ -335,7 +695,8 @@ function installRoute( const document = parseDocument(text); const previous = assignments(document.lines); const conflicts = (Object.entries(previous) as Array<[ManagedAssignmentKey, PreviousAssignment]>) - .filter(([, assignment]) => assignment.present) + .filter(([key, assignment]) => assignment.present + && !(key === "model_provider" && assignment.value === "openai")) .map(([key, assignment]) => `${key}=${JSON.stringify(assignment.value)}`); if (conflicts.length > 0 && !replaceExistingRoute) { throw new Error( @@ -372,6 +733,7 @@ function verifyInstalledRoute(text: string, journal: ManagedRouteJournal): void if (!lines.includes(MANAGED_COMMENT)) { throw new Error("Managed Codex route marker changed after setup; refusing to overwrite it"); } + if (journal.version === 5 || journal.version === 6) verifyInstalledFeatures(text, journal); } function previousAssignmentMatches(current: PreviousAssignment, previous: PreviousAssignment): boolean { @@ -379,7 +741,10 @@ function previousAssignmentMatches(current: PreviousAssignment, previous: Previo && (!current.present || current.value === previous.value); } -function verifyRestoredRoute(text: string, journal: CodexIntegrationJournal): void { +function verifyRestoredRoute( + text: string, + journal: CodexIntegrationJournal | LegacyCodexIntegrationJournalV5 | LegacyCodexIntegrationJournalV4, +): void { const lines = splitLines(text); const current = assignments(lines); for (const key of ["openai_base_url", "model_provider", "model_catalog_json"] as const) { @@ -390,6 +755,28 @@ function verifyRestoredRoute(text: string, journal: CodexIntegrationJournal): vo if (lines.includes(MANAGED_COMMENT)) { throw new Error("Managed Codex route marker is present while the bridge is disconnected"); } + if (journal.version === 5 || journal.version === 6) { + const previousFeatures: Array = [ + ["remote_compaction_v2", journal.previousRemoteCompactionV2], + ["multi_agent", journal.previousMultiAgent], + ]; + if (journal.version === 6) { + previousFeatures.push(["multi_agent_v2", journal.previousMultiAgentV2]); + } + for (const [key, previous] of previousFeatures) { + const current = key === "multi_agent_v2" + ? findMultiAgentV2Assignment(lines) + : findFeatureAssignment(lines, key); + const matches = current.present === previous.present + && (current.tableName ?? "features") === (previous.tableName ?? "features") + && (!current.present || current.rawLine === previous.rawLine); + if (!matches) { + throw new Error( + `Codex [features].${key} changed while the bridge was disconnected; refusing to overwrite the user's newer value`, + ); + } + } + } } function assertPreservedPreviousAssignments( @@ -403,6 +790,30 @@ function assertPreservedPreviousAssignments( } } +function assertPreservedPreviousFeature( + actual: PreviousFeatureAssignment, + expected: PreviousFeatureAssignment, + key: string, +): void { + const matches = actual.present === expected.present + && (actual.tableName ?? "features") === (expected.tableName ?? "features") + && (!actual.present || actual.rawLine === expected.rawLine); + if (!matches) { + throw new Error( + `Codex [features].${key} changed while the bridge was disconnected; refusing to replace it`, + ); + } +} + +function updateManagedRouteUrl(text: string, journal: ManagedRouteJournal, installedUrl: string): string { + verifyInstalledRoute(text, journal); + const document = parseDocument(text); + const current = findTopLevelAssignment(document.lines, "openai_base_url"); + if (current.index === undefined) throw new Error("Managed Codex openai_base_url is missing"); + document.lines[current.index] = `openai_base_url = ${JSON.stringify(installedUrl)}`; + return renderDocument(document); +} + function restoreManagedRoute(text: string, journal: ManagedRouteJournal): string { verifyInstalledRoute(text, journal); const document = parseDocument(text); @@ -425,7 +836,10 @@ function restoreManagedRoute(text: string, journal: ManagedRouteJournal): string const index = Math.min(item.previous.index ?? firstTableIndex(document.lines), firstTableIndex(document.lines)); insertDocumentLine(document, index, item.previous.rawLine); } - return renderDocument(document); + const restoredRoute = renderDocument(document); + return journal.version === 5 || journal.version === 6 + ? restoreManagedFeatures(restoredRoute, journal) + : restoredRoute; } function restoreLegacyV2(text: string, journal: LegacyCodexIntegrationJournal): string { @@ -459,13 +873,32 @@ function readJournal(): AnyCodexIntegrationJournal | undefined { const path = getCodexJournalPath(); if (!existsSync(path)) return undefined; const value = JSON.parse(readFileSync(path, "utf8")) as Record; - if (value.version === 4 + if (value.version === 6 && typeof value.active === "boolean" && value.installed && value.previous + && value.previousRemoteCompactionV2 + && value.previousMultiAgent + && value.previousMultiAgentV2 && typeof value.configPath === "string") { return value as unknown as CodexIntegrationJournal; } + if (value.version === 5 + && typeof value.active === "boolean" + && value.installed + && value.previous + && value.previousRemoteCompactionV2 + && value.previousMultiAgent + && typeof value.configPath === "string") { + return value as unknown as LegacyCodexIntegrationJournalV5; + } + if (value.version === 4 + && typeof value.active === "boolean" + && value.installed + && value.previous + && typeof value.configPath === "string") { + return value as unknown as LegacyCodexIntegrationJournalV4; + } if (value.version === 3 && value.installed && value.previous && typeof value.configPath === "string") { return value as unknown as LegacyCodexIntegrationJournalV3; } @@ -499,8 +932,10 @@ export function preflightCodexIntegration( const existing = readJournal(); const installedUrl = routeUrl(config); if (existing) assertJournalTargetsConfig(existing, configPath); - if (existing?.version === 3 || existing?.version === 4) { - if (existing.version === 4 && !existing.active) verifyRestoredRoute(currentText, existing); + if (existing?.version === 3 || existing?.version === 4 || existing?.version === 5 || existing?.version === 6) { + if ((existing.version === 4 || existing.version === 5 || existing.version === 6) && !existing.active) { + verifyRestoredRoute(currentText, existing); + } else verifyInstalledRoute(currentText, existing); return; } @@ -511,7 +946,7 @@ export function preflightCodexIntegration( } baseline = restoreLegacyV2(currentText, existing); } - installRoute(baseline, installedUrl, options.replaceExistingRoute === true); + installIntegrationConfig(baseline, installedUrl, options.replaceExistingRoute === true); } export function installCodexIntegration( @@ -525,25 +960,51 @@ export function installCodexIntegration( const installedUrl = routeUrl(config); if (existing) assertJournalTargetsConfig(existing, configPath); - if (existing?.version === 3 || existing?.version === 4) { + if (existing?.version === 6) { let installedText: string; - if (existing.version === 4 && !existing.active) { + let previousRemoteCompactionV2 = existing.previousRemoteCompactionV2; + let previousMultiAgent = existing.previousMultiAgent; + let previousMultiAgentV2 = existing.previousMultiAgentV2; + if (!existing.active) { verifyRestoredRoute(currentText, existing); - const patched = installRoute(currentText, installedUrl, true); + const patched = installIntegrationConfig(currentText, installedUrl, true); assertPreservedPreviousAssignments(patched.previous, existing.previous); + assertPreservedPreviousFeature( + patched.previousRemoteCompactionV2, + existing.previousRemoteCompactionV2, + "remote_compaction_v2", + ); + assertPreservedPreviousFeature( + patched.previousMultiAgent, + existing.previousMultiAgent, + "multi_agent", + ); + assertPreservedPreviousFeature( + patched.previousMultiAgentV2, + existing.previousMultiAgentV2, + "multi_agent_v2", + ); installedText = patched.text; + // Preserve unrelated [features] edits made while disconnected by remembering whether the + // table itself now belongs to the user's baseline. + previousRemoteCompactionV2 = patched.previousRemoteCompactionV2; + previousMultiAgent = patched.previousMultiAgent; + previousMultiAgentV2 = patched.previousMultiAgentV2; } else { - verifyInstalledRoute(currentText, existing); - const document = parseDocument(currentText); - const current = findTopLevelAssignment(document.lines, "openai_base_url"); - document.lines[current.index!] = `openai_base_url = ${JSON.stringify(installedUrl)}`; - installedText = renderDocument(document); + installedText = updateManagedRouteUrl(currentText, existing, installedUrl); } const updated: CodexIntegrationJournal = { ...existing, - version: 4, active: true, - installed: { openai_base_url: installedUrl }, + installed: { + openai_base_url: installedUrl, + remote_compaction_v2: false, + multi_agent: true, + multi_agent_v2: false, + }, + previousRemoteCompactionV2, + previousMultiAgent, + previousMultiAgentV2, }; writeFilesWithCompensation([ { path: configPath, data: installedText }, @@ -552,6 +1013,92 @@ export function installCodexIntegration( return updated; } + if (existing?.version === 5) { + let installedText: string; + let previousRemoteCompactionV2 = existing.previousRemoteCompactionV2; + let previousMultiAgent = existing.previousMultiAgent; + let previousMultiAgentV2: PreviousFeatureAssignment; + if (!existing.active) { + verifyRestoredRoute(currentText, existing); + const patched = installIntegrationConfig(currentText, installedUrl, true); + assertPreservedPreviousAssignments(patched.previous, existing.previous); + assertPreservedPreviousFeature( + patched.previousRemoteCompactionV2, + existing.previousRemoteCompactionV2, + "remote_compaction_v2", + ); + assertPreservedPreviousFeature( + patched.previousMultiAgent, + existing.previousMultiAgent, + "multi_agent", + ); + installedText = patched.text; + previousRemoteCompactionV2 = patched.previousRemoteCompactionV2; + previousMultiAgent = patched.previousMultiAgent; + previousMultiAgentV2 = patched.previousMultiAgentV2; + } else { + const routedText = updateManagedRouteUrl(currentText, existing, installedUrl); + const multiAgentV2 = installMultiAgentV2Feature(routedText); + installedText = multiAgentV2.text; + previousMultiAgentV2 = multiAgentV2.previous; + } + const updated: CodexIntegrationJournal = { + version: 6, + active: true, + configPath: existing.configPath, + installed: { + openai_base_url: installedUrl, + remote_compaction_v2: false, + multi_agent: true, + multi_agent_v2: false, + }, + previous: existing.previous, + previousRemoteCompactionV2, + previousMultiAgent, + previousMultiAgentV2, + ...(existing.format ? { format: existing.format } : {}), + }; + writeFilesWithCompensation([ + { path: configPath, data: installedText }, + { path: getCodexJournalPath(), data: `${JSON.stringify(updated, null, 2)}\n` }, + ], [getCodexModelsCachePath()]); + return updated; + } + + if (existing?.version === 3 || existing?.version === 4) { + let routedText: string; + if (existing.version === 4 && !existing.active) { + verifyRestoredRoute(currentText, existing); + const patched = installRoute(currentText, installedUrl, true); + assertPreservedPreviousAssignments(patched.previous, existing.previous); + routedText = patched.text; + } else { + routedText = updateManagedRouteUrl(currentText, existing, installedUrl); + } + const features = installManagedFeatures(routedText); + const updated: CodexIntegrationJournal = { + version: 6, + active: true, + configPath: existing.configPath, + installed: { + openai_base_url: installedUrl, + remote_compaction_v2: false, + multi_agent: true, + multi_agent_v2: false, + }, + previous: existing.previous, + previousRemoteCompactionV2: features.previousRemoteCompactionV2, + previousMultiAgent: features.previousMultiAgent, + previousMultiAgentV2: features.previousMultiAgentV2, + ...(existing.format ? { format: existing.format } : {}), + }; + writeFilesWithCompensation([ + { path: configPath, data: features.text }, + { path: getCodexJournalPath(), data: `${JSON.stringify(updated, null, 2)}\n` }, + ], [getCodexModelsCachePath()]); + return updated; + } + let baseline = currentText; if (existing?.version === 2) { if (existsSync(existing.catalogPath) && sha256(readFileSync(existing.catalogPath)) !== existing.catalogSha256) { @@ -559,13 +1106,21 @@ export function installCodexIntegration( } baseline = restoreLegacyV2(currentText, existing); } - const patched = installRoute(baseline, installedUrl, options.replaceExistingRoute === true); + const patched = installIntegrationConfig(baseline, installedUrl, options.replaceExistingRoute === true); const journal: CodexIntegrationJournal = { - version: 4, + version: 6, active: true, configPath, - installed: { openai_base_url: installedUrl }, + installed: { + openai_base_url: installedUrl, + remote_compaction_v2: false, + multi_agent: true, + multi_agent_v2: false, + }, previous: patched.previous, + previousRemoteCompactionV2: patched.previousRemoteCompactionV2, + previousMultiAgent: patched.previousMultiAgent, + previousMultiAgentV2: patched.previousMultiAgentV2, format: textFormat(baseline), }; writeFilesWithCompensation([ @@ -585,16 +1140,17 @@ export function deactivateCodexIntegration(): SetCodexIntegrationActiveResult { assertJournalTargetsConfig(existing, getCodexConfigPath()); if (!existsSync(existing.configPath)) throw new Error(`Codex config is missing: ${existing.configPath}`); const current = readFileSync(existing.configPath, "utf8"); - if (existing.version === 4 && !existing.active) { + if ((existing.version === 4 || existing.version === 5 || existing.version === 6) && !existing.active) { verifyRestoredRoute(current, existing); return { changed: false, active: false }; } const restored = restoreManagedRoute(current, existing); - const disconnected: CodexIntegrationJournal = { - ...existing, - version: 4, - active: false, - }; + const disconnected: + | CodexIntegrationJournal + | LegacyCodexIntegrationJournalV5 + | LegacyCodexIntegrationJournalV4 = existing.version === 6 || existing.version === 5 + ? { ...existing, active: false } + : { ...existing, version: 4, active: false }; writeFilesWithCompensation([ { path: existing.configPath, data: restored }, { path: getCodexJournalPath(), data: `${JSON.stringify(disconnected, null, 2)}\n` }, @@ -611,16 +1167,83 @@ export function activateCodexIntegration(): SetCodexIntegrationActiveResult { assertJournalTargetsConfig(existing, getCodexConfigPath()); if (!existsSync(existing.configPath)) throw new Error(`Codex config is missing: ${existing.configPath}`); const current = readFileSync(existing.configPath, "utf8"); - if (existing.version === 3 || existing.active) { + if (existing.version === 6 && existing.active) { verifyInstalledRoute(current, existing); return { changed: false, active: true }; } - verifyRestoredRoute(current, existing); - const patched = installRoute(current, existing.installed.openai_base_url, true); - assertPreservedPreviousAssignments(patched.previous, existing.previous); - const connected: CodexIntegrationJournal = { ...existing, active: true }; + if (existing.version === 5 && existing.active) { + verifyInstalledRoute(current, existing); + const multiAgentV2 = installMultiAgentV2Feature(current); + const connected: CodexIntegrationJournal = { + version: 6, + active: true, + configPath: existing.configPath, + installed: { + openai_base_url: existing.installed.openai_base_url, + remote_compaction_v2: false, + multi_agent: true, + multi_agent_v2: false, + }, + previous: existing.previous, + previousRemoteCompactionV2: existing.previousRemoteCompactionV2, + previousMultiAgent: existing.previousMultiAgent, + previousMultiAgentV2: multiAgentV2.previous, + ...(existing.format ? { format: existing.format } : {}), + }; + writeFilesWithCompensation([ + { path: existing.configPath, data: multiAgentV2.text }, + { path: getCodexJournalPath(), data: `${JSON.stringify(connected, null, 2)}\n` }, + ], [getCodexModelsCachePath()]); + return { changed: true, active: true }; + } + let routedText: string; + if ((existing.version === 4 || existing.version === 5 || existing.version === 6) && !existing.active) { + verifyRestoredRoute(current, existing); + const patched = installRoute(current, existing.installed.openai_base_url, true); + assertPreservedPreviousAssignments(patched.previous, existing.previous); + routedText = patched.text; + } else { + verifyInstalledRoute(current, existing); + routedText = current; + } + const features = installManagedFeatures(routedText); + if (existing.version === 5 || existing.version === 6) { + assertPreservedPreviousFeature( + features.previousRemoteCompactionV2, + existing.previousRemoteCompactionV2, + "remote_compaction_v2", + ); + assertPreservedPreviousFeature( + features.previousMultiAgent, + existing.previousMultiAgent, + "multi_agent", + ); + if (existing.version === 6) { + assertPreservedPreviousFeature( + features.previousMultiAgentV2, + existing.previousMultiAgentV2, + "multi_agent_v2", + ); + } + } + const connected: CodexIntegrationJournal = { + version: 6, + active: true, + configPath: existing.configPath, + installed: { + openai_base_url: existing.installed.openai_base_url, + remote_compaction_v2: false, + multi_agent: true, + multi_agent_v2: false, + }, + previous: existing.previous, + previousRemoteCompactionV2: features.previousRemoteCompactionV2, + previousMultiAgent: features.previousMultiAgent, + previousMultiAgentV2: features.previousMultiAgentV2, + ...(existing.format ? { format: existing.format } : {}), + }; writeFilesWithCompensation([ - { path: existing.configPath, data: patched.text }, + { path: existing.configPath, data: features.text }, { path: getCodexJournalPath(), data: `${JSON.stringify(connected, null, 2)}\n` }, ], [getCodexModelsCachePath()]); return { changed: true, active: true }; @@ -637,7 +1260,7 @@ export function uninstallCodexIntegration(): UninstallCodexIntegrationResult { throw new Error(`Managed legacy catalog changed after setup: ${journal.catalogPath}`); } restored = restoreLegacyV2(current, journal); - } else if (journal.version === 4 && !journal.active) { + } else if ((journal.version === 4 || journal.version === 5 || journal.version === 6) && !journal.active) { verifyRestoredRoute(current, journal); restored = current; } else { @@ -683,8 +1306,12 @@ export function inspectCodexIntegration(): { try { assertJournalTargetsConfig(journal, getCodexConfigPath()); const text = readFileSync(journal.configPath, "utf8"); - if (journal.version === 4 && !journal.active) verifyRestoredRoute(text, journal); - else if (journal.version === 3 || journal.version === 4) verifyInstalledRoute(text, journal); + if ((journal.version === 4 || journal.version === 5 || journal.version === 6) && !journal.active) { + verifyRestoredRoute(text, journal); + } + else if (journal.version === 3 || journal.version === 4 || journal.version === 5 || journal.version === 6) { + verifyInstalledRoute(text, journal); + } else { const lines = splitLines(text); for (const key of ["model_provider", "model_catalog_json"] as const) { @@ -700,9 +1327,13 @@ export function inspectCodexIntegration(): { } return { installed: Boolean(journal), - active: journal?.version === 4 ? journal.active : Boolean(journal), + active: journal?.version === 4 || journal?.version === 5 || journal?.version === 6 + ? journal.active + : Boolean(journal), configPath: getCodexConfigPath(), - ...(journal?.version === 3 || journal?.version === 4 ? { routeUrl: journal.installed.openai_base_url } : {}), + ...(journal?.version === 3 || journal?.version === 4 || journal?.version === 5 || journal?.version === 6 + ? { routeUrl: journal.installed.openai_base_url } + : {}), ...(journal ? { journal } : {}), errors, }; diff --git a/src/config.ts b/src/config.ts index 79230e66e..acb57d122 100644 --- a/src/config.ts +++ b/src/config.ts @@ -353,6 +353,8 @@ export function providerConfig(config: AppConfig): CodexProviderConfig { contextWindow: config.contextWindow, modelInputModalities: Object.fromEntries(models.map(model => [model, ["text", "image"]])), modelReasoningEfforts: { "gpt-5.6-sol": efforts }, + // Sol is the ordinary/native route. Web GPT's temporary/saved picker entries + // override this at the request boundary and force xhigh independently. modelDefaultReasoningEfforts: { "gpt-5.6-sol": "high" }, noReasoningModels: [], chatgptWeb: { diff --git a/src/launcher-browser-host.ts b/src/launcher-browser-host.ts index e262970d3..32f9d0a08 100644 --- a/src/launcher-browser-host.ts +++ b/src/launcher-browser-host.ts @@ -147,9 +147,14 @@ export async function selectLauncherPage( browser: Browser, descriptor: LauncherBrowserHostDescriptor, timeoutMs: number, + surfaceId = descriptor.surfaceId, + abortSignal?: AbortSignal, ): Promise<{ context: BrowserContext; page: Page }> { const deadline = Date.now() + timeoutMs; do { + if (abortSignal?.aborted) { + throw new DOMException("Launcher browser connection aborted", "AbortError"); + } const candidates = browser.contexts().flatMap(context => context.pages().map(page => ({ context, page }))); const inspected = await Promise.all(candidates.map(async candidate => ({ ...candidate, @@ -158,7 +163,7 @@ export async function selectLauncherPage( .__CODEX_WEB_GPT_SURFACE_ID__, ).catch(() => undefined), }))); - const owned = inspected.filter(candidate => candidate.surfaceId === descriptor.surfaceId); + const owned = inspected.filter(candidate => candidate.surfaceId === surfaceId); if (owned.length === 1) { return { context: owned[0].context, page: owned[0].page }; } @@ -173,7 +178,12 @@ export async function selectLauncherPage( export async function connectLauncherBrowserHost( descriptorPath: string, timeoutMs = 20_000, + surfaceId?: string, + abortSignal?: AbortSignal, ): Promise { + if (abortSignal?.aborted) { + throw new DOMException("Launcher browser connection aborted", "AbortError"); + } const descriptor = readLauncherBrowserHostDescriptor(descriptorPath); await assertCdpReady(descriptor, Math.min(timeoutMs, 5_000)); let browser: Browser; @@ -182,8 +192,26 @@ export async function connectLauncherBrowserHost( } catch (error) { throw new Error(`Could not connect Playwright to the launcher browser: ${error instanceof Error ? error.message : String(error)}`); } - const { context, page } = await selectLauncherPage(browser, descriptor, timeoutMs); - return { descriptor, browser, context, page }; + const closeOnAbort = () => { void browser.close().catch(() => {}); }; + abortSignal?.addEventListener("abort", closeOnAbort, { once: true }); + try { + if (abortSignal?.aborted) { + throw new DOMException("Launcher browser connection aborted", "AbortError"); + } + const { context, page } = await selectLauncherPage( + browser, + descriptor, + timeoutMs, + surfaceId, + abortSignal, + ); + return { descriptor, browser, context, page }; + } catch (error) { + await browser.close().catch(() => {}); + throw error; + } finally { + abortSignal?.removeEventListener("abort", closeOnAbort); + } } export async function inspectLauncherBrowserHost( @@ -191,8 +219,15 @@ export async function inspectLauncherBrowserHost( options: { detectPro?: boolean; timeoutMs?: number } = {}, ): Promise<{ proAvailable?: boolean; url: string }> { const descriptor = readLauncherBrowserHostDescriptor(descriptorPath); + const timeoutMs = options.timeoutMs ?? (options.detectPro + ? LAUNCHER_CAPABILITY_INSPECTION_TIMEOUT_MS + : LAUNCHER_SESSION_INSPECTION_TIMEOUT_MS); const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 30_000); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); try { const response = await fetch(`${descriptor.control.endpoint}/v1/session/inspect`, { method: "POST", @@ -213,12 +248,18 @@ export async function inspectLauncherBrowserHost( } return { url: body.url, ...(options.detectPro ? { proAvailable: body.proAvailable as boolean } : {}) }; } catch (error) { - throw new Error(`Launcher ChatGPT session could not be verified: ${error instanceof Error ? error.message : String(error)}`); + const detail = timedOut + ? `session inspection timed out after ${timeoutMs}ms` + : error instanceof Error ? error.message : String(error); + throw new Error(`Launcher ChatGPT session could not be verified: ${detail}`); } finally { clearTimeout(timer); } } +export const LAUNCHER_SESSION_INSPECTION_TIMEOUT_MS = 30_000; +export const LAUNCHER_CAPABILITY_INSPECTION_TIMEOUT_MS = 120_000; + export type LauncherTurnActivity = | { phase: "start"; traceId: string; helperPid: number } | { @@ -238,7 +279,7 @@ export async function notifyLauncherTurn( timeoutMs = activity.phase === "end" ? LAUNCHER_TURN_END_TIMEOUT_MS : LAUNCHER_TURN_START_TIMEOUT_MS, -): Promise { +): Promise<{ surfaceId?: string }> { const descriptor = readLauncherBrowserHostDescriptor(descriptorPath); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); @@ -256,6 +297,14 @@ export async function notifyLauncherTurn( const detail = await response.text().catch(() => ""); throw new Error(`HTTP ${response.status}${detail ? `: ${detail}` : ""}`); } + const body = await response.json().catch(() => ({})) as Record; + if (activity.phase === "start") { + if (typeof body.surfaceId !== "string" || !/^[A-Za-z0-9_-]{32}$/.test(body.surfaceId)) { + throw new Error("Launcher browser control channel returned an invalid turn surface id"); + } + return { surfaceId: body.surfaceId }; + } + return {}; } catch (error) { throw new Error(`Launcher browser control channel failed: ${error instanceof Error ? error.message : String(error)}`); } finally { diff --git a/src/model-catalog.ts b/src/model-catalog.ts index 8082a28c8..0d1841e96 100644 --- a/src/model-catalog.ts +++ b/src/model-catalog.ts @@ -3,15 +3,14 @@ import type { CodexModelContextOverride } from "./codex-integration"; import { availableChatGptWebModelRoutes, CHATGPT_WEB_MODEL_PREFIX, + resolveChatGptWebContextLimits, type ChatGptWebModelRoute, } from "./chatgpt-web-models"; type JsonObject = Record; -/** ChatGPT Web task history is bounded independently from native Codex model configuration. */ -export const CHATGPT_WEB_CONTEXT_WINDOW = 256_000; -/** Leave enough room for Codex to submit and receive the checkpoint summary before the hard cap. */ -export const CHATGPT_WEB_AUTO_COMPACT_TOKEN_LIMIT = Math.floor(CHATGPT_WEB_CONTEXT_WINDOW * 0.9); +/** Keep all five routed models inside Codex's five-entry spawn-agent override registry. */ +export const CHATGPT_WEB_MODEL_PRIORITY = 0; function object(value: unknown, label: string): JsonObject { if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -66,6 +65,7 @@ export function buildChatGptWebModel( if (!templateSlug || templateSlug.startsWith(CHATGPT_WEB_MODEL_PREFIX)) { throw new Error("ChatGPT Web model template must be a native Codex model"); } + const limits = resolveChatGptWebContextLimits(config.proAvailable); const model: JsonObject = { ...structuredClone(template), slug: route.slug, @@ -73,14 +73,24 @@ export function buildChatGptWebModel( description: route.description, input_modalities: ["text", "image"], visibility: "list", - supported_in_api: false, - tool_mode: config.mode === "full" && !route.requiresPro ? template.tool_mode : null, + // These slugs are implemented by this local Responses-compatible bridge. Marking them false + // makes Codex drop them from spawn_agent whenever openai_base_url points at the bridge. + supported_in_api: true, + priority: CHATGPT_WEB_MODEL_PRIORITY, + // Codex MultiAgent V2 encrypts delegated task payloads for native OpenAI models. A browser + // provider cannot decrypt that cross-backend payload, so every routed Web model must stay on + // the native V1 surface where `message` and `fork_context` remain ordinary Codex context. + multi_agent_version: "v1", + // Keep every routed Web model inside Codex's native code-mode and subagent model registry. + // Pro's lack of local computer tools is enforced by the adapter runtime; `requiresPro` is only + // an account-entitlement gate and must not make the model disappear from native orchestration. + tool_mode: config.mode === "full" ? template.tool_mode : null, upgrade: null, default_reasoning_level: route.codexEffort, supported_reasoning_levels: [reasoningLevel(template, route.codexEffort, route.displayName)], - context_window: CHATGPT_WEB_CONTEXT_WINDOW, - max_context_window: CHATGPT_WEB_CONTEXT_WINDOW, - auto_compact_token_limit: CHATGPT_WEB_AUTO_COMPACT_TOKEN_LIMIT, + context_window: limits.contextWindow, + max_context_window: limits.contextWindow, + auto_compact_token_limit: limits.autoCompactTokenLimit, // ChatGPT Web has no Codex service tier. Never inherit the native template's Fast tiers. additional_speed_tiers: [], service_tiers: [], diff --git a/src/native-passthrough.ts b/src/native-passthrough.ts index 4ebedf17c..674c8c800 100644 --- a/src/native-passthrough.ts +++ b/src/native-passthrough.ts @@ -15,7 +15,7 @@ const HOP_BY_HOP_HEADERS = new Set([ ]); export type NativeFetch = (request: Request) => Promise; -export type NativeCodexEndpoint = "models" | "responses" | "responses/compact"; +export type NativeCodexEndpoint = "models" | "responses" | "responses/compact" | "alpha/search"; type JsonObject = Record; diff --git a/src/responses/compaction.ts b/src/responses/compaction.ts index 0de30c31f..da2caaa57 100644 --- a/src/responses/compaction.ts +++ b/src/responses/compaction.ts @@ -70,53 +70,107 @@ export function compactionItemToText(encryptedContent: string | undefined): stri /** codex-rs compact.rs COMPACT_USER_MESSAGE_MAX_TOKENS = 20k tokens (~4 chars/token). */ const COMPACT_V1_RETAINED_CHAR_BUDGET = 20_000 * 4; -/** Extract plain-text user messages from a Responses `input` array (for v1 compact retention). */ -export function extractCompactUserMessages(input: unknown): string[] { +type CompactMessageItem = Record; + +interface CompactContentBlock extends Record { + type?: string; + text?: string; + image_url?: string; +} + +/** + * Extract original user message items from a Responses `input` array. + * + * Keeping the original item metadata matters: Codex uses it after `/responses/compact` to + * distinguish real user turns from contextual user-role wrappers. Images remain structured + * `input_image` blocks so the browser adapter can upload them as attachments; their data URL is + * never copied into the textual ChatGPT transport envelope. + */ +export function extractCompactUserMessages(input: unknown): CompactMessageItem[] { if (!Array.isArray(input)) return []; - const out: string[] = []; + const out: CompactMessageItem[] = []; for (const item of input) { if (!item || typeof item !== "object" || Array.isArray(item)) continue; - const rec = item as { type?: string; role?: string; content?: unknown }; + const rec = item as CompactMessageItem & { type?: string; role?: string; content?: unknown }; if (rec.type !== undefined && rec.type !== "message") continue; if (rec.role !== "user") continue; - let text = ""; - if (typeof rec.content === "string") text = rec.content; - else if (Array.isArray(rec.content)) { - text = rec.content - .map(b => { - if (!b || typeof b !== "object") return ""; - const block = b as { type?: string; text?: string }; - return (block.type === "input_text" || block.type === "text") && typeof block.text === "string" ? block.text : ""; - }) - .join(""); - } - if (text.trim().length > 0) out.push(text); + out.push(structuredClone(rec)); } return out; } -function compactUserMessageItem(text: string): Record { +function compactUserMessageItem(text: string): CompactMessageItem { return { type: "message", role: "user", content: [{ type: "input_text", text }] }; } -/** Build the v1 compact `output` array: retained recent user messages + the summary message. */ -export function buildCompactV1Output(userMessages: string[], summary: string): Record[] { - const selected: string[] = []; +function compactContentBlocks(item: CompactMessageItem): CompactContentBlock[] { + if (typeof item.content === "string") { + return [{ type: "input_text", text: item.content }]; + } + if (!Array.isArray(item.content)) return []; + return item.content + .filter((block): block is CompactContentBlock => Boolean(block && typeof block === "object" && !Array.isArray(block))) + .map(block => structuredClone(block)); +} + +function textBlock(block: CompactContentBlock): boolean { + return (block.type === "input_text" || block.type === "text") && typeof block.text === "string"; +} + +function imageBlock(block: CompactContentBlock): boolean { + return block.type === "input_image" && typeof block.image_url === "string"; +} + +/** + * Build the v1 compact replacement history. + * + * Text follows Codex's 20k-token retained-user-message budget. Image history is independently + * bounded to ChatGPT's ten-attachment limit, newest first. This prevents an old image corpus from + * immediately refilling Codex's context window after a successful compact while still preserving + * the visual context the browser model can actually receive. + */ +export function buildCompactV1Output( + userMessages: CompactMessageItem[], + summary: string, + maxImages = 10, +): CompactMessageItem[] { + const selected: CompactMessageItem[] = []; let remaining = COMPACT_V1_RETAINED_CHAR_BUDGET; - for (let i = userMessages.length - 1; i >= 0 && remaining > 0; i--) { - const msg = userMessages[i]; - if (msg.length <= remaining) { - selected.push(msg); - remaining -= msg.length; - } else { - // Budget partially covers this older message: keep its tail (most recent context) and stop. - selected.push(msg.slice(msg.length - remaining)); - break; + let retainedImages = 0; + for (let i = userMessages.length - 1; i >= 0 && (remaining > 0 || retainedImages < maxImages); i--) { + const message = structuredClone(userMessages[i]!); + const blocks = compactContentBlocks(message); + const retainedReversed: CompactContentBlock[] = []; + for (let blockIndex = blocks.length - 1; blockIndex >= 0; blockIndex -= 1) { + const block = blocks[blockIndex]!; + if (imageBlock(block)) { + if (retainedImages < maxImages) { + retainedImages += 1; + retainedReversed.push(block); + } + continue; + } + if (!textBlock(block) || remaining === 0) continue; + const text = block.text!; + if (text.length <= remaining) { + remaining -= text.length; + retainedReversed.push({ ...block, type: "input_text", text }); + } else { + retainedReversed.push({ ...block, type: "input_text", text: text.slice(text.length - remaining) }); + remaining = 0; + } + } + const content = retainedReversed.reverse(); + if (content.length > 0) { + message.type = "message"; + message.role = "user"; + message.content = content; + selected.push(message); } } selected.reverse(); // codex-rs compact.rs uses "{SUMMARY_PREFIX}\n{summary}" (single newline) and detects stored // summaries by that exact prefix — keep the same shape. const summaryText = summary.trim().length > 0 ? `${SUMMARY_PREFIX}\n${summary}` : "(no summary available)"; - return [...selected.map(compactUserMessageItem), compactUserMessageItem(summaryText)]; + return [...selected, compactUserMessageItem(summaryText)]; } diff --git a/src/responses/parser.ts b/src/responses/parser.ts index cb255d600..d1c10d86b 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -54,6 +54,14 @@ function inputContentParts(blocks: unknown[] | string | undefined): string | Cod return parts; } +function containsOpaqueEncryptedContent(value: unknown): boolean { + if (!Array.isArray(value)) return false; + return value.some(block => isObj(block) + && block.type === "encrypted_content" + && typeof block.encrypted_content === "string" + && block.encrypted_content.length > 0); +} + type OutputBlock = { type: "output_text"; text: string } | { type: "text"; text: string } | { type: "refusal"; refusal: string }; function outputTextOf(blocks: unknown[] | string | undefined): CodexTextContent[] { @@ -253,6 +261,7 @@ export function parseRequest(body: unknown): CodexParsedRequest { // synthetic `{type:"compaction"}` output item (src/responses/compaction.ts). Flagged for the server. let compactionRequest = false; let contextCompactionBoundary = false; + let opaqueMultiAgentV2Payload = false; if (typeof data.instructions === "string" && data.instructions.length > 0) { systemPrompt.push(data.instructions); @@ -309,6 +318,10 @@ export function parseRequest(body: unknown): CodexParsedRequest { content?: unknown; }; + if (containsOpaqueEncryptedContent(agentMessage.content)) { + opaqueMultiAgentV2Payload = true; + } + const content = inputContentParts( agentMessage.content as unknown[] | string | undefined, ); @@ -596,6 +609,7 @@ export function parseRequest(body: unknown): CodexParsedRequest { ...(structuredOutput ? { _structuredOutput: true } : {}), ...(compactionRequest ? { _compactionRequest: true } : {}), ...(contextCompactionBoundary ? { _contextCompactionBoundary: true } : {}), + ...(opaqueMultiAgentV2Payload ? { _opaqueMultiAgentV2Payload: true } : {}), }; } diff --git a/src/responses/schema.ts b/src/responses/schema.ts index db89d9c32..27e36ff72 100644 --- a/src/responses/schema.ts +++ b/src/responses/schema.ts @@ -49,6 +49,17 @@ const assistantMessageItemSchema = z.object({ content: z.union([z.string(), z.array(outputContentBlockSchema)]).optional(), phase: z.enum(["commentary", "final_answer"]).optional(), }); +const agentMessageItemSchema = z.object({ + type: z.literal("agent_message"), + author: z.string().optional(), + recipient: z.string().optional(), + // MultiAgent V1 sends normal input content. V2 may send only encrypted_content; accept that + // shape so the adapter can reject it explicitly instead of silently manufacturing an empty task. + content: z.union([ + z.string(), + z.array(z.union([inputContentBlockSchema, encryptedContentBlockSchema])), + ]).optional(), +}).loose(); const reasoningItemSchema = z.object({ type: z.literal("reasoning"), id: z.string().optional(), @@ -88,6 +99,7 @@ export const inputItemSchema = z.union([ userMessageItemSchema, systemMessageItemSchema, assistantMessageItemSchema, + agentMessageItemSchema, reasoningItemSchema, functionCallItemSchema, functionCallOutputItemSchema, diff --git a/src/server.ts b/src/server.ts index 76373b228..b15e8431d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -148,6 +148,7 @@ export function routeChatGptWebRequest(parsed: CodexParsedRequest, config: AppCo const route = requireChatGptWebModelRoute(parsed.modelId, config.proAvailable); parsed.modelId = CHATGPT_WEB_BACKEND_MODEL; parsed.options.reasoning = route.adapterEffort; + parsed._chatGptWebConversationMode = route.conversationMode; return route; } @@ -179,6 +180,17 @@ export async function modelsRequest( return new Response(body, { status: upstream.status, statusText: upstream.statusText, headers }); } +export async function nativeSearchRequest( + req: Request, + fetchUpstream?: NativeFetch, +): Promise { + try { + return await forwardNativeCodexRequest(req, "alpha/search", fetchUpstream); + } catch (error) { + return formatErrorResponse(502, "upstream_error", error instanceof Error ? error.message : String(error)); + } +} + function toolBridgeMaps(parsed: CodexParsedRequest): { toolNsMap: Map; freeformToolNames: Set; @@ -492,6 +504,10 @@ export function startServer( if (draining) return formatErrorResponse(503, "server_error", "codex-chatgpt-web is draining for a requested service operation"); return httpTurns.track(() => compactRequest(req, config), req.signal); } + if (req.method === "POST" && url.pathname === "/v1/alpha/search") { + if (draining) return formatErrorResponse(503, "server_error", "codex-chatgpt-web is draining for a requested service operation"); + return httpTurns.track(() => nativeSearchRequest(req, dependencies.fetchUpstream), req.signal); + } return new Response("Not found", { status: 404 }); }, }); diff --git a/src/types.ts b/src/types.ts index 6c5f353b9..42d445b8f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,7 @@ export interface CodexParsedRequest { modelId: string; + /** Conversation surface selected by a chatgpt-web/* model route. */ + _chatGptWebConversationMode?: "temporary" | "saved"; previousResponseId?: string; context: CodexContext; stream: boolean; @@ -36,6 +38,11 @@ export interface CodexParsedRequest { * provider-private continuation caches again on every later turn. */ _contextCompactionBoundary?: boolean; + /** + * True when Codex MultiAgent V2 delegated an agent_message as provider-private encrypted_content. + * ChatGPT Web has no OpenAI backend key for that blob and must fail before opening the browser. + */ + _opaqueMultiAgentV2Payload?: boolean; } export interface CodexContext { @@ -310,7 +317,7 @@ export interface CodexProviderConfig { brokerSocketPath?: string; /** Persisted, trusted Codex task authority used for follow-up turns that omit the envelope. */ threadEnvironmentStatePath?: string; - /** Maximum duration of one complete browser response. */ + /** Optional explicit safety ceiling. Browser turns have no absolute deadline by default. */ turnTimeoutMs?: number; /** Keep the single controlled browser visible. */ headed?: boolean; diff --git a/src/version.ts b/src/version.ts index a77b29a7b..2afe39aae 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.0.1-ko.4"; +export const VERSION = "1.1.2-ko.4"; diff --git a/tests/browser-worker-contract.test.ts b/tests/browser-worker-contract.test.ts index 11660a29b..2ebf8d60e 100644 --- a/tests/browser-worker-contract.test.ts +++ b/tests/browser-worker-contract.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import { readFileSync } from "node:fs"; -import { ChatGptBrowserWorker, ChatGptTurnDomHealthTracker, ChatGptVisibleTraceTracker, chatGptSubmissionEvidence, isChatGptTraceControl, redactChatGptUiDiagnostic } from "../src/adapters/chatgpt-web/browser-worker"; +import type { Page } from "playwright-core"; +import { ChatGptBrowserWorker, ChatGptTurnDomHealthTracker, ChatGptVisibleTraceTracker, MAX_CHATGPT_BROWSER_TABS, assertChatGptWebInputWithinContextWindow, chatGptSubmissionEvidence, isChatGptTraceControl, redactChatGptUiDiagnostic, resolveBrowserConfig, resolveChatGptToolConfirmation, throwIfChatGptRateLimitDialog, throwIfChatGptTerminalErrorAlert } from "../src/adapters/chatgpt-web/browser-worker"; import { CHATGPT_INTERNAL_COMPACTION_MARKER, containsChatGptCompactionMarker, stripChatGptTransportMarkers } from "../src/adapters/chatgpt-web/prompt"; test("Codex context uses the owned CDP composer transport, never the operating-system clipboard", () => { @@ -18,6 +19,95 @@ test("completed prompts activate the scoped semantic send control", () => { expect(workerSource).not.toContain('getByTestId("send-button").dispatchEvent("click")'); }); +test("browser turns run concurrently up to the five-tab limit", async () => { + expect(MAX_CHATGPT_BROWSER_TABS).toBe(5); + const releases = new Map void>(); + const worker = Object.assign(Object.create(ChatGptBrowserWorker.prototype), { + config: { browserHost: "managed-chrome" }, + activeRuns: new Map(), + runExclusive: (turn: { traceId: string }) => new Promise(resolve => { + releases.set(turn.traceId, () => resolve(turn.traceId)); + }), + }) as ChatGptBrowserWorker; + const browserTurn = (traceId: string) => ({ + traceId, + modelId: "chatgpt-web/high", + capabilities: { localToolsEnabled: false, proAvailable: true }, + prepare: async () => ({ text: traceId, images: [], release() {} }), + onTextDelta() {}, + }); + + const active = Array.from({ length: 5 }, (_unused, index) => worker.run(browserTurn(`trace_${index + 1}`))); + await Promise.resolve(); + expect(releases.size).toBe(5); + await expect(worker.run(browserTurn("trace_6"))).rejects.toThrow("at most 5 simultaneous browser turns"); + + releases.get("trace_1")?.(); + await active[0]; + const sixth = worker.run(browserTurn("trace_6")); + await Promise.resolve(); + expect(releases.has("trace_6")).toBeTrue(); + for (const traceId of ["trace_2", "trace_3", "trace_4", "trace_5", "trace_6"]) { + releases.get(traceId)?.(); + } + await Promise.all([...active.slice(1), sixth]); +}); + +test("browser turns have no absolute deadline unless one is explicitly configured", () => { + const provider = { adapter: "chatgpt-web" as const, baseUrl: "browser://chatgpt" }; + expect(resolveBrowserConfig(provider).turnTimeoutMs).toBeUndefined(); + expect(resolveBrowserConfig({ + ...provider, + chatgptWeb: { turnTimeoutMs: 123_000 }, + }).turnTimeoutMs).toBe(123_000); + expect(() => resolveBrowserConfig({ + ...provider, + chatgptWeb: { turnTimeoutMs: 0 }, + })).toThrow("turnTimeoutMs must be a positive finite number"); +}); + +test("browser stage timeout aborts late page acquisition", async () => { + let acquisitionAborted = false; + const runStage = (ChatGptBrowserWorker.prototype as unknown as { + runStage( + traceId: string, + stage: string, + timeoutMs: number, + action: (signal: AbortSignal) => Promise, + ): Promise; + }).runStage; + + const result = runStage.call( + {}, + "trace_timeout", + "browser_page", + 10, + async (signal) => await new Promise((resolve) => { + signal.addEventListener("abort", () => { + acquisitionAborted = true; + resolve("late page"); + }, { once: true }); + }), + ); + + await expect(result).rejects.toThrow("ChatGPT browser stage timed out: browser_page"); + expect(acquisitionAborted).toBeTrue(); +}); + +test("closing the launcher page is an immediate terminal turn error", async () => { + const responseDomSnapshot = (ChatGptBrowserWorker.prototype as unknown as { + responseDomSnapshot(responseTurn: unknown): Promise; + }).responseDomSnapshot; + const responseTurn = { + evaluate: async () => { throw new Error("Target page has been closed"); }, + page: () => ({ isClosed: () => true }), + }; + + await expect(responseDomSnapshot.call({}, responseTurn)).rejects.toThrow( + "ChatGPT browser tab was closed; the Codex turn was terminated", + ); +}); + test("connector verification and real tool turns share one Playwright selector", () => { const workerSource = readFileSync(new URL("../src/adapters/chatgpt-web/browser-worker.ts", import.meta.url), "utf8"); expect(workerSource.match(/this\.selectConnector\(page\)/g)?.length).toBe(2); @@ -389,6 +479,168 @@ test("effort selection uses structural menu indices instead of localized labels" expect(workerSource).not.toMatch(/getByRole\("button", \{\s*name: "(?:Instant|Medium|High|Extra High|Pro)"/); }); +test("effort selection handles the known ChatGPT rate-limit dialog before keyboard activation", () => { + const workerSource = readFileSync(new URL("../src/adapters/chatgpt-web/browser-worker.ts", import.meta.url), "utf8"); + const selectionStart = workerSource.indexOf("private async selectModelAndEffort"); + const selectionEnd = workerSource.indexOf("private async activeComposer", selectionStart); + const selectionSource = workerSource.slice(selectionStart, selectionEnd); + const guard = selectionSource.indexOf("throwIfChatGptRateLimitDialog(page)"); + const activation = selectionSource.indexOf('currentEffort.press("Enter")'); + + expect(workerSource).toContain("Too many requests"); + expect(workerSource).toContain("making requests too quickly"); + expect(guard).toBeGreaterThan(-1); + expect(activation).toBeGreaterThan(guard); + expect(selectionSource).not.toContain("currentEffort.click"); + expect(selectionSource).not.toContain("is unavailable"); +}); + +function dialogPage(text: string): { page: Page; pressed: string[] } { + let matches = true; + const pressed: string[] = []; + const button = { + last: () => button, + isVisible: async () => matches, + press: async (key: string) => { pressed.push(key); }, + }; + const dialog = { + filter: ({ hasText }: { hasText: string | RegExp }) => { + matches &&= typeof hasText === "string" ? text.includes(hasText) : hasText.test(text); + return dialog; + }, + last: () => dialog, + isVisible: async () => matches, + getByRole: () => button, + }; + return { + page: { locator: () => dialog } as unknown as Page, + pressed, + }; +} + +test("the known ChatGPT rate-limit dialog is acknowledged and returns a structured 429", async () => { + const fixture = dialogPage("Too many requests. You're making requests too quickly."); + + await expect(throwIfChatGptRateLimitDialog(fixture.page)).rejects.toMatchObject({ + name: "ChatGptWebAdapterError", + status: 429, + errorType: "rate_limit_error", + code: "rate_limit_exceeded", + retryable: true, + }); + expect(fixture.pressed).toEqual(["Enter"]); +}); + +test("unrelated ChatGPT dialogs are left untouched", async () => { + const fixture = dialogPage("Confirm another action"); + + await throwIfChatGptRateLimitDialog(fixture.page); + expect(fixture.pressed).toEqual([]); +}); + +test("the known terminal ChatGPT error alert returns a structured retryable failure", async () => { + const fixture = dialogPage( + "Something went wrong. If this issue persists please contact us through our help center at help.openai.com.", + ); + + await expect(throwIfChatGptTerminalErrorAlert(fixture.page)).rejects.toMatchObject({ + name: "ChatGptWebAdapterError", + status: 502, + errorType: "server_error", + code: "upstream_server_error", + retryable: true, + }); + expect(fixture.pressed).toEqual([]); +}); + +test("unrelated ChatGPT alerts are not terminal", async () => { + const fixture = dialogPage("Your file was uploaded successfully"); + + await throwIfChatGptTerminalErrorAlert(fixture.page); + expect(fixture.pressed).toEqual([]); +}); + +function toolConfirmationPage(options: { disappearAfterReads?: number } = {}): { + page: Page; + pressed: string[]; +} { + let reads = 0; + let visible = true; + const pressed: string[] = []; + const button = (name: string) => ({ + last: () => button(name), + waitFor: async () => {}, + press: async (key: string) => { + pressed.push(`${name}:${key}`); + visible = false; + }, + }); + const dialog = { + filter: ({ hasText }: { hasText: string }) => { + expect(hasText).toBe("Allow ChatGPT to use Codex Native?"); + return dialog; + }, + last: () => dialog, + isVisible: async () => { + reads += 1; + if (options.disappearAfterReads !== undefined && reads >= options.disappearAfterReads) visible = false; + return visible; + }, + getByRole: (_role: string, input: { name: string }) => button(input.name), + waitFor: async ({ state }: { state: string }) => { + expect(state).toBe("hidden"); + expect(visible).toBeFalse(); + }, + }; + return { + page: { locator: () => dialog } as unknown as Page, + pressed, + }; +} + +test("manual ChatGPT connector approval pauses and resumes the same browser turn", async () => { + const fixture = toolConfirmationPage({ disappearAfterReads: 3 }); + + expect(await resolveChatGptToolConfirmation(fixture.page, "Codex Native", false, undefined, 100)).toBeTrue(); + expect(fixture.pressed).toEqual([]); +}); + +test("an unanswered ChatGPT connector approval is denied instead of aborting the turn", async () => { + const fixture = toolConfirmationPage(); + + expect(await resolveChatGptToolConfirmation(fixture.page, "Codex Native", false, undefined, 2)).toBeTrue(); + expect(fixture.pressed).toEqual(["Deny:Enter"]); +}); + +test("explicit connector auto-approval still selects Allow once", async () => { + const fixture = toolConfirmationPage(); + + expect(await resolveChatGptToolConfirmation(fixture.page, "Codex Native", true)).toBeTrue(); + expect(fixture.pressed).toEqual(["Allow once:Enter"]); +}); + +test("browser preflight fails closed with Codex's native context-window error contract", () => { + expect(() => assertChatGptWebInputWithinContextWindow(225_000, false)).toThrow( + "225,000-token context window", + ); + try { + assertChatGptWebInputWithinContextWindow(225_000, false); + throw new Error("expected context-window preflight to fail"); + } catch (error) { + expect(error).toMatchObject({ + name: "ChatGptWebAdapterError", + status: 400, + errorType: "invalid_request_error", + code: "context_length_exceeded", + retryable: false, + }); + expect(String(error)).toContain("/compact"); + } + + expect(() => assertChatGptWebInputWithinContextWindow(224_999, false)).not.toThrow(); + expect(() => assertChatGptWebInputWithinContextWindow(255_999, true)).not.toThrow(); +}); + test("browser diagnostics redact context envelopes and capability values", () => { const diagnostic = redactChatGptUiDiagnostic( "private context turn_12345678901234567890 binding_12345678901234567890", @@ -398,7 +650,7 @@ test("browser diagnostics redact context envelopes and capability values", () => expect(diagnostic).toContain("[redacted]"); }); -test("visible DOM trace emits statuses and stable commentary but withholds the final answer", () => { +test("visible DOM trace emits statuses but leaves every Markdown root to the final answer stream", () => { const tracker = new ChatGptVisibleTraceTracker(100); const initialBlocks = [ { kind: "status", text: "Reviewed architecture documentation" }, @@ -415,10 +667,8 @@ test("visible DOM trace emits statuses and stable commentary but withholds the f { kind: "markdown", text: "Final answer still streaming" }, ] as const; expect(tracker.observe([...commentaryBlocks], false, 1_200)).toEqual([ - { kind: "commentary", text: "The implementation has a concrete state drift." }, ]); - expect(tracker.observe([...commentaryBlocks], false, 1_300)).toEqual([]); - expect(tracker.observe([...commentaryBlocks], false, 1_400)).toEqual([ + expect(tracker.observe([...commentaryBlocks], false, 1_300)).toEqual([ { kind: "reasoning", text: "Inspecting runtime evidence" }, ]); expect(tracker.observe([ @@ -426,27 +676,36 @@ test("visible DOM trace emits statuses and stable commentary but withholds the f ], true)).toEqual([]); }); -test("visible DOM trace streams a growing commentary block as append-only deltas", () => { +test("visible DOM trace never reclassifies growing Markdown as commentary", () => { const tracker = new ChatGptVisibleTraceTracker(100); const initial = [ { kind: "markdown", text: "I’m reading" }, { kind: "status", text: "Read context file contents" }, ] as const; - expect(tracker.observe([...initial], false, 1_000)).toEqual([ - { kind: "commentary", text: "I’m reading" }, - ]); + expect(tracker.observe([...initial], false, 1_000)).toEqual([]); const expanded = [ { kind: "markdown", text: "I’m reading the repository’s mandatory architecture" }, { kind: "status", text: "Read context file contents" }, ] as const; - expect(tracker.observe([...expanded], false, 1_050)).toEqual([ - { kind: "commentary", text: " the repository’s mandatory architecture", continuation: true }, - ]); - expect(tracker.observe([...expanded], false, 1_100)).toEqual([]); - expect(tracker.observe([...expanded], false, 1_150)).toEqual([]); - expect(tracker.observe([...expanded], false, 1_250)).toEqual([ + expect(tracker.observe([...expanded], false, 1_050)).toEqual([]); + expect(tracker.observe([...expanded], false, 1_100)).toEqual([ { kind: "reasoning", text: "Read context file contents" }, ]); + expect(tracker.observe([...expanded], false, 1_150)).toEqual([]); + expect(tracker.observe([...expanded], false, 1_250)).toEqual([]); +}); + +test("response DOM aggregation keeps every top-level Markdown root in the final answer", () => { + const workerSource = readFileSync(new URL("../src/adapters/chatgpt-web/browser-worker.ts", import.meta.url), "utf8"); + expect(workerSource).toContain('const renderedRoots = [...root.querySelectorAll(".markdown")]'); + expect(workerSource).toContain('fullHtml: renderedRoots.map(candidate => candidate.innerHTML).join("")'); + expect(workerSource).toContain("markdownBuffer.observe(snapshot.fullHtml)"); + expect(workerSource).not.toContain("stableHtml:"); + expect(workerSource).not.toContain("observeStableHtml"); + expect(workerSource).toContain("const overlapsRenderedAnswer = (candidate: HTMLElement)"); + expect(workerSource).toContain("!overlapsRenderedAnswer(semantic)"); + expect(workerSource).toContain("!overlapsRenderedAnswer(container)"); + expect(workerSource).not.toContain('fullHtml: rendered?.innerHTML ?? ""'); }); test("visible DOM trace waits out animated Pro fragments and appends genuine growth", () => { diff --git a/tests/chatgpt-session.test.ts b/tests/chatgpt-session.test.ts new file mode 100644 index 000000000..bb6fc457c --- /dev/null +++ b/tests/chatgpt-session.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test"; +import { + assertChatGptConversationPage, + chatGptConversationUrl, + CHATGPT_SAVED_CHAT_URL, + CHATGPT_TEMPORARY_CHAT_URL, +} from "../src/chatgpt-session"; + +test("selects the correct ChatGPT conversation surface", async () => { + expect(chatGptConversationUrl("temporary")).toBe(CHATGPT_TEMPORARY_CHAT_URL); + expect(chatGptConversationUrl("saved")).toBe(CHATGPT_SAVED_CHAT_URL); + + await assertChatGptConversationPage({ url: () => CHATGPT_TEMPORARY_CHAT_URL } as never, "temporary"); + await assertChatGptConversationPage({ url: () => "https://chatgpt.com/c/saved-conversation" } as never, "saved"); + await expect(assertChatGptConversationPage( + { url: () => CHATGPT_TEMPORARY_CHAT_URL } as never, + "saved", + )).rejects.toThrow("saved conversation surface"); +}); diff --git a/tests/chatgpt-web-harness.test.ts b/tests/chatgpt-web-harness.test.ts index 1ae5f8d7e..c21fae9f2 100644 --- a/tests/chatgpt-web-harness.test.ts +++ b/tests/chatgpt-web-harness.test.ts @@ -9,14 +9,16 @@ import { ChatGptCompletionTracker, chatGptImageFilePayloads, chatGptPromptFilePa import { ChatGptBrowserWorker, type BrowserTurn } from "../src/adapters/chatgpt-web/browser-worker"; import { extractChatGptTurnEnvironment, extractChatGptTurnIdentity } from "../src/adapters/chatgpt-web/environment"; import { createChatGptWebAdapter } from "../src/adapters/chatgpt-web/index"; -import { chatGptHtmlToMarkdown, ChatGptMarkdownStream } from "../src/adapters/chatgpt-web/markdown"; +import { chatGptHtmlToMarkdown, ChatGptMarkdownBuffer } from "../src/adapters/chatgpt-web/markdown"; import { CHATGPT_WEB_MODEL_ID, resolveChatGptWebModelMode } from "../src/adapters/chatgpt-web/model"; import { chatGptReadOnlyContextWarning, compileChatGptWebPrompt } from "../src/adapters/chatgpt-web/prompt"; +import { scopeChatGptTurnEnvironment } from "../src/adapters/chatgpt-web/project-boundary"; import { ChatGptTextFeed, ChatGptTraceFeed, ChatGptTurnSessions, chatGptTurnExecutionKey } from "../src/adapters/chatgpt-web/turn-execution"; import { callTurnBroker, TurnBroker, type BrokerToolResult } from "../src/adapters/chatgpt-web/turn-broker"; import { defaultBrokerEndpoint } from "../src/config"; import { estimateChatGptWebUsage } from "../src/adapters/chatgpt-web/usage"; import { decodeCompactionSummary, SUMMARY_PREFIX } from "../src/responses/compaction"; +import { parseRequest } from "../src/responses/parser"; import type { AdapterEvent, CodexParsedRequest, CodexProviderConfig, CodexTool } from "../src/types"; const tempRoot = join(tmpdir(), `codex-chatgpt-web-harness-${process.pid}-${Date.now()}`); @@ -34,7 +36,7 @@ const tools: CodexTool[] = [ const environmentXml = ` ${tempRoot} - ${tempRoot} + ${tempRoot}workspace-write `; const toolCapabilities = { localToolsEnabled: true, proAvailable: true }; const readOnlyCapabilities = { localToolsEnabled: false, proAvailable: true }; @@ -101,13 +103,53 @@ function toolResult(value: Record): BrokerToolResult { } describe("ChatGPT outer-native harness v3", () => { + test("rejects an opaque MultiAgent V2 child payload before starting the browser", async () => { + const request = parseRequest({ + model: "chatgpt-web/pro", + stream: true, + reasoning: { effort: "ultra" }, + input: [{ + type: "agent_message", + author: "parent", + recipient: "child", + content: [{ type: "encrypted_content", encrypted_content: "opaque-native-v2-payload" }], + }], + }); + expect(request._opaqueMultiAgentV2Payload).toBe(true); + + const socketPath = brokerTestEndpoint(`cgw-h3-v2-reject-${process.pid}-${Date.now()}`); + const provider: CodexProviderConfig = { + adapter: "chatgpt-web", + baseUrl: "browser://chatgpt-v2-reject-test", + chatgptWeb: { brokerSocketPath: socketPath, localToolsEnabled: false, proAvailable: true }, + }; + const worker = ChatGptBrowserWorker.forProvider(provider); + const originalRun = worker.run.bind(worker); + let browserStarts = 0; + (worker as unknown as { run: (turn: BrowserTurn) => Promise }).run = async () => { + browserStarts += 1; + return "unexpected"; + }; + try { + await expect(createChatGptWebAdapter(provider).runTurn!( + request, + { headers: new Headers() }, + () => {}, + )).rejects.toThrow("require a V1-rooted task"); + expect(browserStarts).toBe(0); + } finally { + (worker as unknown as { run: (turn: BrowserTurn) => Promise }).run = originalRun; + await TurnBroker.forSocket(socketPath).close(); + } + }); + test("extracts authoritative environment, tool registry, and turn identity from the Codex wire envelope", () => { const request = rawWireRequest(environmentXml); expect(extractChatGptTurnEnvironment(request)).toEqual({ cwd: tempRoot, roots: [tempRoot], writableRoots: [tempRoot], - sandboxPolicy: { type: "dangerFullAccess" }, + sandboxPolicy: { type: "workspaceWrite", writableRoots: [tempRoot], networkAccess: false }, tools, }); expect(extractChatGptTurnIdentity(request)).toEqual({ @@ -165,7 +207,7 @@ describe("ChatGPT outer-native harness v3", () => { cwd: tempRoot, roots: [tempRoot], writableRoots: [tempRoot], - sandboxPolicy: { type: "dangerFullAccess" }, + sandboxPolicy: { type: "workspaceWrite", writableRoots: [tempRoot], networkAccess: false }, tools, }); }); @@ -197,7 +239,7 @@ describe("ChatGPT outer-native harness v3", () => { cwd: tempRoot, roots: [tempRoot], writableRoots: [tempRoot], - sandboxPolicy: { type: "dangerFullAccess" }, + sandboxPolicy: { type: "workspaceWrite", writableRoots: [tempRoot], networkAccess: false }, tools, }); }); @@ -237,6 +279,13 @@ describe("ChatGPT outer-native harness v3", () => { timestamp: Date.now(), }); expect(chatGptTurnExecutionKey(first)).toBe(chatGptTurnExecutionKey(second)); + const steered = structuredClone(second); + steered.context.messages.push({ + role: "user", + content: "Stop and review the implementation before continuing", + timestamp: Date.now(), + }); + expect(chatGptTurnExecutionKey(steered)).not.toBe(chatGptTurnExecutionKey(second)); const compact = structuredClone(first); compact._compactionRequest = true; expect(chatGptTurnExecutionKey(compact)).not.toBe(chatGptTurnExecutionKey(first)); @@ -315,6 +364,10 @@ describe("ChatGPT outer-native harness v3", () => { localToolsEnabled: false, proAvailable: false, })).toThrow("Pro effort is not available"); + expect(() => resolveChatGptWebModelMode(CHATGPT_WEB_MODEL_ID, "xhigh", { + localToolsEnabled: true, + proAvailable: false, + })).toThrow("Extra High effort is not available"); expect(() => resolveChatGptWebModelMode("unknown", "high", toolCapabilities)).toThrow("model is not supported"); }); @@ -385,6 +438,67 @@ describe("ChatGPT outer-native harness v3", () => { expect(imageUsage.inputTokens).toBeGreaterThanOrEqual(textUsage.inputTokens + 3_500); }); + test("keeps the ChatGPT rate-limit dialog distinct from model capacity and UI failures", () => { + const rateLimit = buildResponseJSON([{ + type: "error", + message: "ChatGPT rate limit: too many requests are being made too quickly. Wait before retrying.", + status: 429, + errorType: "rate_limit_error", + code: "rate_limit_exceeded", + retryable: true, + }], CHATGPT_WEB_MODEL_ID) as { + status: string; + retryable: boolean; + error: { type: string; code: string }; + }; + expect(rateLimit).toMatchObject({ + status: "failed", + retryable: true, + error: { type: "rate_limit_error", code: "rate_limit_exceeded" }, + }); + + const missingEffort = buildResponseJSON([{ + type: "error", + message: "ChatGPT effort menu did not expose item index 1; item count: 0", + status: 502, + errorType: "server_error", + code: "upstream_server_error", + retryable: false, + }], CHATGPT_WEB_MODEL_ID) as { + status: string; + retryable: boolean; + error: { type: string; code: string }; + }; + expect(missingEffort).toMatchObject({ + status: "failed", + retryable: false, + error: { type: "server_error", code: "upstream_server_error" }, + }); + expect(missingEffort.error.code).not.toBe("server_is_overloaded"); + + const contextWindow = buildResponseJSON([{ + type: "error", + message: "This task exceeds the 225,000-token context window. Switch models, run /compact, then retry.", + status: 400, + errorType: "invalid_request_error", + code: "context_length_exceeded", + retryable: false, + }], CHATGPT_WEB_MODEL_ID) as { + status: string; + retryable: boolean; + error: { type: string; code: string; message: string }; + }; + expect(contextWindow).toMatchObject({ + status: "failed", + retryable: false, + error: { + type: "invalid_request_error", + code: "context_length_exceeded", + }, + }); + expect(contextWindow.error.message).toContain("/compact"); + }); + test("returns one native compaction item with preserved estimated usage", () => { const request = parsed(); const summary = "Completed the tool loop; continue with the deployment check."; @@ -436,19 +550,21 @@ describe("ChatGPT outer-native harness v3", () => { expect(tracker.update({ ...state, running: true }, 8_100)).toBe(false); }); - test("preserves GFM formatting and streams only a stable rendered prefix", () => { + test("preserves GFM formatting while buffering mutable rendered Markdown until completion", () => { const heading = '

Format Probe

'; const bold = '

bold

'; const list = '
  • alpha

  • beta

'; const html = `${heading}${bold}${list}`; expect(chatGptHtmlToMarkdown(html)).toBe("## Format Probe\n\n**bold**\n\n- alpha\n- beta"); - const stream = new ChatGptMarkdownStream(); - expect(stream.observeStableHtml(heading)).toBe(""); - expect(stream.observeStableHtml(heading)).toBe("## Format Probe"); - const final = stream.finish(html); - expect(final.delta).toBe("\n\n**bold**\n\n- alpha\n- beta"); - expect(final.markdown).toBe("## Format Probe\n\n**bold**\n\n- alpha\n- beta"); + const buffer = new ChatGptMarkdownBuffer(); + buffer.observe(`${heading}

Source

`); + buffer.observe(`${heading}

Source

`); + buffer.observe(html); + expect(buffer.finish()).toEqual({ + delta: "## Format Probe\n\n**bold**\n\n- alpha\n- beta", + markdown: "## Format Probe\n\n**bold**\n\n- alpha\n- beta", + }); }); test("drops decorative HTML images without removing textual links", () => { @@ -800,7 +916,7 @@ describe("ChatGPT outer-native harness v3", () => { gatewayOnlyEnvironment.tools = gatewayOnlyEnvironment.tools.filter(tool => ( tool.name === "exec" || tool.name === "search_openai_docs" )); - const token = await broker.register(gatewayOnlyEnvironment, 60_000); + const token = await broker.register(gatewayOnlyEnvironment); const transport = new StdioClientTransport({ command: process.execPath, args: ["src/cli.ts", "mcp", "--broker-socket", socketPath], @@ -827,6 +943,7 @@ describe("ChatGPT outer-native harness v3", () => { const bindingId = (bound.structuredContent as { binding_id?: string } | undefined)?.binding_id; expect(bindingId).toStartWith("binding_"); expect((bound.structuredContent as { execution: string }).execution).toBe("outer_codex_native"); + expect((bound.structuredContent as { expires_at: string | null }).expires_at).toBeNull(); expect((bound.structuredContent as { outer_tool_gateway: string }).outer_tool_gateway).toBe("exec"); expect((bound.structuredContent as { command_tool: string }).command_tool).toBe("exec_command"); @@ -865,4 +982,67 @@ describe("ChatGPT outer-native harness v3", () => { await broker.close(); } }, 30_000); + + test("keeps a danger-full native task project-bound at the MCP implementation", async () => { + const socketPath = brokerTestEndpoint(`cgw-h3-project-boundary-${process.pid}-${Date.now()}`); + const broker = TurnBroker.forSocket(socketPath); + const nativeEnvironment = extractChatGptTurnEnvironment(parsed(environmentXml)); + nativeEnvironment.sandboxPolicy = { type: "dangerFullAccess" }; + const boundedEnvironment = scopeChatGptTurnEnvironment(nativeEnvironment); + boundedEnvironment.tools = boundedEnvironment.tools.filter(tool => tool.name === "exec"); + const token = await broker.register(boundedEnvironment, 60_000); + const transport = new StdioClientTransport({ + command: process.execPath, + args: ["src/cli.ts", "mcp", "--broker-socket", socketPath], + cwd: process.cwd(), + stderr: "pipe", + }); + const client = new Client({ name: "codex-chatgpt-web-project-boundary-test", version: "1.0.0" }); + const call = (name: string, args: Record) => client.callTool({ name, arguments: args }); + + try { + await client.connect(transport); + const bound = await call("codex_bind_turn", { turn_token: token }); + const structured = bound.structuredContent as { binding_id: string; project_boundary: boolean; sandbox: string }; + expect(structured.project_boundary).toBe(true); + expect(structured.sandbox).toBe("workspaceWrite"); + + const inventory = await call("codex_tool_inventory", { binding_id: structured.binding_id }); + expect(inventory.structuredContent).toMatchObject({ tools: [], total: 0, project_boundary: true }); + + const generic = await call("codex_tool_call", { + binding_id: structured.binding_id, + wire_name: "exec", + input: "text('bypass')", + }); + expect(generic.isError).toBe(true); + expect(JSON.stringify(generic.content)).toContain("Generic outer-tool calls are disabled"); + + const execPromise = call("codex_exec", { binding_id: structured.binding_id, cmd: "pwd" }); + if (process.platform !== "darwin") { + const unsupported = await execPromise; + expect(unsupported.isError).toBe(true); + expect(JSON.stringify(unsupported.content)).toContain( + "Project-bound Web GPT shell execution is currently supported on macOS only", + ); + return; + } + const [execRequest] = await broker.nextToolBatch(token); + expect(execRequest?.input).toContain("/usr/bin/sandbox-exec -p"); + expect(execRequest?.input).toContain(JSON.stringify(tempRoot)); + broker.completeTool(token, execRequest!.callId, toolResult({ output: tempRoot, exit_code: 0 })); + expect((await execPromise).structuredContent).toEqual({ output: tempRoot, exit_code: 0 }); + + const escapedPatch = await call("codex_apply_patch", { + binding_id: structured.binding_id, + patch: "*** Begin Patch\n*** Add File: /tmp/outside.txt\n+no\n*** End Patch", + }); + expect(escapedPatch.isError).toBe(true); + expect(JSON.stringify(escapedPatch.content)).toContain("outside the active Codex project"); + } finally { + await client.close().catch(() => {}); + broker.revoke(token); + await broker.close(); + } + }, 30_000); }); diff --git a/tests/chatgpt-web-models.test.ts b/tests/chatgpt-web-models.test.ts index 758e7b52b..5cec31f26 100644 --- a/tests/chatgpt-web-models.test.ts +++ b/tests/chatgpt-web-models.test.ts @@ -4,6 +4,7 @@ import { CHATGPT_WEB_BACKEND_MODEL, CHATGPT_WEB_MODEL_ROUTES, requireChatGptWebModelRoute, + resolveChatGptWebContextLimits, } from "../src/chatgpt-web-models"; import { defaultConfig } from "../src/config"; import { routeChatGptWebRequest } from "../src/server"; @@ -22,39 +23,117 @@ function parsed(modelId: string, reasoning = "medium"): CodexParsedRequest { describe("fixed ChatGPT Web model routes", () => { test("uses unique stable slugs and one explicit adapter effort per model", () => { expect(new Set(CHATGPT_WEB_MODEL_ROUTES.map(route => route.slug)).size).toBe(CHATGPT_WEB_MODEL_ROUTES.length); + expect(new Set(CHATGPT_WEB_MODEL_ROUTES.map(route => route.ocxModelId)).size).toBe(CHATGPT_WEB_MODEL_ROUTES.length); expect(CHATGPT_WEB_MODEL_ROUTES.map(route => [route.slug, route.codexEffort, route.adapterEffort])).toEqual([ - ["chatgpt-web/light", "low", "low"], - ["chatgpt-web/medium", "medium", "medium"], - ["chatgpt-web/high", "high", "high"], - ["chatgpt-web/extra-high", "xhigh", "xhigh"], - ["chatgpt-web/pro", "ultra", "max"], + ["chatgpt-web/temporary/low", "low", "low"], + ["chatgpt-web/temporary/medium", "medium", "medium"], + ["chatgpt-web/temporary/high", "high", "high"], + ["chatgpt-web/temporary/extra-high", "xhigh", "xhigh"], + ["chatgpt-web/saved/low", "low", "low"], + ["chatgpt-web/saved/medium", "medium", "medium"], + ["chatgpt-web/saved/high", "high", "high"], + ["chatgpt-web/saved/extra-high", "xhigh", "xhigh"], + ["chatgpt-web/temporary/pro", "ultra", "max"], + ["chatgpt-web/saved/pro", "ultra", "max"], ]); - expect(CHATGPT_WEB_MODEL_ROUTES[0]?.displayName).toBe("ChatGPT Web — Instant"); + expect(CHATGPT_WEB_MODEL_ROUTES[0]?.displayName).toBe("Web / 임시 / 낮음"); + expect(CHATGPT_WEB_MODEL_ROUTES[0]?.conversationMode).toBe("temporary"); + expect(CHATGPT_WEB_MODEL_ROUTES[4]?.conversationMode).toBe("saved"); }); - test("does not expose or resolve Pro without the account capability", () => { - expect(availableChatGptWebModelRoutes(false).map(route => route.slug)).not.toContain("chatgpt-web/pro"); - expect(() => requireChatGptWebModelRoute("chatgpt-web/pro", false)) - .toThrow("Pro is not available for this account"); + test("accepts collision-resistant OpenCodex aliases without changing the authoritative Web route", () => { + const config = defaultConfig("browser-only"); + config.proAvailable = true; + const request = parsed("webgpt-temporary-high", "low"); + + expect(routeChatGptWebRequest(request, config)).toMatchObject({ + slug: "chatgpt-web/temporary/high", + ocxModelId: "webgpt-temporary-high", + adapterEffort: "high", + conversationMode: "temporary", + }); + expect(request.modelId).toBe(CHATGPT_WEB_BACKEND_MODEL); + }); + + test("keeps legacy model ids as hidden compatibility aliases", () => { + const config = defaultConfig("full"); + config.proAvailable = true; + expect(requireChatGptWebModelRoute("webgpt-high", true).slug).toBe("chatgpt-web/temporary/high"); + expect(requireChatGptWebModelRoute("chatgpt-web/saved", true).slug) + .toBe("chatgpt-web/saved/extra-high"); + expect(routeChatGptWebRequest(parsed("webgpt-pro"), config).slug) + .toBe("chatgpt-web/temporary/pro"); + }); + + test("exposes only Plus-eligible routes without the Pro account capability", () => { + expect(availableChatGptWebModelRoutes(false).map(route => route.slug)).toEqual([ + "chatgpt-web/temporary/low", + "chatgpt-web/temporary/medium", + "chatgpt-web/temporary/high", + "chatgpt-web/saved/low", + "chatgpt-web/saved/medium", + "chatgpt-web/saved/high", + ]); + expect(availableChatGptWebModelRoutes(true)).toEqual(CHATGPT_WEB_MODEL_ROUTES); + expect(() => requireChatGptWebModelRoute("chatgpt-web/temporary/extra-high", false)) + .toThrow("Web / 임시 / 매우 높음 is not available for this account"); + expect(() => requireChatGptWebModelRoute("chatgpt-web/saved/pro", false)) + .toThrow("Web / 저장 / Pro is not available for this account"); + }); + + test("uses one account-specific context limit for every Web mode with ten-percent compact headroom", () => { + expect(resolveChatGptWebContextLimits(false)).toEqual({ + contextWindow: 225_000, + autoCompactTokenLimit: 202_500, + }); + expect(resolveChatGptWebContextLimits(true)).toEqual({ + contextWindow: 256_000, + autoCompactTokenLimit: 230_400, + }); }); test("binds the selected model authoritatively and ignores a conflicting request effort", () => { - const request = parsed("chatgpt-web/high", "low"); + const request = parsed("chatgpt-web/saved/high", "low"); const rawSnapshot = structuredClone(request._rawBody); const route = routeChatGptWebRequest(request, defaultConfig("browser-only")); - expect(route.slug).toBe("chatgpt-web/high"); + expect(route.slug).toBe("chatgpt-web/saved/high"); expect(request.modelId).toBe(CHATGPT_WEB_BACKEND_MODEL); expect(request.options.reasoning).toBe("high"); + expect(request._chatGptWebConversationMode).toBe("saved"); expect(request._rawBody).toEqual(rawSnapshot); }); + test("combines temporary and saved conversations with the selected effort independently", () => { + const temporary = parsed("chatgpt-web/temporary/high", "low"); + const saved = parsed("chatgpt-web/saved/extra-high", "low"); + const config = defaultConfig("browser-only"); + config.proAvailable = true; + + expect(routeChatGptWebRequest(temporary, config)).toMatchObject({ + slug: "chatgpt-web/temporary/high", + codexEffort: "high", + conversationMode: "temporary", + }); + expect(temporary.options.reasoning).toBe("high"); + expect(temporary._chatGptWebConversationMode).toBe("temporary"); + + expect(routeChatGptWebRequest(saved, config)).toMatchObject({ + slug: "chatgpt-web/saved/extra-high", + codexEffort: "xhigh", + conversationMode: "saved", + }); + expect(saved.options.reasoning).toBe("xhigh"); + expect(saved._chatGptWebConversationMode).toBe("saved"); + }); + test("binds the Pro model to the browser Pro effort and fails closed for unknown routes", () => { const config = defaultConfig("full"); config.proAvailable = true; - const request = parsed("chatgpt-web/pro", "low"); + const request = parsed("chatgpt-web/saved/pro", "low"); expect(routeChatGptWebRequest(request, config).adapterEffort).toBe("max"); expect(request.options.reasoning).toBe("max"); + expect(request._chatGptWebConversationMode).toBe("saved"); expect(() => routeChatGptWebRequest(parsed("chatgpt-web/not-enabled"), config)) .toThrow("model is not enabled"); }); diff --git a/tests/codex-integration.test.ts b/tests/codex-integration.test.ts index cc6c74f32..ff0d1938d 100644 --- a/tests/codex-integration.test.ts +++ b/tests/codex-integration.test.ts @@ -54,16 +54,21 @@ describe("reversible native Codex route integration", () => { }); }); - test("installs only openai_base_url and keeps the built-in openai provider", () => { + test("keeps the built-in openai provider and manages the bounded V1 Web surface", () => { const { codexHome } = fixture(); const configPath = join(codexHome, "config.toml"); - const original = `model = "gpt-5.6-sol"\n\n[features]\ngoals = true\n`; + const original = `model = "gpt-5.6-sol"\n\n[features]\nmulti_agent = false # user choice\ngoals = true\n`; writeFileSync(configPath, original); const journal = installCodexIntegration(defaultConfig("browser-only")); const installed = readFileSync(configPath, "utf8"); - expect(journal.version).toBe(4); + expect(journal.version).toBe(6); expect(installed).toContain('openai_base_url = "http://127.0.0.1:17841/v1"'); + expect(installed).toContain("remote_compaction_v2 = false # Managed by codex-chatgpt-web"); + expect(installed).toContain("multi_agent = true # Managed by codex-chatgpt-web"); + expect(installed).toContain("multi_agent_v2 = false # Managed by codex-chatgpt-web"); + expect(installed).not.toContain("multi_agent = false"); + expect(installed).toContain("goals = true"); expect(installed).not.toMatch(/^\s*model_provider\s*=/m); expect(installed).not.toMatch(/^\s*model_catalog_json\s*=/m); expect(installed).not.toContain("[model_providers.codex-chatgpt-web]"); @@ -73,6 +78,93 @@ describe("reversible native Codex route integration", () => { expect(uninstallCodexIntegration()).toEqual({ changed: false }); }); + test("accepts an explicitly persisted built-in openai provider and restores it exactly", () => { + const { codexHome } = fixture(); + const configPath = join(codexHome, "config.toml"); + const original = 'model = "gpt-5.6-sol"\nmodel_provider = "openai" # explicit built-in default\n'; + writeFileSync(configPath, original); + + expect(() => preflightCodexIntegration(defaultConfig("browser-only"))).not.toThrow(); + installCodexIntegration(defaultConfig("browser-only")); + expect(readFileSync(configPath, "utf8")).not.toMatch(/^\s*model_provider\s*=/m); + + uninstallCodexIntegration(); + expect(readFileSync(configPath, "utf8")).toBe(original); + }); + + test("restores an explicit remote_compaction_v2 setting byte-for-byte", () => { + const { codexHome } = fixture(); + const configPath = join(codexHome, "config.toml"); + const original = 'model = "gpt-5.6-sol"\n\n[features]\nremote_compaction_v2 = true # user choice\ngoals = true\n'; + writeFileSync(configPath, original); + + installCodexIntegration(defaultConfig("browser-only")); + const installed = readFileSync(configPath, "utf8"); + expect(installed).toContain("remote_compaction_v2 = false # Managed by codex-chatgpt-web"); + expect(installed).not.toContain("remote_compaction_v2 = true"); + + uninstallCodexIntegration(); + expect(readFileSync(configPath, "utf8")).toBe(original); + }); + + test("restores an explicit multi_agent setting byte-for-byte", () => { + const { codexHome } = fixture(); + const configPath = join(codexHome, "config.toml"); + const original = 'model = "gpt-5.6-sol"\n\n[features]\nmulti_agent = false # user choice\ngoals = true\n'; + writeFileSync(configPath, original); + + installCodexIntegration(defaultConfig("full")); + const installed = readFileSync(configPath, "utf8"); + expect(installed).toContain("multi_agent = true # Managed by codex-chatgpt-web"); + expect(installed).not.toContain("multi_agent = false"); + + uninstallCodexIntegration(); + expect(readFileSync(configPath, "utf8")).toBe(original); + }); + + test("restores an explicit multi_agent_v2 setting byte-for-byte", () => { + const { codexHome } = fixture(); + const configPath = join(codexHome, "config.toml"); + const original = 'model = "gpt-5.6-sol"\n\n[features]\nmulti_agent_v2 = true # user choice\ngoals = true\n'; + writeFileSync(configPath, original); + + installCodexIntegration(defaultConfig("full")); + const installed = readFileSync(configPath, "utf8"); + expect(installed).toContain("multi_agent_v2 = false # Managed by codex-chatgpt-web"); + expect(installed).not.toContain("multi_agent_v2 = true"); + + uninstallCodexIntegration(); + expect(readFileSync(configPath, "utf8")).toBe(original); + }); + + test("manages and restores the structured multi_agent_v2 feature table", () => { + const { codexHome } = fixture(); + const configPath = join(codexHome, "config.toml"); + const original = [ + 'model = "gpt-5.6-sol"', + "", + "[features]", + "multi_agent = true", + "", + "[features.multi_agent_v2]", + "enabled = true # user choice", + "hide_spawn_agent_metadata = true", + "", + ].join("\n"); + writeFileSync(configPath, original); + + installCodexIntegration(defaultConfig("full")); + const installed = readFileSync(configPath, "utf8"); + expect(installed).not.toMatch(/^multi_agent_v2\s*=/m); + expect(installed).toContain( + "enabled = false # Managed by codex-chatgpt-web: keeps routed Web subagent payloads readable.", + ); + expect(installed).toContain("hide_spawn_agent_metadata = true"); + + uninstallCodexIntegration(); + expect(readFileSync(configPath, "utf8")).toBe(original); + }); + test("invalidates Codex's provider-agnostic model cache on install and uninstall", () => { const { codexHome } = fixture(); const configPath = join(codexHome, "config.toml"); @@ -147,6 +239,9 @@ describe("reversible native Codex route integration", () => { expect(activateCodexIntegration()).toEqual({ changed: true, active: true }); const reconnected = readFileSync(configPath, "utf8"); expect(reconnected).toContain('openai_base_url = "http://127.0.0.1:17841/v1"'); + expect(reconnected).toContain("remote_compaction_v2 = false # Managed by codex-chatgpt-web"); + expect(reconnected).toContain("multi_agent = true # Managed by codex-chatgpt-web"); + expect(reconnected).toContain("multi_agent_v2 = false # Managed by codex-chatgpt-web"); expect(reconnected).toContain('approval_policy = "never"'); expect(inspectCodexIntegration()).toMatchObject({ installed: true, active: true }); expect(activateCodexIntegration()).toEqual({ changed: false, active: true }); @@ -163,7 +258,7 @@ describe("reversible native Codex route integration", () => { deactivateCodexIntegration(); expect(JSON.parse(readFileSync(getCodexJournalPath(), "utf8"))).toMatchObject({ - version: 4, + version: 6, active: false, }); expect(inspectCodexIntegration()).toMatchObject({ installed: true, active: false, errors: [] }); @@ -173,11 +268,20 @@ describe("reversible native Codex route integration", () => { test("upgrades an existing v3 route journal when it is disconnected for the first time", () => { const { codexHome } = fixture(); const configPath = join(codexHome, "config.toml"); - const original = 'model = "gpt-5.6-sol"\n'; + const original = 'model = "gpt-5.6-sol"\n\n[features]\ngoals = true\n'; writeFileSync(configPath, original); installCodexIntegration(defaultConfig("browser-only")); const previous = JSON.parse(readFileSync(getCodexJournalPath(), "utf8")); + const legacyInstalled = readFileSync(configPath, "utf8") + .replace(/^(?:remote_compaction_v2 = false|multi_agent = true|multi_agent_v2 = false).*\n/gm, ""); + writeFileSync(configPath, legacyInstalled); delete previous.active; + delete previous.previousRemoteCompactionV2; + delete previous.previousMultiAgent; + delete previous.previousMultiAgentV2; + delete previous.installed.remote_compaction_v2; + delete previous.installed.multi_agent; + delete previous.installed.multi_agent_v2; previous.version = 3; writeFileSync(getCodexJournalPath(), `${JSON.stringify(previous, null, 2)}\n`); @@ -189,6 +293,68 @@ describe("reversible native Codex route integration", () => { }); }); + test("upgrades an active v4 route journal to the managed V1 Web surface", () => { + const { codexHome } = fixture(); + const configPath = join(codexHome, "config.toml"); + writeFileSync(configPath, 'model = "gpt-5.6-sol"\n\n[features]\ngoals = true\n'); + installCodexIntegration(defaultConfig("browser-only")); + const legacy = JSON.parse(readFileSync(getCodexJournalPath(), "utf8")); + delete legacy.previousRemoteCompactionV2; + delete legacy.previousMultiAgent; + delete legacy.previousMultiAgentV2; + delete legacy.installed.remote_compaction_v2; + delete legacy.installed.multi_agent; + delete legacy.installed.multi_agent_v2; + legacy.version = 4; + writeFileSync(getCodexJournalPath(), `${JSON.stringify(legacy, null, 2)}\n`); + writeFileSync( + configPath, + readFileSync(configPath, "utf8") + .replace(/^(?:remote_compaction_v2 = false|multi_agent = true|multi_agent_v2 = false).*\n/gm, ""), + ); + + const upgraded = installCodexIntegration(defaultConfig("browser-only")); + expect(upgraded.version).toBe(6); + expect(readFileSync(configPath, "utf8")).toContain( + "remote_compaction_v2 = false # Managed by codex-chatgpt-web", + ); + expect(readFileSync(configPath, "utf8")).toContain( + "multi_agent = true # Managed by codex-chatgpt-web", + ); + expect(readFileSync(configPath, "utf8")).toContain( + "multi_agent_v2 = false # Managed by codex-chatgpt-web", + ); + }); + + test("upgrades an active v5 journal without losing its preserved feature baseline", () => { + const { codexHome } = fixture(); + const configPath = join(codexHome, "config.toml"); + const original = 'model = "gpt-5.6-sol"\n\n[features]\nmulti_agent_v2 = true # user choice\ngoals = true\n'; + writeFileSync(configPath, original); + installCodexIntegration(defaultConfig("full")); + const legacy = JSON.parse(readFileSync(getCodexJournalPath(), "utf8")); + delete legacy.previousMultiAgentV2; + delete legacy.installed.multi_agent_v2; + legacy.version = 5; + writeFileSync(getCodexJournalPath(), `${JSON.stringify(legacy, null, 2)}\n`); + writeFileSync( + configPath, + readFileSync(configPath, "utf8").replace( + /^multi_agent_v2 = false.*$/m, + "multi_agent_v2 = true # user choice", + ), + ); + + const upgraded = installCodexIntegration(defaultConfig("full")); + expect(upgraded.version).toBe(6); + expect(readFileSync(configPath, "utf8")).toContain( + "multi_agent_v2 = false # Managed by codex-chatgpt-web", + ); + + uninstallCodexIntegration(); + expect(readFileSync(configPath, "utf8")).toBe(original); + }); + test("fails closed when the native route changes while the bridge is disconnected", () => { const { codexHome } = fixture(); const configPath = join(codexHome, "config.toml"); @@ -286,6 +452,45 @@ describe("reversible native Codex route integration", () => { expect(readFileSync(configPath, "utf8")).toBe(changed); }); + test("fails closed when the managed compaction feature changes after setup", () => { + const { codexHome } = fixture(); + const configPath = join(codexHome, "config.toml"); + writeFileSync(configPath, 'model = "gpt-5.6-sol"\n'); + installCodexIntegration(defaultConfig("browser-only")); + const changed = readFileSync(configPath, "utf8") + .replace(/^remote_compaction_v2 = false.*$/m, "remote_compaction_v2 = true"); + writeFileSync(configPath, changed); + + expect(() => uninstallCodexIntegration()).toThrow("remote_compaction_v2 changed after setup"); + expect(readFileSync(configPath, "utf8")).toBe(changed); + }); + + test("fails closed when the managed multi-agent feature changes after setup", () => { + const { codexHome } = fixture(); + const configPath = join(codexHome, "config.toml"); + writeFileSync(configPath, 'model = "gpt-5.6-sol"\n'); + installCodexIntegration(defaultConfig("full")); + const changed = readFileSync(configPath, "utf8") + .replace(/^multi_agent = true.*$/m, "multi_agent = false"); + writeFileSync(configPath, changed); + + expect(() => uninstallCodexIntegration()).toThrow("multi_agent changed after setup"); + expect(readFileSync(configPath, "utf8")).toBe(changed); + }); + + test("fails closed when the managed V1 subagent transport changes after setup", () => { + const { codexHome } = fixture(); + const configPath = join(codexHome, "config.toml"); + writeFileSync(configPath, 'model = "gpt-5.6-sol"\n'); + installCodexIntegration(defaultConfig("full")); + const changed = readFileSync(configPath, "utf8") + .replace(/^multi_agent_v2 = false.*$/m, "multi_agent_v2 = true"); + writeFileSync(configPath, changed); + + expect(() => uninstallCodexIntegration()).toThrow("multi_agent_v2 changed after setup"); + expect(readFileSync(configPath, "utf8")).toBe(changed); + }); + test("keeps every user line byte-for-byte when line endings are mixed", () => { const { codexHome } = fixture(); const configPath = join(codexHome, "config.toml"); diff --git a/tests/compaction-v1.test.ts b/tests/compaction-v1.test.ts new file mode 100644 index 000000000..bd51dae10 --- /dev/null +++ b/tests/compaction-v1.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test"; +import { + buildCompactV1Output, + extractCompactUserMessages, +} from "../src/responses/compaction"; + +test("v1 compaction keeps only the newest ten structured images without copying them into text", () => { + const input = Array.from({ length: 12 }, (_, index) => ({ + type: "message", + role: "user", + id: `user-${index}`, + metadata: { source: `turn-${index}` }, + content: [ + { type: "input_text", text: `request-${index}` }, + { + type: "input_image", + image_url: `data:image/png;base64,image-${index}`, + detail: "high", + }, + ], + })); + + const output = buildCompactV1Output(extractCompactUserMessages(input), "checkpoint"); + const retained = output.slice(0, -1) as Array<{ + id?: string; + metadata?: { source?: string }; + content: Array<{ type: string; text?: string; image_url?: string; detail?: string }>; + }>; + expect(retained).toHaveLength(12); + expect(retained.map(item => item.id)).toEqual(input.map(item => item.id)); + expect(retained.map(item => item.metadata?.source)).toEqual(input.map(item => item.metadata.source)); + const imageUrls = retained.flatMap(item => item.content + .filter(block => block.type === "input_image") + .map(block => block.image_url)); + expect(imageUrls).toEqual(input.slice(2).map(item => item.content[1]!.image_url)); + expect(retained.flatMap(item => item.content) + .filter(block => block.type === "input_text") + .every(block => !block.text?.includes("data:image"))).toBe(true); + expect(retained.at(-1)?.content.at(-1)).toMatchObject({ detail: "high" }); +}); diff --git a/tests/environment.test.ts b/tests/environment.test.ts index e64f0ddb4..f7c5c9ce1 100644 --- a/tests/environment.test.ts +++ b/tests/environment.test.ts @@ -17,10 +17,25 @@ const environmentXml = ` ${root} `; -function currentWire(options: { workspace?: string; sandbox?: string; includeIds?: boolean } = {}): CodexParsedRequest { +function filesystemEnvironmentXml(permissionProfileXml: string): string { + return ` + ${root} + ${root}${permissionProfileXml} +`; +} + +const dangerFullAccessProfileXml = ``; +const workspaceWriteProfileXml = `:root${root}:slash_tmp:tmpdir${root}/.git`; +const readOnlyProfileXml = `:root`; +const externalProfileXml = ``; + +function currentWire( + options: { workspace?: string; sandbox?: string; includeIds?: boolean; environmentXml?: string } = {}, +): CodexParsedRequest { const workspace = options.workspace ?? root; const sandbox = options.sandbox ?? "none"; const includeIds = options.includeIds ?? true; + const envXml = options.environmentXml ?? environmentXml; const turnMetadata = { thread_id: "thread_current", turn_id: "turn_current", @@ -41,7 +56,7 @@ function currentWire(options: { workspace?: string; sandbox?: string; includeIds role: "user", content: [ { type: "input_text", text: "native app context" }, - { type: "input_text", text: environmentXml }, + { type: "input_text", text: envXml }, ], }, { @@ -80,6 +95,71 @@ describe("trusted current Codex environment envelope", () => { expect(() => extractChatGptTurnEnvironment(currentWire({ includeIds: false }))) .toThrow("missing cwd"); }); + + test("selects the primary cwd from Codex project environments", () => { + const projectEnvironment = ` + + ${resolve(root, "secondary")} + ${root} + + ${root}${dangerFullAccessProfileXml} +`; + expect(extractChatGptTurnEnvironment(currentWire({ environmentXml: projectEnvironment }))).toEqual({ + cwd: root, + roots: [root], + writableRoots: [root], + sandboxPolicy: { type: "dangerFullAccess" }, + tools: [], + }); + }); +}); + +describe("permission_profile sandbox detection (Codex CLI 0.146+)", () => { + test("new-format workspace-write resolves with a workspaceWrite sandbox policy", () => { + expect(extractChatGptTurnEnvironment(currentWire({ + sandbox: "workspace-write", + environmentXml: filesystemEnvironmentXml(workspaceWriteProfileXml), + }))).toEqual({ + cwd: root, + roots: [root], + writableRoots: [root], + sandboxPolicy: { type: "workspaceWrite", writableRoots: [root], networkAccess: false }, + tools: [], + }); + }); + + test("new-format read-only resolves with a readOnly sandbox policy", () => { + expect(extractChatGptTurnEnvironment(currentWire({ + sandbox: "read-only", + environmentXml: filesystemEnvironmentXml(readOnlyProfileXml), + }))).toEqual({ + cwd: root, + roots: [root], + writableRoots: [], + sandboxPolicy: { type: "readOnly", networkAccess: false }, + tools: [], + }); + }); + + test("new-format danger-full-access still resolves dangerFullAccess", () => { + expect(extractChatGptTurnEnvironment(currentWire({ + sandbox: "none", + environmentXml: filesystemEnvironmentXml(dangerFullAccessProfileXml), + }))).toEqual({ + cwd: root, + roots: [root], + writableRoots: [root], + sandboxPolicy: { type: "dangerFullAccess" }, + tools: [], + }); + }); + + test("permission_profile type=external remains unmapped and fails closed", () => { + expect(() => extractChatGptTurnEnvironment(currentWire({ + sandbox: "workspace-write", + environmentXml: filesystemEnvironmentXml(externalProfileXml), + }))).toThrow("missing cwd"); + }); }); describe("trusted Codex task environment continuity", () => { diff --git a/tests/launcher-browser-host.test.ts b/tests/launcher-browser-host.test.ts index 4d963f861..4691a888a 100644 --- a/tests/launcher-browser-host.test.ts +++ b/tests/launcher-browser-host.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { LAUNCHER_TURN_END_TIMEOUT_MS, LAUNCHER_TURN_START_TIMEOUT_MS, + LAUNCHER_CAPABILITY_INSPECTION_TIMEOUT_MS, LAUNCHER_BROWSER_HOST_KIND, inspectLauncherBrowserHost, notifyLauncherTurn, @@ -71,7 +72,9 @@ test("launcher turn control sends authenticated lifecycle events", async () => { body: JSON.parse(Buffer.concat(chunks).toString("utf8")), }; response.writeHead(200, { "content-type": "application/json" }); - response.end('{"ok":true}\n'); + response.end(request.url === "/v1/turn/start" + ? '{"ok":true,"surfaceId":"launcher_surface_id_0123456789AB"}\n' + : '{"ok":true}\n'); }); await new Promise((resolve, reject) => { server.once("error", reject); @@ -81,7 +84,11 @@ test("launcher turn control sends authenticated lifecycle events", async () => { const address = server.address(); if (!address || typeof address === "string") throw new Error("test server has no port"); const path = descriptorFile(`http://127.0.0.1:${address.port}`); - await notifyLauncherTurn(path, { phase: "start", traceId: "abc123def456", helperPid: process.pid }); + await expect(notifyLauncherTurn(path, { + phase: "start", + traceId: "abc123def456", + helperPid: process.pid, + })).resolves.toEqual({ surfaceId: "launcher_surface_id_0123456789AB" }); expect(received.authorization).toBe("Bearer launcher-control-token-0123456789abcdefghijklmnop"); expect(received.body).toEqual({ phase: "start", traceId: "abc123def456", helperPid: process.pid }); await notifyLauncherTurn(path, { @@ -102,6 +109,7 @@ test("launcher turn control sends authenticated lifecycle events", async () => { }); test("launcher session verification uses the authenticated control channel instead of Bun CDP", async () => { + expect(LAUNCHER_CAPABILITY_INSPECTION_TIMEOUT_MS).toBe(120_000); const server = createServer(async (request, response) => { const chunks: Buffer[] = []; for await (const chunk of request) chunks.push(Buffer.from(chunk)); @@ -133,6 +141,30 @@ test("launcher session verification uses the authenticated control channel inste } }); +test("launcher session verification reports its own deadline instead of a generic abort", async () => { + const server = createServer(async (request, response) => { + for await (const _chunk of request) { /* consume request */ } + await new Promise(resolveDelay => setTimeout(resolveDelay, 30)); + if (!response.destroyed) { + response.writeHead(500, { "content-type": "application/json" }); + response.end('{"error":"late"}\n'); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + try { + const address = server.address(); + if (!address || typeof address === "string") throw new Error("test server has no port"); + const path = descriptorFile(`http://127.0.0.1:${address.port}`); + await expect(inspectLauncherBrowserHost(path, { detectPro: true, timeoutMs: 5 })) + .rejects.toThrow("session inspection timed out after 5ms"); + } finally { + await new Promise(resolveClose => server.close(() => resolveClose())); + } +}); + test("launcher descriptor rejects non-loopback browser ownership", () => { const path = descriptorFile(); const value = JSON.parse(readFileSync(path, "utf8")); @@ -180,3 +212,20 @@ test("launcher page selection rejects duplicated ownership markers", async () => "2 surfaces with the same ownership id", ); }); + +test("launcher page selection stops immediately when acquisition is aborted", async () => { + const descriptor = readLauncherBrowserHostDescriptor(descriptorFile()); + const browser = { + contexts: () => [], + } as unknown as Browser; + const controller = new AbortController(); + controller.abort(); + + expect(selectLauncherPage( + browser, + descriptor, + 60_000, + descriptor.surfaceId, + controller.signal, + )).rejects.toMatchObject({ name: "AbortError" }); +}); diff --git a/tests/launcher-helper-client.test.ts b/tests/launcher-helper-client.test.ts index bcf52d849..0f436add5 100644 --- a/tests/launcher-helper-client.test.ts +++ b/tests/launcher-helper-client.test.ts @@ -2,8 +2,9 @@ import { afterEach, expect, test } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { ChatGptWebAdapterError } from "../src/adapters/chatgpt-web/adapter-error"; import { LauncherBrowserHelperClient } from "../src/adapters/chatgpt-web/launcher-helper-client"; -import type { ResolvedBrowserConfig } from "../src/adapters/chatgpt-web/browser-worker"; +import type { BrowserTurn, ResolvedBrowserConfig } from "../src/adapters/chatgpt-web/browser-worker"; import { LAUNCHER_BROWSER_HOST_KIND } from "../src/launcher-browser-host"; const roots: string[] = []; @@ -129,3 +130,60 @@ test("an abort dispatched during run submission cannot overtake the run frame", expect(messages).toEqual(["run", "abort"]); expect(released).toBe(true); }); + +test("structured helper errors preserve the ChatGPT adapter failure contract", async () => { + const client = new LauncherBrowserHelperClient({ + appName: "Codex Native", + browserHost: "launcher", + browserHostDescriptorPath: "/durable/launcher.json", + storageStatePath: "/durable/unused-state.json", + chromeExecutablePath: "/durable/unused-chrome", + turnTimeoutMs: 60_000, + headed: true, + autoApproveToolCalls: false, + }); + const internal = client as unknown as { + child?: unknown; + pending: Map void; + reject: (error: Error) => void; + }>; + handleLine(child: unknown, line: string): void; + }; + const child = {}; + internal.child = child; + const result = new Promise((resolveResult, rejectResult) => { + internal.pending.set("rate-limit-123", { + turn: { + traceId: "rate-limit-123", + modelId: "chatgpt-web/medium", + capabilities: { localToolsEnabled: false, proAvailable: false }, + prepare: async () => ({ text: "inspect", images: [], release() {} }), + onTextDelta() {}, + }, + resolve: resolveResult, + reject: rejectResult, + }); + }); + + internal.handleLine(child, JSON.stringify({ + type: "error", + id: "rate-limit-123", + name: "ChatGptWebAdapterError", + message: "ChatGPT rate limit: too many requests are being made too quickly. Wait before retrying.", + status: 429, + errorType: "rate_limit_error", + code: "rate_limit_exceeded", + retryable: true, + })); + + const error = await result.then(() => undefined, failure => failure); + expect(error).toBeInstanceOf(ChatGptWebAdapterError); + expect(error).toMatchObject({ + status: 429, + errorType: "rate_limit_error", + code: "rate_limit_exceeded", + retryable: true, + }); +}); diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index ca45773c7..3232a17dd 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -1,10 +1,9 @@ import { describe, expect, test } from "bun:test"; import { defaultConfig } from "../src/config"; -import { CHATGPT_WEB_MODEL_ROUTES } from "../src/chatgpt-web-models"; +import { CHATGPT_WEB_MODEL_ROUTES, resolveChatGptWebContextLimits } from "../src/chatgpt-web-models"; import { augmentNativeModelCatalog, - CHATGPT_WEB_AUTO_COMPACT_TOKEN_LIMIT, - CHATGPT_WEB_CONTEXT_WINDOW, + CHATGPT_WEB_MODEL_PRIORITY, } from "../src/model-catalog"; function source(): Record { @@ -19,6 +18,7 @@ function source(): Record { shell_type: "shell_command", visibility: "list", supported_in_api: true, + multi_agent_version: "v2", base_instructions: "native harness", supported_reasoning_levels: [ { effort: "low", description: "Low" }, @@ -56,15 +56,19 @@ describe("native /models augmentation", () => { expect(web.map(model => model.display_name)).toEqual(CHATGPT_WEB_MODEL_ROUTES.map(route => route.displayName)); for (const [index, model] of web.entries()) { const route = CHATGPT_WEB_MODEL_ROUTES[index]!; + const limits = resolveChatGptWebContextLimits(true); expect(model).toMatchObject({ slug: route.slug, display_name: route.displayName, - tool_mode: route.requiresPro ? null : "code_mode_only", + tool_mode: "code_mode_only", default_reasoning_level: route.codexEffort, supported_reasoning_levels: [{ effort: route.codexEffort, description: route.displayName }], - context_window: CHATGPT_WEB_CONTEXT_WINDOW, - max_context_window: CHATGPT_WEB_CONTEXT_WINDOW, - auto_compact_token_limit: CHATGPT_WEB_AUTO_COMPACT_TOKEN_LIMIT, + multi_agent_version: "v1", + supported_in_api: true, + priority: CHATGPT_WEB_MODEL_PRIORITY, + context_window: limits.contextWindow, + max_context_window: limits.contextWindow, + auto_compact_token_limit: limits.autoCompactTokenLimit, additional_speed_tiers: [], service_tiers: [], default_service_tier: null, @@ -73,13 +77,35 @@ describe("native /models augmentation", () => { } }); - test("owns only its namespace, is idempotent, and omits account-gated Pro when unavailable", () => { + test("keeps every routed Web model in Codex's V1 spawn-agent model registry", () => { + const config = defaultConfig("full"); + config.proAvailable = true; + const models = augmentNativeModelCatalog(source(), config).models as Array>; + + // Codex treats a custom openai_base_url as an API-compatible provider, filters out models + // unsupported by that API, sorts by priority, and exposes at most five spawn overrides. + const spawnOverrides = models + .filter(model => model.supported_in_api === true && model.visibility === "list") + .toSorted((left, right) => Number(left.priority) - Number(right.priority)) + .slice(0, 5) + .map(model => model.slug); + + expect(spawnOverrides).toEqual([ + "chatgpt-web/temporary/low", + "chatgpt-web/temporary/medium", + "chatgpt-web/temporary/high", + "chatgpt-web/temporary/extra-high", + "chatgpt-web/saved/low", + ]); + }); + + test("owns only its namespace, is idempotent, and omits Pro-only modes when unavailable", () => { const config = defaultConfig("browser-only"); config.proAvailable = false; const polluted = source(); (polluted.models as unknown[]).push( { slug: "chatgpt-web/gpt-5.6-sol", display_name: "legacy generic route" }, - { slug: "chatgpt-web/pro", display_name: "stale Pro route" }, + { slug: "chatgpt-web/pro", display_name: "stale legacy Pro route" }, ); const first = augmentNativeModelCatalog(polluted, config); const second = augmentNativeModelCatalog(first, config); @@ -89,7 +115,15 @@ describe("native /models augmentation", () => { CHATGPT_WEB_MODEL_ROUTES.filter(route => !route.requiresPro).map(route => route.slug), ); expect(web.every(model => model.tool_mode === null)).toBe(true); + expect(web.every(model => model.multi_agent_version === "v1")).toBe(true); expect(web.every(model => (model.supported_reasoning_levels as unknown[]).length === 1)).toBe(true); + expect(web.map(model => ({ + contextWindow: model.context_window, + autoCompactTokenLimit: model.auto_compact_token_limit, + }))).toEqual(Array.from({ length: 6 }, () => ({ + contextWindow: 225_000, + autoCompactTokenLimit: 202_500, + }))); }); test("honors an explicit Codex context override without replacing or reordering native models", () => { @@ -98,7 +132,7 @@ describe("native /models augmentation", () => { // model_context_window is one top-level Codex setting, so it must not depend on which model // the config's `model` line happens to name - that line can hold a ChatGPT Web slug. const result = augmentNativeModelCatalog(native, defaultConfig("full"), { - model: "chatgpt-web/medium", + model: "chatgpt-web/temporary/medium", contextWindow: 371_851, }); const models = result.models as Array>; @@ -112,9 +146,10 @@ describe("native /models augmentation", () => { ]); expect(models[1]!.context_window).toBe(300_000); for (const model of models.slice(3)) { - expect(model.context_window).toBe(CHATGPT_WEB_CONTEXT_WINDOW); - expect(model.max_context_window).toBe(CHATGPT_WEB_CONTEXT_WINDOW); - expect(model.auto_compact_token_limit).toBe(CHATGPT_WEB_AUTO_COMPACT_TOKEN_LIMIT); + const limits = resolveChatGptWebContextLimits(false); + expect(model.context_window).toBe(limits.contextWindow); + expect(model.max_context_window).toBe(limits.contextWindow); + expect(model.auto_compact_token_limit).toBe(limits.autoCompactTokenLimit); } }); @@ -145,7 +180,7 @@ describe("native /models augmentation", () => { const result = augmentNativeModelCatalog(native, defaultConfig("full")); const web = (result.models as Array>) .filter(model => String(model.slug).startsWith("chatgpt-web/")); - expect(web.length).toBe(4); + expect(web.length).toBe(6); expect(web.every(model => model.shell_type === "shell_command")).toBe(true); expect(web.every(model => model.tool_mode === "code_mode_only")).toBe(true); }); diff --git a/tests/model-contract.test.ts b/tests/model-contract.test.ts index 87319a8d9..cd1e71ff1 100644 --- a/tests/model-contract.test.ts +++ b/tests/model-contract.test.ts @@ -1,8 +1,13 @@ import { expect, test } from "bun:test"; -import { CHATGPT_WEB_MODEL_ID, resolveChatGptWebModelMode } from "../src/adapters/chatgpt-web/model"; +import { CHATGPT_WEB_MODEL_ID, chatGptWebLocalToolsAllowed, resolveChatGptWebModelMode } from "../src/adapters/chatgpt-web/model"; test("the browser adapter maps fixed routed efforts to the visible ChatGPT modes", () => { const capabilities = { localToolsEnabled: true, proAvailable: true }; + expect(resolveChatGptWebModelMode(CHATGPT_WEB_MODEL_ID, undefined, capabilities)).toMatchObject({ + effort: "xhigh", + displayLabel: "Extra High", + uiEffortIndex: 3, + }); expect(resolveChatGptWebModelMode(CHATGPT_WEB_MODEL_ID, "low", capabilities)).toMatchObject({ displayLabel: "Instant", uiEffortIndex: 0, @@ -26,7 +31,7 @@ test("the browser adapter maps fixed routed efforts to the visible ChatGPT modes }); }); -test("capabilities gate tools and Pro explicitly without changing the selected model", () => { +test("capabilities gate tools and Pro-only efforts explicitly without changing the selected model", () => { expect(resolveChatGptWebModelMode(CHATGPT_WEB_MODEL_ID, "high", { localToolsEnabled: false, proAvailable: true, @@ -35,6 +40,10 @@ test("capabilities gate tools and Pro explicitly without changing the selected m localToolsEnabled: false, proAvailable: false, })).toThrow("Pro effort is not available"); + expect(() => resolveChatGptWebModelMode(CHATGPT_WEB_MODEL_ID, "xhigh", { + localToolsEnabled: true, + proAvailable: false, + })).toThrow("Extra High effort is not available"); expect(() => resolveChatGptWebModelMode("unknown", "high", { localToolsEnabled: false, proAvailable: true, @@ -44,3 +53,10 @@ test("capabilities gate tools and Pro explicitly without changing the selected m proAvailable: true, })).toThrow("effort is not supported"); }); + +test("does not inherit danger-full-access into the Web GPT tool bridge", () => { + const capabilities = { localToolsEnabled: true, proAvailable: false }; + expect(chatGptWebLocalToolsAllowed(capabilities, "dangerFullAccess")).toBe(false); + expect(chatGptWebLocalToolsAllowed(capabilities, "readOnly")).toBe(false); + expect(chatGptWebLocalToolsAllowed(capabilities, "workspaceWrite")).toBe(true); +}); diff --git a/tests/native-passthrough.test.ts b/tests/native-passthrough.test.ts index 5e7b051b5..e3d117635 100644 --- a/tests/native-passthrough.test.ts +++ b/tests/native-passthrough.test.ts @@ -66,6 +66,31 @@ test("forwards native Codex compaction requests to the official compact endpoint expect(await response.json()).toEqual({ output: [] }); }); +test("forwards standalone Web Search through the authenticated native Codex route", async () => { + const body = JSON.stringify({ query: "Codex Web Search passthrough" }); + const request = new Request("http://127.0.0.1:17841/v1/alpha/search?locale=en", { + method: "POST", + headers: { + authorization: "Bearer codex-oauth-token", + "content-type": "application/json", + host: "127.0.0.1:17841", + }, + body, + }); + let upstreamRequest: Request | undefined; + const response = await forwardNativeCodexRequest(request, "alpha/search", async input => { + upstreamRequest = input; + return Response.json({ results: [{ title: "result" }] }); + }); + + expect(upstreamRequest!.url).toBe("https://chatgpt.com/backend-api/codex/alpha/search?locale=en"); + expect(upstreamRequest!.method).toBe("POST"); + expect(upstreamRequest!.headers.get("authorization")).toBe("Bearer codex-oauth-token"); + expect(upstreamRequest!.headers.get("host")).toBeNull(); + expect(await upstreamRequest!.text()).toBe(body); + expect(await response.json()).toEqual({ results: [{ title: "result" }] }); +}); + test("removes ChatGPT Web item identities before native Codex compaction", async () => { const body = { model: "gpt-5.6-sol", diff --git a/tests/project-boundary.test.ts b/tests/project-boundary.test.ts new file mode 100644 index 000000000..5493cfa32 --- /dev/null +++ b/tests/project-boundary.test.ts @@ -0,0 +1,94 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ChatGptTurnEnvironment } from "../src/adapters/chatgpt-web/environment"; +import { + assertProjectPatch, + assertProjectPath, + projectBoundCommand, + projectSandboxProfile, + scopeChatGptTurnEnvironment, +} from "../src/adapters/chatgpt-web/project-boundary"; + +const root = join(tmpdir(), `codex-web-project-boundary-${process.pid}-${Date.now()}`); +const outside = join(tmpdir(), `codex-web-project-outside-${process.pid}-${Date.now()}`); +mkdirSync(root, { recursive: true }); +mkdirSync(outside, { recursive: true }); +afterAll(() => { + rmSync(root, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); +}); + +function dangerEnvironment(): ChatGptTurnEnvironment { + return { + cwd: root, + roots: [root], + writableRoots: [root], + sandboxPolicy: { type: "dangerFullAccess" }, + tools: [], + }; +} + +describe("Web GPT project boundary", () => { + test("narrows a broader native task without changing its trusted project roots", () => { + expect(scopeChatGptTurnEnvironment(dangerEnvironment())).toEqual({ + cwd: root, + roots: [root], + writableRoots: [root], + sandboxPolicy: { type: "workspaceWrite", writableRoots: [root], networkAccess: false }, + tools: [], + enforceProjectBoundary: true, + }); + }); + + test("defaults commands to the bound cwd and wraps them in the macOS project sandbox", async () => { + const environment = scopeChatGptTurnEnvironment(dangerEnvironment()); + const bounded = await projectBoundCommand(environment, "pwd", undefined, "darwin"); + expect(bounded.workdir).toBe(root); + expect(bounded.command).toStartWith("/usr/bin/sandbox-exec -p "); + expect(bounded.command).toContain("/bin/zsh -lc 'pwd'"); + expect(projectSandboxProfile(environment)).toContain(`(subpath ${JSON.stringify(root)})`); + expect(projectSandboxProfile(environment)).not.toContain("(allow network-outbound)"); + }); + + test("fails closed when the project shell sandbox is unavailable", async () => { + const environment = scopeChatGptTurnEnvironment(dangerEnvironment()); + await expect(projectBoundCommand(environment, "pwd", undefined, "linux")) + .rejects.toThrow("currently supported on macOS only"); + await expect(projectBoundCommand(environment, "pwd", undefined, "win32")) + .rejects.toThrow("currently supported on macOS only"); + }); + + test("rejects explicit paths and patch targets outside the active project", async () => { + const environment = scopeChatGptTurnEnvironment(dangerEnvironment()); + await expect(assertProjectPath(environment, outside, "read")).rejects.toThrow("outside the active Codex project"); + await expect(assertProjectPatch(environment, `*** Begin Patch\n*** Update File: ${join(outside, "escape.txt")}\n@@\n-old\n+new\n*** End Patch`)) + .rejects.toThrow("outside the active Codex project"); + }); + + test("rejects a project symlink that resolves outside the project", async () => { + const environment = scopeChatGptTurnEnvironment(dangerEnvironment()); + const outsideFile = join(outside, "secret.txt"); + const link = join(root, "escape-link"); + writeFileSync(outsideFile, "secret"); + try { + symlinkSync(outsideFile, link); + await expect(assertProjectPath(environment, link, "read")).rejects.toThrow("symbolic link"); + } finally { + rmSync(link, { force: true }); + rmSync(outsideFile, { force: true }); + } + }); + + test("keeps an existing native workspace-write boundary unchanged", () => { + const environment: ChatGptTurnEnvironment = { + cwd: root, + roots: [root], + writableRoots: [root], + sandboxPolicy: { type: "workspaceWrite", writableRoots: [root], networkAccess: true }, + tools: [], + }; + expect(scopeChatGptTurnEnvironment(environment)).toBe(environment); + }); +}); diff --git a/tests/prompt-contract.test.ts b/tests/prompt-contract.test.ts index 16aedf74d..99ccec2b4 100644 --- a/tests/prompt-contract.test.ts +++ b/tests/prompt-contract.test.ts @@ -138,6 +138,41 @@ test("a long task keeps the newest images and drops the overflow instead of fail expect(compiled.text).toContain("step 13"); }); +test("Web compaction attaches the newest ten images as files and never embeds their base64 in prompt text", () => { + const imagePayloads = Array.from({ length: 13 }, (_unused, index) => + Buffer.from(`compaction-image-${index + 1}`).toString("base64")); + const parsed: CodexParsedRequest = { + modelId: CHATGPT_WEB_MODEL_ID, + context: { + systemPrompt: ["preserve-system"], + messages: imagePayloads.map((payload, index) => ({ + role: "user" as const, + content: [ + { type: "text" as const, text: `checkpoint ${index + 1}` }, + { type: "image" as const, imageUrl: `data:image/png;base64,${payload}` }, + ], + timestamp: index + 1, + })), + }, + stream: true, + options: { reasoning: "high" }, + _compactionRequest: true, + }; + + const compiled = compileChatGptWebPrompt( + parsed, + { localToolsEnabled: false, proAvailable: true }, + ); + + expect(compiled.images.map(image => image.imageUrl)).toEqual( + imagePayloads.slice(-10).map(payload => `data:image/png;base64,${payload}`), + ); + expect(compiled.text).not.toContain("data:image"); + for (const payload of imagePayloads) expect(compiled.text).not.toContain(payload); + expect(compiled.text.match(/"type":"image_attachment"/g)).toHaveLength(10); + expect(compiled.text.match(/older image not attached/g)).toHaveLength(3); +}); + test("the replayed context never carries a finished turn's broker handles", () => { const staleToken = "turn_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; const staleBinding = "binding_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; diff --git a/tests/server-compaction.test.ts b/tests/server-compaction.test.ts index d2d719663..a1e41bfd6 100644 --- a/tests/server-compaction.test.ts +++ b/tests/server-compaction.test.ts @@ -113,16 +113,21 @@ test("rejects an unknown routed compact model instead of treating it as ChatGPT expect(body.error.message).toContain("model is not enabled"); }); -test("rejects the Pro routed model before opening a browser when the account has no Pro access", async () => { - const response = await responseRequest(new Request("http://127.0.0.1:17841/v1/responses", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "chatgpt-web/pro", input: "test", stream: false }), - }), defaultConfig("browser-only")); +test("rejects Pro-only routed models before opening a browser when the account has no Pro access", async () => { + for (const [routedModel, label] of [ + ["chatgpt-web/temporary/extra-high", "Web / 임시 / 매우 높음"], + ["chatgpt-web/saved/pro", "Web / 저장 / Pro"], + ] as const) { + const response = await responseRequest(new Request("http://127.0.0.1:17841/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: routedModel, input: "test", stream: false }), + }), defaultConfig("browser-only")); - expect(response.status).toBe(400); - const body = await response.json() as { error: { message: string } }; - expect(body.error.message).toContain("Pro is not available for this account"); + expect(response.status).toBe(400); + const body = await response.json() as { error: { message: string } }; + expect(body.error.message).toContain(`${label} is not available for this account`); + } }); test("refuses a ChatGPT Web continuation when local previous-response state is unavailable", async () => { diff --git a/tests/server-lifecycle.test.ts b/tests/server-lifecycle.test.ts index ecd373701..bfd92c647 100644 --- a/tests/server-lifecycle.test.ts +++ b/tests/server-lifecycle.test.ts @@ -246,6 +246,36 @@ test("health proves that Codex received a successful augmented model catalog", a } }); +test("server exposes authenticated standalone Web Search on the routed v1 base URL", async () => { + const config = { ...defaultConfig("browser-only"), port: 0 }; + let upstreamRequest: Request | undefined; + const server = startServer(config, { + fetchUpstream: async request => { + upstreamRequest = request; + return Response.json({ results: ["native-search-result"] }); + }, + }); + const endpoint = `http://127.0.0.1:${server.port}`; + try { + const response = await fetch(`${endpoint}/v1/alpha/search`, { + method: "POST", + headers: { + authorization: "Bearer test-codex-session", + "content-type": "application/json", + }, + body: JSON.stringify({ query: "bridge route" }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ results: ["native-search-result"] }); + expect(upstreamRequest!.url).toBe("https://chatgpt.com/backend-api/codex/alpha/search"); + expect(upstreamRequest!.headers.get("authorization")).toBe("Bearer test-codex-session"); + expect(await upstreamRequest!.json()).toEqual({ query: "bridge route" }); + } finally { + await server.stop(true); + } +}); + test("authenticated shutdown requires a verified idle drain", async () => { const config = { ...defaultConfig("browser-only"), port: 0 }; const server = startServer(config); diff --git a/tests/server-models.test.ts b/tests/server-models.test.ts index 137f3f9a5..b3e146cdb 100644 --- a/tests/server-models.test.ts +++ b/tests/server-models.test.ts @@ -1,6 +1,9 @@ import { expect, test } from "bun:test"; import { defaultConfig } from "../src/config"; -import { CHATGPT_WEB_AUTO_COMPACT_TOKEN_LIMIT, CHATGPT_WEB_CONTEXT_WINDOW } from "../src/model-catalog"; +import { + CHATGPT_WEB_MODEL_PRIORITY, +} from "../src/model-catalog"; +import { resolveChatGptWebContextLimits } from "../src/chatgpt-web-models"; import { modelsRequest } from "../src/server"; test("proxies official /models auth and query, then appends the fixed ChatGPT Web models", async () => { @@ -35,20 +38,30 @@ test("proxies official /models auth and query, then appends the fixed ChatGPT We context_window?: number; max_context_window?: number; auto_compact_token_limit?: number; + supported_in_api?: boolean; + priority?: number; }>; }; expect(body.models.map(model => model.slug)).toEqual([ "gpt-5.6-sol", - "chatgpt-web/light", - "chatgpt-web/medium", - "chatgpt-web/high", - "chatgpt-web/extra-high", - "chatgpt-web/pro", + "chatgpt-web/temporary/low", + "chatgpt-web/temporary/medium", + "chatgpt-web/temporary/high", + "chatgpt-web/temporary/extra-high", + "chatgpt-web/saved/low", + "chatgpt-web/saved/medium", + "chatgpt-web/saved/high", + "chatgpt-web/saved/extra-high", + "chatgpt-web/temporary/pro", + "chatgpt-web/saved/pro", ]); expect(body.models[0]!.max_context_window).toBe(371_851); for (const model of body.models.slice(1)) { - expect(model.context_window).toBe(CHATGPT_WEB_CONTEXT_WINDOW); - expect(model.max_context_window).toBe(CHATGPT_WEB_CONTEXT_WINDOW); - expect(model.auto_compact_token_limit).toBe(CHATGPT_WEB_AUTO_COMPACT_TOKEN_LIMIT); + const limits = resolveChatGptWebContextLimits(true); + expect(model.context_window).toBe(limits.contextWindow); + expect(model.max_context_window).toBe(limits.contextWindow); + expect(model.auto_compact_token_limit).toBe(limits.autoCompactTokenLimit); + expect(model.supported_in_api).toBe(true); + expect(model.priority).toBe(CHATGPT_WEB_MODEL_PRIORITY); } }); diff --git a/tests/turn-broker-lifecycle.test.ts b/tests/turn-broker-lifecycle.test.ts index d6102325d..99aea5ad9 100644 --- a/tests/turn-broker-lifecycle.test.ts +++ b/tests/turn-broker-lifecycle.test.ts @@ -51,7 +51,7 @@ test("session cache expiry never cancels a still-active long browser turn", asyn sessions.clear(); }); -test("a starting turn reclaims the abandoned turn whose surface it takes over", () => { +test("five active turns coexist and a sixth fails closed", () => { const sessions = new ChatGptTurnSessions(); let cancelled = 0; const runtime = () => ({ @@ -62,20 +62,19 @@ test("a starting turn reclaims the abandoned turn whose surface it takes over", cancel: () => { cancelled += 1; }, }); - // Parked between tool batches: stopping the task leaves no request to abort. - sessions.getOrCreate("stopped-turn", runtime); - expect(sessions.activeCount()).toBe(1); - - sessions.getOrCreate("next-turn", runtime); - expect(cancelled).toBe(1); - expect(sessions.activeCount()).toBe(1); + const active = Array.from({ length: 5 }, (_unused, index) => ( + sessions.getOrCreate(`turn-${index + 1}`, runtime) + )); + expect(sessions.activeCount()).toBe(5); + expect(cancelled).toBe(0); + expect(() => sessions.getOrCreate("turn-6", runtime)).toThrow("at most 5 simultaneous browser turns"); - // Resuming the same turn is never a takeover. - sessions.getOrCreate("next-turn", () => { + expect(sessions.getOrCreate("turn-3", () => { throw new Error("an in-flight turn must be reused"); - }); - expect(cancelled).toBe(1); + })).toBe(active[2]); + expect(cancelled).toBe(0); sessions.clear(); + expect(cancelled).toBe(5); }); test("settled replay sessions expire from their last use instead of their creation time", async () => { @@ -125,6 +124,27 @@ test("turn broker creates its private runtime directory on a cold start", async } }); +test("turn broker tokens do not expire while their browser turn is still alive", async () => { + const root = mkdtempSync(join(tmpdir(), "cgw-broker-unbounded-")); + const socketPath = defaultBrokerEndpoint(root); + const broker = TurnBroker.forSocket(socketPath); + try { + const token = await broker.register({ + cwd: root, + roots: [root], + writableRoots: [root], + sandboxPolicy: { type: "dangerFullAccess" }, + tools: [], + }); + await Bun.sleep(5); + await expect(callTurnBroker<{ bindingId: string }>(socketPath, { method: "claim", token })) + .resolves.toMatchObject({ bindingId: expect.any(String) }); + } finally { + await broker.close(); + rmSync(root, { recursive: true, force: true }); + } +}); + test("turn broker names the finished turn that owns a replayed handle", async () => { const root = mkdtempSync(join(tmpdir(), "cgw-broker-")); const socketPath = defaultBrokerEndpoint(root);