From d3049cb76ff9cc3d6a073e82ebb9080ea456c797 Mon Sep 17 00:00:00 2001 From: vechen <147659719+miuuyy@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:36:03 +0300 Subject: [PATCH 01/23] Enable Pro subagent spawning --- launcher/package.json | 2 +- package.json | 2 +- scripts/install.sh | 2 +- src/model-catalog.ts | 5 ++++- src/version.ts | 2 +- tests/model-catalog.test.ts | 2 +- 6 files changed, 9 insertions(+), 6 deletions(-) diff --git a/launcher/package.json b/launcher/package.json index 80e7c7c9b..d0a66b881 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -1,6 +1,6 @@ { "name": "codex-web-gpt-launcher", - "version": "1.0.1", + "version": "1.0.2", "private": true, "description": "Desktop control center for Codex ChatGPT Web", "author": "miuuyy", diff --git a/package.json b/package.json index d44bcd5d2..9b8eaa089 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-chatgpt-web", - "version": "1.0.1", + "version": "1.0.2", "private": true, "description": "A focused local Responses bridge that runs Codex tasks through a user-authenticated ChatGPT web session.", "repository": { diff --git a/scripts/install.sh b/scripts/install.sh index 022c12b44..eb07e981a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ set -eu REPOSITORY="${CODEX_CHATGPT_WEB_REPOSITORY:-miuuyy/codex-chatgpt-web}" -VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.0.1}" +VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.0.2}" 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/src/model-catalog.ts b/src/model-catalog.ts index 8082a28c8..d58180fbc 100644 --- a/src/model-catalog.ts +++ b/src/model-catalog.ts @@ -74,7 +74,10 @@ export function buildChatGptWebModel( input_modalities: ["text", "image"], visibility: "list", supported_in_api: false, - tool_mode: config.mode === "full" && !route.requiresPro ? template.tool_mode : null, + // 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)], diff --git a/src/version.ts b/src/version.ts index 4d8b1baa7..956106291 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.0.1"; +export const VERSION = "1.0.2"; diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index ca45773c7..30370b3a2 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -59,7 +59,7 @@ describe("native /models augmentation", () => { 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, From 2dfc791a08b8b98ded98712f6d816e91dcda12da Mon Sep 17 00:00:00 2001 From: vechen <147659719+miuuyy@users.noreply.github.com> Date: Sun, 2 Aug 2026 07:40:18 +0300 Subject: [PATCH 02/23] Support parallel ChatGPT Web task tabs Add five task-bound browser surfaces, V1 Web subagent routing, and complete multi-root response rendering. --- README.md | 3 +- README.zh-CN.md | 3 +- docs/architecture.md | 19 +- docs/security-model.md | 8 +- launcher/electron/browser-host.cjs | 297 ++++++++++++++++++--- launcher/electron/control-server.cjs | 4 +- launcher/electron/main.cjs | 2 + launcher/electron/preload.cjs | 2 + launcher/package.json | 2 +- launcher/src/App.tsx | 66 ++++- launcher/src/i18n.ts | 6 +- launcher/src/styles.css | 4 +- launcher/src/types.ts | 15 ++ launcher/tests/browser-host.test.cjs | 129 ++++++++- launcher/tests/control-server.test.cjs | 5 +- package.json | 2 +- scripts/install.sh | 2 +- src/adapters/chatgpt-web/browser-worker.ts | 221 ++++++++------- src/adapters/chatgpt-web/concurrency.ts | 6 + src/adapters/chatgpt-web/index.ts | 7 + src/adapters/chatgpt-web/turn-execution.ts | 22 +- src/launcher-browser-host.ts | 23 +- src/model-catalog.ts | 4 + src/responses/parser.ts | 14 + src/responses/schema.ts | 12 + src/types.ts | 5 + src/version.ts | 2 +- tests/browser-worker-contract.test.ts | 66 +++-- tests/chatgpt-web-harness.test.ts | 41 +++ tests/launcher-browser-host.test.ts | 10 +- tests/model-catalog.test.ts | 3 + tests/turn-broker-lifecycle.test.ts | 23 +- 32 files changed, 805 insertions(+), 223 deletions(-) create mode 100644 src/adapters/chatgpt-web/concurrency.ts diff --git a/README.md b/README.md index 310c98750..f47119865 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 5cad1df46..5ccaa64b1 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/docs/architecture.md b/docs/architecture.md index 9450278b6..df16a9384 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 ▲ @@ -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 88a86cbb2..c5f677d47 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"]', @@ -129,8 +129,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; @@ -170,6 +171,142 @@ class BrowserHost { 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 +364,37 @@ class BrowserHost { snapshot() { const contents = this.activeView()?.webContents; - return readBrowserNavigationState(contents, { - ...this.state, + const selected = this.selectedTurnTab(); + 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.turnTabs.values()].map((tab) => this.tabSnapshot(tab)) + : [{ + id: "home", + traceId: null, + title: this.state.title || "ChatGPT", + status: this.state.status, + loading: this.state.loading === true, + active: true, + closable: false, + }], + maxTabs: MAX_BROWSER_TABS, + }; } setState(patch) { @@ -249,6 +412,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 +422,50 @@ class BrowserHost { } activeView() { - return this.authView || this.view; + return this.authView || this.selectedTurnTab()?.view || this.view; } 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(); + } + + closeTab(tabId) { + const tab = this.turnTabs.get(tabId); + if (!tab) throw new Error("Browser tab does not exist"); + this.turnTabs.delete(tabId); + if (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 === tabId) { + this.selectedTabId = [...this.turnTabs.keys()].at(-1) || "home"; + } + this.syncViewVisibility(); + this.publishState?.(this.snapshot()); + this.writeDescriptor(); + 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 +569,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 +616,59 @@ 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 (hideAfterTurn && !this.activeTraceId) this.hide(); 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 }); } + this.publishState?.(this.snapshot()); } async returnToIdle() { @@ -1153,6 +1359,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(); } } 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 e40274d2e..d1cf3d8f3 100644 --- a/launcher/electron/main.cjs +++ b/launcher/electron/main.cjs @@ -377,6 +377,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..2f4de259f 100644 --- a/launcher/electron/preload.cjs +++ b/launcher/electron/preload.cjs @@ -17,6 +17,8 @@ contextBridge.exposeInMainWorld("codexWebLauncher", { 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/package.json b/launcher/package.json index d0a66b881..ce578ccac 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -1,6 +1,6 @@ { "name": "codex-web-gpt-launcher", - "version": "1.0.2", + "version": "1.1.0", "private": true, "description": "Desktop control center for Codex ChatGPT Web", "author": "miuuyy", diff --git a/launcher/src/App.tsx b/launcher/src/App.tsx index 4df3b52e8..4440eff10 100644 --- a/launcher/src/App.tsx +++ b/launcher/src/App.tsx @@ -596,7 +596,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); @@ -612,20 +611,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} +
+ ))}
@@ -1633,12 +1664,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 56aed6946..bb910dcff 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.", @@ -155,7 +156,8 @@ const zh: Record = { hideSidebar: "隐藏侧边栏", showSidebar: "显示侧边栏", resizeSidebar: "调整侧边栏宽度", - hideTab: "隐藏浏览器", + hideTab: "关闭标签页", + browserTabLimit: "最多可同时运行五个 ChatGPT Web 标签页,以避免向您的 ChatGPT 帐户发送过多并行流量。", browserAddress: "ChatGPT 浏览器", noActiveTask: "当前没有任务", noActiveTaskBody: "当 Codex 启动 Web 模型任务时,ChatGPT 会显示在这里。", diff --git a/launcher/src/styles.css b/launcher/src/styles.css index 8aea0f49a..9da685404 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; diff --git a/launcher/src/types.ts b/launcher/src/types.ts index 7f3ba0b9e..c08da9fae 100644 --- a/launcher/src/types.ts +++ b/launcher/src/types.ts @@ -34,6 +34,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 { @@ -92,6 +105,8 @@ export interface LauncherApi { 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 054f4b394..83333a699 100644 --- a/launcher/tests/browser-host.test.cjs +++ b/launcher/tests/browser-host.test.cjs @@ -698,9 +698,14 @@ test("manual browser operations disable background throttling until completion", }); 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", @@ -710,3 +715,125 @@ 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 = { + 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 () => { + const ended = { + id: "tab-ended", + traceId: "trace_ended", + helperPid: 555, + status: "running", + loading: true, + view: { webContents: { isDestroyed: () => false, setBackgroundThrottling() {} } }, + }; + 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(), + 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(active.status, "running"); + assert.equal(fixture.activeTraceId, active.traceId); +}); 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/package.json b/package.json index 9b8eaa089..524977755 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-chatgpt-web", - "version": "1.0.2", + "version": "1.1.0", "private": true, "description": "A focused local Responses bridge that runs Codex tasks through a user-authenticated ChatGPT web session.", "repository": { diff --git a/scripts/install.sh b/scripts/install.sh index eb07e981a..1535edf93 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ set -eu REPOSITORY="${CODEX_CHATGPT_WEB_REPOSITORY:-miuuyy/codex-chatgpt-web}" -VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.0.2}" +VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.1.0}" 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/src/adapters/chatgpt-web/browser-worker.ts b/src/adapters/chatgpt-web/browser-worker.ts index 1f3ac4019..07a19725f 100644 --- a/src/adapters/chatgpt-web/browser-worker.ts +++ b/src/adapters/chatgpt-web/browser-worker.ts @@ -24,6 +24,9 @@ import { import { loginVerificationMarkerPath } from "../../browser-login"; import { connectLauncherBrowserHost, notifyLauncherTurn } from "../../launcher-browser-host"; import { LauncherBrowserHelperClient } from "./launcher-helper-client"; +import { MAX_CHATGPT_BROWSER_TABS } from "./concurrency"; + +export { MAX_CHATGPT_BROWSER_TABS } from "./concurrency"; const workers = new Map(); @@ -43,7 +46,6 @@ 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; - /** * 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. @@ -215,18 +217,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 +231,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. + // ChatGptMarkdownStream 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 +244,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; } @@ -374,24 +347,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,40 +392,26 @@ 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 { const startedAt = performance.now(); console.info(`[chatgpt-web] browser turn ${traceId} stage=${stage} started`); 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}`)); }, timeoutMs); }); @@ -443,7 +420,6 @@ export class ChatGptBrowserWorker { 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 +450,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( @@ -813,7 +808,13 @@ export class ChatGptBrowserWorker { && rect.height > 0; }; - const rendered = [...root.querySelectorAll(".markdown")].at(-1); + // 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 renderedChildren = rendered ? [...rendered.children] : []; const completionAction = rendered ? [...root.querySelectorAll(completionActionSelector)] @@ -823,7 +824,7 @@ 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")); root.querySelectorAll( 'button, [role="status"], [aria-busy="true"], [data-testid*="cot"], [data-testid*="reason"], [data-testid*="thought"]', ).forEach(candidate => { @@ -848,9 +849,12 @@ 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(""), + stableHtml: [ + ...renderedRoots.slice(0, -1).map(candidate => candidate.innerHTML), + ...renderedChildren.slice(0, -1).map(child => child.outerHTML), + ].join(""), completionActionVisible: completionAction !== undefined, traceBlocks, }; @@ -908,16 +912,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,14 +947,26 @@ 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()); + const page = await this.runStage(turn.traceId, "browser_page", browserStageTimeouts.browserPage, async () => { + if (!launcherSurfaceId) return await this.pageForNewTurn(); + const connection = await connectLauncherBrowserHost( + this.config.browserHostDescriptorPath!, + browserStageTimeouts.browserPage, + launcherSurfaceId, + ); + 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})`, ); @@ -1043,8 +1061,8 @@ 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. + // Commit only when ChatGPT exposes response-scoped completion actions, but keep every + // top-level Markdown root in that response as one final-answer stream. if (snapshot.completionActionVisible) { const stableDelta = markdownStream.observeStableHtml(snapshot.stableHtml); if (stableDelta) turn.onTextDelta(stableDelta); @@ -1096,6 +1114,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/index.ts b/src/adapters/chatgpt-web/index.ts index 6e26dc268..b9e6e8e74 100644 --- a/src/adapters/chatgpt-web/index.ts +++ b/src/adapters/chatgpt-web/index.ts @@ -248,6 +248,13 @@ 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) { diff --git a/src/adapters/chatgpt-web/turn-execution.ts b/src/adapters/chatgpt-web/turn-execution.ts index e9d66571a..6030e71d1 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 } @@ -254,27 +255,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/launcher-browser-host.ts b/src/launcher-browser-host.ts index e262970d3..20288b2ed 100644 --- a/src/launcher-browser-host.ts +++ b/src/launcher-browser-host.ts @@ -147,6 +147,7 @@ export async function selectLauncherPage( browser: Browser, descriptor: LauncherBrowserHostDescriptor, timeoutMs: number, + surfaceId = descriptor.surfaceId, ): Promise<{ context: BrowserContext; page: Page }> { const deadline = Date.now() + timeoutMs; do { @@ -158,7 +159,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,6 +174,7 @@ export async function selectLauncherPage( export async function connectLauncherBrowserHost( descriptorPath: string, timeoutMs = 20_000, + surfaceId?: string, ): Promise { const descriptor = readLauncherBrowserHostDescriptor(descriptorPath); await assertCdpReady(descriptor, Math.min(timeoutMs, 5_000)); @@ -182,8 +184,13 @@ 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 }; + try { + const { context, page } = await selectLauncherPage(browser, descriptor, timeoutMs, surfaceId); + return { descriptor, browser, context, page }; + } catch (error) { + await browser.close().catch(() => {}); + throw error; + } } export async function inspectLauncherBrowserHost( @@ -238,7 +245,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 +263,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 d58180fbc..61a0177f6 100644 --- a/src/model-catalog.ts +++ b/src/model-catalog.ts @@ -74,6 +74,10 @@ export function buildChatGptWebModel( input_modalities: ["text", "image"], visibility: "list", supported_in_api: false, + // 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. 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/types.ts b/src/types.ts index 6c5f353b9..ab963c39c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,6 +36,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 { diff --git a/src/version.ts b/src/version.ts index 956106291..f5da58116 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.0.2"; +export const VERSION = "1.1.0"; diff --git a/tests/browser-worker-contract.test.ts b/tests/browser-worker-contract.test.ts index 11660a29b..23be5bdb6 100644 --- a/tests/browser-worker-contract.test.ts +++ b/tests/browser-worker-contract.test.ts @@ -1,6 +1,6 @@ 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 { ChatGptBrowserWorker, ChatGptTurnDomHealthTracker, ChatGptVisibleTraceTracker, MAX_CHATGPT_BROWSER_TABS, chatGptSubmissionEvidence, isChatGptTraceControl, redactChatGptUiDiagnostic } 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 +18,40 @@ 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("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); @@ -398,7 +432,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 +449,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 +458,31 @@ 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('...renderedRoots.slice(0, -1).map(candidate => candidate.innerHTML)'); + 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-web-harness.test.ts b/tests/chatgpt-web-harness.test.ts index 1ae5f8d7e..f3bce9ba0 100644 --- a/tests/chatgpt-web-harness.test.ts +++ b/tests/chatgpt-web-harness.test.ts @@ -17,6 +17,7 @@ import { callTurnBroker, TurnBroker, type BrokerToolResult } from "../src/adapte 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()}`); @@ -101,6 +102,46 @@ 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({ diff --git a/tests/launcher-browser-host.test.ts b/tests/launcher-browser-host.test.ts index 4d963f861..0ea11bffa 100644 --- a/tests/launcher-browser-host.test.ts +++ b/tests/launcher-browser-host.test.ts @@ -71,7 +71,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 +83,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, { diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index 30370b3a2..e883df4fd 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -19,6 +19,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" }, @@ -62,6 +63,7 @@ describe("native /models augmentation", () => { tool_mode: "code_mode_only", default_reasoning_level: route.codexEffort, supported_reasoning_levels: [{ effort: route.codexEffort, description: route.displayName }], + multi_agent_version: "v1", context_window: CHATGPT_WEB_CONTEXT_WINDOW, max_context_window: CHATGPT_WEB_CONTEXT_WINDOW, auto_compact_token_limit: CHATGPT_WEB_AUTO_COMPACT_TOKEN_LIMIT, @@ -89,6 +91,7 @@ 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); }); diff --git a/tests/turn-broker-lifecycle.test.ts b/tests/turn-broker-lifecycle.test.ts index d6102325d..043057c93 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 () => { From 2441ff6bf61f81e4cd2240b206852802626ec399 Mon Sep 17 00:00:00 2001 From: vechen <147659719+miuuyy@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:19:25 +0300 Subject: [PATCH 03/23] Stabilize parallel browser turn lifecycle --- launcher/electron/browser-host.cjs | 35 ++++++++++---- launcher/tests/browser-host.test.cjs | 53 ++++++++++++++++++++++ src/adapters/chatgpt-web/browser-worker.ts | 37 +++++++++++++-- src/launcher-browser-host.ts | 23 +++++++++- tests/browser-worker-contract.test.ts | 42 +++++++++++++++++ tests/launcher-browser-host.test.ts | 17 +++++++ 6 files changed, 191 insertions(+), 16 deletions(-) diff --git a/launcher/electron/browser-host.cjs b/launcher/electron/browser-host.cjs index c5f677d47..5f4b5fac2 100644 --- a/launcher/electron/browser-host.cjs +++ b/launcher/electron/browser-host.cjs @@ -365,6 +365,15 @@ class BrowserHost { snapshot() { const contents = this.activeView()?.webContents; 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, @@ -383,16 +392,11 @@ class BrowserHost { }), activeTabId: this.selectedTabId, tabs: this.turnTabs.size > 0 - ? [...this.turnTabs.values()].map((tab) => this.tabSnapshot(tab)) - : [{ - id: "home", - traceId: null, - title: this.state.title || "ChatGPT", - status: this.state.status, - loading: this.state.loading === true, - active: true, - closable: false, - }], + ? [ + ...(this.selectedTabId === "home" ? [homeTab] : []), + ...[...this.turnTabs.values()].map((tab) => this.tabSnapshot(tab)), + ] + : [homeTab], maxTabs: MAX_BROWSER_TABS, }; } @@ -425,6 +429,14 @@ class BrowserHost { 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); const selected = this.selectedTurnTab(); @@ -685,10 +697,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; } @@ -1322,6 +1336,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); diff --git a/launcher/tests/browser-host.test.cjs b/launcher/tests/browser-host.test.cjs index 83333a699..a8f6bb1c7 100644 --- a/launcher/tests/browser-host.test.cjs +++ b/launcher/tests/browser-host.test.cjs @@ -161,6 +161,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,9 +679,11 @@ 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 fixture = { activeTraceId: null, manualOperation: null, + activateHomeSurface: () => surfaces.push("home"), setState() {}, view: { webContents: { @@ -693,10 +696,60 @@ 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.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("a stale helper cannot end a replacement turn with the same trace id", async () => { const turnTabs = new Map([["tab-1", { id: "tab-1", diff --git a/src/adapters/chatgpt-web/browser-worker.ts b/src/adapters/chatgpt-web/browser-worker.ts index 07a19725f..252cf8b82 100644 --- a/src/adapters/chatgpt-web/browser-worker.ts +++ b/src/adapters/chatgpt-web/browser-worker.ts @@ -405,17 +405,24 @@ export class ChatGptBrowserWorker { if (browser) await browser.close(); } - 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; try { const timeout = new Promise((_, rejectTimeout) => { timer = setTimeout(() => { 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) { @@ -858,7 +865,12 @@ export class ChatGptBrowserWorker { 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; } @@ -956,13 +968,25 @@ export class ChatGptBrowserWorker { 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, async () => { - if (!launcherSurfaceId) return await this.pageForNewTurn(); + 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; }); @@ -1029,6 +1053,9 @@ export class ChatGptBrowserWorker { 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(() => {}); diff --git a/src/launcher-browser-host.ts b/src/launcher-browser-host.ts index 20288b2ed..821485dfd 100644 --- a/src/launcher-browser-host.ts +++ b/src/launcher-browser-host.ts @@ -148,9 +148,13 @@ export async function selectLauncherPage( 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, @@ -175,7 +179,11 @@ 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; @@ -184,12 +192,25 @@ 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 closeOnAbort = () => { void browser.close().catch(() => {}); }; + abortSignal?.addEventListener("abort", closeOnAbort, { once: true }); try { - const { context, page } = await selectLauncherPage(browser, descriptor, timeoutMs, surfaceId); + 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); } } diff --git a/tests/browser-worker-contract.test.ts b/tests/browser-worker-contract.test.ts index 23be5bdb6..1de2cae4c 100644 --- a/tests/browser-worker-contract.test.ts +++ b/tests/browser-worker-contract.test.ts @@ -52,6 +52,48 @@ test("browser turns run concurrently up to the five-tab limit", async () => { await Promise.all([...active.slice(1), sixth]); }); +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); diff --git a/tests/launcher-browser-host.test.ts b/tests/launcher-browser-host.test.ts index 0ea11bffa..918755ede 100644 --- a/tests/launcher-browser-host.test.ts +++ b/tests/launcher-browser-host.test.ts @@ -186,3 +186,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" }); +}); From e32b24e74afd755b31022b56fc981e6b4a357de6 Mon Sep 17 00:00:00 2001 From: vechen <147659719+miuuyy@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:18:13 +0300 Subject: [PATCH 04/23] Support Windows PowerShell 5.1 installer --- .github/workflows/ci.yml | 12 ++++++++++++ launcher/tests/packaging-contract.test.cjs | 2 ++ scripts/install-launcher.ps1 | 7 +++---- 3 files changed, 17 insertions(+), 4 deletions(-) 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/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/scripts/install-launcher.ps1 b/scripts/install-launcher.ps1 index 5cf803735..f4dfadb99 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" From ba24327396dffecebbcbb351d22cfc79d22847b2 Mon Sep 17 00:00:00 2001 From: vechen <147659719+miuuyy@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:42:52 +0300 Subject: [PATCH 05/23] Release 1.1.1 --- launcher/electron/browser-host.cjs | 25 +- launcher/package.json | 2 +- launcher/tests/browser-host.test.cjs | 98 ++- package.json | 2 +- scripts/install.sh | 2 +- src/adapters/chatgpt-web/browser-worker.ts | 55 +- src/adapters/chatgpt-web/environment.ts | 9 +- src/adapters/chatgpt-web/index.ts | 10 +- src/adapters/chatgpt-web/markdown.ts | 39 +- src/adapters/chatgpt-web/turn-broker.ts | 21 +- src/codex-integration.ts | 704 +++++++++++++++++++-- src/model-catalog.ts | 7 +- src/native-passthrough.ts | 2 +- src/responses/compaction.ts | 114 +++- src/server.ts | 15 + src/types.ts | 2 +- src/version.ts | 2 +- tests/browser-worker-contract.test.ts | 22 +- tests/chatgpt-web-harness.test.ts | 18 +- tests/codex-integration.test.ts | 201 +++++- tests/compaction-v1.test.ts | 40 ++ tests/environment.test.ts | 67 +- tests/model-catalog.test.ts | 19 + tests/native-passthrough.test.ts | 25 + tests/prompt-contract.test.ts | 35 + tests/server-lifecycle.test.ts | 30 + tests/server-models.test.ts | 10 +- tests/turn-broker-lifecycle.test.ts | 21 + 28 files changed, 1432 insertions(+), 165 deletions(-) create mode 100644 tests/compaction-v1.test.ts diff --git a/launcher/electron/browser-host.cjs b/launcher/electron/browser-host.cjs index 5f4b5fac2..003a71409 100644 --- a/launcher/electron/browser-host.cjs +++ b/launcher/electron/browser-host.cjs @@ -458,22 +458,26 @@ class BrowserHost { return this.snapshot(); } - closeTab(tabId) { - const tab = this.turnTabs.get(tabId); - if (!tab) throw new Error("Browser tab does not exist"); - this.turnTabs.delete(tabId); - if (tab.status === "running") { + 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 === tabId) { + 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(); } @@ -676,11 +680,16 @@ class BrowserHost { tab.message = status === "completed" ? "Task completed" : message || `ChatGPT turn ${status}`; tab.loading = false; if (!tab.view.webContents.isDestroyed()) tab.view.webContents.setBackgroundThrottling(true); - if (hideAfterTurn && !this.activeTraceId) this.hide(); if (status === "completed") { this.logger.info("browser.tab_completed", { tabId: tab.id, traceId }); } - this.publishState?.(this.snapshot()); + // 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() { diff --git a/launcher/package.json b/launcher/package.json index ce578ccac..5716989ad 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -1,6 +1,6 @@ { "name": "codex-web-gpt-launcher", - "version": "1.1.0", + "version": "1.1.1", "private": true, "description": "Desktop control center for Codex ChatGPT Web", "author": "miuuyy", diff --git a/launcher/tests/browser-host.test.cjs b/launcher/tests/browser-host.test.cjs index a8f6bb1c7..06a9f61ab 100644 --- a/launcher/tests/browser-host.test.cjs +++ b/launcher/tests/browser-host.test.cjs @@ -750,6 +750,40 @@ test("selected home surface remains represented while task tabs are retained", ( 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", @@ -780,7 +814,7 @@ test("closing a running browser tab preserves ownership until its helper reports webContents: { isDestroyed: () => false, close: () => closed.push("contents") }, }, }; - const fixture = { + const fixture = Object.assign(Object.create(BrowserHost.prototype), { turnTabs: new Map([[tab.id, tab]]), closedTurnOwners: new Map(), selectedTabId: tab.id, @@ -790,7 +824,7 @@ test("closing a running browser tab preserves ownership until its helper reports publishState() {}, writeDescriptor() {}, logger: { info() {} }, - }; + }); BrowserHost.prototype.closeTab.call(fixture, tab.id); @@ -853,13 +887,15 @@ test("five browser tabs are a hard account-safety limit", () => { }); 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() {} } }, + view: { webContents: { isDestroyed: () => false, setBackgroundThrottling() {}, close: () => { closedViews += 1; } } }, }; const active = { id: "tab-active", @@ -872,6 +908,13 @@ test("ending one browser turn does not stop another running tab", async () => { 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"), @@ -887,6 +930,55 @@ test("ending one browser turn does not stop another running tab", async () => { ); 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/package.json b/package.json index 524977755..b512f147b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-chatgpt-web", - "version": "1.1.0", + "version": "1.1.1", "private": true, "description": "A focused local Responses bridge that runs Codex tasks through a user-authenticated ChatGPT web session.", "repository": { diff --git a/scripts/install.sh b/scripts/install.sh index 1535edf93..0b86678b1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ set -eu REPOSITORY="${CODEX_CHATGPT_WEB_REPOSITORY:-miuuyy/codex-chatgpt-web}" -VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.1.0}" +VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.1.1}" 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/src/adapters/chatgpt-web/browser-worker.ts b/src/adapters/chatgpt-web/browser-worker.ts index 252cf8b82..08a1fe34c 100644 --- a/src/adapters/chatgpt-web/browser-worker.ts +++ b/src/adapters/chatgpt-web/browser-worker.ts @@ -4,7 +4,7 @@ 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 { CHATGPT_INTERNAL_COMPACTION_MARKER, CHATGPT_MAX_INPUT_IMAGES, containsChatGptCompactionMarker, stripChatGptTransportMarkers, type CompiledChatGptWebPrompt, type ChatGptWebPromptImage } from "./prompt"; import { estimateCompiledChatGptWebInputTokens } from "./usage"; @@ -42,7 +42,6 @@ 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; @@ -89,7 +88,7 @@ export interface ResolvedBrowserConfig { browserHostDescriptorPath?: string; storageStatePath: string; chromeExecutablePath: string; - turnTimeoutMs: number; + turnTimeoutMs?: number; headed: boolean; autoApproveToolCalls: boolean; } @@ -200,7 +199,6 @@ interface ChatGptResponseDomSnapshot { responsePresent: boolean; visibleText: string; fullHtml: string; - stableHtml: string; completionActionVisible: boolean; traceBlocks: ChatGptVisibleTraceBlock[]; } @@ -209,7 +207,6 @@ const absentResponseDomSnapshot = (): ChatGptResponseDomSnapshot => ({ responsePresent: false, visibleText: "", fullHtml: "", - stableHtml: "", completionActionVisible: false, traceBlocks: [], }); @@ -234,7 +231,7 @@ export class ChatGptVisibleTraceTracker { // 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. - // ChatGptMarkdownStream owns all Markdown roots; this tracker owns status/reasoning only. + // 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") @@ -278,20 +275,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, }; @@ -822,7 +824,6 @@ export class ChatGptBrowserWorker { .filter(candidate => !candidate.parentElement?.closest(".markdown")) .filter(visible); const rendered = renderedRoots.at(-1); - const renderedChildren = rendered ? [...rendered.children] : []; const completionAction = rendered ? [...root.querySelectorAll(completionActionSelector)] .filter(visible) @@ -832,15 +833,24 @@ export class ChatGptBrowserWorker { const completionActionSet = new Set(completionAction ? [completionAction] : []); const candidates = new Map(); 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"); } }); @@ -858,10 +868,6 @@ export class ChatGptBrowserWorker { responsePresent: true, visibleText: renderedRoots.map(candidate => candidate.innerText.trim()).filter(Boolean).join("\n\n"), fullHtml: renderedRoots.map(candidate => candidate.innerHTML).join(""), - stableHtml: [ - ...renderedRoots.slice(0, -1).map(candidate => candidate.innerHTML), - ...renderedChildren.slice(0, -1).map(child => child.outerHTML), - ].join(""), completionActionVisible: completionAction !== undefined, traceBlocks, }; @@ -967,7 +973,9 @@ export class ChatGptBrowserWorker { 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 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(); @@ -1049,7 +1057,7 @@ 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 (;;) { @@ -1061,7 +1069,9 @@ export class ChatGptBrowserWorker { 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(); @@ -1077,6 +1087,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); @@ -1088,12 +1099,6 @@ export class ChatGptBrowserWorker { completionActionVisible: snapshot.completionActionVisible, }); if (domError) throw new Error(domError); - // Commit only when ChatGPT exposes response-scoped completion actions, but keep every - // top-level Markdown root in that response as one final-answer stream. - if (snapshot.completionActionVisible) { - const stableDelta = markdownStream.observeStableHtml(snapshot.stableHtml); - if (stableDelta) turn.onTextDelta(stableDelta); - } if (completionTracker.update({ responsePresent: snapshot.responsePresent, running, @@ -1104,7 +1109,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"); } diff --git a/src/adapters/chatgpt-web/environment.ts b/src/adapters/chatgpt-web/environment.ts index ac382245d..6dac2cd7c 100644 --- a/src/adapters/chatgpt-web/environment.ts +++ b/src/adapters/chatgpt-web/environment.ts @@ -84,8 +84,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"; } diff --git a/src/adapters/chatgpt-web/index.ts b/src/adapters/chatgpt-web/index.ts index b9e6e8e74..77fe55e47 100644 --- a/src/adapters/chatgpt-web/index.ts +++ b/src/adapters/chatgpt-web/index.ts @@ -4,7 +4,7 @@ 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 { ChatGptBrowserWorker } from "./browser-worker"; import { extractChatGptTurnEnvironment, extractChatGptTurnIdentity } from "./environment"; import { resolveChatGptWebModelMode, type ChatGptWebCapabilities } from "./model"; import { chatGptReadOnlyContextWarning, compileChatGptWebPrompt } from "./prompt"; @@ -155,7 +155,7 @@ function validateBatchTools(parsed: CodexParsedRequest, requests: BrokerToolRequ 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, @@ -209,7 +209,11 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider reasoning: parsed.options.reasoning, capabilities, 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); 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/turn-broker.ts b/src/adapters/chatgpt-web/turn-broker.ts index 5cebb929e..f307dd9e9 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 { @@ -133,13 +133,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 +162,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 +492,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/codex-integration.ts b/src/codex-integration.ts index 1590e1021..44a00bf1c 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, @@ -372,6 +732,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 +740,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 +754,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 +789,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 +835,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 +872,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 +931,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 +945,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 +959,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 +1012,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 +1105,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 +1139,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 +1166,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 +1259,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 +1305,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 +1326,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/model-catalog.ts b/src/model-catalog.ts index 61a0177f6..e62258b4f 100644 --- a/src/model-catalog.ts +++ b/src/model-catalog.ts @@ -12,6 +12,8 @@ type JsonObject = Record; 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)) { @@ -73,7 +75,10 @@ export function buildChatGptWebModel( description: route.description, input_modalities: ["text", "image"], visibility: "list", - supported_in_api: false, + // 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. 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/server.ts b/src/server.ts index 76373b228..f79cafb2e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -179,6 +179,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 +503,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 ab963c39c..5a75d9b45 100644 --- a/src/types.ts +++ b/src/types.ts @@ -315,7 +315,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 f5da58116..9db5e4134 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.1.0"; +export const VERSION = "1.1.1"; diff --git a/tests/browser-worker-contract.test.ts b/tests/browser-worker-contract.test.ts index 1de2cae4c..b52710229 100644 --- a/tests/browser-worker-contract.test.ts +++ b/tests/browser-worker-contract.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { readFileSync } from "node:fs"; -import { ChatGptBrowserWorker, ChatGptTurnDomHealthTracker, ChatGptVisibleTraceTracker, MAX_CHATGPT_BROWSER_TABS, chatGptSubmissionEvidence, isChatGptTraceControl, redactChatGptUiDiagnostic } from "../src/adapters/chatgpt-web/browser-worker"; +import { ChatGptBrowserWorker, ChatGptTurnDomHealthTracker, ChatGptVisibleTraceTracker, MAX_CHATGPT_BROWSER_TABS, chatGptSubmissionEvidence, isChatGptTraceControl, redactChatGptUiDiagnostic, resolveBrowserConfig } 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", () => { @@ -52,6 +52,19 @@ test("browser turns run concurrently up to the five-tab limit", async () => { 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 { @@ -523,7 +536,12 @@ test("response DOM aggregation keeps every top-level Markdown root in the final 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('...renderedRoots.slice(0, -1).map(candidate => candidate.innerHTML)'); + 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 ?? ""'); }); diff --git a/tests/chatgpt-web-harness.test.ts b/tests/chatgpt-web-harness.test.ts index f3bce9ba0..4ab0ab486 100644 --- a/tests/chatgpt-web-harness.test.ts +++ b/tests/chatgpt-web-harness.test.ts @@ -9,7 +9,7 @@ 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 { ChatGptTextFeed, ChatGptTraceFeed, ChatGptTurnSessions, chatGptTurnExecutionKey } from "../src/adapters/chatgpt-web/turn-execution"; @@ -477,19 +477,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", () => { diff --git a/tests/codex-integration.test.ts b/tests/codex-integration.test.ts index cc6c74f32..1f112b812 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,79 @@ describe("reversible native Codex route integration", () => { expect(uninstallCodexIntegration()).toEqual({ changed: false }); }); + 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 +225,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 +244,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 +254,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 +279,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 +438,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..1bf413342 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 }, ], }, { @@ -82,6 +97,54 @@ describe("trusted current Codex environment envelope", () => { }); }); +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", () => { test("persists the trusted first-turn authority and refreshes tools from every follow-up", () => { const stateRoot = mkdtempSync(join(tmpdir(), "codex-chatgpt-thread-environment-")); diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index e883df4fd..5b4f6513d 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -5,6 +5,7 @@ import { augmentNativeModelCatalog, CHATGPT_WEB_AUTO_COMPACT_TOKEN_LIMIT, CHATGPT_WEB_CONTEXT_WINDOW, + CHATGPT_WEB_MODEL_PRIORITY, } from "../src/model-catalog"; function source(): Record { @@ -64,6 +65,8 @@ describe("native /models augmentation", () => { default_reasoning_level: route.codexEffort, supported_reasoning_levels: [{ effort: route.codexEffort, description: route.displayName }], multi_agent_version: "v1", + supported_in_api: true, + priority: CHATGPT_WEB_MODEL_PRIORITY, context_window: CHATGPT_WEB_CONTEXT_WINDOW, max_context_window: CHATGPT_WEB_CONTEXT_WINDOW, auto_compact_token_limit: CHATGPT_WEB_AUTO_COMPACT_TOKEN_LIMIT, @@ -75,6 +78,22 @@ describe("native /models augmentation", () => { } }); + 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_MODEL_ROUTES.map(route => route.slug)); + }); + test("owns only its namespace, is idempotent, and omits account-gated Pro when unavailable", () => { const config = defaultConfig("browser-only"); config.proAvailable = false; 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/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-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..c6d932adf 100644 --- a/tests/server-models.test.ts +++ b/tests/server-models.test.ts @@ -1,6 +1,10 @@ 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_AUTO_COMPACT_TOKEN_LIMIT, + CHATGPT_WEB_CONTEXT_WINDOW, + CHATGPT_WEB_MODEL_PRIORITY, +} from "../src/model-catalog"; import { modelsRequest } from "../src/server"; test("proxies official /models auth and query, then appends the fixed ChatGPT Web models", async () => { @@ -35,6 +39,8 @@ 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([ @@ -50,5 +56,7 @@ test("proxies official /models auth and query, then appends the fixed ChatGPT We 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); + 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 043057c93..99aea5ad9 100644 --- a/tests/turn-broker-lifecycle.test.ts +++ b/tests/turn-broker-lifecycle.test.ts @@ -124,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); From d101e22dcca1b2bf7d944295151664d3b5f2d0db Mon Sep 17 00:00:00 2001 From: vechen <147659719+miuuyy@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:51:10 +0300 Subject: [PATCH 06/23] Stabilize Windows runtime for 1.1.1 --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 6 ++--- LICENSES/{Bun-1.3.11.md => Bun-1.3.14.md} | 10 +++++++- README.md | 2 +- README.zh-CN.md | 2 +- bun.lock | 16 +++++++------ launcher/tests/packaging-contract.test.cjs | 5 ++++ package.json | 10 ++++---- scripts/build-runtime-bundle.ts | 13 ++++++++-- scripts/check-version.ts | 28 ++++++++++++++++++++-- scripts/generate-third-party-notices.ts | 6 ++--- scripts/install-launcher.ps1 | 2 +- scripts/install.sh | 4 ++-- 13 files changed, 78 insertions(+), 28 deletions(-) rename LICENSES/{Bun-1.3.11.md => Bun-1.3.14.md} (82%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 479884b97..7528e10fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: } - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.11 + bun-version: 1.3.14 - run: bun install --frozen-lockfile - run: bun install --frozen-lockfile working-directory: launcher diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca35691ed..ea575e324 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.11 + bun-version: 1.3.14 - run: bun install --frozen-lockfile - run: bun install --frozen-lockfile working-directory: launcher @@ -72,7 +72,7 @@ jobs: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.11 + bun-version: 1.3.14 - run: bun install --frozen-lockfile - run: bun install --frozen-lockfile working-directory: launcher @@ -90,7 +90,7 @@ jobs: cp LICENSE release-assets/LICENSE cp LICENSES/NOTICE.md release-assets/NOTICE.md cp LICENSES/OpenCodex-MIT.txt release-assets/OpenCodex-MIT.txt - cp LICENSES/Bun-1.3.11.md release-assets/Bun-1.3.11.md + cp LICENSES/Bun-1.3.14.md release-assets/Bun-1.3.14.md cp dist/THIRD_PARTY_NOTICES.txt release-assets/THIRD_PARTY_NOTICES.txt cp scripts/install.sh release-assets/install.sh cp scripts/install-launcher.sh release-assets/install-launcher.sh diff --git a/LICENSES/Bun-1.3.11.md b/LICENSES/Bun-1.3.14.md similarity index 82% rename from LICENSES/Bun-1.3.11.md rename to LICENSES/Bun-1.3.14.md index dbe4cfbec..42c24aaa0 100644 --- a/LICENSES/Bun-1.3.11.md +++ b/LICENSES/Bun-1.3.14.md @@ -24,21 +24,29 @@ Bun statically links these libraries: | [`brotli`](https://github.com/google/brotli) | MIT | | [`libarchive`](https://github.com/libarchive/libarchive) | [several licenses](https://github.com/libarchive/libarchive/blob/master/COPYING) | | [`lol-html`](https://github.com/cloudflare/lol-html/tree/master/c-api) | BSD 3-Clause | +| [`ls-hpack`](https://github.com/litespeedtech/ls-hpack) | MIT | +| [`ls-qpack`](https://github.com/litespeedtech/ls-qpack) | MIT | +| [`lsquic`](https://github.com/litespeedtech/lsquic) | MIT (portions derived from [Chromium proto-quic](https://github.com/litespeedtech/lsquic/blob/master/LICENSE.chrome), BSD 3-Clause) | | [`mimalloc`](https://github.com/microsoft/mimalloc) | MIT | | [`picohttp`](https://github.com/h2o/picohttpparser) | dual-licensed under the Perl License or the MIT License | | [`zstd`](https://github.com/facebook/zstd) | dual-licensed under the BSD License or GPLv2 license | | [`simdutf`](https://github.com/simdutf/simdutf) | Apache 2.0 | | [`tinycc`](https://github.com/tinycc/tinycc) | LGPL v2.1 | | [`uSockets`](https://github.com/uNetworking/uSockets) | Apache 2.0 | -| [`zlib-cloudflare`](https://github.com/cloudflare/zlib) | zlib | +| [`zlib-ng`](https://github.com/zlib-ng/zlib-ng) | zlib | | [`c-ares`](https://github.com/c-ares/c-ares) | MIT licensed | | [`libicu`](https://github.com/unicode-org/icu) 72 | [license here](https://github.com/unicode-org/icu/blob/main/icu4c/LICENSE) | | [`libbase64`](https://github.com/aklomp/base64/blob/master/LICENSE) | BSD 2-Clause | | [`libuv`](https://github.com/libuv/libuv) (on Windows) | MIT | | [`libdeflate`](https://github.com/ebiggers/libdeflate) | MIT | +| [`libjpeg-turbo`](https://github.com/libjpeg-turbo/libjpeg-turbo) | [BSD 3-Clause / IJG / zlib](https://github.com/libjpeg-turbo/libjpeg-turbo/blob/main/LICENSE.md) | +| [`libspng`](https://github.com/randy408/libspng) | BSD 2-Clause | +| [`libwebp`](https://github.com/webmproject/libwebp) | BSD 3-Clause | +| [`highway`](https://github.com/google/highway) | Apache 2.0 | | [`uucode`](https://github.com/jacobsandlund/uucode) | MIT | | A fork of [`uWebsockets`](https://github.com/jarred-sumner/uwebsockets) | Apache 2.0 licensed | | Parts of [Tigerbeetle's IO code](https://github.com/tigerbeetle/tigerbeetle/blob/532c8b70b9142c17e07737ab6d3da68d7500cbca/src/io/windows.zig#L1) | Apache 2.0 licensed | +| `__cxa_thread_atexit` fallback from [LLVM libc++abi](https://github.com/llvm/llvm-project/blob/llvmorg-19.1.0/libcxxabi/src/cxa_thread_atexit.cpp) | Apache 2.0 with LLVM exception | ## Polyfills diff --git a/README.md b/README.md index f47119865..d339194ad 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ cd codex-chatgpt-web && \ bun run app ``` -This source path requires Bun 1.3.11. The command installs locked dependencies and opens the app. +This source path requires Bun 1.3.14. The command installs locked dependencies and opens the app. ## Modes diff --git a/README.zh-CN.md b/README.zh-CN.md index 5ccaa64b1..9843663eb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -97,7 +97,7 @@ cd codex-chatgpt-web && \ bun run app ``` -源码方式需要 Bun 1.3.11。该命令会安装锁定版本的依赖并打开应用。 +源码方式需要 Bun 1.3.14。该命令会安装锁定版本的依赖并打开应用。 ## 模式 diff --git a/bun.lock b/bun.lock index 577ced4dd..09b33ee4b 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", @@ -14,7 +14,7 @@ "zod": "4.4.3", }, "devDependencies": { - "@types/bun": "1.3.11", + "@types/bun": "1.3.14", "@types/turndown": "5.0.5", "typescript": "5.9.3", }, @@ -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,9 +31,9 @@ "@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=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], @@ -45,7 +47,7 @@ "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], - "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -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/launcher/tests/packaging-contract.test.cjs b/launcher/tests/packaging-contract.test.cjs index 02f5aa428..36118bc70 100644 --- a/launcher/tests/packaging-contract.test.cjs +++ b/launcher/tests/packaging-contract.test.cjs @@ -56,6 +56,11 @@ test("release installers resolve checksummed native launcher assets", () => { assert.match(windowsInstaller, /codex-web-gpt-\$Version-win-\$Arch\.exe/); assert.match(windowsInstaller, /\[Environment\]::Is64BitOperatingSystem/); assert.doesNotMatch(windowsInstaller, /RuntimeInformation/); + const expectedWindowsExecutable = `Programs\\${manifest.name}\\${manifest.build.productName}.exe`; + assert.ok( + windowsInstaller.includes(expectedWindowsExecutable), + `the PowerShell installer must launch the NSIS executable at ${expectedWindowsExecutable}`, + ); }); test("CI packages and smoke-launches on macOS, Windows, and Linux", () => { diff --git a/package.json b/package.json index b512f147b..20b1637d7 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "bugs": { "url": "https://github.com/miuuyy/codex-chatgpt-web/issues" }, - "packageManager": "bun@1.3.11", + "packageManager": "bun@1.3.14", "type": "module", "bin": { "codex-chatgpt-web": "./src/cli.ts" @@ -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", @@ -55,15 +55,17 @@ "zod": "4.4.3" }, "devDependencies": { - "@types/bun": "1.3.11", + "@types/bun": "1.3.14", "@types/turndown": "5.0.5", "typescript": "5.9.3" }, "engines": { - "bun": ">=1.3.11 <1.4" + "bun": "1.3.14" }, "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/build-runtime-bundle.ts b/scripts/build-runtime-bundle.ts index 47c09e29f..e2bae5fb1 100644 --- a/scripts/build-runtime-bundle.ts +++ b/scripts/build-runtime-bundle.ts @@ -3,6 +3,17 @@ import { join, resolve } from "node:path"; import { VERSION } from "../src/version"; const root = resolve(import.meta.dir, ".."); +const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { + version?: string; + packageManager?: string; +}; +if (packageJson.version !== VERSION) throw new Error("package.json and runtime version are out of sync"); +const packageManagerMatch = /^bun@(\d+\.\d+\.\d+)$/.exec(packageJson.packageManager ?? ""); +if (!packageManagerMatch) throw new Error("package.json must pin an exact Bun packageManager version"); +const expectedBunVersion = packageManagerMatch[1]; +if (Bun.version !== expectedBunVersion) { + throw new Error(`Runtime bundle requires Bun ${expectedBunVersion}, received ${Bun.version}`); +} const output = resolve(process.argv[2] ?? join(root, "dist", "runtime")); const appDir = join(output, "app"); const runtimeDir = join(output, "runtime"); @@ -84,8 +95,6 @@ exec "$root/runtime/bun" "$root/app/cli.js" "$@" writeFileSync(join(binDir, launcherName), launcher, process.platform === "win32" ? undefined : { mode: 0o755 }); if (process.platform !== "win32") chmodSync(join(binDir, launcherName), 0o755); -const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { version?: string }; -if (packageJson.version !== VERSION) throw new Error("package.json and runtime version are out of sync"); const playwrightPackage = join(appDir, "node_modules", "playwright-core", "package.json"); writeFileSync(join(output, "manifest.json"), `${JSON.stringify({ schemaVersion: 1, diff --git a/scripts/check-version.ts b/scripts/check-version.ts index d82119fb1..52f729dc2 100644 --- a/scripts/check-version.ts +++ b/scripts/check-version.ts @@ -2,15 +2,39 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; const root = resolve(import.meta.dir, ".."); -const packageVersion = (JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")) as { version?: string }).version; +const packageJson = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")) as { + version?: string; + packageManager?: string; + devDependencies?: Record; + engines?: Record; +}; +const packageVersion = packageJson.version; if (!packageVersion) throw new Error("package.json has no version"); +const packageManagerMatch = /^bun@(\d+\.\d+\.\d+)$/.exec(packageJson.packageManager ?? ""); +if (!packageManagerMatch) throw new Error("package.json must pin an exact Bun packageManager version"); +const bunVersion = packageManagerMatch[1]; +if (Bun.version !== bunVersion) throw new Error(`Expected Bun ${bunVersion}, received ${Bun.version}`); +if (packageJson.devDependencies?.["@types/bun"] !== bunVersion) { + throw new Error(`@types/bun is not synchronized to ${bunVersion}`); +} +if (packageJson.engines?.bun !== bunVersion) throw new Error(`engines.bun is not synchronized to ${bunVersion}`); const expected = [ ["src/version.ts", `export const VERSION = ${JSON.stringify(packageVersion)};`], ["scripts/install.sh", `VERSION=\"\${CODEX_CHATGPT_WEB_VERSION:-${packageVersion}}\"`], + ["README.md", `requires Bun ${bunVersion}.`], + ["README.zh-CN.md", `Bun ${bunVersion}`], + ["scripts/install.sh", `Bun-${bunVersion}.md`], + ["scripts/generate-third-party-notices.ts", `Bun ${bunVersion}`], + [".github/workflows/ci.yml", `bun-version: ${bunVersion}`], + [".github/workflows/release.yml", `Bun-${bunVersion}.md`], ] as const; for (const [path, needle] of expected) { if (!readFileSync(resolve(root, path), "utf8").includes(needle)) throw new Error(`${path} is not synchronized to ${packageVersion}`); } +const releaseWorkflow = readFileSync(resolve(root, ".github/workflows/release.yml"), "utf8"); +if (releaseWorkflow.split(`bun-version: ${bunVersion}`).length - 1 !== 2) { + throw new Error(`release.yml must pin Bun ${bunVersion} in both jobs`); +} const launcherVersion = (JSON.parse(readFileSync(resolve(root, "launcher/package.json"), "utf8")) as { version?: string }).version; if (launcherVersion !== packageVersion) throw new Error(`launcher/package.json is not synchronized to ${packageVersion}`); -process.stdout.write(`VERSION_SYNC_OK ${packageVersion}\n`); +process.stdout.write(`VERSION_SYNC_OK ${packageVersion} bun@${bunVersion}\n`); diff --git a/scripts/generate-third-party-notices.ts b/scripts/generate-third-party-notices.ts index 6faf3cc2a..ed75b1e84 100644 --- a/scripts/generate-third-party-notices.ts +++ b/scripts/generate-third-party-notices.ts @@ -69,16 +69,16 @@ const sections = [...visited.values()] ].join("\n"); }); -const bunLicense = readFileSync(join(root, "LICENSES", "Bun-1.3.11.md"), "utf8").trim(); +const bunLicense = readFileSync(join(root, "LICENSES", "Bun-1.3.14.md"), "utf8").trim(); const output = [ "codex-chatgpt-web third-party notices", "", "This file covers runtime JavaScript packages bundled into the standalone executable.", - "The executable also embeds Bun 1.3.11; Bun's licensing and relinking notice follows first.", + "The executable also embeds Bun 1.3.14; Bun's licensing and relinking notice follows first.", "Project/OpenCodex notices are distributed separately in LICENSES/NOTICE.md and OpenCodex-MIT.txt.", "", "=".repeat(80), - "Bun 1.3.11 runtime", + "Bun 1.3.14 runtime", "=".repeat(80), bunLicense, "", diff --git a/scripts/install-launcher.ps1 b/scripts/install-launcher.ps1 index f4dfadb99..fcdba6aeb 100644 --- a/scripts/install-launcher.ps1 +++ b/scripts/install-launcher.ps1 @@ -67,7 +67,7 @@ try { if ($Actual -ne $Expected) { throw "SHA-256 verification failed for $Asset" } $Process = Start-Process -FilePath $Installer -ArgumentList "/S" -Wait -PassThru if ($Process.ExitCode -ne 0) { throw "Installer exited with code $($Process.ExitCode)" } - $Executable = Join-Path $env:LOCALAPPDATA "Programs\Codex Web GPT\Codex Web GPT.exe" + $Executable = Join-Path $env:LOCALAPPDATA "Programs\codex-web-gpt-launcher\Codex Web GPT.exe" if (-not (Test-Path $Executable)) { throw "Installed launcher was not found at $Executable" } Start-Process $Executable Write-Host "Installed $Executable" diff --git a/scripts/install.sh b/scripts/install.sh index 0b86678b1..02d0d84a6 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -36,7 +36,7 @@ if [ -z "$EXPECTED" ] || [ "$ACTUAL" != "$EXPECTED" ]; then exit 1 fi -for DOC in LICENSE NOTICE.md OpenCodex-MIT.txt Bun-1.3.11.md THIRD_PARTY_NOTICES.txt; do +for DOC in LICENSE NOTICE.md OpenCodex-MIT.txt Bun-1.3.14.md THIRD_PARTY_NOTICES.txt; do curl -fsSL "$BASE_URL/$DOC" -o "$TEMP_DIR/$DOC" DOC_EXPECTED="$(awk -v asset="$DOC" '$2 == asset { print $1 }' "$TEMP_DIR/checksums.txt")" DOC_ACTUAL="$(shasum -a 256 "$TEMP_DIR/$DOC" | awk '{ print $1 }')" @@ -69,7 +69,7 @@ fi ln -sfn "$TARGET_DIR/bin/codex-chatgpt-web" "$BIN_DIR/.codex-chatgpt-web.next" mv -f "$BIN_DIR/.codex-chatgpt-web.next" "$BIN_DIR/codex-chatgpt-web" rm -f "$BIN_DIR/codex-chatgpt-web.legacy-standalone" -for DOC in LICENSE NOTICE.md OpenCodex-MIT.txt Bun-1.3.11.md THIRD_PARTY_NOTICES.txt; do +for DOC in LICENSE NOTICE.md OpenCodex-MIT.txt Bun-1.3.14.md THIRD_PARTY_NOTICES.txt; do install -m 0644 "$TEMP_DIR/$DOC" "$DOC_DIR/$DOC" done if [ -e "$BACKUP_DIR" ]; then rm -rf "$BACKUP_DIR"; fi From 8f7c2599f8a7aa09aaa0baa9507a48c557143a14 Mon Sep 17 00:00:00 2001 From: Albro3459 Date: Mon, 3 Aug 2026 15:24:30 -0500 Subject: [PATCH 07/23] Bind Codex turns that have no turn timeout --- src/adapters/chatgpt-web/mcp-server.ts | 23 +++++++------ src/adapters/chatgpt-web/turn-broker.ts | 13 +++++-- tests/chatgpt-web-harness.test.ts | 45 ++++++++++++++++++++++++ tests/turn-broker-lifecycle.test.ts | 46 ++++++++++++++++++++++++- 4 files changed, 114 insertions(+), 13 deletions(-) diff --git a/src/adapters/chatgpt-web/mcp-server.ts b/src/adapters/chatgpt-web/mcp-server.ts index c8afdaedc..c12738933 100644 --- a/src/adapters/chatgpt-web/mcp-server.ts +++ b/src/adapters/chatgpt-web/mcp-server.ts @@ -8,11 +8,11 @@ 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 +70,8 @@ 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 | null { + return environment.expiresAt === undefined ? null : Math.max(1, environment.expiresAt - Date.now()); } function asMcpResult(value: BrokerToolResult) { @@ -124,15 +124,16 @@ 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"); + const expiresAt = resolved.environment.expiresAt; + if (expiresAt !== undefined && 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 +149,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 +161,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 +196,9 @@ 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(), + 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, diff --git a/src/adapters/chatgpt-web/turn-broker.ts b/src/adapters/chatgpt-web/turn-broker.ts index f307dd9e9..c222a0168 100644 --- a/src/adapters/chatgpt-web/turn-broker.ts +++ b/src/adapters/chatgpt-web/turn-broker.ts @@ -498,10 +498,16 @@ export class TurnBroker { } } +/** + * A turn registered without a TTL has no deadline to bound its tool calls against, so a null + * timeout waits for as long as the turn itself lives. Undefined keeps the bounded default, because + * a caller that cannot compute a deadline must not silently inherit an unbounded wait. An + * unbounded call still ends when the turn is revoked or the broker drops the connection. + */ export async function callTurnBroker( socketPath: string, request: Omit, - timeoutMs = 5_000, + timeoutMs: number | null = 5_000, ): Promise { const id = opaqueId("request"); return new Promise((resolveCall, rejectCall) => { @@ -515,9 +521,12 @@ export async function callTurnBroker( socket.destroy(); rejectCall(error); }; - const timer = setTimeout(() => finishError(new Error("ChatGPT web turn broker timed out")), timeoutMs); + const timer = timeoutMs === null + ? undefined + : setTimeout(() => finishError(new Error("ChatGPT web turn broker timed out")), timeoutMs); socket.setEncoding("utf8"); socket.once("error", error => finishError(new Error(`ChatGPT web turn broker unavailable: ${error.message}`))); + socket.once("close", () => finishError(new Error("ChatGPT web turn broker closed the connection"))); socket.once("connect", () => socket.write(`${JSON.stringify({ id, ...request })}\n`)); socket.on("data", chunk => { if (settled) return; diff --git a/tests/chatgpt-web-harness.test.ts b/tests/chatgpt-web-harness.test.ts index 4ab0ab486..4dbad55f9 100644 --- a/tests/chatgpt-web-harness.test.ts +++ b/tests/chatgpt-web-harness.test.ts @@ -908,4 +908,49 @@ describe("ChatGPT outer-native harness v3", () => { await broker.close(); } }, 30_000); + + test("serves the outer-native bridge contract over MCP stdio for a turn registered without a turn timeout", async () => { + const socketPath = brokerTestEndpoint(`cgw-h3-mcp-no-ttl-${process.pid}-${Date.now()}`); + const broker = TurnBroker.forSocket(socketPath); + const gatewayOnlyEnvironment = extractChatGptTurnEnvironment(parsed(environmentXml)); + gatewayOnlyEnvironment.tools = gatewayOnlyEnvironment.tools.filter(tool => ( + tool.name === "exec" || tool.name === "search_openai_docs" + )); + const token = await broker.register(gatewayOnlyEnvironment); + 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-harness-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 }); + expect(bound.content).toEqual([{ type: "text", text: expect.stringContaining("binding_") }]); + expect(bound.isError).not.toBe(true); + const binding = bound.structuredContent as { binding_id: string; expires_at: string | null }; + expect(binding.binding_id).toStartWith("binding_"); + expect(binding.expires_at).toBeNull(); + + const execPromise = call("codex_exec", { binding_id: binding.binding_id, cmd: "pwd", workdir: tempRoot }); + const [execRequest] = await Promise.race([ + broker.nextToolBatch(token), + execPromise.then(response => { + throw new Error(`codex_exec settled before reaching the broker: ${JSON.stringify(response.content)}`); + }), + ]); + expect(execRequest).toMatchObject({ wireName: "exec", freeform: true }); + expect(execRequest?.input).toContain(`tools["exec_command"](${JSON.stringify({ cmd: "pwd", workdir: tempRoot })})`); + broker.completeTool(token, execRequest!.callId, toolResult({ output: tempRoot, exit_code: 0 })); + expect((await execPromise).structuredContent).toEqual({ output: tempRoot, exit_code: 0 }); + } finally { + await client.close().catch(() => {}); + broker.revoke(token); + await broker.close(); + } + }, 30_000); }); diff --git a/tests/turn-broker-lifecycle.test.ts b/tests/turn-broker-lifecycle.test.ts index 99aea5ad9..16d66e8bc 100644 --- a/tests/turn-broker-lifecycle.test.ts +++ b/tests/turn-broker-lifecycle.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import { ChatGptTextFeed, ChatGptTraceFeed, ChatGptTurnSessions } from "../src/adapters/chatgpt-web/turn-execution"; -import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs"; +import { createServer, type Socket } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { callTurnBroker, TurnBroker } from "../src/adapters/chatgpt-web/turn-broker"; @@ -145,6 +146,49 @@ test("turn broker tokens do not expire while their browser turn is still alive", } }); +function unansweredBrokerEndpoint(name: string, onConnection: (socket: Socket) => void) { + const root = mkdtempSync(join(tmpdir(), name)); + const socketPath = defaultBrokerEndpoint(root); + if (!isWindowsPipeEndpoint(socketPath)) mkdirSync(dirname(socketPath), { recursive: true }); + const server = createServer(onConnection); + return { + socketPath, + listen: () => new Promise(ready => server.listen(socketPath, ready)), + close: async () => { + await new Promise(done => server.close(() => done())); + rmSync(root, { recursive: true, force: true }); + }, + }; +} + +test("an unbounded broker call fails when the broker closes without answering", async () => { + const broker = unansweredBrokerEndpoint("cgw-broker-closed-", socket => socket.on("data", () => socket.end())); + await broker.listen(); + try { + await expect(callTurnBroker(broker.socketPath, { method: "claim", token: "turn_closed" }, null)) + .rejects.toThrow("closed the connection"); + } finally { + await broker.close(); + } +}, 10_000); + +test("an unbounded broker call outlives the bounded default timeout", async () => { + const accepted: Socket[] = []; + const broker = unansweredBrokerEndpoint("cgw-broker-slow-", socket => { accepted.push(socket); }); + await broker.listen(); + try { + const call = callTurnBroker(broker.socketPath, { method: "claim", token: "turn_unbounded" }, null); + const outcome = await Promise.race([ + call.then(() => "settled", () => "settled"), + Bun.sleep(5_300).then(() => "pending"), + ]); + expect(outcome).toBe("pending"); + } finally { + for (const socket of accepted) socket.destroy(); + await broker.close(); + } +}, 15_000); + test("turn broker names the finished turn that owns a replayed handle", async () => { const root = mkdtempSync(join(tmpdir(), "cgw-broker-")); const socketPath = defaultBrokerEndpoint(root); From 8be48636d208286849cb524a4fbcbc72fcdd9381 Mon Sep 17 00:00:00 2001 From: vechen <147659719+miuuyy@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:51:58 +0300 Subject: [PATCH 08/23] Handle ChatGPT rate-limit dialog explicitly --- src/adapters/chatgpt-web/adapter-error.ts | 22 +++++++ .../chatgpt-web/browser-helper-main.ts | 7 +++ src/adapters/chatgpt-web/browser-worker.ts | 59 +++++++++++++++--- src/adapters/chatgpt-web/index.ts | 12 ++++ .../chatgpt-web/launcher-helper-client.ts | 51 +++++++++++++-- tests/browser-worker-contract.test.ts | 62 ++++++++++++++++++- tests/chatgpt-web-harness.test.ts | 39 ++++++++++++ tests/launcher-helper-client.test.ts | 60 +++++++++++++++++- 8 files changed, 298 insertions(+), 14 deletions(-) create mode 100644 src/adapters/chatgpt-web/adapter-error.ts 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..6ad947329 100644 --- a/src/adapters/chatgpt-web/browser-helper-main.ts +++ b/src/adapters/chatgpt-web/browser-helper-main.ts @@ -2,6 +2,7 @@ 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 { createProcessLineWriter } from "./process-line-writer"; import type { CompiledChatGptWebPrompt } from "./prompt"; @@ -132,6 +133,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 08a1fe34c..2436a00cd 100644 --- a/src/adapters/chatgpt-web/browser-worker.ts +++ b/src/adapters/chatgpt-web/browser-worker.ts @@ -25,6 +25,7 @@ import { loginVerificationMarkerPath } from "../../browser-login"; import { connectLauncherBrowserHost, notifyLauncherTurn } from "../../launcher-browser-host"; 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"; @@ -55,6 +56,32 @@ 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 browserStageTimeouts = { browserPage: 60_000, navigation: 70_000, @@ -515,19 +542,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") { @@ -537,6 +578,7 @@ export class ChatGptBrowserWorker { await page.keyboard.press("Escape"); return mode; } + await throwIfChatGptRateLimitDialog(page); await effortChoice.press("Enter"); const deadline = Date.now() + 40_000; @@ -544,7 +586,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())), diff --git a/src/adapters/chatgpt-web/index.ts b/src/adapters/chatgpt-web/index.ts index 77fe55e47..e06546327 100644 --- a/src/adapters/chatgpt-web/index.ts +++ b/src/adapters/chatgpt-web/index.ts @@ -4,6 +4,7 @@ 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 { ChatGptWebAdapterError } from "./adapter-error"; import { ChatGptBrowserWorker } from "./browser-worker"; import { extractChatGptTurnEnvironment, extractChatGptTurnIdentity } from "./environment"; import { resolveChatGptWebModelMode, type ChatGptWebCapabilities } from "./model"; @@ -415,6 +416,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..654d8b6ec 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"); @@ -261,9 +295,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/tests/browser-worker-contract.test.ts b/tests/browser-worker-contract.test.ts index b52710229..fa9993f49 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, MAX_CHATGPT_BROWSER_TABS, chatGptSubmissionEvidence, isChatGptTraceControl, redactChatGptUiDiagnostic, resolveBrowserConfig } from "../src/adapters/chatgpt-web/browser-worker"; +import type { Page } from "playwright-core"; +import { ChatGptBrowserWorker, ChatGptTurnDomHealthTracker, ChatGptVisibleTraceTracker, MAX_CHATGPT_BROWSER_TABS, chatGptSubmissionEvidence, isChatGptTraceControl, redactChatGptUiDiagnostic, resolveBrowserConfig, throwIfChatGptRateLimitDialog } 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", () => { @@ -478,6 +479,65 @@ 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("browser diagnostics redact context envelopes and capability values", () => { const diagnostic = redactChatGptUiDiagnostic( "private context turn_12345678901234567890 binding_12345678901234567890", diff --git a/tests/chatgpt-web-harness.test.ts b/tests/chatgpt-web-harness.test.ts index 4dbad55f9..dc844c955 100644 --- a/tests/chatgpt-web-harness.test.ts +++ b/tests/chatgpt-web-harness.test.ts @@ -426,6 +426,45 @@ 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"); + }); + test("returns one native compaction item with preserved estimated usage", () => { const request = parsed(); const summary = "Completed the tool loop; continue with the deployment check."; 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, + }); +}); From 6100ffdcea35be06dbd51a2ed5eedfbde0a17c9a Mon Sep 17 00:00:00 2001 From: vechen <147659719+miuuyy@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:51:18 +0300 Subject: [PATCH 09/23] Fix account-aware Web model failures --- README.md | 4 +- launcher/package.json | 2 +- launcher/src/i18n.ts | 4 +- launcher/tests/design-contract.test.cjs | 2 + package.json | 2 +- scripts/install.sh | 2 +- src/adapters/chatgpt-web/browser-worker.ts | 33 ++++++++++++++++ src/adapters/chatgpt-web/model.ts | 1 + src/chatgpt-web-models.ts | 26 ++++++++++-- src/cli.ts | 4 +- src/codex-integration.ts | 3 +- src/model-catalog.ts | 12 +++--- src/version.ts | 2 +- tests/browser-worker-contract.test.ts | 46 +++++++++++++++++++++- tests/chatgpt-web-harness.test.ts | 26 ++++++++++++ tests/chatgpt-web-models.test.ts | 23 ++++++++++- tests/codex-integration.test.ts | 14 +++++++ tests/model-catalog.test.ts | 30 ++++++++------ tests/model-contract.test.ts | 6 ++- tests/server-compaction.test.ts | 23 ++++++----- tests/server-models.test.ts | 10 ++--- 21 files changed, 225 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index d339194ad..bb9904178 100644 --- a/README.md +++ b/README.md @@ -110,8 +110,8 @@ This source path requires Bun 1.3.14. 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/launcher/package.json b/launcher/package.json index 5716989ad..a51303532 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -1,6 +1,6 @@ { "name": "codex-web-gpt-launcher", - "version": "1.1.1", + "version": "1.1.2", "private": true, "description": "Desktop control center for Codex ChatGPT Web", "author": "miuuyy", diff --git a/launcher/src/i18n.ts b/launcher/src/i18n.ts index bb910dcff..5b8aa6e01 100644 --- a/launcher/src/i18n.ts +++ b/launcher/src/i18n.ts @@ -70,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", @@ -196,7 +196,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", diff --git a/launcher/tests/design-contract.test.cjs b/launcher/tests/design-contract.test.cjs index de94570ee..d1fbe42f0 100644 --- a/launcher/tests/design-contract.test.cjs +++ b/launcher/tests/design-contract.test.cjs @@ -133,6 +133,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/package.json b/package.json index 20b1637d7..63caa8390 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-chatgpt-web", - "version": "1.1.1", + "version": "1.1.2", "private": true, "description": "A focused local Responses bridge that runs Codex tasks through a user-authenticated ChatGPT web session.", "repository": { diff --git a/scripts/install.sh b/scripts/install.sh index 02d0d84a6..a6dc24a39 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ set -eu REPOSITORY="${CODEX_CHATGPT_WEB_REPOSITORY:-miuuyy/codex-chatgpt-web}" -VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.1.1}" +VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.1.2}" 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/src/adapters/chatgpt-web/browser-worker.ts b/src/adapters/chatgpt-web/browser-worker.ts index 2436a00cd..402e53641 100644 --- a/src/adapters/chatgpt-web/browser-worker.ts +++ b/src/adapters/chatgpt-web/browser-worker.ts @@ -23,6 +23,7 @@ import { } 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"; @@ -82,6 +83,31 @@ export async function throwIfChatGptRateLimitDialog(page: Page): Promise { ); } +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 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, @@ -632,6 +658,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(), @@ -1018,6 +1045,10 @@ export class ChatGptBrowserWorker { try { if (turn.abortSignal?.aborted) throw new DOMException("ChatGPT web turn aborted", "AbortError"); const estimatedInputTokens = estimateCompiledChatGptWebInputTokens(prepared, turn.modelId); + assertChatGptWebInputWithinContextWindow( + estimatedInputTokens, + turn.capabilities.proAvailable, + ); const deadline = this.config.turnTimeoutMs === undefined ? undefined : Date.now() + this.config.turnTimeoutMs; @@ -1122,6 +1153,8 @@ export class ChatGptBrowserWorker { lastHeartbeat = Date.now(); } + await throwIfChatGptTerminalErrorAlert(page); + if (mode.localTools && await this.handleToolConfirmation(page)) { await new Promise(resolveSleep => setTimeout(resolveSleep, 250)); continue; diff --git a/src/adapters/chatgpt-web/model.ts b/src/adapters/chatgpt-web/model.ts index 30706b3f8..158361287 100644 --- a/src/adapters/chatgpt-web/model.ts +++ b/src/adapters/chatgpt-web/model.ts @@ -30,6 +30,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/chatgpt-web-models.ts b/src/chatgpt-web-models.ts index 366ad7bae..eb7330e4d 100644 --- a/src/chatgpt-web-models.ts +++ b/src/chatgpt-web-models.ts @@ -4,6 +4,26 @@ 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 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; displayName: string; @@ -47,10 +67,10 @@ export const CHATGPT_WEB_MODEL_ROUTES: readonly ChatGptWebModelRoute[] = [ { slug: "chatgpt-web/extra-high", displayName: "ChatGPT Web — Extra High", - description: "ChatGPT Web Extra High through the native Codex harness.", + description: "Account-gated ChatGPT Web Extra High through the native Codex harness.", codexEffort: "xhigh", adapterEffort: "xhigh", - requiresPro: false, + requiresPro: true, }, { slug: "chatgpt-web/pro", @@ -78,7 +98,7 @@ export function requireChatGptWebModelRoute(modelId: string, proAvailable: boole const route = routesBySlug.get(modelId); 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 4d5cc2188..aa74f96fc 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 44a00bf1c..c436c981e 100644 --- a/src/codex-integration.ts +++ b/src/codex-integration.ts @@ -695,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( diff --git a/src/model-catalog.ts b/src/model-catalog.ts index e62258b4f..0d1841e96 100644 --- a/src/model-catalog.ts +++ b/src/model-catalog.ts @@ -3,15 +3,12 @@ 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; @@ -68,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, @@ -90,9 +88,9 @@ export function buildChatGptWebModel( 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/version.ts b/src/version.ts index 9db5e4134..33f364d92 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.1.1"; +export const VERSION = "1.1.2"; diff --git a/tests/browser-worker-contract.test.ts b/tests/browser-worker-contract.test.ts index fa9993f49..7f4463209 100644 --- a/tests/browser-worker-contract.test.ts +++ b/tests/browser-worker-contract.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import type { Page } from "playwright-core"; -import { ChatGptBrowserWorker, ChatGptTurnDomHealthTracker, ChatGptVisibleTraceTracker, MAX_CHATGPT_BROWSER_TABS, chatGptSubmissionEvidence, isChatGptTraceControl, redactChatGptUiDiagnostic, resolveBrowserConfig, throwIfChatGptRateLimitDialog } from "../src/adapters/chatgpt-web/browser-worker"; +import { ChatGptBrowserWorker, ChatGptTurnDomHealthTracker, ChatGptVisibleTraceTracker, MAX_CHATGPT_BROWSER_TABS, assertChatGptWebInputWithinContextWindow, chatGptSubmissionEvidence, isChatGptTraceControl, redactChatGptUiDiagnostic, resolveBrowserConfig, 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", () => { @@ -538,6 +538,50 @@ test("unrelated ChatGPT dialogs are left untouched", async () => { 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([]); +}); + +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", diff --git a/tests/chatgpt-web-harness.test.ts b/tests/chatgpt-web-harness.test.ts index dc844c955..43f5329fb 100644 --- a/tests/chatgpt-web-harness.test.ts +++ b/tests/chatgpt-web-harness.test.ts @@ -356,6 +356,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"); }); @@ -463,6 +467,28 @@ describe("ChatGPT outer-native harness v3", () => { 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", () => { diff --git a/tests/chatgpt-web-models.test.ts b/tests/chatgpt-web-models.test.ts index 758e7b52b..1123215d5 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"; @@ -32,12 +33,30 @@ describe("fixed ChatGPT Web model routes", () => { expect(CHATGPT_WEB_MODEL_ROUTES[0]?.displayName).toBe("ChatGPT Web — Instant"); }); - test("does not expose or resolve Pro without the account capability", () => { - expect(availableChatGptWebModelRoutes(false).map(route => route.slug)).not.toContain("chatgpt-web/pro"); + test("exposes only Plus-eligible routes without the Pro account capability", () => { + expect(availableChatGptWebModelRoutes(false).map(route => route.slug)).toEqual([ + "chatgpt-web/light", + "chatgpt-web/medium", + "chatgpt-web/high", + ]); + expect(availableChatGptWebModelRoutes(true)).toEqual(CHATGPT_WEB_MODEL_ROUTES); + expect(() => requireChatGptWebModelRoute("chatgpt-web/extra-high", false)) + .toThrow("Extra High is not available for this account"); expect(() => requireChatGptWebModelRoute("chatgpt-web/pro", false)) .toThrow("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 rawSnapshot = structuredClone(request._rawBody); diff --git a/tests/codex-integration.test.ts b/tests/codex-integration.test.ts index 1f112b812..ff0d1938d 100644 --- a/tests/codex-integration.test.ts +++ b/tests/codex-integration.test.ts @@ -78,6 +78,20 @@ 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"); diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index 5b4f6513d..5b2ed4f6f 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -1,10 +1,8 @@ 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"; @@ -58,6 +56,7 @@ 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, @@ -67,9 +66,9 @@ describe("native /models augmentation", () => { multi_agent_version: "v1", supported_in_api: true, priority: CHATGPT_WEB_MODEL_PRIORITY, - 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, additional_speed_tiers: [], service_tiers: [], default_service_tier: null, @@ -94,7 +93,7 @@ describe("native /models augmentation", () => { expect(spawnOverrides).toEqual(CHATGPT_WEB_MODEL_ROUTES.map(route => route.slug)); }); - test("owns only its namespace, is idempotent, and omits account-gated Pro when unavailable", () => { + 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(); @@ -112,6 +111,14 @@ describe("native /models augmentation", () => { 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([ + { contextWindow: 225_000, autoCompactTokenLimit: 202_500 }, + { contextWindow: 225_000, autoCompactTokenLimit: 202_500 }, + { contextWindow: 225_000, autoCompactTokenLimit: 202_500 }, + ]); }); test("honors an explicit Codex context override without replacing or reordering native models", () => { @@ -134,9 +141,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); } }); @@ -167,7 +175,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(3); 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..3a555fa23 100644 --- a/tests/model-contract.test.ts +++ b/tests/model-contract.test.ts @@ -26,7 +26,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 +35,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, diff --git a/tests/server-compaction.test.ts b/tests/server-compaction.test.ts index d2d719663..410538a24 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/extra-high", "Extra High"], + ["chatgpt-web/pro", "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-models.test.ts b/tests/server-models.test.ts index c6d932adf..3491638e2 100644 --- a/tests/server-models.test.ts +++ b/tests/server-models.test.ts @@ -1,10 +1,9 @@ import { expect, test } from "bun:test"; import { defaultConfig } from "../src/config"; import { - CHATGPT_WEB_AUTO_COMPACT_TOKEN_LIMIT, - CHATGPT_WEB_CONTEXT_WINDOW, 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 () => { @@ -53,9 +52,10 @@ test("proxies official /models auth and query, then appends the fixed ChatGPT We ]); 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); } From 2e79cb15a6266916ef2298bda7cc94ead4f32af3 Mon Sep 17 00:00:00 2001 From: vechen <147659719+miuuyy@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:32:37 +0300 Subject: [PATCH 10/23] Fix Web turn lifecycle regressions --- launcher/electron/browser-host.cjs | 14 +++++ launcher/tests/browser-host.test.cjs | 25 +++++++++ src/adapters/chatgpt-web/browser-worker.ts | 56 ++++++++++++++------ src/adapters/chatgpt-web/environment.ts | 25 ++++++++- src/adapters/chatgpt-web/turn-execution.ts | 3 ++ src/launcher-browser-host.ts | 17 +++++- tests/browser-worker-contract.test.ts | 61 +++++++++++++++++++++- tests/chatgpt-web-harness.test.ts | 7 +++ tests/environment.test.ts | 17 ++++++ tests/launcher-browser-host.test.ts | 26 +++++++++ 10 files changed, 231 insertions(+), 20 deletions(-) diff --git a/launcher/electron/browser-host.cjs b/launcher/electron/browser-host.cjs index 003a71409..dcb7279e1 100644 --- a/launcher/electron/browser-host.cjs +++ b/launcher/electron/browser-host.cjs @@ -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; @@ -165,6 +173,11 @@ 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" }); }); @@ -1397,6 +1410,7 @@ module.exports = { BrowserHost, CHATGPT_VIEWPORT_CSS, IDLE_BROWSER_URL, + initializationNavigationWasSuperseded, isTemporaryChatUrl, TEMPORARY_CHAT_URL, }; diff --git a/launcher/tests/browser-host.test.cjs b/launcher/tests/browser-host.test.cjs index 06a9f61ab..e3257d885 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 = { diff --git a/src/adapters/chatgpt-web/browser-worker.ts b/src/adapters/chatgpt-web/browser-worker.ts index 402e53641..4b5fc3ff8 100644 --- a/src/adapters/chatgpt-web/browser-worker.ts +++ b/src/adapters/chatgpt-web/browser-worker.ts @@ -47,6 +47,7 @@ export async function closeChatGptBrowserWorkers(): Promise { 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. @@ -96,6 +97,40 @@ export async function throwIfChatGptTerminalErrorAlert(page: Page): 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, @@ -862,20 +897,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; @@ -1155,7 +1176,12 @@ export class ChatGptBrowserWorker { await throwIfChatGptTerminalErrorAlert(page); - if (mode.localTools && await this.handleToolConfirmation(page)) { + if (mode.localTools && await resolveChatGptToolConfirmation( + page, + this.config.appName, + this.config.autoApproveToolCalls, + turn.abortSignal, + )) { await new Promise(resolveSleep => setTimeout(resolveSleep, 250)); continue; } diff --git a/src/adapters/chatgpt-web/environment.ts b/src/adapters/chatgpt-web/environment.ts index 6dac2cd7c..8605dd288 100644 --- a/src/adapters/chatgpt-web/environment.ts +++ b/src/adapters/chatgpt-web/environment.ts @@ -136,7 +136,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()))); @@ -221,6 +226,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); @@ -239,7 +260,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/turn-execution.ts b/src/adapters/chatgpt-web/turn-execution.ts index 6030e71d1..6ba84e137 100644 --- a/src/adapters/chatgpt-web/turn-execution.ts +++ b/src/adapters/chatgpt-web/turn-execution.ts @@ -128,6 +128,9 @@ export function chatGptTurnExecutionKey(parsed: CodexParsedRequest): string { threadId: identity.threadId, turnId: identity.turnId, purpose: parsed._compactionRequest ? "compaction" : "response", + userInputs: parsed.context.messages + .filter(message => message.role === "user") + .map(message => message.content), }; return createHash("sha256").update(JSON.stringify({ modelId: parsed.modelId, diff --git a/src/launcher-browser-host.ts b/src/launcher-browser-host.ts index 821485dfd..32f9d0a08 100644 --- a/src/launcher-browser-host.ts +++ b/src/launcher-browser-host.ts @@ -219,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", @@ -241,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 } | { diff --git a/tests/browser-worker-contract.test.ts b/tests/browser-worker-contract.test.ts index 7f4463209..2ebf8d60e 100644 --- a/tests/browser-worker-contract.test.ts +++ b/tests/browser-worker-contract.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import type { Page } from "playwright-core"; -import { ChatGptBrowserWorker, ChatGptTurnDomHealthTracker, ChatGptVisibleTraceTracker, MAX_CHATGPT_BROWSER_TABS, assertChatGptWebInputWithinContextWindow, chatGptSubmissionEvidence, isChatGptTraceControl, redactChatGptUiDiagnostic, resolveBrowserConfig, throwIfChatGptRateLimitDialog, throwIfChatGptTerminalErrorAlert } from "../src/adapters/chatgpt-web/browser-worker"; +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", () => { @@ -560,6 +560,65 @@ test("unrelated ChatGPT alerts are not terminal", async () => { 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", diff --git a/tests/chatgpt-web-harness.test.ts b/tests/chatgpt-web-harness.test.ts index 43f5329fb..3ce9f98e7 100644 --- a/tests/chatgpt-web-harness.test.ts +++ b/tests/chatgpt-web-harness.test.ts @@ -278,6 +278,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)); diff --git a/tests/environment.test.ts b/tests/environment.test.ts index 1bf413342..f7c5c9ce1 100644 --- a/tests/environment.test.ts +++ b/tests/environment.test.ts @@ -95,6 +95,23 @@ 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+)", () => { diff --git a/tests/launcher-browser-host.test.ts b/tests/launcher-browser-host.test.ts index 918755ede..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, @@ -108,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)); @@ -139,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")); From a7e61dcea24715229ef1273fae2fb28a95805a0d Mon Sep 17 00:00:00 2001 From: vechen <147659719+miuuyy@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:48:48 +0300 Subject: [PATCH 11/23] Stabilize launcher recovery and Web turns --- LICENSES/tiktoken-MIT.txt | 21 +++ README.md | 4 +- README.zh-CN.md | 3 +- bun.lock | 3 + docs/architecture.md | 17 +- launcher/electron/browser-host.cjs | 103 +++++------- launcher/electron/cdp-input.cjs | 27 +++ launcher/electron/main.cjs | 79 ++++++++- launcher/electron/runtime-install.cjs | 16 +- launcher/electron/runtime-supervisor.cjs | 124 ++++++++------ launcher/electron/runtime.cjs | 109 ++++++++++-- launcher/package.json | 2 +- launcher/scripts/smoke-package.cjs | 3 +- launcher/src/App.tsx | 5 +- launcher/tests/browser-host.test.cjs | 78 +++++++-- launcher/tests/cdp-input.test.cjs | 31 ++++ launcher/tests/design-contract.test.cjs | 8 + launcher/tests/runtime-host.test.cjs | 105 +++++++++++- launcher/tests/runtime-install.test.cjs | 26 ++- launcher/tests/runtime-supervisor.test.cjs | 182 +++++++++++++++++---- package.json | 3 +- scripts/build-runtime-bundle.ts | 9 + scripts/generate-third-party-notices.ts | 14 +- scripts/install-launcher.sh | 2 +- scripts/install.sh | 2 +- scripts/smoke-release.ts | 5 +- src/adapters/chatgpt-web/browser-worker.ts | 54 ++++-- src/adapters/chatgpt-web/environment.ts | 33 ++-- src/adapters/chatgpt-web/index.ts | 9 +- src/adapters/chatgpt-web/mcp-server.ts | 37 ++++- src/adapters/chatgpt-web/prompt.ts | 5 +- src/adapters/chatgpt-web/turn-execution.ts | 7 + src/adapters/chatgpt-web/usage.ts | 25 ++- src/cli.ts | 7 +- src/config.ts | 13 +- src/lib/token-estimate.ts | 73 ++++----- src/responses/compaction.ts | 4 +- src/server.ts | 20 ++- src/version.ts | 2 +- tests/browser-worker-contract.test.ts | 65 +++++++- tests/chatgpt-web-harness.test.ts | 42 ++++- tests/chatgpt-web-usage.test.ts | 31 ++++ tests/cli.test.ts | 72 ++++++++ tests/compaction-v1.test.ts | 8 + tests/environment.test.ts | 24 ++- tests/prompt-contract.test.ts | 3 + tests/server-compaction.test.ts | 29 ++++ tests/token-estimate.test.ts | 29 ++++ 48 files changed, 1251 insertions(+), 322 deletions(-) create mode 100644 LICENSES/tiktoken-MIT.txt create mode 100644 tests/chatgpt-web-usage.test.ts create mode 100644 tests/token-estimate.test.ts diff --git a/LICENSES/tiktoken-MIT.txt b/LICENSES/tiktoken-MIT.txt new file mode 100644 index 000000000..83ed1036f --- /dev/null +++ b/LICENSES/tiktoken-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 OpenAI, Shantanu Jain + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index bb9904178..86d302189 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,9 @@ policies. ## Quick start -Install the desktop launcher: +Install or update the desktop launcher. To update or repair an existing installation, quit the +launcher and run the same command again; it replaces the application and embedded runtime while +preserving the ChatGPT profile and launcher configuration. **macOS or Linux** diff --git a/README.zh-CN.md b/README.zh-CN.md index 9843663eb..e5b0c69f7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -64,7 +64,8 @@ Codex 任务的工具。 ## 快速开始 -安装桌面启动器: +安装或更新桌面启动器。若要更新或修复现有安装,请先退出启动器,然后再次运行同一条命令;它会 +替换应用程序和内置运行时,同时保留 ChatGPT 配置文件和启动器配置。 **macOS 或 Linux** diff --git a/bun.lock b/bun.lock index 09b33ee4b..8493ac92a 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "chromium-bidi": "12.1.0", "fflate": "^0.8.2", "playwright-core": "^1.62.0", + "tiktoken": "1.0.22", "turndown": "7.2.0", "turndown-plugin-gfm": "1.0.2", "zod": "4.4.3", @@ -213,6 +214,8 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "tiktoken": ["tiktoken@1.0.22", "", {}, "sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="], diff --git a/docs/architecture.md b/docs/architecture.md index df16a9384..258ae0b76 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,12 +51,17 @@ the JSON and are attached natively with stable references. The runtime does not JSONL file, upload a synthetic context document, include prompt hashes, or truncate the envelope. Attachment acceptance and send readiness are verified before the turn begins. -ChatGPT owns context compaction inside that browser response. The appended models intentionally -advertise no Codex context window or auto-compaction threshold, and routed compaction v1/v2 calls -fail explicitly instead of opening a second summarizer turn. A prompt-level checkpoint marker is -translated into a visible Codex trace item; tool-capable turns re-bind the same capability after -that checkpoint. Visible ChatGPT status rows become reasoning summaries, while stable prose between -rows becomes native Codex commentary. +The appended models advertise the authenticated account's context window and a ten-percent +auto-compaction reserve. Usage is counted with the GPT-5 tokenizer plus fixed platform/image +reserves, rather than inferred from character length. The ChatGPT composer also has an independent +inline-size boundary: usage accounting asks Codex to compact before that boundary, and a prompt +that still exceeds the proven hard ceiling fails explicitly before any browser turn opens. + +Routed compaction v1/v2 runs as a dedicated read-only browser summarization turn with no broker or +local tools, then returns the native replacement-history shape expected by Codex. A prompt-level +checkpoint marker is translated into a visible Codex trace item; tool-capable turns re-bind the +same capability after that checkpoint. Visible ChatGPT status rows become reasoning summaries, +while stable prose between rows becomes native Codex commentary. ## Installation and service lifecycle diff --git a/launcher/electron/browser-host.cjs b/launcher/electron/browser-host.cjs index dcb7279e1..4c57cb72b 100644 --- a/launcher/electron/browser-host.cjs +++ b/launcher/electron/browser-host.cjs @@ -4,7 +4,9 @@ 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 { + dispatchTrustedClick, dispatchTrustedKey, evaluatePage, } = require("./cdp-input.cjs"); @@ -131,6 +133,7 @@ class BrowserHost { this.helper = helper; this.logger = logger; this.publishState = publishState; + this.dispatchTrustedClick = dispatchTrustedClick; this.dispatchTrustedKey = dispatchTrustedKey; this.evaluatePage = evaluatePage; this.verifyConnectorWithBrowserHelper = verifyConnectorWithBrowserHelper; @@ -648,7 +651,16 @@ class BrowserHost { 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`); + if (processRunning(existing.helperPid)) { + throw new Error(`ChatGPT browser turn ${traceId} is owned by another helper process`); + } + this.logger.warn("browser.stale_turn_owner_replaced", { + tabId: existing.id, + traceId, + previousHelperPid: existing.helperPid, + helperPid, + evidence: "previous helper exited", + }); } existing.helperPid = helperPid; existing.status = "running"; @@ -910,6 +922,19 @@ class BrowserHost { } } + async clickTrustedBrowserPoint(point) { + try { + await this.dispatchTrustedClick({ + debuggerClient: this.view.webContents.debugger, + point, + }); + } catch (error) { + throw new Error( + `ChatGPT trusted browser click failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + async evaluateBrowserPage(expression) { const contents = this.view.webContents; try { @@ -1072,10 +1097,12 @@ class BrowserHost { url: location.href, }; } + const rect = control.getBoundingClientRect(); return { found: true, label: normalize(control.innerText || control.textContent), expanded: control.getAttribute('aria-expanded'), + point: { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }, composer: Boolean(composer), form: true, readyState: document.readyState, @@ -1084,26 +1111,6 @@ class BrowserHost { })()`); } - async focusEffortControl() { - return await this.evaluateBrowserPage(`(() => { - /* effort-control-focus */ - const visible = (element) => { - const style = getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; - }; - const composer = ${visibleElementScript(COMPOSER_SELECTOR)}; - const form = composer?.closest('form'); - const controls = Array.from(form?.querySelectorAll( - 'button[aria-haspopup="menu"][data-tone="neutral"]' - ) || []).filter(visible); - const control = controls.at(-1); - if (!control) return false; - control.focus({ preventScroll: true }); - return document.activeElement === control; - })()`); - } - async waitForEffortControl(timeoutMs, pollMs) { const deadline = Date.now() + timeoutMs; let control; @@ -1150,66 +1157,42 @@ class BrowserHost { if (!candidate || !target) { return { open: Boolean(candidate), count: candidate?.items.length || 0, target: null }; } + const rect = target.getBoundingClientRect(); return { open: true, count: candidate.items.length, target: { label: normalize(target.innerText || target.textContent), checked: target.getAttribute('aria-checked'), + point: { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }, }, }; })()`); } - async focusEffortMenuItem(targetIndex) { - return await this.evaluateBrowserPage(`(() => { - /* effort-menu-focus */ - const targetIndex = ${targetIndex}; - const visible = (element) => { - const style = getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; - }; - const composer = ${visibleElementScript(COMPOSER_SELECTOR)}; - const control = Array.from(composer?.closest('form')?.querySelectorAll( - 'button[aria-haspopup="menu"][data-tone="neutral"]' - ) || []).filter(visible).at(-1); - const controlledId = control?.getAttribute('aria-controls'); - const controlled = controlledId ? document.getElementById(controlledId) : null; - const roots = [ - ...(controlled ? [controlled] : []), - ...Array.from(document.querySelectorAll(${JSON.stringify(EFFORT_MENU_SELECTOR)})), - ]; - const candidates = [...new Set(roots)].filter(visible).map((menu) => ( - Array.from(menu.querySelectorAll('[role="menuitemradio"]')).filter(visible) - )).filter(items => items.length > 0) - .sort((left, right) => right.length - left.length); - const target = candidates[0]?.[targetIndex]; - if (!target) return false; - target.focus({ preventScroll: true }); - return document.activeElement === target; - })()`); - } - async openEffortMenu(targetIndex, timeoutMs, pollMs, knownControl) { - const control = knownControl?.found ? knownControl : await this.readEffortControl(); + let control = knownControl?.found ? knownControl : await this.readEffortControl(); if (!control.found) { throw new Error("ChatGPT effort control disappeared before its menu could open"); } if (control.expanded !== "true") { - if (!await this.focusEffortControl()) { - throw new Error("ChatGPT effort control could not receive focus"); + // Re-resolve immediately before activation so the click is derived from the current + // composer-owned semantic control, never from a stale or hard-coded viewport coordinate. + control = await this.readEffortControl(); + if (!control.found || !control.point) { + throw new Error("ChatGPT effort control disappeared before activation"); } - await this.pressTrustedBrowserKey("Enter"); + await this.clickTrustedBrowserPoint(control.point); } return await this.waitForEffortMenu(targetIndex, timeoutMs, pollMs); } - async chooseEffortMenuItem(targetIndex) { - if (!await this.focusEffortMenuItem(targetIndex)) { - throw new Error(`ChatGPT effort item index ${targetIndex} could not receive focus`); + async chooseEffortMenuItem(targetIndex, knownMenu) { + const menu = knownMenu?.target ? knownMenu : await this.readEffortMenu(targetIndex); + if (!menu.target?.point) { + throw new Error(`ChatGPT effort item index ${targetIndex} disappeared before activation`); } - await this.pressTrustedBrowserKey("Enter"); + await this.clickTrustedBrowserPoint(menu.target.point); } async waitForEffortMenu(targetIndex, timeoutMs, pollMs) { @@ -1247,7 +1230,7 @@ class BrowserHost { this.pressBrowserKey("Escape"); return { effort: "High", changed: false }; } - await this.chooseEffortMenuItem(targetIndex); + await this.chooseEffortMenuItem(targetIndex, menu); const deadline = Date.now() + confirmTimeoutMs; let confirmed = menu; diff --git a/launcher/electron/cdp-input.cjs b/launcher/electron/cdp-input.cjs index 11ef30e6a..c25618940 100644 --- a/launcher/electron/cdp-input.cjs +++ b/launcher/electron/cdp-input.cjs @@ -35,6 +35,32 @@ async function dispatchTrustedKey({ debuggerClient, key }) { }); } +async function dispatchTrustedClick({ debuggerClient, point }) { + if (!Number.isFinite(point?.x) || !Number.isFinite(point?.y)) { + throw new Error("CDP click point is invalid"); + } + const position = { x: point.x, y: point.y }; + await withWebContentsDebugger(debuggerClient, async (sendCommand) => { + await sendCommand("Input.dispatchMouseEvent", { + ...position, + type: "mouseMoved", + button: "none", + }); + await sendCommand("Input.dispatchMouseEvent", { + ...position, + type: "mousePressed", + button: "left", + clickCount: 1, + }); + await sendCommand("Input.dispatchMouseEvent", { + ...position, + type: "mouseReleased", + button: "left", + clickCount: 1, + }); + }); +} + async function evaluatePage({ debuggerClient, expression }) { if (typeof expression !== "string" || !expression.trim()) { throw new Error("CDP evaluation expression is required"); @@ -56,6 +82,7 @@ async function evaluatePage({ debuggerClient, expression }) { } module.exports = { + dispatchTrustedClick, dispatchTrustedKey, evaluatePage, withWebContentsDebugger, diff --git a/launcher/electron/main.cjs b/launcher/electron/main.cjs index d1cf3d8f3..e10537956 100644 --- a/launcher/electron/main.cjs +++ b/launcher/electron/main.cjs @@ -154,6 +154,28 @@ function startCatalogVerificationMonitor({ logger, stateStore }) { void check(); } +async function restoreCodexRouteAfterRuntimeFailure({ logger, stateStore }) { + try { + const route = await runtimeHost.restoreBridgeRoute("runtime-start-fail-safe"); + if (!route.installed || route.active) return { restored: false }; + const state = stateStore.update({ + bridgeEnabled: false, + codexCatalogVerified: false, + codexRestartRequired: true, + }); + send("launcher:state-changed", state); + stopCatalogVerificationMonitor(); + logger.warn("bridge.route_restored_after_runtime_failure", { + changed: route.changed === true, + }); + return { restored: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error("bridge.route_restore_after_runtime_failure_failed", { message }); + return { restored: false, error: message }; + } +} + function trayImage() { if (process.platform !== "darwin") { return nativeImage.createFromPath(APP_ICON_PATH).resize({ width: 18, height: 18 }); @@ -701,6 +723,27 @@ async function start() { return; } void (async () => { + const upgrade = await runtimeHost.upgradeManagedRuntime(); + if (upgrade.updated) { + const state = stateStore.update({ + bridgeEnabled: upgrade.bridgeEnabled, + coreSetupComplete: true, + codexCatalogVerified: false, + codexRestartRequired: true, + ...(upgrade.mode === "browser-only" ? { + mcpRuntimeInstalled: false, + mcpSetupComplete: false, + mcpGuideStep: 0, + } : {}), + }); + send("launcher:state-changed", state); + logger.info("runtime.release_upgraded", { + fromVersion: upgrade.fromVersion, + toVersion: upgrade.toVersion, + mode: upgrade.mode, + bridgeEnabled: upgrade.bridgeEnabled, + }); + } try { const route = await runtimeHost.bridgeStatus(); if (route.installed) { @@ -717,7 +760,7 @@ async function start() { }); } return runtimeSupervisor.startIfConfigured(); - })().then((runtime) => { + })().then(async (runtime) => { if (runtime.status === "bridge-disabled") { stopCatalogVerificationMonitor(); return; @@ -740,6 +783,7 @@ async function start() { return; } if (runtime.status === "not-configured") { + const routeRecovery = await restoreCodexRouteAfterRuntimeFailure({ logger, stateStore }); const current = stateStore.read(); if (current.coreSetupComplete || current.mcpRuntimeInstalled || current.mcpSetupComplete) { const state = stateStore.update({ @@ -751,23 +795,42 @@ async function start() { }); send("launcher:state-changed", state); } + if (routeRecovery.error) { + publishOperation({ + name: "runtime-start", + status: "failed", + message: `Local runtime is not configured; restoring the previous Codex route also failed: ${routeRecovery.error}`, + }); + } return; } + const routeRecovery = await restoreCodexRouteAfterRuntimeFailure({ logger, stateStore }); const state = stateStore.update({ coreSetupComplete: false, codexCatalogVerified: false }); send("launcher:state-changed", state); if (runtime.status === "external" || runtime.status === "needs-setup") { + const detail = runtime.detail || ( + runtime.status === "external" + ? "Another process owns the configured Codex Web GPT runtime" + : "The installed runtime configuration must be repaired from Setup" + ); publishOperation({ name: "runtime-start", status: "failed", - message: runtime.detail || ( - runtime.status === "external" - ? "Another process owns the configured Codex Web GPT runtime" - : "The installed runtime configuration must be repaired from Setup" - ), + message: routeRecovery.error + ? `${detail}; restoring the previous Codex route also failed: ${routeRecovery.error}` + : routeRecovery.restored + ? `${detail}; the previous Codex route was restored, restart Codex once` + : detail, }); } - }).catch((error) => { - const message = error instanceof Error ? error.message : String(error); + }).catch(async (error) => { + const primary = error instanceof Error ? error.message : String(error); + const routeRecovery = await restoreCodexRouteAfterRuntimeFailure({ logger, stateStore }); + const message = routeRecovery.error + ? `${primary}; restoring the previous Codex route also failed: ${routeRecovery.error}` + : routeRecovery.restored + ? `${primary}; the previous Codex route was restored, restart Codex once` + : primary; logger.error("runtime.startup_failed", { message }); const state = stateStore.update({ coreSetupComplete: false, codexCatalogVerified: false }); send("launcher:state-changed", state); diff --git a/launcher/electron/runtime-install.cjs b/launcher/electron/runtime-install.cjs index 48a840493..8b3fee28e 100644 --- a/launcher/electron/runtime-install.cjs +++ b/launcher/electron/runtime-install.cjs @@ -3,14 +3,16 @@ const path = require("node:path"); const { renameAtomicFile } = require("./atomic-file.cjs"); const { runtimeBundlePaths } = require("./runtime-command.cjs"); -function validateRuntimeBundle(runtimeRoot, { version, platform, arch }) { +function validateRuntimeBundle(runtimeRoot, { version, platform, arch, bundleId }) { const manifestPath = path.join(runtimeRoot, "manifest.json"); if (!fs.existsSync(manifestPath)) throw new Error(`Runtime manifest is missing: ${manifestPath}`); const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); if (manifest.schemaVersion !== 1 || manifest.appVersion !== version || manifest.platform !== platform - || manifest.arch !== arch) { + || manifest.arch !== arch + || !/^[a-f0-9]{64}$/.test(manifest.bundleId) + || (bundleId && manifest.bundleId !== bundleId)) { throw new Error( `Runtime bundle identity mismatch: expected ${version} ${platform}/${arch}, received ${JSON.stringify(manifest)}`, ); @@ -36,6 +38,8 @@ function ensurePackagedRuntime({ app, coreHome, resourcesPath }) { }; const source = path.join(resourcesPath, "runtime"); validateRuntimeBundle(source, identity); + const sourceManifest = JSON.parse(fs.readFileSync(path.join(source, "manifest.json"), "utf8")); + const expectedIdentity = { ...identity, bundleId: sourceManifest.bundleId }; const versionsRoot = path.join(coreHome, "versions"); const destination = path.join( versionsRoot, @@ -43,7 +47,7 @@ function ensurePackagedRuntime({ app, coreHome, resourcesPath }) { ); if (fs.existsSync(destination)) { try { - return validateRuntimeBundle(destination, identity); + return validateRuntimeBundle(destination, expectedIdentity); } catch { // A terminated installer or external cleanup can leave a version directory present but // incomplete. Rebuild the launcher-owned bundle transactionally from the signed package. @@ -56,14 +60,14 @@ function ensurePackagedRuntime({ app, coreHome, resourcesPath }) { let previousMoved = false; try { fs.cpSync(source, temporary, { recursive: true, errorOnExist: true, force: false }); - validateRuntimeBundle(temporary, identity); + validateRuntimeBundle(temporary, expectedIdentity); if (fs.existsSync(destination)) { renameAtomicFile(destination, previous); previousMoved = true; } try { renameAtomicFile(temporary, destination); - validateRuntimeBundle(destination, identity); + validateRuntimeBundle(destination, expectedIdentity); } catch (error) { fs.rmSync(destination, { recursive: true, force: true }); if (previousMoved) { @@ -91,7 +95,7 @@ function ensurePackagedRuntime({ app, coreHome, resourcesPath }) { } } try { fs.chmodSync(destination, 0o700); } catch {} - return validateRuntimeBundle(destination, identity); + return validateRuntimeBundle(destination, expectedIdentity); } module.exports = { diff --git a/launcher/electron/runtime-supervisor.cjs b/launcher/electron/runtime-supervisor.cjs index 0b35a56f9..c8aeb1e84 100644 --- a/launcher/electron/runtime-supervisor.cjs +++ b/launcher/electron/runtime-supervisor.cjs @@ -96,6 +96,12 @@ function tunnelRuntimeStopped(health) { || (health?.state === "stopped" && health?.processRunning === false); } +function runtimeOwnershipMayBeLive(state) { + if (!state) return false; + if (processRunning(state.daemonPid) || processRunning(state.tunnelPid)) return true; + return ["starting", "ready", "degraded", "stopping"].includes(state.status); +} + function conciseTunnelLog(value) { if (typeof value !== "string" || !value.trim()) return undefined; const tail = value.trim().split(/\r?\n/).slice(-3).join(" | "); @@ -143,13 +149,18 @@ function tunnelCommandQuoted(value) { return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; } -function managedTunnelMcpCommand(config) { - return [...config.runtimeCommand, "mcp", "--broker-socket", config.brokerSocketPath] +function managedTunnelMcpCommand(invocation) { + if (!invocation + || typeof invocation.executable !== "string" + || !Array.isArray(invocation.args)) { + throw new Error("Launcher tunnel MCP command requires an explicit runtime invocation"); + } + return [invocation.executable, ...invocation.args] .map(tunnelCommandQuoted) .join(" "); } -function managedTunnelConnectArgs(config) { +function managedTunnelConnectArgs(config, invocation) { const tunnel = config.tunnel; if (!tunnel) throw new Error("launcher-owned tunnel has no runtime configuration"); return [ @@ -160,7 +171,7 @@ function managedTunnelConnectArgs(config) { "--tunnel-client-bin", tunnel.binaryPath, "--tunnel-id", tunnel.tunnelId, "--runtime-api-key", `file:${tunnel.runtimeKeyFile}`, - "--mcp-command", managedTunnelMcpCommand(config), + "--mcp-command", managedTunnelMcpCommand(invocation), "--json", ]; } @@ -513,11 +524,14 @@ class RuntimeSupervisor { async readTunnelHealth(config) { const tunnel = config.tunnel; + // `runtimes status` performs an optional control-plane lookup when the saved runtime key is + // available. The cleanup dry run is the official local-only inventory and never removes + // entries without `--apply`, so proxy or control-plane failures cannot block supervision. const result = await this.runTunnelCommand( config, - ["runtimes", "status", tunnel.alias, "--json"], + ["runtimes", "cleanup", "--json"], 5_000, - "Tunnel health probe", + "Local tunnel inventory probe", ); if (result.code !== 0) { return { @@ -526,60 +540,67 @@ class RuntimeSupervisor { state: undefined, processRunning: undefined, healthy: undefined, - absent: tunnelRuntimeAbsent(result.output), - statusKnown: tunnelRuntimeAbsent(result.output), + absent: false, + statusKnown: false, detail: tunnelControlDiagnostic(result), }; } try { const parsed = JSON.parse(result.output); - const healthBaseUrl = loopbackHealthBaseURL(parsed.health_url ?? parsed.healthUrl); + if (!Array.isArray(parsed.entries)) throw new Error("local inventory has no entries array"); + const entry = parsed.entries.find(candidate => candidate?.alias === tunnel.alias); + if (!entry) { + return { + ready: false, + pid: null, + state: "stopped", + processRunning: false, + healthy: false, + absent: true, + statusKnown: true, + detail: `alias=${tunnel.alias}; local_inventory=absent`, + }; + } + const runtimeState = entry.runtime_state; + if (!["stopped", "starting", "healthy", "ready"].includes(runtimeState)) { + throw new Error(`local inventory reported unsupported runtime_state=${String(runtimeState)}`); + } + const liveRuntime = entry.live_runtime && typeof entry.live_runtime === "object" + ? entry.live_runtime + : {}; + const healthBaseUrl = loopbackHealthBaseURL(liveRuntime.base_url); if (healthBaseUrl) this.tunnelHealthBaseUrl = healthBaseUrl; - const pid = Number.isInteger(parsed.pid) && parsed.pid > 0 - ? parsed.pid - : Number.isInteger(parsed.process?.pid) && parsed.process.pid > 0 - ? parsed.process.pid + const pid = Number.isInteger(liveRuntime.system?.pid) && liveRuntime.system.pid > 0 + ? liveRuntime.system.pid + : Number.isInteger(liveRuntime.status?.pid) && liveRuntime.status.pid > 0 + ? liveRuntime.status.pid : null; - const runtimeState = parsed.runtime_state ?? parsed.state ?? parsed.status; - const issues = Array.isArray(parsed.local?.issues) - ? parsed.local.issues.filter(issue => typeof issue === "string").slice(0, 3) - : []; - const logTail = typeof parsed.launch_diagnostics?.log_tail === "string" - ? parsed.launch_diagnostics.log_tail - : typeof parsed.local?.log?.tail === "string" - ? parsed.local.log.tail - : undefined; - const conciseLog = conciseTunnelLog(logTail); + const processRunning = runtimeState !== "stopped"; + const healthy = runtimeState === "healthy" || runtimeState === "ready"; + const ready = runtimeState === "ready"; const detail = [ ["state", runtimeState], - ["process_running", parsed.process_running ?? parsed.processRunning], - ["healthy", parsed.healthy], - ["ready", parsed.ready], - ["health_url", parsed.health_url || parsed.healthUrl ? "present" : "missing"], + ["process_running", processRunning], + ["healthy", healthy], + ["ready", ready], + ["classification", entry.classification], + ["live_admin", liveRuntime.found === true], ["pid", pid ?? "missing"], ] .filter(([, value]) => value !== undefined) .map(([key, value]) => `${key}=${String(value)}`) - .concat(issues) - .concat(conciseLog ? [`runtime_log=${conciseLog}`] : []) .join("; "); return { - ready: parsed.process_running === true - && parsed.healthy === true - && parsed.ready === true, + ready, pid, - state: typeof runtimeState === "string" ? runtimeState : undefined, - processRunning: typeof parsed.process_running === "boolean" - ? parsed.process_running - : typeof parsed.processRunning === "boolean" - ? parsed.processRunning - : undefined, - healthy: typeof parsed.healthy === "boolean" ? parsed.healthy : undefined, + state: runtimeState, + processRunning, + healthy, absent: false, statusKnown: true, - detail: redactText(detail || "status JSON did not expose tunnel readiness fields").slice(0, 2_000), + detail: redactText(detail).slice(0, 2_000), }; - } catch { + } catch (error) { return { ready: false, pid: null, @@ -588,7 +609,8 @@ class RuntimeSupervisor { healthy: undefined, absent: false, statusKnown: false, - detail: `status command returned invalid JSON: ${redactText(result.output || "[empty]").slice(0, 500)}`, + detail: `local inventory returned invalid JSON: ${errorMessage(error)};` + + ` ${redactText(result.output || "[empty]").slice(0, 500)}`, }; } } @@ -804,9 +826,10 @@ class RuntimeSupervisor { } async runTunnelConnectCommand(config) { + const invocation = this.runtimeCommand(["mcp", "--broker-socket", config.brokerSocketPath]); return await this.runTunnelCommand( config, - managedTunnelConnectArgs(config), + managedTunnelConnectArgs(config, invocation), TUNNEL_START_TIMEOUT_MS, "Tunnel managed startup", ); @@ -956,7 +979,8 @@ class RuntimeSupervisor { return { status: "not-configured" }; } if (config.releaseVersion !== this.app.getVersion()) { - if (await this.proxyHealth(config) || this.readState()) { + const ownershipState = this.readState(); + if (await this.proxyHealth(config) || runtimeOwnershipMayBeLive(ownershipState)) { try { const recovered = await this.stopStaleOwnedRuntime(config); if (!recovered) { @@ -980,7 +1004,7 @@ class RuntimeSupervisor { if (!this.daemon && !this.tunnel) { const healthyRuntime = await this.proxyHealth(config); const ownershipState = this.readState(); - if (healthyRuntime || ownershipState) { + if (healthyRuntime || runtimeOwnershipMayBeLive(ownershipState)) { try { const recovered = await this.stopStaleOwnedRuntime(config); if (!recovered) { @@ -1621,19 +1645,23 @@ class RuntimeSupervisor { let drained = false; let tunnelStopped = false; try { - if (config?.mode === "full" && !this.tunnel) { + const ownershipState = this.readState(); + const healthyRuntime = config ? await this.proxyHealth(config) : false; + const runtimeMayBeLive = healthyRuntime || runtimeOwnershipMayBeLive(ownershipState); + if (config?.mode === "full" + && !this.tunnel + && (runtimeMayBeLive || !ownershipState)) { await this.adoptConfiguredTunnelForStop(config); } if (!this.daemon && !this.tunnel) { if (!config) { - const ownershipState = this.readState(); if (ownershipState && ( processRunning(ownershipState.daemonPid) || processRunning(ownershipState.tunnelPid) )) { throw new Error("runtime configuration is missing while launcher ownership processes are still alive"); } - } else if (await this.proxyHealth(config) || this.readState()) { + } else if (runtimeMayBeLive) { const recovered = await this.stopStaleOwnedRuntime(config); if (!recovered) { throw new Error("an existing runtime could not be safely recovered"); diff --git a/launcher/electron/runtime.cjs b/launcher/electron/runtime.cjs index f6493f629..b0875c53a 100644 --- a/launcher/electron/runtime.cjs +++ b/launcher/electron/runtime.cjs @@ -529,6 +529,31 @@ class RuntimeHost { return parseBridgeRouteResult(result.stdout, { requireInstalled: true }); } + async restoreBridgeRouteWithinOperation(operationName) { + const current = await this.bridgeStatus(operationName); + if (!current.installed || !current.active) return current; + const disconnected = await this.run(operationName, ["route", "disconnect"], { + embedded: true, + message: "Restoring the previous Codex route", + successMessage: "Previous Codex route restored", + timeoutMs: 15_000, + }); + return { + ...parseBridgeRouteResult(disconnected.stdout, { expectedActive: false }), + installed: true, + }; + } + + async restoreBridgeRoute(operationName = "bridge-route-restore") { + if (this.currentOperation()) throw new Error(`Another launcher operation is active: ${this.currentOperation()}`); + this.lifecycleOperation = operationName; + try { + return await this.restoreBridgeRouteWithinOperation(operationName); + } finally { + this.lifecycleOperation = null; + } + } + async setBridgeEnabled(enabled) { const desired = enabled === true; const name = desired ? "bridge-connect" : "bridge-disconnect"; @@ -618,26 +643,42 @@ class RuntimeHost { const previousRuntime = this.runtimeConfigSnapshot(); this.lifecycleOperation = name; try { - if (previousRuntime.owner === "external") this.supervisor.prepareExternalMigration(); - else await this.supervisor.stopForSetup(); - return await this.run(name, ["uninstall", "--yes", "--launcher-control"], { - embedded: true, - env: this.launcherControlEnvironment(), - message: "Restoring the previous Codex route", - successMessage: "Codex Web GPT integration removed", - timeoutMs: UNINSTALL_TIMEOUT_MS, - }); - } catch (error) { - let recoveryError; try { - await this.restorePreviousRuntime(previousRuntime, name); - } catch (caught) { - recoveryError = caught; + if (previousRuntime.owner === "external") this.supervisor.prepareExternalMigration(); + else await this.supervisor.stopForSetup(); + } catch (error) { + try { + await this.restoreBridgeRouteWithinOperation(name); + } catch (routeError) { + throw new Error( + `${error instanceof Error ? error.message : String(error)}; restoring the previous Codex route also failed:` + + ` ${routeError instanceof Error ? routeError.message : String(routeError)}`, + ); + } + throw new Error( + `${error instanceof Error ? error.message : String(error)}; the previous Codex route was restored,` + + " but launcher runtime cleanup did not complete", + ); + } + try { + return await this.run(name, ["uninstall", "--yes", "--launcher-control"], { + embedded: true, + env: this.launcherControlEnvironment(), + message: "Restoring the previous Codex route", + successMessage: "Codex Web GPT integration removed", + timeoutMs: UNINSTALL_TIMEOUT_MS, + }); + } catch (error) { + try { + await this.restoreBridgeRouteWithinOperation(name); + } catch (routeError) { + throw new Error( + `${error instanceof Error ? error.message : String(error)}; restoring the previous Codex route also failed:` + + ` ${routeError instanceof Error ? routeError.message : String(routeError)}`, + ); + } + throw error; } - if (!recoveryError) throw error; - const primary = error instanceof Error ? error.message : String(error); - const recovery = recoveryError instanceof Error ? recoveryError.message : String(recoveryError); - throw new Error(`${primary}; restoring the launcher runtime also failed: ${recovery}`); } finally { this.lifecycleOperation = null; } @@ -663,6 +704,38 @@ class RuntimeHost { return { ...result, mode }; } + async upgradeManagedRuntime() { + if (this.currentOperation()) throw new Error(`Another launcher operation is active: ${this.currentOperation()}`); + const existing = this.runtimeConfigSnapshot(); + const currentVersion = this.app.getVersion(); + if (existing.owner !== "launcher" || existing.config?.releaseVersion === currentVersion) { + return { updated: false }; + } + const route = await this.bridgeStatus("runtime-upgrade-route"); + const args = [ + "setup", + existing.mode === "full" ? "--full" : "--browser-only", + "--browser-host-descriptor", + this.browserDescriptorPath, + "--acknowledge-unofficial", + "--restart-service", + ]; + const result = await this.runSetup("runtime-upgrade", args, { + message: `Upgrading launcher runtime from ${existing.config.releaseVersion} to ${currentVersion}`, + successMessage: `Launcher runtime upgraded to ${currentVersion}`, + timeoutMs: existing.mode === "full" ? MCP_SETUP_TIMEOUT_MS : CORE_SETUP_TIMEOUT_MS, + }); + if (!route.active) await this.setBridgeEnabled(false); + return { + updated: true, + mode: existing.mode, + bridgeEnabled: route.active, + fromVersion: existing.config.releaseVersion, + toVersion: currentVersion, + stdout: result.stdout, + }; + } + setupMcp({ tunnelId = "", runtimeKey = "", replace = false } = {}) { if (this.currentOperation()) throw new Error(`Another launcher operation is active: ${this.currentOperation()}`); const reuseSavedCredentials = replace !== true && this.mcpCredentialsConfigured(); diff --git a/launcher/package.json b/launcher/package.json index a51303532..99d67788c 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -1,6 +1,6 @@ { "name": "codex-web-gpt-launcher", - "version": "1.1.2", + "version": "1.1.3", "private": true, "description": "Desktop control center for Codex ChatGPT Web", "author": "miuuyy", diff --git a/launcher/scripts/smoke-package.cjs b/launcher/scripts/smoke-package.cjs index 7bea273ad..31fff3e25 100644 --- a/launcher/scripts/smoke-package.cjs +++ b/launcher/scripts/smoke-package.cjs @@ -105,7 +105,8 @@ try { ); if (installedManifest.appVersion !== expectedVersion || installedManifest.platform !== process.platform - || installedManifest.arch !== process.arch) { + || installedManifest.arch !== process.arch + || !/^[a-f0-9]{64}$/.test(installedManifest.bundleId)) { throw new Error(`Packaged launcher installed the wrong durable runtime: ${JSON.stringify(installedManifest)}`); } process.stdout.write(`PACKAGED_LAUNCHER_SMOKE_OK ${process.platform}/${process.arch}\n`); diff --git a/launcher/src/App.tsx b/launcher/src/App.tsx index 4440eff10..c76d604c2 100644 --- a/launcher/src/App.tsx +++ b/launcher/src/App.tsx @@ -1383,6 +1383,9 @@ function FieldRow({ children, label }: { children: ReactNode; label: string }) { } function DoctorSummary({ copy, report }: { copy: Copy; report: DoctorReport }) { + const visibleChecks = report.ok + ? report.checks.slice(-6) + : report.checks.filter((check) => check.status !== "ok"); return (
@@ -1390,7 +1393,7 @@ function DoctorSummary({ copy, report }: { copy: Copy; report: DoctorReport }) { {report.ok ? copy.healthy : copy.needsAttention}
- {report.checks.slice(-6).map((check) => ( + {visibleChecks.map((check) => (

{check.message} diff --git a/launcher/tests/browser-host.test.cjs b/launcher/tests/browser-host.test.cjs index e3257d885..3e1906ec6 100644 --- a/launcher/tests/browser-host.test.cjs +++ b/launcher/tests/browser-host.test.cjs @@ -293,25 +293,27 @@ test("smoke effort selection uses trusted input and semantic checked state", asy assert.match(source, /\[role="group"\]:has\(\[role="menuitemradio"\]\)/); assert.match(source, /\[role="menuitemradio"\]/); assert.match(cdpSource, /Input\.dispatchKeyEvent/); + assert.match(cdpSource, /Input\.dispatchMouseEvent/); assert.match(cdpSource, /debuggerClient/); assert.doesNotMatch(source, /:popover-open/); assert.doesNotMatch(source, /data-radix-collection-item/); let controlReads = 0; let menuReads = 0; + const trustedClicks = []; const trustedKeys = []; const inputEvents = []; const fixture = { pressBrowserKey: BrowserHost.prototype.pressBrowserKey, pressTrustedBrowserKey: BrowserHost.prototype.pressTrustedBrowserKey, + clickTrustedBrowserPoint: BrowserHost.prototype.clickTrustedBrowserPoint, readEffortControl: BrowserHost.prototype.readEffortControl, - focusEffortControl: BrowserHost.prototype.focusEffortControl, readEffortMenu: BrowserHost.prototype.readEffortMenu, - focusEffortMenuItem: BrowserHost.prototype.focusEffortMenuItem, waitForEffortControl: BrowserHost.prototype.waitForEffortControl, waitForEffortMenu: BrowserHost.prototype.waitForEffortMenu, openEffortMenu: BrowserHost.prototype.openEffortMenu, chooseEffortMenuItem: BrowserHost.prototype.chooseEffortMenuItem, + dispatchTrustedClick: async (input) => trustedClicks.push(input), dispatchTrustedKey: async (input) => trustedKeys.push(input), evaluatePage: async ({ expression }) => { if (expression.includes("effort-control-read")) { @@ -333,7 +335,6 @@ test("smoke effort selection uses trusted input and semantic checked state", asy url: "https://chatgpt.com/?temporary-chat=true", }; } - if (expression.includes("effort-control-focus")) return true; if (expression.includes("effort-menu-read")) { menuReads += 1; if ([1, 3].includes(menuReads)) { @@ -349,7 +350,6 @@ test("smoke effort selection uses trusted input and semantic checked state", asy }, }; } - if (expression.includes("effort-menu-focus")) return true; throw new Error("Unexpected browser script"); }, evaluateBrowserPage: BrowserHost.prototype.evaluateBrowserPage, @@ -370,13 +370,14 @@ test("smoke effort selection uses trusted input and semantic checked state", asy }); assert.deepEqual(result, { effort: "High", changed: true }); - assert.equal(controlReads, 3); + assert.equal(controlReads, 5); assert.equal(menuReads, 4); - assert.deepEqual(trustedKeys, [ - { debuggerClient: {}, key: "Enter" }, - { debuggerClient: {}, key: "Enter" }, - { debuggerClient: {}, key: "Enter" }, + assert.deepEqual(trustedClicks, [ + { debuggerClient: {}, point: { x: 120, y: 80 } }, + { debuggerClient: {}, point: { x: 160, y: 140 } }, + { debuggerClient: {}, point: { x: 120, y: 80 } }, ]); + assert.deepEqual(trustedKeys, []); assert.deepEqual(inputEvents, [ { type: "keyDown", keyCode: "Escape" }, { type: "keyUp", keyCode: "Escape" }, @@ -643,8 +644,65 @@ test("connector verification is effort-independent and works while the browser s test("connector verification has no independent CDP typing or coordinate-click path", () => { const source = fs.readFileSync(path.join(__dirname, "../electron/browser-host.cjs"), "utf8"); + const start = source.indexOf("async runConnectorVerification"); + const end = source.indexOf("async inspectSession", start); + const verificationSource = source.slice(start, end); assert.match(source, /verifyConnectorWithBrowserHelper/); - assert.doesNotMatch(source, /typeTrustedBrowserText|clickTrustedBrowserPoint|connectorMenuOpen|waitForConnectorSuggestion/); + assert.doesNotMatch(verificationSource, /typeTrustedBrowserText|clickTrustedBrowserPoint|connectorMenuOpen|waitForConnectorSuggestion/); +}); + +test("a live helper retains exclusive ownership of its running turn", () => { + const tab = { + id: "tab-live-owner", + traceId: "trace_live_owner", + helperPid: process.pid, + status: "running", + }; + assert.throws( + () => BrowserHost.prototype.beginTurn.call({ + manualOperation: null, + turnTabs: new Map([[tab.id, tab]]), + }, tab.traceId, false, process.pid + 1), + /owned by another helper process/, + ); +}); + +test("a replacement helper takes over only after the previous owner exited", () => { + const deadPid = 2_147_483_647; + const tab = { + id: "tab-dead-owner", + surfaceId: "surface-dead-owner", + traceId: "trace_dead_owner", + helperPid: deadPid, + status: "running", + loading: true, + message: "ChatGPT is working", + view: { + webContents: { + isDestroyed: () => false, + setBackgroundThrottling() {}, + }, + }, + }; + const warnings = []; + const fixture = Object.assign(Object.create(BrowserHost.prototype), { + manualOperation: null, + turnTabs: new Map([[tab.id, tab]]), + selectedTabId: "home", + syncViewVisibility() {}, + snapshot: () => ({ tabs: [] }), + publishState() {}, + writeDescriptor() {}, + logger: { info() {}, warn: (event, detail) => warnings.push([event, detail]) }, + }); + + const lease = BrowserHost.prototype.beginTurn.call(fixture, tab.traceId, false, process.pid); + + assert.deepEqual(lease, { surfaceId: tab.surfaceId, tabId: tab.id }); + assert.equal(tab.helperPid, process.pid); + assert.equal(warnings.length, 1); + assert.equal(warnings[0][0], "browser.stale_turn_owner_replaced"); + assert.equal(warnings[0][1].previousHelperPid, deadPid); }); test("connector verification preserves an already hydrated Temporary Chat page", async () => { diff --git a/launcher/tests/cdp-input.test.cjs b/launcher/tests/cdp-input.test.cjs index d7e3e1eb8..13a58cc87 100644 --- a/launcher/tests/cdp-input.test.cjs +++ b/launcher/tests/cdp-input.test.cjs @@ -1,6 +1,7 @@ const assert = require("node:assert/strict"); const test = require("node:test"); const { + dispatchTrustedClick, dispatchTrustedKey, evaluatePage, } = require("../electron/cdp-input.cjs"); @@ -84,6 +85,36 @@ test("trusted Enter is dispatched through the owned Electron WebContents target" assert.equal(detached(), true); }); +test("trusted pointer activation is dispatched at the resolved DOM point", async () => { + const { client, commands, detached } = createDebugger(); + await dispatchTrustedClick({ + debuggerClient: client, + point: { x: 123.5, y: 88.25 }, + }); + assert.deepEqual(commands, [ + { + method: "Input.dispatchMouseEvent", + params: { type: "mouseMoved", x: 123.5, y: 88.25, button: "none" }, + }, + { + method: "Input.dispatchMouseEvent", + params: { type: "mousePressed", x: 123.5, y: 88.25, button: "left", clickCount: 1 }, + }, + { + method: "Input.dispatchMouseEvent", + params: { type: "mouseReleased", x: 123.5, y: 88.25, button: "left", clickCount: 1 }, + }, + ]); + assert.equal(detached(), true); +}); + +test("trusted pointer activation rejects a missing DOM point", async () => { + await assert.rejects( + dispatchTrustedClick({ debuggerClient: createDebugger().client, point: null }), + /CDP click point is invalid/, + ); +}); + test("pre-attached WebContents debugger ownership is preserved", async () => { const { client, detached } = createDebugger([{ result: { type: "number", value: 5 }, diff --git a/launcher/tests/design-contract.test.cjs b/launcher/tests/design-contract.test.cjs index d1fbe42f0..ec08fd20a 100644 --- a/launcher/tests/design-contract.test.cjs +++ b/launcher/tests/design-contract.test.cjs @@ -102,6 +102,14 @@ test("settings expose a persistent fail-closed Codex bridge switch and status in assert.match(i18nSource, /Turning it off restores your previous model route without deleting setup or saved credentials/); }); +test("doctor summary never hides failed checks behind trailing healthy checks", () => { + assert.match( + appSource, + /report\.ok\s*\?\s*report\.checks\.slice\(-6\)\s*:\s*report\.checks\.filter\(\(check\) => check\.status !== "ok"\)/, + ); + assert.match(appSource, /visibleChecks\.map\(\(check\) =>/); +}); + test("settings use a dark custom language menu and quiet native scrollbars", () => { assert.doesNotMatch(appSource, /