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/17] 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/17] 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/17] 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 aae64801e9a9592ba3bce6a69d8fe42735dc5dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=83=81=ED=9B=88?= Date: Sun, 2 Aug 2026 21:49:24 +0900 Subject: [PATCH 04/17] Preserve OCX route for full MCP setup --- launcher/electron/main.cjs | 11 +++++++---- launcher/electron/runtime.cjs | 7 ++++++- launcher/package.json | 2 +- launcher/tests/runtime-host.test.cjs | 9 ++++++++- package.json | 2 +- scripts/install.sh | 2 +- src/cli.ts | 8 +++++++- src/setup.ts | 29 ++++++++++++++++++++-------- src/version.ts | 2 +- tests/setup-lifecycle.test.ts | 11 ++++++++++- 10 files changed, 63 insertions(+), 20 deletions(-) diff --git a/launcher/electron/main.cjs b/launcher/electron/main.cjs index 3120ff0e1..a0f30ba1d 100644 --- a/launcher/electron/main.cjs +++ b/launcher/electron/main.cjs @@ -24,7 +24,7 @@ const { installProcessDiagnosticGuards, registerLoggedIpc, } = require("./logging.cjs"); -const { RuntimeHost } = require("./runtime.cjs"); +const { RuntimeHost, launcherRuntimeAllowedForRoute } = require("./runtime.cjs"); const { ensurePackagedRuntime } = require("./runtime-install.cjs"); const { RuntimeSupervisor } = require("./runtime-supervisor.cjs"); const { createStateStore, validateSidebarState } = require("./state.cjs"); @@ -511,7 +511,7 @@ function registerIpc({ logger, stateStore }) { runtimeKey: typeof input?.runtimeKey === "string" ? input.runtimeKey : "", replace: input?.replace === true, }); - stateStore.update({ mcpRuntimeInstalled: true, mcpGuideStep: 2, codexRestartRequired: true }); + stateStore.update({ mcpRuntimeInstalled: true, mcpGuideStep: 2, codexRestartRequired: false }); return { ok: true, stdout: result.stdout }; }); handle("launcher:set-mcp-step", (_event, step) => { @@ -725,7 +725,9 @@ async function start() { const state = stateStore.update({ bridgeEnabled: route.active }); send("launcher:state-changed", state); } - if (!route.active) return { status: "bridge-disabled" }; + let config = null; + try { config = runtimeSupervisor.readConfig(); } catch {} + if (!launcherRuntimeAllowedForRoute(route, config)) return { status: "bridge-disabled" }; } } catch (error) { logger.warn("bridge.route_status_failed", { @@ -752,7 +754,8 @@ async function start() { const state = stateStore.update(patch); send("launcher:state-changed", state); } - startCatalogVerificationMonitor({ logger, stateStore }); + if (current.bridgeEnabled) startCatalogVerificationMonitor({ logger, stateStore }); + else stopCatalogVerificationMonitor(); return; } if (runtime.status === "not-configured") { diff --git a/launcher/electron/runtime.cjs b/launcher/electron/runtime.cjs index f6493f629..78f8b6117 100644 --- a/launcher/electron/runtime.cjs +++ b/launcher/electron/runtime.cjs @@ -12,6 +12,10 @@ const MAX_CAPTURE_BYTES = 8 * 1024 * 1024; const MAX_RUNTIME_LOG_LINE_CHARS = 64 * 1024; const CORE_SETUP_TIMEOUT_MS = 5 * 60_000; const MCP_SETUP_TIMEOUT_MS = 10 * 60_000; + +function launcherRuntimeAllowedForRoute(route, config) { + return route?.active === true || config?.mode === "full"; +} const UNINSTALL_TIMEOUT_MS = 2 * 60_000; const MAX_CHECKPOINT_FILE_BYTES = 16 * 1024 * 1024; @@ -677,6 +681,7 @@ class RuntimeHost { "--full", "--browser-host-descriptor", this.browserDescriptorPath, + "--preserve-codex-route", ]; if (reuseSavedCredentials) { args.push("--acknowledge-unofficial", "--restart-service"); @@ -777,4 +782,4 @@ class RuntimeHost { } } -module.exports = { RuntimeHost }; +module.exports = { RuntimeHost, launcherRuntimeAllowedForRoute }; diff --git a/launcher/package.json b/launcher/package.json index 41be54b9e..a938cc97a 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -1,6 +1,6 @@ { "name": "codex-web-gpt-launcher", - "version": "1.0.1-ko.3", + "version": "1.0.1-ko.4", "private": true, "description": "Desktop control center for Codex ChatGPT Web", "author": "miuuyy; Korean localization by AgenticLab-SH", diff --git a/launcher/tests/runtime-host.test.cjs b/launcher/tests/runtime-host.test.cjs index 8130969d9..c177e8aab 100644 --- a/launcher/tests/runtime-host.test.cjs +++ b/launcher/tests/runtime-host.test.cjs @@ -3,7 +3,7 @@ const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); -const { RuntimeHost } = require("../electron/runtime.cjs"); +const { RuntimeHost, launcherRuntimeAllowedForRoute } = require("../electron/runtime.cjs"); function hostFor(existingConfig) { const host = new RuntimeHost({ @@ -57,6 +57,7 @@ test("MCP setup reuses valid private credentials without exposing or rewriting t "--full", "--browser-host-descriptor", "/runtime/launcher-browser.json", + "--preserve-codex-route", "--acknowledge-unofficial", "--restart-service", ]); @@ -65,6 +66,12 @@ test("MCP setup reuses valid private credentials without exposing or rewriting t } }); +test("full MCP runtime remains available while another Codex route is active", () => { + assert.equal(launcherRuntimeAllowedForRoute({ active: false }, { mode: "full" }), true); + assert.equal(launcherRuntimeAllowedForRoute({ active: false }, { mode: "browser-only" }), false); + assert.equal(launcherRuntimeAllowedForRoute({ active: true }, { mode: "browser-only" }), true); +}); + test("MCP credential replacement remains explicit and requires a complete new pair", async () => { const fixture = hostFor(null); await assert.rejects( diff --git a/package.json b/package.json index c0efd497b..c48927b95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-chatgpt-web", - "version": "1.0.1-ko.3", + "version": "1.0.1-ko.4", "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 ea119f5c1..3f4bb5d78 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ set -eu REPOSITORY="${CODEX_CHATGPT_WEB_REPOSITORY:-AgenticLab-SH/codex-chatgpt-web}" -VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.0.1-ko.3}" +VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.0.1-ko.4}" BIN_DIR="${CODEX_CHATGPT_WEB_BIN_DIR:-$HOME/.local/bin}" LIB_DIR="${CODEX_CHATGPT_WEB_LIB_DIR:-$HOME/.local/lib/codex-chatgpt-web}" DOC_DIR="${CODEX_CHATGPT_WEB_DOC_DIR:-$HOME/.local/share/doc/codex-chatgpt-web}" diff --git a/src/cli.ts b/src/cli.ts index 4d5cc2188..fb3478277 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -52,6 +52,7 @@ Setup options: --tunnel-id ID Existing OpenAI tunnel id (full mode) --runtime-key-file PATH File containing a Tunnels Read+Use runtime key --replace-codex-route Reversibly replace an existing openai_base_url + --preserve-codex-route Keep the current Codex route and configure the full MCP tunnel only --restart-service Explicitly restart this project's daemon after an update --login Refresh the stored ChatGPT login even if one exists --auto-approve-tool-calls Opt in to per-call browser clicks on "Allow once" prompts @@ -137,6 +138,7 @@ async function setupCommand(args: string[]): Promise { options.forceLogin = takeFlag(args, "--login"); options.autoApproveToolCalls = takeFlag(args, "--auto-approve-tool-calls"); options.replaceCodexRoute = takeFlag(args, "--replace-codex-route"); + options.preserveCodexRoute = takeFlag(args, "--preserve-codex-route"); options.restartService = takeFlag(args, "--restart-service"); assertNoArgs(args); @@ -174,7 +176,11 @@ async function setupCommand(args: string[]): Promise { stdout.write("One account-level step remains: attach the tunnel to the ChatGPT connector named in config.\n"); stdout.write("Open: https://chatgpt.com/#settings/Connectors\n"); } - stdout.write("Restart the Codex app once so its native model catalog refreshes through the installed route.\n"); + if (result.codexRestartRequired) { + stdout.write("Restart the Codex app once so its native model catalog refreshes through the installed route.\n"); + } else { + stdout.write("The existing Codex model route was preserved; no Codex restart is required for this MCP-only setup.\n"); + } } async function doctorCommand(args: string[]): Promise { diff --git a/src/setup.ts b/src/setup.ts index 15891b462..b765e832e 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -40,6 +40,7 @@ export interface SetupOptions { forceLogin?: boolean; autoApproveToolCalls?: boolean; replaceCodexRoute?: boolean; + preserveCodexRoute?: boolean; restartService?: boolean; acknowledgedUnofficial?: boolean; tunnelId?: string; @@ -53,7 +54,7 @@ export interface SetupResult { loginCreated: boolean; serviceLoaded: boolean; tunnelReady: boolean | null; - codexRestartRequired: true; + codexRestartRequired: boolean; connectorSetupRequired: boolean; } @@ -74,6 +75,13 @@ export function existingFullSetupCredentials(existing: AppConfig | undefined): E }; } +export function setupManagesCodexRoute(options: Pick): boolean { + if (options.replaceCodexRoute && options.preserveCodexRoute) { + throw new Error("Choose either --replace-codex-route or --preserve-codex-route, not both"); + } + return options.preserveCodexRoute !== true; +} + function loadExistingConfig(): AppConfig | undefined { if (!existsSync(getConfigPath())) return undefined; return loadConfigForSetup(); @@ -253,6 +261,7 @@ async function bootstrapTunnelProfile(config: AppConfig): Promise { export async function setup(options: SetupOptions): Promise { const existing = loadExistingConfig(); const config = baseConfig(existing, options); + const manageCodexRoute = setupManagesCodexRoute(options); const launcherOwned = config.browserHost === "launcher"; if (!launcherOwned && process.platform !== "darwin") { throw new Error( @@ -260,9 +269,11 @@ export async function setup(options: SetupOptions): Promise { + "Use the Codex Web GPT launcher on Windows or Linux.", ); } - preflightCodexIntegration(config, { - replaceExistingRoute: options.replaceCodexRoute, - }); + if (manageCodexRoute) { + preflightCodexIntegration(config, { + replaceExistingRoute: options.replaceCodexRoute, + }); + } const refreshTunnelWorker = tunnelWorkerRuntimeChanged(existing, config); if (existing && options.restartService) config.controlToken = randomBytes(32).toString("base64url"); const beforeService = getServiceStatus(); @@ -379,9 +390,11 @@ export async function setup(options: SetupOptions): Promise { launcherOwned && existing && existing.browserHost !== "launcher", ); if (!migratingTerminalRuntime) removeLegacyRuntimeArtifacts(config); - installCodexIntegration(config, { - replaceExistingRoute: options.replaceCodexRoute, - }); + if (manageCodexRoute) { + installCodexIntegration(config, { + replaceExistingRoute: options.replaceCodexRoute, + }); + } return { mode: config.mode, @@ -389,7 +402,7 @@ export async function setup(options: SetupOptions): Promise { loginCreated, serviceLoaded: launcherOwned ? false : getServiceStatus().loaded, tunnelReady, - codexRestartRequired: true, + codexRestartRequired: manageCodexRoute, connectorSetupRequired: config.mode === "full", }; } diff --git a/src/version.ts b/src/version.ts index 31fe0b02e..a77b29a7b 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.0.1-ko.3"; +export const VERSION = "1.0.1-ko.4"; diff --git a/tests/setup-lifecycle.test.ts b/tests/setup-lifecycle.test.ts index a7d87bff3..78b517139 100644 --- a/tests/setup-lifecycle.test.ts +++ b/tests/setup-lifecycle.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { launcherCapabilityProbeRequired, setupProxyIsReady } from "../src/setup"; +import { launcherCapabilityProbeRequired, setupManagesCodexRoute, setupProxyIsReady } from "../src/setup"; const config = { mode: "browser-only" as const, @@ -36,3 +36,12 @@ test("repeat launcher setup reuses the previously verified Pro capability", () = proAvailable: true, } as never)).toBe(true); }); + +test("full MCP setup can preserve an existing Codex route without accepting replacement", () => { + expect(setupManagesCodexRoute({ preserveCodexRoute: true })).toBe(false); + expect(setupManagesCodexRoute({ replaceCodexRoute: false })).toBe(true); + expect(() => setupManagesCodexRoute({ + replaceCodexRoute: true, + preserveCodexRoute: true, + })).toThrow("Choose either --replace-codex-route or --preserve-codex-route"); +}); 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 05/17] 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 13d1a2779ebb471359620f13f8cc6f9f1274ce03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=83=81=ED=9B=88?= Date: Mon, 3 Aug 2026 21:11:09 +0900 Subject: [PATCH 06/17] Add upstream release update notice --- launcher/electron/main.cjs | 48 +++++++++- launcher/electron/preload.cjs | 2 + launcher/electron/state.cjs | 5 ++ launcher/electron/upstream-update.cjs | 89 +++++++++++++++++++ launcher/src/App.tsx | 112 ++++++++++++++++++++++++ launcher/src/i18n.ts | 27 ++++++ launcher/src/styles.css | 72 +++++++++++++++ launcher/src/types.ts | 17 ++++ launcher/tests/state.test.cjs | 3 + launcher/tests/upstream-update.test.cjs | 70 +++++++++++++++ 10 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 launcher/electron/upstream-update.cjs create mode 100644 launcher/tests/upstream-update.test.cjs diff --git a/launcher/electron/main.cjs b/launcher/electron/main.cjs index a0f30ba1d..43b71dec7 100644 --- a/launcher/electron/main.cjs +++ b/launcher/electron/main.cjs @@ -28,6 +28,10 @@ const { RuntimeHost, launcherRuntimeAllowedForRoute } = require("./runtime.cjs") const { ensurePackagedRuntime } = require("./runtime-install.cjs"); const { RuntimeSupervisor } = require("./runtime-supervisor.cjs"); const { createStateStore, validateSidebarState } = require("./state.cjs"); +const { + UPSTREAM_REPOSITORY_URL, + checkForUpstreamUpdate, +} = require("./upstream-update.cjs"); const { MIN_WINDOW_BOUNDS, readWindowState, @@ -205,6 +209,20 @@ async function openWebUrl(url) { await shell.openExternal(parsed.toString()); } +function externalUrlAllowed(value) { + if (ALLOWED_EXTERNAL_URLS.has(value)) return true; + try { + const parsed = new URL(value); + return parsed.protocol === "https:" + && parsed.hostname === "github.com" + && parsed.pathname.startsWith("/miuuyy/codex-chatgpt-web/releases/") + && parsed.search === "" + && parsed.hash === ""; + } catch { + return false; + } +} + function rendererNavigationAllowed(value) { let target; try { @@ -377,10 +395,38 @@ function registerIpc({ logger, stateStore }) { }); handle("launcher:open-external", async (_event, url) => { - if (!ALLOWED_EXTERNAL_URLS.has(url)) throw new Error("External URL is not allowlisted"); + if (!externalUrlAllowed(url)) throw new Error("External URL is not allowlisted"); await openWebUrl(url); return true; }); + handle("launcher:check-upstream-update", async () => { + try { + const result = await checkForUpstreamUpdate(app.getVersion()); + logger.info("launcher.upstream_update_checked", { + status: result.status, + currentVersion: result.currentVersion, + latestTag: result.latest?.tag ?? null, + }); + return result; + } catch (error) { + logger.debug("launcher.upstream_update_check_unavailable", { + message: error instanceof Error ? error.message : String(error), + }); + return { + status: "unavailable", + currentVersion: app.getVersion(), + latest: null, + }; + } + }); + handle("launcher:dismiss-upstream-update", (_event, tag) => { + if (typeof tag !== "string" || tag.trim().length === 0 || tag.length > 128) { + throw new Error("Invalid upstream release tag"); + } + const state = stateStore.update({ upstreamUpdateDismissedTag: tag.trim() }); + send("launcher:state-changed", state); + return state; + }); handle("launcher:browser-bounds", (_event, bounds) => { browserHost?.setBounds(validateBounds(bounds)); diff --git a/launcher/electron/preload.cjs b/launcher/electron/preload.cjs index a2af47ac1..6531423eb 100644 --- a/launcher/electron/preload.cjs +++ b/launcher/electron/preload.cjs @@ -12,6 +12,8 @@ contextBridge.exposeInMainWorld("codexWebLauncher", { openSocial: (target) => ipcRenderer.invoke("launcher:open-social", target), completeOnboarding: (language) => ipcRenderer.invoke("launcher:complete-onboarding", language), openExternal: (url) => ipcRenderer.invoke("launcher:open-external", url), + checkForUpstreamUpdate: () => ipcRenderer.invoke("launcher:check-upstream-update"), + dismissUpstreamUpdate: (tag) => ipcRenderer.invoke("launcher:dismiss-upstream-update", tag), setBrowserBounds: (bounds) => ipcRenderer.invoke("launcher:browser-bounds", bounds), setBrowserSurfaceActive: (active) => ipcRenderer.invoke("launcher:browser-surface-active", active), showBrowser: () => ipcRenderer.invoke("launcher:browser-show"), diff --git a/launcher/electron/state.cjs b/launcher/electron/state.cjs index 785af5767..6e87e0cd1 100644 --- a/launcher/electron/state.cjs +++ b/launcher/electron/state.cjs @@ -13,6 +13,7 @@ const DEFAULT_STATE = Object.freeze({ bridgeEnabled: true, keepRunningOnClose: true, showBrowserDuringTurns: true, + upstreamUpdateDismissedTag: null, browserSmokePassed: false, browserSmokeVersion: null, sidebarOpen: true, @@ -41,6 +42,10 @@ function readState(filePath) { ]) { if (typeof state[key] !== "boolean") state[key] = DEFAULT_STATE[key]; } + if (state.upstreamUpdateDismissedTag !== null + && (typeof state.upstreamUpdateDismissedTag !== "string" || state.upstreamUpdateDismissedTag.length > 128)) { + state.upstreamUpdateDismissedTag = DEFAULT_STATE.upstreamUpdateDismissedTag; + } if (state.browserSmokeVersion !== null && (typeof state.browserSmokeVersion !== "string" || state.browserSmokeVersion.length > 128)) { state.browserSmokeVersion = DEFAULT_STATE.browserSmokeVersion; diff --git a/launcher/electron/upstream-update.cjs b/launcher/electron/upstream-update.cjs new file mode 100644 index 000000000..f7500005a --- /dev/null +++ b/launcher/electron/upstream-update.cjs @@ -0,0 +1,89 @@ +const UPSTREAM_OWNER = "miuuyy"; +const UPSTREAM_REPOSITORY = "codex-chatgpt-web"; +const UPSTREAM_REPOSITORY_URL = `https://github.com/${UPSTREAM_OWNER}/${UPSTREAM_REPOSITORY}`; +const UPSTREAM_LATEST_RELEASE_URL = `https://api.github.com/repos/${UPSTREAM_OWNER}/${UPSTREAM_REPOSITORY}/releases/latest`; +const REQUEST_TIMEOUT_MS = 5_000; + +function versionParts(value) { + if (typeof value !== "string") return null; + const match = value.trim().replace(/^v/i, "").match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/); + if (!match) return null; + return match.slice(1, 4).map((part) => Number(part)); +} + +function compareReleaseVersion(left, right) { + const leftParts = versionParts(left); + const rightParts = versionParts(right); + if (!leftParts || !rightParts) return 0; + for (let index = 0; index < leftParts.length; index += 1) { + if (leftParts[index] !== rightParts[index]) return leftParts[index] > rightParts[index] ? 1 : -1; + } + return 0; +} + +function releaseUrlIsSafe(value) { + if (typeof value !== "string") return false; + try { + const url = new URL(value); + return url.protocol === "https:" + && url.hostname === "github.com" + && url.pathname.startsWith(`/${UPSTREAM_OWNER}/${UPSTREAM_REPOSITORY}/releases/`) + && url.search === "" + && url.hash === ""; + } catch { + return false; + } +} + +function parseReleasePayload(payload) { + if (!payload || typeof payload !== "object") throw new Error("GitHub release response is not an object"); + const tag = typeof payload.tag_name === "string" ? payload.tag_name.trim() : ""; + const url = typeof payload.html_url === "string" ? payload.html_url.trim() : ""; + if (!versionParts(tag)) throw new Error("GitHub release has no supported version tag"); + if (!releaseUrlIsSafe(url)) throw new Error("GitHub release URL did not match the upstream repository"); + const name = typeof payload.name === "string" && payload.name.trim() + ? payload.name.trim().slice(0, 200) + : tag; + const publishedAt = typeof payload.published_at === "string" ? payload.published_at : null; + return { tag, name, url, publishedAt }; +} + +async function fetchLatestRelease({ fetchImpl = globalThis.fetch, timeoutMs = REQUEST_TIMEOUT_MS } = {}) { + if (typeof fetchImpl !== "function") throw new Error("Fetch is unavailable"); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(UPSTREAM_LATEST_RELEASE_URL, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "codex-web-gpt-launcher", + }, + signal: controller.signal, + }); + if (!response || !response.ok) { + throw new Error(`GitHub release check returned HTTP ${response?.status ?? "unknown"}`); + } + return parseReleasePayload(await response.json()); + } finally { + clearTimeout(timeout); + } +} + +async function checkForUpstreamUpdate(currentVersion, options = {}) { + const latest = await fetchLatestRelease(options); + return { + status: compareReleaseVersion(latest.tag, currentVersion) > 0 ? "available" : "current", + currentVersion, + latest, + }; +} + +module.exports = { + UPSTREAM_LATEST_RELEASE_URL, + UPSTREAM_REPOSITORY_URL, + checkForUpstreamUpdate, + compareReleaseVersion, + fetchLatestRelease, + parseReleasePayload, + releaseUrlIsSafe, +}; diff --git a/launcher/src/App.tsx b/launcher/src/App.tsx index cbabb27bf..4e82c62f8 100644 --- a/launcher/src/App.tsx +++ b/launcher/src/App.tsx @@ -19,6 +19,7 @@ import type { LogRecord, OperationState, Surface, + UpstreamUpdateResult, } from "./types"; const api = window.codexWebLauncher; @@ -35,6 +36,7 @@ export function App() { const [browser, setBrowser] = useState(null); const [operation, setOperation] = useState(null); const [logs, setLogs] = useState([]); + const [upstreamUpdate, setUpstreamUpdate] = useState(null); const [error, setError] = useState(null); useEffect(() => { @@ -47,6 +49,11 @@ export function App() { setLogs(next.logs); setOperation(next.operation); if (next.operation?.status === "failed") setError(next.operation.message); + void api.checkForUpstreamUpdate().then((result) => { + if (!cancelled) setUpstreamUpdate(result); + }).catch(() => { + // Update checks are advisory and must never prevent the launcher from opening. + }); }).catch((cause) => setError(messageOf(cause))); const unsubscribeState = api.onStateChanged((state) => { setSnapshot((current) => current @@ -110,12 +117,24 @@ export function App() { language={language} logs={logs} operation={operation} + setUpstreamUpdate={setUpstreamUpdate} setError={setError} snapshot={snapshot} + upstreamUpdate={upstreamUpdate} updateState={updateState} /> )} + {snapshot.state.onboardingComplete && upstreamUpdate?.status === "available" && upstreamUpdate.latest + && upstreamUpdate.latest.tag !== snapshot.state.upstreamUpdateDismissedTag ? ( + + ) : null} {error ? setError(null)} /> : null} @@ -282,8 +301,10 @@ function LauncherShell({ language, logs, operation, + setUpstreamUpdate, setError, snapshot, + upstreamUpdate, updateState, }: { browser: BrowserState | null; @@ -291,8 +312,10 @@ function LauncherShell({ language: Language; logs: LogRecord[]; operation: OperationState | null; + setUpstreamUpdate: (update: UpstreamUpdateResult) => void; setError: (error: string | null) => void; snapshot: LauncherSnapshot; + upstreamUpdate: UpstreamUpdateResult | null; updateState: (state: LauncherState) => void; }) { const [surface, setSurface] = useState( @@ -520,8 +543,10 @@ function LauncherShell({ ) : null} @@ -1102,20 +1127,25 @@ function ActivitySurface({ function SettingsSurface({ copy, language, + setUpstreamUpdate, setError, snapshot, + upstreamUpdate, updateState, }: { copy: Copy; language: Language; + setUpstreamUpdate: (update: UpstreamUpdateResult) => void; setError: (error: string | null) => void; snapshot: LauncherSnapshot; + upstreamUpdate: UpstreamUpdateResult | null; updateState: (state: LauncherState) => void; }) { const [doctor, setDoctor] = useState(null); const [busy, setBusy] = useState(false); const [turnsCancelled, setTurnsCancelled] = useState(false); const [integrationRemoved, setIntegrationRemoved] = useState(false); + const [updateBusy, setUpdateBusy] = useState(false); const updateLanguage = async (next: Language) => { try { @@ -1172,6 +1202,17 @@ function SettingsSurface({ setBusy(false); } }; + const checkForUpdates = async () => { + setUpdateBusy(true); + setError(null); + try { + setUpstreamUpdate(await api!.checkForUpstreamUpdate()); + } catch (cause) { + setError(messageOf(cause)); + } finally { + setUpdateBusy(false); + } + }; return ( @@ -1214,6 +1255,24 @@ function SettingsSurface({
+ + +
+
+ + + ); +} + function ContentSurface({ children, eyebrow, diff --git a/launcher/src/i18n.ts b/launcher/src/i18n.ts index 26e22ec4e..dc3db892d 100644 --- a/launcher/src/i18n.ts +++ b/launcher/src/i18n.ts @@ -123,6 +123,15 @@ const en = { notConfigured: "Not configured", error: "Something went wrong", dismiss: "Dismiss", + updateAvailable: "Upstream update available", + updateAvailableBody: "A newer release is available in the original repository:", + openRelease: "Open release", + dismissUpdate: "Later", + checkUpdates: "Check upstream updates", + checkingUpdates: "Checking for updates", + latestRelease: "Latest", + upToDate: "Already up to date", + updateCheckUnavailable: "Could not check right now", } as const; const zh: Record = { @@ -248,6 +257,15 @@ const zh: Record = { notConfigured: "未配置", error: "出现错误", dismiss: "关闭", + updateAvailable: "上游仓库有新版本", + updateAvailableBody: "原始仓库发布了新版本:", + openRelease: "打开发布页面", + dismissUpdate: "稍后提醒", + checkUpdates: "检查上游更新", + checkingUpdates: "正在检查更新", + latestRelease: "最新", + upToDate: "已经是最新版本", + updateCheckUnavailable: "暂时无法检查", }; const ko: Record = { @@ -373,6 +391,15 @@ const ko: Record = { notConfigured: "구성되지 않음", error: "문제가 발생했습니다", dismiss: "닫기", + updateAvailable: "원본 레포 업데이트 있음", + updateAvailableBody: "원본 저장소에 새 릴리스가 있습니다:", + openRelease: "릴리스 페이지 열기", + dismissUpdate: "나중에", + checkUpdates: "원본 레포 업데이트 확인", + checkingUpdates: "업데이트 확인 중", + latestRelease: "최신", + upToDate: "최신 상태입니다", + updateCheckUnavailable: "지금은 확인할 수 없습니다", }; export type Copy = typeof en; diff --git a/launcher/src/styles.css b/launcher/src/styles.css index 8aea0f49a..f68fd0d80 100644 --- a/launcher/src/styles.css +++ b/launcher/src/styles.css @@ -1927,6 +1927,78 @@ code { color: var(--color-text-primary); } +.upstream-update-toast { + position: fixed; + z-index: 450; + top: 58px; + right: 18px; + display: grid; + width: min(420px, calc(100vw - 36px)); + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: 11px; + padding: 14px 14px 13px; + border: 1px solid color-mix(in srgb, var(--color-text-success) 34%, var(--color-border-heavy)); + border-radius: var(--radius-md); + background: var(--color-background-elevated); + box-shadow: 0 16px 38px -18px rgb(0 0 0 / 82%); +} + +.upstream-update-toast > svg { + width: 18px; + height: 18px; + margin-top: 1px; + color: var(--color-text-success); +} + +.upstream-update-toast strong { + font-size: var(--text-sm); + font-weight: 600; +} + +.upstream-update-toast p { + margin: 4px 0 9px; + color: var(--color-text-secondary); + font-size: var(--text-xs); + line-height: 1.45; +} + +.upstream-update-toast p b { + color: var(--color-text-primary); + font-weight: 600; +} + +.upstream-update-actions { + display: flex; + gap: 10px; +} + +.upstream-update-actions button, +.upstream-update-close { + border: 0; + background: transparent; + color: var(--color-text-secondary); + font-size: var(--text-xs); +} + +.upstream-update-actions button:first-child { + color: var(--color-text-success); +} + +.upstream-update-actions button:hover, +.upstream-update-close:hover { + color: var(--color-text-primary); +} + +.upstream-update-close { + padding: 1px; +} + +.upstream-update-close svg { + width: 15px; + height: 15px; +} + .launch-loading, .fatal-message { display: flex; diff --git a/launcher/src/types.ts b/launcher/src/types.ts index b30270549..645aa6170 100644 --- a/launcher/src/types.ts +++ b/launcher/src/types.ts @@ -11,6 +11,7 @@ export interface LauncherState { bridgeEnabled: boolean; keepRunningOnClose: boolean; showBrowserDuringTurns: boolean; + upstreamUpdateDismissedTag?: string | null; sidebarOpen: boolean; sidebarWidth: number; browserSmokePassed?: boolean; @@ -62,6 +63,20 @@ export interface OperationState { message: string; } +export interface UpstreamRelease { + tag: string; + name: string; + url: string; + publishedAt: string | null; +} + +export interface UpstreamUpdateResult { + status: "available" | "current" | "unavailable"; + currentVersion: string; + latest: UpstreamRelease | null; + error?: string; +} + export interface LauncherSnapshot { state: LauncherState; browser: BrowserState | null; @@ -87,6 +102,8 @@ export interface LauncherApi { openSocial(target: "github" | "x"): Promise; completeOnboarding(language: Language): Promise; openExternal(url: string): Promise; + checkForUpstreamUpdate(): Promise; + dismissUpstreamUpdate(tag: string): Promise; setBrowserBounds(bounds: { x: number; y: number; width: number; height: number }): Promise; setBrowserSurfaceActive(active: boolean): Promise; showBrowser(): Promise; diff --git a/launcher/tests/state.test.cjs b/launcher/tests/state.test.cjs index 3f650a962..3c7d60604 100644 --- a/launcher/tests/state.test.cjs +++ b/launcher/tests/state.test.cjs @@ -20,6 +20,7 @@ test("launcher state persists onboarding, language, and autostart atomically", ( bridgeEnabled: true, keepRunningOnClose: true, showBrowserDuringTurns: true, + upstreamUpdateDismissedTag: null, browserSmokePassed: false, browserSmokeVersion: null, sidebarOpen: true, @@ -43,6 +44,7 @@ test("launcher state persists onboarding, language, and autostart atomically", ( bridgeEnabled: true, keepRunningOnClose: false, showBrowserDuringTurns: true, + upstreamUpdateDismissedTag: null, browserSmokePassed: true, browserSmokeVersion: "0.2.0", sidebarOpen: true, @@ -104,6 +106,7 @@ test("persisted sidebar corruption is repaired without changing the rest of laun bridgeEnabled: true, keepRunningOnClose: true, showBrowserDuringTurns: true, + upstreamUpdateDismissedTag: null, browserSmokePassed: false, browserSmokeVersion: null, sidebarOpen: true, diff --git a/launcher/tests/upstream-update.test.cjs b/launcher/tests/upstream-update.test.cjs new file mode 100644 index 000000000..9b9cb48b7 --- /dev/null +++ b/launcher/tests/upstream-update.test.cjs @@ -0,0 +1,70 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + UPSTREAM_LATEST_RELEASE_URL, + UPSTREAM_REPOSITORY_URL, + checkForUpstreamUpdate, + compareReleaseVersion, + parseReleasePayload, + releaseUrlIsSafe, +} = require("../electron/upstream-update.cjs"); + +test("upstream release versions compare numeric base versions", () => { + assert.equal(compareReleaseVersion("v1.1.0", "1.0.1-ko.4"), 1); + assert.equal(compareReleaseVersion("1.0.1", "v1.0.1-ko.4"), 0); + assert.equal(compareReleaseVersion("v1.0.0", "1.0.1"), -1); + assert.equal(compareReleaseVersion("not-a-version", "1.0.0"), 0); +}); + +test("release payload is restricted to the upstream GitHub repository", () => { + const release = parseReleasePayload({ + tag_name: "v1.1.0", + name: "Parallel browser tasks", + html_url: `${UPSTREAM_REPOSITORY_URL}/releases/tag/v1.1.0`, + published_at: "2026-08-02T00:00:00Z", + }); + assert.deepEqual(release, { + tag: "v1.1.0", + name: "Parallel browser tasks", + url: `${UPSTREAM_REPOSITORY_URL}/releases/tag/v1.1.0`, + publishedAt: "2026-08-02T00:00:00Z", + }); + assert.equal(releaseUrlIsSafe(`${UPSTREAM_REPOSITORY_URL}/releases/tag/v1.1.0`), true); + assert.equal(releaseUrlIsSafe("https://github.com/other/repo/releases/tag/v1.1.0"), false); + assert.throws(() => parseReleasePayload({ + tag_name: "v1.1.0", + html_url: "https://evil.example/release", + }), /did not match/); +}); + +test("update check reports an available upstream release without credentials", async () => { + let request; + const result = await checkForUpstreamUpdate("1.0.1-ko.4", { + fetchImpl: async (url, options) => { + request = { url, options }; + return { + ok: true, + async json() { + return { + tag_name: "v1.1.0", + name: "Parallel browser tasks", + html_url: `${UPSTREAM_REPOSITORY_URL}/releases/tag/v1.1.0`, + published_at: "2026-08-02T00:00:00Z", + }; + }, + }; + }, + }); + assert.equal(request.url, UPSTREAM_LATEST_RELEASE_URL); + assert.equal(request.options.headers.Authorization, undefined); + assert.deepEqual(result, { + status: "available", + currentVersion: "1.0.1-ko.4", + latest: { + tag: "v1.1.0", + name: "Parallel browser tasks", + url: `${UPSTREAM_REPOSITORY_URL}/releases/tag/v1.1.0`, + publishedAt: "2026-08-02T00:00:00Z", + }, + }); +}); From 82b540ee37078d35b4a220d9d2c34013a31eef85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=83=81=ED=9B=88?= Date: Tue, 4 Aug 2026 09:22:18 +0900 Subject: [PATCH 07/17] Port upstream v1.1.1 safely and default Extra High --- bun.lock | 10 +- launcher/electron/browser-host.cjs | 25 +- launcher/package.json | 2 +- launcher/tests/browser-host.test.cjs | 98 ++- package.json | 6 +- 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/model.ts | 2 +- src/adapters/chatgpt-web/turn-broker.ts | 21 +- src/codex-integration.ts | 704 +++++++++++++++++++-- src/config.ts | 2 +- 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/model-contract.test.ts | 5 + 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 + 32 files changed, 1448 insertions(+), 172 deletions(-) create mode 100644 tests/compaction-v1.test.ts diff --git a/bun.lock b/bun.lock index 577ced4dd..a545b9cd3 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "codex-chatgpt-web", "dependencies": { - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/sdk": "^1.30.0", "chromium-bidi": "12.1.0", "fflate": "^0.8.2", "playwright-core": "^1.62.0", @@ -22,6 +22,8 @@ }, "overrides": { "@hono/node-server": "2.0.12", + "fast-uri": "3.1.5", + "hono": "4.12.34", "zod-to-json-schema": "3.25.1", }, "packages": { @@ -29,7 +31,7 @@ "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], @@ -99,7 +101,7 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="], + "fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="], "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], @@ -121,7 +123,7 @@ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], - "hono": ["hono@4.12.32", "", {}, "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg=="], + "hono": ["hono@4.12.34", "", {}, "sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], diff --git a/launcher/electron/browser-host.cjs b/launcher/electron/browser-host.cjs index 6a317cb3f..d83a99d08 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 f16e4ab7e..4d79a6948 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -1,6 +1,6 @@ { "name": "codex-web-gpt-launcher", - "version": "1.1.0-ko.1", + "version": "1.1.1-ko.1", "private": true, "description": "Desktop control center for Codex ChatGPT Web", "author": "miuuyy; Korean localization by AgenticLab-SH", diff --git a/launcher/tests/browser-host.test.cjs b/launcher/tests/browser-host.test.cjs index 24f92714f..ed58592fd 100644 --- a/launcher/tests/browser-host.test.cjs +++ b/launcher/tests/browser-host.test.cjs @@ -786,6 +786,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", @@ -816,7 +850,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, @@ -826,7 +860,7 @@ test("closing a running browser tab preserves ownership until its helper reports publishState() {}, writeDescriptor() {}, logger: { info() {} }, - }; + }); BrowserHost.prototype.closeTab.call(fixture, tab.id); @@ -889,13 +923,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", @@ -908,6 +944,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"), @@ -923,6 +966,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 4109c3e14..d065f36d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-chatgpt-web", - "version": "1.1.0-ko.1", + "version": "1.1.1-ko.1", "private": true, "description": "A focused local Responses bridge that runs Codex tasks through a user-authenticated ChatGPT web session.", "repository": { @@ -46,7 +46,7 @@ "verify": "bun run scripts/verify.ts" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/sdk": "^1.30.0", "chromium-bidi": "12.1.0", "fflate": "^0.8.2", "playwright-core": "^1.62.0", @@ -64,6 +64,8 @@ }, "overrides": { "@hono/node-server": "2.0.12", + "fast-uri": "3.1.5", + "hono": "4.12.34", "zod-to-json-schema": "3.25.1" }, "keywords": [ diff --git a/scripts/install.sh b/scripts/install.sh index fe25dba81..a7e9d9740 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ set -eu REPOSITORY="${CODEX_CHATGPT_WEB_REPOSITORY:-AgenticLab-SH/codex-chatgpt-web}" -VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.1.0-ko.1}" +VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.1.1-ko.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/model.ts b/src/adapters/chatgpt-web/model.ts index 30706b3f8..e10af80fe 100644 --- a/src/adapters/chatgpt-web/model.ts +++ b/src/adapters/chatgpt-web/model.ts @@ -21,7 +21,7 @@ export function resolveChatGptWebModelMode( if (modelId !== CHATGPT_WEB_MODEL_ID) { throw new Error(`ChatGPT web model is not supported: ${modelId}`); } - const effort = reasoning ?? "high"; + const effort = reasoning ?? "xhigh"; switch (effort) { case "low": return { modelId, effort, displayLabel: "Instant", uiEffortIndex: 0, localTools: capabilities.localToolsEnabled }; 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/config.ts b/src/config.ts index 79230e66e..fcd5ec976 100644 --- a/src/config.ts +++ b/src/config.ts @@ -353,7 +353,7 @@ export function providerConfig(config: AppConfig): CodexProviderConfig { contextWindow: config.contextWindow, modelInputModalities: Object.fromEntries(models.map(model => [model, ["text", "image"]])), modelReasoningEfforts: { "gpt-5.6-sol": efforts }, - modelDefaultReasoningEfforts: { "gpt-5.6-sol": "high" }, + modelDefaultReasoningEfforts: { "gpt-5.6-sol": "xhigh" }, noReasoningModels: [], chatgptWeb: { appName: config.appName, 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 cf1f9334a..759b94e3e 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.1.0-ko.1"; +export const VERSION = "1.1.1-ko.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/model-contract.test.ts b/tests/model-contract.test.ts index 87319a8d9..0713a467f 100644 --- a/tests/model-contract.test.ts +++ b/tests/model-contract.test.ts @@ -3,6 +3,11 @@ import { CHATGPT_WEB_MODEL_ID, resolveChatGptWebModelMode } from "../src/adapter test("the browser adapter maps fixed routed efforts to the visible ChatGPT modes", () => { const capabilities = { localToolsEnabled: true, proAvailable: true }; + expect(resolveChatGptWebModelMode(CHATGPT_WEB_MODEL_ID, undefined, capabilities)).toMatchObject({ + effort: "xhigh", + displayLabel: "Extra High", + uiEffortIndex: 3, + }); expect(resolveChatGptWebModelMode(CHATGPT_WEB_MODEL_ID, "low", capabilities)).toMatchObject({ displayLabel: "Instant", uiEffortIndex: 0, 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 7e46e2e6c5c89e4f82520f80c78d7f702431a62a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=83=81=ED=9B=88?= Date: Tue, 4 Aug 2026 10:46:00 +0900 Subject: [PATCH 08/17] Restrict Web GPT tools to workspace-write threads --- launcher/package.json | 2 +- launcher/src/i18n.ts | 12 +++++------ package.json | 2 +- scripts/install.sh | 2 +- src/adapters/chatgpt-web/index.ts | 36 +++++++++++++++++++++---------- src/adapters/chatgpt-web/model.ts | 15 +++++++++++++ src/version.ts | 2 +- tests/chatgpt-web-harness.test.ts | 8 +++---- tests/model-contract.test.ts | 9 +++++++- 9 files changed, 62 insertions(+), 26 deletions(-) diff --git a/launcher/package.json b/launcher/package.json index 4d79a6948..aead8c89b 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -1,6 +1,6 @@ { "name": "codex-web-gpt-launcher", - "version": "1.1.1-ko.1", + "version": "1.1.1-ko.2", "private": true, "description": "Desktop control center for Codex ChatGPT Web", "author": "miuuyy; Korean localization by AgenticLab-SH", diff --git a/launcher/src/i18n.ts b/launcher/src/i18n.ts index 2916dde58..7b89ae0a6 100644 --- a/launcher/src/i18n.ts +++ b/launcher/src/i18n.ts @@ -339,14 +339,14 @@ const ko: Record = { next: "다음", done: "완료", guideVideo: "안내 영상", - mcpStepOne: "Tunnel과 API 키 만들기", + mcpStepOne: "Tunnel과 런타임 키 만들기", mcpStepOneBody: "OpenAI Tunnel을 만들고 Tunnel ID를 복사한 다음, Tunnels Read + Use 권한이 있는 일반 API 키를 만드세요. 키는 Tunnel 실행에만 필요하며 모델 API 사용료를 발생시키지 않습니다.", openTunnels: "Tunnels 열기", - openKeys: "API 키 만들기", + openKeys: "런타임 키 만들기", mcpStepTwo: "로컬 하네스 연결", - mcpStepTwoBody: "Tunnel ID와 API 키를 입력하세요. Tunnel은 ChatGPT 플러그인을 사용할 OpenAI 계정과 같아야 합니다. 키는 비공개 로컬 저장소에만 보관되고 런처 로그에는 기록되지 않습니다.", + mcpStepTwoBody: "Tunnel ID와 런타임 키는 Tunnel을 관리하는 Platform 조직의 자격증명이어야 합니다. 사용할 ChatGPT Business 워크스페이스는 이 Tunnel에 별도로 연결합니다. 기존 자격 증명이 있으면 새로 만들지 말고 재사용하세요. 키는 비공개 로컬 저장소에만 보관되고 런처 로그에는 기록되지 않습니다.", tunnelId: "Tunnel ID", - runtimeKey: "API 키(Admin 키 아님)", + runtimeKey: "런타임 API 키(Admin 키 아님)", connect: "하네스 연결", reconnect: "하네스 다시 연결", credentialsConfigured: "Tunnel 자격 증명 저장됨", @@ -354,8 +354,8 @@ const ko: Record = { replaceCredentials: "자격 증명 교체", keepCredentials: "저장된 자격 증명 유지", mcpStepThree: "ChatGPT 커넥터 연결", - mcpStepThreeBody: "커넥터를 만들기 전에 ChatGPT 설정에서 개발자 모드를 켜세요. ChatGPT 플러그인에서 커넥터를 만들고 Tunnel을 선택한 뒤, 만든 Tunnel을 지정하고 인증을 없음으로 설정하세요. 이름은 정확히 Codex Native로 지정한 다음 런타임을 검증합니다.", - openConnectors: "ChatGPT 플러그인 열기", + mcpStepThreeBody: "ChatGPT의 Apps/Plugins에서 개발자 모드 Custom App을 만들고, 연결 방식에서 Tunnel, 기존 Codex Native Tunnel, 인증 없음(None)을 선택하세요. 도구를 스캔한 뒤 이름을 정확히 Codex Native로 지정하고 런타임을 검증합니다.", + openConnectors: "ChatGPT Apps/Plugins 열기", connectorName: "커넥터 이름", verifyRuntime: "런타임 검증", activityTitle: "런타임 활동", diff --git a/package.json b/package.json index d065f36d2..7d5db15a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-chatgpt-web", - "version": "1.1.1-ko.1", + "version": "1.1.1-ko.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 a7e9d9740..4d3466017 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ set -eu REPOSITORY="${CODEX_CHATGPT_WEB_REPOSITORY:-AgenticLab-SH/codex-chatgpt-web}" -VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.1.1-ko.1}" +VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.1.1-ko.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/index.ts b/src/adapters/chatgpt-web/index.ts index 77fe55e47..04423885c 100644 --- a/src/adapters/chatgpt-web/index.ts +++ b/src/adapters/chatgpt-web/index.ts @@ -6,7 +6,7 @@ import type { ProviderAdapter } from "../base"; import { parseDataUrl } from "../image"; import { ChatGptBrowserWorker } from "./browser-worker"; import { extractChatGptTurnEnvironment, extractChatGptTurnIdentity } from "./environment"; -import { resolveChatGptWebModelMode, type ChatGptWebCapabilities } from "./model"; +import { chatGptWebLocalToolsAllowed, resolveChatGptWebModelMode, type ChatGptWebCapabilities } from "./model"; import { chatGptReadOnlyContextWarning, compileChatGptWebPrompt } from "./prompt"; import { TurnBroker, type BrokerToolRequest, type BrokerToolResult } from "./turn-broker"; import { ChatGptTextFeed, ChatGptTraceFeed, chatGptTurnExecutionKey, chatGptTurnSessions, type ChatGptBrowserOutcome, type ChatGptTraceEvent, type ChatGptTurnRuntime, type ChatGptTurnSession } from "./turn-execution"; @@ -152,6 +152,15 @@ function validateBatchTools(parsed: CodexParsedRequest, requests: BrokerToolRequ } } +function capabilitiesForRuntime( + capabilities: ChatGptWebCapabilities, + runtime: ChatGptTurnRuntime, +): ChatGptWebCapabilities { + return runtime.mode === "tools" + ? capabilities + : { ...capabilities, localToolsEnabled: false }; +} + export function createChatGptWebAdapter(provider: CodexProviderConfig): ProviderAdapter { const worker = ChatGptBrowserWorker.forProvider(provider); const broker = TurnBroker.forSocket(brokerSocketPath(provider)); @@ -175,7 +184,11 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider environment: ReturnType | undefined, traceId: string, ): ChatGptTurnRuntime => { - const mode = resolveChatGptWebModelMode(parsed.modelId, parsed.options.reasoning, capabilities); + const runtimeCapabilities = { + ...capabilities, + localToolsEnabled: chatGptWebLocalToolsAllowed(capabilities, environment?.sandboxPolicy.type), + } satisfies ChatGptWebCapabilities; + const mode = resolveChatGptWebModelMode(parsed.modelId, parsed.options.reasoning, runtimeCapabilities); const browserAbort = new AbortController(); const trace = new ChatGptTraceFeed(); const text = new ChatGptTextFeed(); @@ -184,8 +197,8 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider traceId, modelId: parsed.modelId, reasoning: parsed.options.reasoning, - capabilities, - prepare: async () => ({ ...compileChatGptWebPrompt(parsed, capabilities), release: () => {} }), + capabilities: runtimeCapabilities, + prepare: async () => ({ ...compileChatGptWebPrompt(parsed, runtimeCapabilities), release: () => {} }), abortSignal: browserAbort.signal, onReasoningSummary: (text, continuation) => trace.push({ kind: "reasoning", text, ...(continuation ? { continuation: true } : {}) }), onCommentary: (text, continuation) => trace.push({ kind: "commentary", text, ...(continuation ? { continuation: true } : {}) }), @@ -207,7 +220,7 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider traceId, modelId: parsed.modelId, reasoning: parsed.options.reasoning, - capabilities, + capabilities: runtimeCapabilities, prepare: async () => { const turnToken = await broker.register( environment, @@ -279,6 +292,7 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider try { emit({ type: "heartbeat" }); await session.runExclusive(async () => { + const sessionCapabilities = capabilitiesForRuntime(capabilities, session.runtime); const settled = session.settledOutcome(); if (settled) { if (settled.type === "error") throw settled.error; @@ -292,7 +306,7 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider events.push(event); emit(event); }; - if (!parsed._compactionRequest) emitProContextWarning(parsed, capabilities, emitCaptured); + if (!parsed._compactionRequest) emitProContextWarning(parsed, sessionCapabilities, emitCaptured); const trace = session.runtime.trace.drain(); reasoning = trace.map(event => event.text); emitTraceEvents(trace, emitCaptured); @@ -303,7 +317,7 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider session.setFinalReasoning(reasoning); session.setFinalEvents(events); } - emitBrowserCompletion(settled, estimateChatGptWebUsage(parsed, { answer: settled.answer, reasoning }, capabilities), emit); + emitBrowserCompletion(settled, estimateChatGptWebUsage(parsed, { answer: settled.answer, reasoning }, sessionCapabilities), emit); return; } @@ -319,7 +333,7 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider if (results.length === 0) { const reasoning = session.reasoningForOutstandingReplay(); replayEvents(session.eventsForOutstandingReplay(), emit); - emitToolBatch(outstanding, estimateChatGptWebUsage(parsed, { reasoning, toolRequests: outstanding }, capabilities), emit); + emitToolBatch(outstanding, estimateChatGptWebUsage(parsed, { reasoning, toolRequests: outstanding }, sessionCapabilities), emit); return; } if (results.length !== outstanding.length) { @@ -347,7 +361,7 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider emitTraceEvents(trace, emitRound); }; const emitNewText = (deltas: string[]) => emitTextDeltas(deltas, emitRound); - if (!parsed._compactionRequest) emitProContextWarning(parsed, capabilities, emitRound); + if (!parsed._compactionRequest) emitProContextWarning(parsed, sessionCapabilities, emitRound); emitNewTrace(session.runtime.trace.drain()); emitNewText(session.runtime.text.drain()); const nextTools = turnToken @@ -388,7 +402,7 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider } emitBrowserCompletion( next.outcome, - estimateChatGptWebUsage(parsed, { answer: next.outcome.answer, reasoning: roundReasoning }, capabilities), + estimateChatGptWebUsage(parsed, { answer: next.outcome.answer, reasoning: roundReasoning }, sessionCapabilities), emit, ); return; @@ -401,7 +415,7 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider session.setOutstanding(next.requests, roundReasoning, roundEvents); emitToolBatch( next.requests, - estimateChatGptWebUsage(parsed, { reasoning: roundReasoning, toolRequests: next.requests }, capabilities), + estimateChatGptWebUsage(parsed, { reasoning: roundReasoning, toolRequests: next.requests }, sessionCapabilities), emit, ); return; diff --git a/src/adapters/chatgpt-web/model.ts b/src/adapters/chatgpt-web/model.ts index e10af80fe..e09617a3d 100644 --- a/src/adapters/chatgpt-web/model.ts +++ b/src/adapters/chatgpt-web/model.ts @@ -5,6 +5,21 @@ export interface ChatGptWebCapabilities { proAvailable: boolean; } +export type ChatGptWebSandboxType = "dangerFullAccess" | "readOnly" | "workspaceWrite"; + +/** + * Web GPT gets a narrower capability boundary than the surrounding Codex process. A native + * Codex thread may intentionally use danger-full-access, but that authority must not silently + * become ChatGPT Web's authority. Web GPT local tools are therefore available only when the + * active project thread explicitly advertises workspace-write. + */ +export function chatGptWebLocalToolsAllowed( + capabilities: ChatGptWebCapabilities, + sandboxType: ChatGptWebSandboxType | undefined, +): boolean { + return capabilities.localToolsEnabled && sandboxType === "workspaceWrite"; +} + export interface ChatGptWebModelMode { modelId: string; effort: "low" | "medium" | "high" | "xhigh" | "max"; diff --git a/src/version.ts b/src/version.ts index 759b94e3e..92660e763 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.1.1-ko.1"; +export const VERSION = "1.1.1-ko.2"; diff --git a/tests/chatgpt-web-harness.test.ts b/tests/chatgpt-web-harness.test.ts index 4ab0ab486..0c2913983 100644 --- a/tests/chatgpt-web-harness.test.ts +++ b/tests/chatgpt-web-harness.test.ts @@ -35,7 +35,7 @@ const tools: CodexTool[] = [ const environmentXml = ` ${tempRoot} - ${tempRoot} + ${tempRoot}workspace-write `; const toolCapabilities = { localToolsEnabled: true, proAvailable: true }; const readOnlyCapabilities = { localToolsEnabled: false, proAvailable: true }; @@ -148,7 +148,7 @@ describe("ChatGPT outer-native harness v3", () => { cwd: tempRoot, roots: [tempRoot], writableRoots: [tempRoot], - sandboxPolicy: { type: "dangerFullAccess" }, + sandboxPolicy: { type: "workspaceWrite", writableRoots: [tempRoot], networkAccess: false }, tools, }); expect(extractChatGptTurnIdentity(request)).toEqual({ @@ -206,7 +206,7 @@ describe("ChatGPT outer-native harness v3", () => { cwd: tempRoot, roots: [tempRoot], writableRoots: [tempRoot], - sandboxPolicy: { type: "dangerFullAccess" }, + sandboxPolicy: { type: "workspaceWrite", writableRoots: [tempRoot], networkAccess: false }, tools, }); }); @@ -238,7 +238,7 @@ describe("ChatGPT outer-native harness v3", () => { cwd: tempRoot, roots: [tempRoot], writableRoots: [tempRoot], - sandboxPolicy: { type: "dangerFullAccess" }, + sandboxPolicy: { type: "workspaceWrite", writableRoots: [tempRoot], networkAccess: false }, tools, }); }); diff --git a/tests/model-contract.test.ts b/tests/model-contract.test.ts index 0713a467f..ab383267d 100644 --- a/tests/model-contract.test.ts +++ b/tests/model-contract.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { CHATGPT_WEB_MODEL_ID, resolveChatGptWebModelMode } from "../src/adapters/chatgpt-web/model"; +import { CHATGPT_WEB_MODEL_ID, chatGptWebLocalToolsAllowed, resolveChatGptWebModelMode } from "../src/adapters/chatgpt-web/model"; test("the browser adapter maps fixed routed efforts to the visible ChatGPT modes", () => { const capabilities = { localToolsEnabled: true, proAvailable: true }; @@ -49,3 +49,10 @@ test("capabilities gate tools and Pro explicitly without changing the selected m proAvailable: true, })).toThrow("effort is not supported"); }); + +test("does not inherit danger-full-access into the Web GPT tool bridge", () => { + const capabilities = { localToolsEnabled: true, proAvailable: false }; + expect(chatGptWebLocalToolsAllowed(capabilities, "dangerFullAccess")).toBe(false); + expect(chatGptWebLocalToolsAllowed(capabilities, "readOnly")).toBe(false); + expect(chatGptWebLocalToolsAllowed(capabilities, "workspaceWrite")).toBe(true); +}); From e87700885bb1ce64c3124879d7c9d0b25ef99c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=83=81=ED=9B=88?= Date: Tue, 4 Aug 2026 10:50:28 +0900 Subject: [PATCH 09/17] Refresh Korean MCP setup guidance --- launcher/package.json | 2 +- launcher/src/i18n.ts | 2 +- package.json | 2 +- scripts/install.sh | 2 +- src/version.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/launcher/package.json b/launcher/package.json index aead8c89b..b802eed29 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -1,6 +1,6 @@ { "name": "codex-web-gpt-launcher", - "version": "1.1.1-ko.2", + "version": "1.1.1-ko.3", "private": true, "description": "Desktop control center for Codex ChatGPT Web", "author": "miuuyy; Korean localization by AgenticLab-SH", diff --git a/launcher/src/i18n.ts b/launcher/src/i18n.ts index 7b89ae0a6..3a85a34d0 100644 --- a/launcher/src/i18n.ts +++ b/launcher/src/i18n.ts @@ -340,7 +340,7 @@ const ko: Record = { done: "완료", guideVideo: "안내 영상", mcpStepOne: "Tunnel과 런타임 키 만들기", - mcpStepOneBody: "OpenAI Tunnel을 만들고 Tunnel ID를 복사한 다음, Tunnels Read + Use 권한이 있는 일반 API 키를 만드세요. 키는 Tunnel 실행에만 필요하며 모델 API 사용료를 발생시키지 않습니다.", + mcpStepOneBody: "OpenAI Tunnel을 만들고 Tunnel ID를 확인하세요. 기존 Tunnel이 있으면 새로 만들지 마세요. 새로 연결할 때만 Tunnels Read + Use 권한이 있는 일반 런타임 키가 필요합니다. 이 키는 Tunnel 실행에만 사용되며 모델 API 사용료를 발생시키지 않습니다.", openTunnels: "Tunnels 열기", openKeys: "런타임 키 만들기", mcpStepTwo: "로컬 하네스 연결", diff --git a/package.json b/package.json index 7d5db15a1..ff03301fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-chatgpt-web", - "version": "1.1.1-ko.2", + "version": "1.1.1-ko.3", "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 4d3466017..779695f5d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ set -eu REPOSITORY="${CODEX_CHATGPT_WEB_REPOSITORY:-AgenticLab-SH/codex-chatgpt-web}" -VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.1.1-ko.2}" +VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.1.1-ko.3}" 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/version.ts b/src/version.ts index 92660e763..ee43631e7 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.1.1-ko.2"; +export const VERSION = "1.1.1-ko.3"; From ce1ba036c0ecb8ece486615367d43c675bcd220b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=83=81=ED=9B=88?= Date: Tue, 4 Aug 2026 21:02:23 +0900 Subject: [PATCH 10/17] Add project-bound Web GPT conversations --- README.ko-KR.md | 13 +- .../chatgpt-web/browser-helper-main.ts | 3 + src/adapters/chatgpt-web/browser-worker.ts | 11 +- src/adapters/chatgpt-web/environment.ts | 2 + src/adapters/chatgpt-web/index.ts | 5 +- .../chatgpt-web/launcher-helper-client.ts | 1 + src/adapters/chatgpt-web/mcp-server.ts | 29 +++- src/adapters/chatgpt-web/model.ts | 3 +- src/adapters/chatgpt-web/project-boundary.ts | 164 ++++++++++++++++++ src/adapters/chatgpt-web/turn-broker.ts | 1 + src/adapters/chatgpt-web/turn-execution.ts | 1 + src/chatgpt-session.ts | 20 +++ src/chatgpt-web-models.ts | 45 +++-- src/config.ts | 4 +- src/server.ts | 1 + src/types.ts | 2 + tests/chatgpt-session.test.ts | 19 ++ tests/chatgpt-web-harness.test.ts | 56 ++++++ tests/chatgpt-web-models.test.ts | 29 +++- tests/model-catalog.test.ts | 10 +- tests/project-boundary.test.ts | 86 +++++++++ tests/server-models.test.ts | 2 + 22 files changed, 482 insertions(+), 25 deletions(-) create mode 100644 src/adapters/chatgpt-web/project-boundary.ts create mode 100644 tests/chatgpt-session.test.ts create mode 100644 tests/project-boundary.test.ts diff --git a/README.ko-KR.md b/README.ko-KR.md index ce34cacac..2c543245c 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -1,6 +1,6 @@ # Codex용 ChatGPT Web -Codex의 기본 모델 선택기에서 ChatGPT Web의 Instant, Medium, High, Extra High, Pro를 선택해 +Codex의 기본 모델 선택기에서 ChatGPT Web의 임시대화·저장 대화·즉시·중간·높음·매우 높음·Pro를 선택해 사용하는 로컬 Responses 브리지입니다. 이 포크는 런처의 기본 언어를 한국어로 바꾸고, 한국어 UI를 추가했으며, GitHub/X 페이지를 반드시 열어야 했던 시작 절차를 선택 사항으로 바꿨습니다. @@ -20,6 +20,17 @@ Codex의 기본 모델 선택기에서 ChatGPT Web의 Instant, Medium, High, Ext 못합니다. - 공유 계정, 브라우저 프로필 공유, 과도한 동시 요청, 제한 우회는 사용하지 마세요. +## 모델과 라우팅 + +- `ChatGPT Web — 임시대화 (매우 높음)`은 대화 기록에 남지 않는 Temporary Chat으로 실행됩니다. +- `ChatGPT Web — 저장 대화 (매우 높음)`은 일반 ChatGPT 대화를 만들어 ChatGPT 기록에 남깁니다. +- Web GPT 두 항목은 선택한 노력 수준이 낮게 들어와도 브리지에서 `매우 높음`으로 고정합니다. +- `gpt-5.6-sol`은 Codex 기본 설정에서 `높음`으로 두며, Web GPT 브리지가 브라우저를 열지 않고 + 공식 Codex 엔드포인트로 직접 전달합니다. +- 순수 네이티브 Codex는 `codex --profile codex-native`로 별도 실행할 수 있고, OCX는 + `127.0.0.1:10100`에서 별도 프로세스로 유지됩니다. 한 경로가 내려가도 다른 경로의 인증·라우팅·런타임을 + 덮어쓰지 않습니다. + ## 설치 및 시작 macOS 또는 Linux: diff --git a/src/adapters/chatgpt-web/browser-helper-main.ts b/src/adapters/chatgpt-web/browser-helper-main.ts index 6d80deb8f..a01833456 100644 --- a/src/adapters/chatgpt-web/browser-helper-main.ts +++ b/src/adapters/chatgpt-web/browser-helper-main.ts @@ -3,6 +3,7 @@ import { stdin, stderr, stdout } from "node:process"; import type { CodexProviderConfig } from "../../types"; import { ChatGptBrowserWorker, closeChatGptBrowserWorkers, type BrowserTurn } from "./browser-worker"; import type { ChatGptWebCapabilities } from "./model"; +import type { ChatGptWebConversationMode } from "../../chatgpt-web-models"; import { createProcessLineWriter } from "./process-line-writer"; import type { CompiledChatGptWebPrompt } from "./prompt"; @@ -19,6 +20,7 @@ interface RunMessage { traceId: string; modelId: string; reasoning?: string; + conversationMode?: ChatGptWebConversationMode; capabilities: ChatGptWebCapabilities; prepared: CompiledChatGptWebPrompt; }; @@ -109,6 +111,7 @@ async function run(message: RunMessage): Promise { traceId: message.turn.traceId, modelId: message.turn.modelId, reasoning: message.turn.reasoning, + conversationMode: message.turn.conversationMode, capabilities: message.turn.capabilities, prepare: async () => ({ ...message.turn.prepared, release: () => {} }), abortSignal: abortController.signal, diff --git a/src/adapters/chatgpt-web/browser-worker.ts b/src/adapters/chatgpt-web/browser-worker.ts index 08a1fe34c..ddee26147 100644 --- a/src/adapters/chatgpt-web/browser-worker.ts +++ b/src/adapters/chatgpt-web/browser-worker.ts @@ -6,10 +6,12 @@ import type { CodexProviderConfig } from "../../types"; import { parseDataUrl } from "../image"; import { ChatGptMarkdownBuffer } from "./markdown"; import { resolveChatGptWebModelMode, type ChatGptWebCapabilities, type ChatGptWebModelMode } from "./model"; +import type { ChatGptWebConversationMode } from "../../chatgpt-web-models"; import { CHATGPT_INTERNAL_COMPACTION_MARKER, CHATGPT_MAX_INPUT_IMAGES, containsChatGptCompactionMarker, stripChatGptTransportMarkers, type CompiledChatGptWebPrompt, type ChatGptWebPromptImage } from "./prompt"; import { estimateCompiledChatGptWebInputTokens } from "./usage"; import { assertAuthenticatedChatGptPage, + assertChatGptConversationPage, assertTemporaryChatPage, CHATGPT_ASSISTANT_TURN_SELECTOR, CHATGPT_COMPLETION_ACTION_SELECTOR, @@ -20,6 +22,7 @@ import { CHATGPT_STOP_BUTTON_SELECTOR, CHATGPT_TEMPORARY_CHAT_URL, CHATGPT_USER_TURN_SELECTOR, + chatGptConversationUrl, } from "../../chatgpt-session"; import { loginVerificationMarkerPath } from "../../browser-login"; import { connectLauncherBrowserHost, notifyLauncherTurn } from "../../launcher-browser-host"; @@ -70,6 +73,7 @@ export interface BrowserTurn { traceId: string; modelId: string; reasoning?: string; + conversationMode?: ChatGptWebConversationMode; capabilities: ChatGptWebCapabilities; prepare: () => Promise void }>; abortSignal?: AbortSignal; @@ -1002,8 +1006,9 @@ export class ChatGptBrowserWorker { console.info( `[chatgpt-web] browser turn ${turn.traceId} opened (transport=inline, promptChars=${prepared.text.length}, estimatedInputTokens=${estimatedInputTokens}, images=${prepared.images.length})`, ); - await this.runStage(turn.traceId, "temporary_chat_navigation", browserStageTimeouts.navigation, () => ( - page.goto(CHATGPT_TEMPORARY_CHAT_URL, { waitUntil: "domcontentloaded", timeout: 60_000 }).then(() => undefined) + const conversationMode = turn.conversationMode ?? "temporary"; + await this.runStage(turn.traceId, "conversation_navigation", browserStageTimeouts.navigation, () => ( + page.goto(chatGptConversationUrl(conversationMode), { waitUntil: "domcontentloaded", timeout: 60_000 }).then(() => undefined) )); try { await this.runStage(turn.traceId, "composer_ready", browserStageTimeouts.composerReady, () => ( @@ -1014,7 +1019,7 @@ export class ChatGptBrowserWorker { } await this.runStage(turn.traceId, "session_verification", browserStageTimeouts.sessionVerification, async () => { await assertAuthenticatedChatGptPage(page); - await assertTemporaryChatPage(page); + await assertChatGptConversationPage(page, conversationMode); }); const mode = await this.runStage(turn.traceId, "effort_selection", browserStageTimeouts.effortSelection, () => ( this.selectModelAndEffort(page, turn.modelId, turn.reasoning, turn.capabilities) diff --git a/src/adapters/chatgpt-web/environment.ts b/src/adapters/chatgpt-web/environment.ts index 6dac2cd7c..71411b347 100644 --- a/src/adapters/chatgpt-web/environment.ts +++ b/src/adapters/chatgpt-web/environment.ts @@ -12,6 +12,8 @@ export interface ChatGptTurnEnvironment { writableRoots: string[]; sandboxPolicy: ChatGptSandboxPolicy; tools: CodexTool[]; + /** Enforce a second project-only boundary because the surrounding native harness is broader. */ + enforceProjectBoundary?: true; } export interface ChatGptTurnIdentity { diff --git a/src/adapters/chatgpt-web/index.ts b/src/adapters/chatgpt-web/index.ts index 04423885c..a72e94a5a 100644 --- a/src/adapters/chatgpt-web/index.ts +++ b/src/adapters/chatgpt-web/index.ts @@ -7,6 +7,7 @@ import { parseDataUrl } from "../image"; import { ChatGptBrowserWorker } from "./browser-worker"; import { extractChatGptTurnEnvironment, extractChatGptTurnIdentity } from "./environment"; import { chatGptWebLocalToolsAllowed, resolveChatGptWebModelMode, type ChatGptWebCapabilities } from "./model"; +import { scopeChatGptTurnEnvironment } from "./project-boundary"; import { chatGptReadOnlyContextWarning, compileChatGptWebPrompt } from "./prompt"; import { TurnBroker, type BrokerToolRequest, type BrokerToolResult } from "./turn-broker"; import { ChatGptTextFeed, ChatGptTraceFeed, chatGptTurnExecutionKey, chatGptTurnSessions, type ChatGptBrowserOutcome, type ChatGptTraceEvent, type ChatGptTurnRuntime, type ChatGptTurnSession } from "./turn-execution"; @@ -197,6 +198,7 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider traceId, modelId: parsed.modelId, reasoning: parsed.options.reasoning, + conversationMode: parsed._chatGptWebConversationMode, capabilities: runtimeCapabilities, prepare: async () => ({ ...compileChatGptWebPrompt(parsed, runtimeCapabilities), release: () => {} }), abortSignal: browserAbort.signal, @@ -220,6 +222,7 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider traceId, modelId: parsed.modelId, reasoning: parsed.options.reasoning, + conversationMode: parsed._chatGptWebConversationMode, capabilities: runtimeCapabilities, prepare: async () => { const turnToken = await broker.register( @@ -276,7 +279,7 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider let environment: ReturnType | undefined; if (mode.localTools) { try { - environment = environmentStore.resolve(parsed); + environment = scopeChatGptTurnEnvironment(environmentStore.resolve(parsed)); } catch (error) { const identity = extractChatGptTurnIdentity(parsed); console.warn( diff --git a/src/adapters/chatgpt-web/launcher-helper-client.ts b/src/adapters/chatgpt-web/launcher-helper-client.ts index 80d1affdd..67e1c9795 100644 --- a/src/adapters/chatgpt-web/launcher-helper-client.ts +++ b/src/adapters/chatgpt-web/launcher-helper-client.ts @@ -134,6 +134,7 @@ export class LauncherBrowserHelperClient { traceId: turn.traceId, modelId: turn.modelId, reasoning: turn.reasoning, + conversationMode: turn.conversationMode, capabilities: turn.capabilities, prepared: { text: prepared.text, images: prepared.images } satisfies CompiledChatGptWebPrompt, }, diff --git a/src/adapters/chatgpt-web/mcp-server.ts b/src/adapters/chatgpt-web/mcp-server.ts index c8afdaedc..e4b58fd62 100644 --- a/src/adapters/chatgpt-web/mcp-server.ts +++ b/src/adapters/chatgpt-web/mcp-server.ts @@ -4,6 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import * as z from "zod/v4"; import { namespacedToolName, type CodexTool } from "../../types"; import type { ChatGptTurnEnvironment } from "./environment"; +import { assertProjectPatch, assertProjectPath, projectBoundCommand } from "./project-boundary"; import { callTurnBroker, type BrokerToolResult } from "./turn-broker"; interface ClaimedTurn { @@ -195,6 +196,7 @@ export async function runChatGptMcpServer(options: { brokerSocketPath: string }) roots: claimed.environment.roots, writable_roots: claimed.environment.writableRoots, sandbox: claimed.environment.sandboxPolicy.type, + project_boundary: claimed.environment.enforceProjectBoundary === true, expires_at: new Date(claimed.environment.expiresAt).toISOString(), tool_count: claimed.environment.tools.length, command_tool: commandTool ? wireName(commandTool) : gateway ? "exec_command" : null, @@ -224,17 +226,18 @@ export async function runChatGptMcpServer(options: { brokerSocketPath: string }) const bound = await environment(binding_id); const tool = exactTool(bound, "exec_command") ?? exactTool(bound, "shell_command"); const commandName = tool?.name ?? "exec_command"; + const bounded = await projectBoundCommand(bound, cmd, workdir); const args = commandName === "exec_command" ? { - cmd, - ...(workdir ? { workdir } : {}), + cmd: bounded.command, + workdir: bounded.workdir, ...(yield_time_ms !== undefined ? { yield_time_ms } : {}), ...(max_output_tokens !== undefined ? { max_output_tokens } : {}), ...(tty !== undefined ? { tty } : {}), } : { - command: cmd, - ...(workdir ? { workdir } : {}), + command: bounded.command, + workdir: bounded.workdir, ...(yield_time_ms !== undefined ? { timeout_ms: yield_time_ms } : {}), }; return tool @@ -282,6 +285,7 @@ export async function runChatGptMcpServer(options: { brokerSocketPath: string }) }, async ({ binding_id, patch }) => { const bound = await environment(binding_id); + if (bound.enforceProjectBoundary) await assertProjectPatch(bound, patch); const tool = exactTool(bound, "apply_patch"); if (!tool) return invokeNestedNative(binding_id, bound, "apply_patch", true, { input: patch }); return tool.freeform @@ -304,8 +308,11 @@ export async function runChatGptMcpServer(options: { brokerSocketPath: string }) }, async ({ binding_id, path, detail }) => { const bound = await environment(binding_id); + const boundedPath = bound.enforceProjectBoundary + ? await assertProjectPath(bound, path, "read") + : path; const tool = exactTool(bound, "view_image"); - const payload = { arguments: { path, ...(detail ? { detail } : {}) } }; + const payload = { arguments: { path: boundedPath, ...(detail ? { detail } : {}) } }; return tool ? invokeNative(binding_id, bound, tool, payload) : invokeNestedNative(binding_id, bound, "view_image", false, payload); @@ -328,6 +335,15 @@ export async function runChatGptMcpServer(options: { brokerSocketPath: string }) }, async ({ binding_id, query, offset, limit, include_schema }) => { const bound = await environment(binding_id); + if (bound.enforceProjectBoundary) { + return result({ + tools: [], + total: 0, + next_offset: null, + project_boundary: true, + message: "Use the dedicated project-bound Codex Native tools in this turn.", + }); + } const needle = query?.trim().toLowerCase(); const matches = bound.tools.filter(tool => !needle || [ wireName(tool), @@ -366,6 +382,9 @@ export async function runChatGptMcpServer(options: { brokerSocketPath: string }) }, async ({ binding_id, wire_name, arguments: args, input }) => { const bound = await environment(binding_id); + if (bound.enforceProjectBoundary) { + throw new Error("Generic outer-tool calls are disabled by the Web GPT project boundary"); + } const tool = namedTool(bound, wire_name); if (tool.freeform) { if (input === undefined) throw new Error(`Freeform Codex tool ${wire_name} requires input`); diff --git a/src/adapters/chatgpt-web/model.ts b/src/adapters/chatgpt-web/model.ts index e09617a3d..0c639a5d3 100644 --- a/src/adapters/chatgpt-web/model.ts +++ b/src/adapters/chatgpt-web/model.ts @@ -11,7 +11,8 @@ export type ChatGptWebSandboxType = "dangerFullAccess" | "readOnly" | "workspace * Web GPT gets a narrower capability boundary than the surrounding Codex process. A native * Codex thread may intentionally use danger-full-access, but that authority must not silently * become ChatGPT Web's authority. Web GPT local tools are therefore available only when the - * active project thread explicitly advertises workspace-write. + * active project thread advertises workspace-write, including the separately guarded project + * boundary derived from a broader native task. */ export function chatGptWebLocalToolsAllowed( capabilities: ChatGptWebCapabilities, diff --git a/src/adapters/chatgpt-web/project-boundary.ts b/src/adapters/chatgpt-web/project-boundary.ts new file mode 100644 index 000000000..97db5eb79 --- /dev/null +++ b/src/adapters/chatgpt-web/project-boundary.ts @@ -0,0 +1,164 @@ +import { realpath } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; +import type { ChatGptTurnEnvironment } from "./environment"; + +function pathIdentity(value: string): string { + const normalized = resolve(value); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + +function contains(root: string, path: string): boolean { + const relationship = relative(pathIdentity(root), pathIdentity(path)); + return relationship === "" || (!relationship.startsWith("..") && !isAbsolute(relationship)); +} + +function uniquePaths(values: string[]): string[] { + const unique = new Map(); + for (const value of values.map(path => resolve(path))) { + if (!unique.has(pathIdentity(value))) unique.set(pathIdentity(value), value); + } + return [...unique.values()]; +} + +/** + * Native Codex and OCX may intentionally run without a filesystem sandbox. Web GPT does not + * inherit that authority: its trusted task roots become a separate project boundary while the + * surrounding harness remains unchanged. + */ +export function scopeChatGptTurnEnvironment(environment: ChatGptTurnEnvironment): ChatGptTurnEnvironment { + if (environment.sandboxPolicy.type !== "dangerFullAccess") return environment; + const roots = uniquePaths(environment.roots); + return { + ...environment, + roots, + writableRoots: roots, + sandboxPolicy: { type: "workspaceWrite", writableRoots: roots, networkAccess: false }, + enforceProjectBoundary: true, + }; +} + +function requestedPath(environment: ChatGptTurnEnvironment, value: string): string { + const candidate = resolve(isAbsolute(value) ? value : resolve(environment.cwd, value)); + if (!environment.roots.some(root => contains(root, candidate))) { + throw new Error(`Path is outside the active Codex project: ${value}`); + } + return candidate; +} + +async function canonicalCandidate(path: string): Promise { + const suffix: string[] = []; + let current = path; + for (;;) { + try { + const base = await realpath(current); + return resolve(base, ...suffix); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") throw error; + const parent = dirname(current); + if (parent === current) throw error; + suffix.unshift(basename(current)); + current = parent; + } + } +} + +export async function assertProjectPath( + environment: ChatGptTurnEnvironment, + value: string, + access: "read" | "write", +): Promise { + const candidate = requestedPath(environment, value); + const permittedRoots = access === "write" ? environment.writableRoots : environment.roots; + if (!permittedRoots.some(root => contains(root, candidate))) { + throw new Error(`Path is outside the writable Codex project: ${value}`); + } + const [canonical, ...canonicalRoots] = await Promise.all([ + canonicalCandidate(candidate), + ...permittedRoots.map(root => realpath(root)), + ]); + if (!canonicalRoots.some(root => contains(root, canonical))) { + throw new Error(`Path escapes the active Codex project through a symbolic link: ${value}`); + } + return candidate; +} + +function patchPaths(patch: string): string[] { + const paths: string[] = []; + for (const line of patch.split(/\r?\n/)) { + const match = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/.exec(line) + ?? /^\*\*\* Move to: (.+)$/.exec(line); + if (match) paths.push(match[1]!.trim()); + } + return paths; +} + +export async function assertProjectPatch(environment: ChatGptTurnEnvironment, patch: string): Promise { + const paths = patchPaths(patch); + if (paths.length === 0) throw new Error("Patch contains no recognized project file path"); + await Promise.all(paths.map(path => assertProjectPath(environment, path, "write"))); +} + +function seatbeltString(value: string): string { + return JSON.stringify(resolve(value)); +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function readOnlyToolRoots(): string[] { + const home = homedir(); + return uniquePaths([ + "/Applications", + "/Library", + "/System", + "/bin", + "/opt/homebrew", + "/sbin", + "/usr", + "/usr/local", + resolve(home, "Applications", "Codex Web GPT.app"), + resolve(home, ".codex-chatgpt-web", "versions"), + ]); +} + +export function projectSandboxProfile(environment: ChatGptTurnEnvironment): string { + const readRoots = uniquePaths([...readOnlyToolRoots(), ...environment.roots, tmpdir(), "/private/tmp", "/private/var/folders"]); + const writeRoots = uniquePaths([...environment.writableRoots, tmpdir(), "/private/tmp", "/private/var/folders"]); + return [ + "(version 1)", + "(deny default)", + "(import \"system.sb\")", + "(allow process*)", + "(allow signal (target same-sandbox))", + "(allow sysctl-read)", + "(allow mach-lookup)", + "(allow ipc-posix*)", + "(allow file-read-metadata)", + `(allow file-read* ${readRoots.map(root => `(subpath ${seatbeltString(root)})`).join(" ")})`, + `(allow file-write* ${writeRoots.map(root => `(subpath ${seatbeltString(root)})`).join(" ")})`, + ...(environment.sandboxPolicy.type === "workspaceWrite" && environment.sandboxPolicy.networkAccess + ? ["(allow network-outbound)"] + : []), + ].join(" "); +} + +export async function projectBoundCommand( + environment: ChatGptTurnEnvironment, + command: string, + workdir: string | undefined, + platform = process.platform, +): Promise<{ command: string; workdir: string }> { + const boundedWorkdir = await assertProjectPath(environment, workdir?.trim() || environment.cwd, "read"); + if (!environment.enforceProjectBoundary) return { command, workdir: boundedWorkdir }; + if (platform !== "darwin") { + throw new Error("Project-bound Web GPT shell execution is currently supported on macOS only"); + } + const profile = projectSandboxProfile(environment); + return { + command: `/usr/bin/sandbox-exec -p ${shellQuote(profile)} /bin/zsh -lc ${shellQuote(command)}`, + workdir: boundedWorkdir, + }; +} diff --git a/src/adapters/chatgpt-web/turn-broker.ts b/src/adapters/chatgpt-web/turn-broker.ts index f307dd9e9..7b7a26425 100644 --- a/src/adapters/chatgpt-web/turn-broker.ts +++ b/src/adapters/chatgpt-web/turn-broker.ts @@ -97,6 +97,7 @@ function environmentIdentity(environment: ChatGptTurnEnvironment): string { roots: environment.roots, writableRoots: environment.writableRoots, sandboxPolicy: environment.sandboxPolicy, + enforceProjectBoundary: environment.enforceProjectBoundary === true, }); } diff --git a/src/adapters/chatgpt-web/turn-execution.ts b/src/adapters/chatgpt-web/turn-execution.ts index 6030e71d1..2f548a53a 100644 --- a/src/adapters/chatgpt-web/turn-execution.ts +++ b/src/adapters/chatgpt-web/turn-execution.ts @@ -128,6 +128,7 @@ export function chatGptTurnExecutionKey(parsed: CodexParsedRequest): string { threadId: identity.threadId, turnId: identity.turnId, purpose: parsed._compactionRequest ? "compaction" : "response", + conversationMode: parsed._chatGptWebConversationMode ?? "temporary", }; return createHash("sha256").update(JSON.stringify({ modelId: parsed.modelId, diff --git a/src/chatgpt-session.ts b/src/chatgpt-session.ts index 20c415cc8..0bbaecd5c 100644 --- a/src/chatgpt-session.ts +++ b/src/chatgpt-session.ts @@ -1,6 +1,8 @@ import type { Locator, Page } from "playwright-core"; +import type { ChatGptWebConversationMode } from "./chatgpt-web-models"; export const CHATGPT_TEMPORARY_CHAT_URL = "https://chatgpt.com/?temporary-chat=true"; +export const CHATGPT_SAVED_CHAT_URL = "https://chatgpt.com/"; export const CHATGPT_COMPOSER_SELECTOR = [ '[data-testid="prompt-textarea"]', "#prompt-textarea", @@ -51,6 +53,24 @@ export async function assertTemporaryChatPage(page: Page): Promise { } } +export function chatGptConversationUrl(mode: ChatGptWebConversationMode): string { + return mode === "saved" ? CHATGPT_SAVED_CHAT_URL : CHATGPT_TEMPORARY_CHAT_URL; +} + +export async function assertChatGptConversationPage( + page: Page, + mode: ChatGptWebConversationMode, +): Promise { + if (mode === "temporary") { + await assertTemporaryChatPage(page); + return; + } + const url = new URL(page.url()); + if (url.origin !== new URL(CHATGPT_SAVED_CHAT_URL).origin || url.searchParams.get("temporary-chat") === "true") { + throw new Error(`ChatGPT left the saved conversation surface (${page.url()})`); + } +} + export async function detectChatGptProCapability(page: Page): Promise { const composer = page.locator(CHATGPT_COMPOSER_SELECTOR).last(); const composerForm = composer.locator("xpath=ancestor::form[1]"); diff --git a/src/chatgpt-web-models.ts b/src/chatgpt-web-models.ts index 366ad7bae..8c135d287 100644 --- a/src/chatgpt-web-models.ts +++ b/src/chatgpt-web-models.ts @@ -3,6 +3,7 @@ export const CHATGPT_WEB_BACKEND_MODEL = "gpt-5.6-sol"; export type ChatGptWebCodexEffort = "low" | "medium" | "high" | "xhigh" | "ultra"; export type ChatGptWebAdapterEffort = "low" | "medium" | "high" | "xhigh" | "max"; +export type ChatGptWebConversationMode = "temporary" | "saved"; export interface ChatGptWebModelRoute { slug: string; @@ -11,6 +12,7 @@ export interface ChatGptWebModelRoute { codexEffort: ChatGptWebCodexEffort; adapterEffort: ChatGptWebAdapterEffort; requiresPro: boolean; + conversationMode: ChatGptWebConversationMode; } /** @@ -20,45 +22,68 @@ export interface ChatGptWebModelRoute { * the adapter boundary. */ export const CHATGPT_WEB_MODEL_ROUTES: readonly ChatGptWebModelRoute[] = [ + { + slug: "chatgpt-web/temporary", + displayName: "ChatGPT Web — 임시대화 (매우 높음)", + description: "기록에 남기지 않는 ChatGPT Web 임시대화를 사용합니다. 기본 추론 수준은 매우 높음입니다.", + codexEffort: "xhigh", + adapterEffort: "xhigh", + requiresPro: false, + conversationMode: "temporary", + }, + { + slug: "chatgpt-web/saved", + displayName: "ChatGPT Web — 저장 대화 (매우 높음)", + description: "ChatGPT 대화 기록에 남는 일반 대화를 새로 만듭니다. 기본 추론 수준은 매우 높음입니다.", + codexEffort: "xhigh", + adapterEffort: "xhigh", + requiresPro: false, + conversationMode: "saved", + }, { slug: "chatgpt-web/light", - displayName: "ChatGPT Web — Instant", - description: "ChatGPT Web Instant through the native Codex harness.", + displayName: "ChatGPT Web — 즉시 (임시대화)", + description: "ChatGPT Web 즉시 모드를 임시대화로 사용합니다.", codexEffort: "low", adapterEffort: "low", requiresPro: false, + conversationMode: "temporary", }, { slug: "chatgpt-web/medium", - displayName: "ChatGPT Web — Medium", - description: "ChatGPT Web Medium through the native Codex harness.", + displayName: "ChatGPT Web — 중간 (임시대화)", + description: "ChatGPT Web 중간 추론 모드를 임시대화로 사용합니다.", codexEffort: "medium", adapterEffort: "medium", requiresPro: false, + conversationMode: "temporary", }, { slug: "chatgpt-web/high", - displayName: "ChatGPT Web — High", - description: "ChatGPT Web High through the native Codex harness.", + displayName: "ChatGPT Web — 높음 (임시대화)", + description: "ChatGPT Web 높은 추론 모드를 임시대화로 사용합니다.", codexEffort: "high", adapterEffort: "high", requiresPro: false, + conversationMode: "temporary", }, { slug: "chatgpt-web/extra-high", - displayName: "ChatGPT Web — Extra High", - description: "ChatGPT Web Extra High through the native Codex harness.", + displayName: "ChatGPT Web — 매우 높음 (임시대화)", + description: "ChatGPT Web 매우 높은 추론 모드를 임시대화로 사용합니다.", codexEffort: "xhigh", adapterEffort: "xhigh", requiresPro: false, + conversationMode: "temporary", }, { slug: "chatgpt-web/pro", - displayName: "ChatGPT Web — Pro", - description: "Account-gated ChatGPT Pro through the native Codex harness. Local tool calls are unavailable in this mode.", + displayName: "ChatGPT Web — Pro (임시대화)", + description: "계정에 Pro 권한이 있을 때만 사용할 수 있는 임시대화 모드입니다. 이 모드에서는 로컬 도구를 사용할 수 없습니다.", codexEffort: "ultra", adapterEffort: "max", requiresPro: true, + conversationMode: "temporary", }, ]; diff --git a/src/config.ts b/src/config.ts index fcd5ec976..acb57d122 100644 --- a/src/config.ts +++ b/src/config.ts @@ -353,7 +353,9 @@ export function providerConfig(config: AppConfig): CodexProviderConfig { contextWindow: config.contextWindow, modelInputModalities: Object.fromEntries(models.map(model => [model, ["text", "image"]])), modelReasoningEfforts: { "gpt-5.6-sol": efforts }, - modelDefaultReasoningEfforts: { "gpt-5.6-sol": "xhigh" }, + // Sol is the ordinary/native route. Web GPT's temporary/saved picker entries + // override this at the request boundary and force xhigh independently. + modelDefaultReasoningEfforts: { "gpt-5.6-sol": "high" }, noReasoningModels: [], chatgptWeb: { appName: config.appName, diff --git a/src/server.ts b/src/server.ts index f79cafb2e..b15e8431d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -148,6 +148,7 @@ export function routeChatGptWebRequest(parsed: CodexParsedRequest, config: AppCo const route = requireChatGptWebModelRoute(parsed.modelId, config.proAvailable); parsed.modelId = CHATGPT_WEB_BACKEND_MODEL; parsed.options.reasoning = route.adapterEffort; + parsed._chatGptWebConversationMode = route.conversationMode; return route; } diff --git a/src/types.ts b/src/types.ts index 5a75d9b45..42d445b8f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,7 @@ export interface CodexParsedRequest { modelId: string; + /** Conversation surface selected by a chatgpt-web/* model route. */ + _chatGptWebConversationMode?: "temporary" | "saved"; previousResponseId?: string; context: CodexContext; stream: boolean; diff --git a/tests/chatgpt-session.test.ts b/tests/chatgpt-session.test.ts new file mode 100644 index 000000000..bb6fc457c --- /dev/null +++ b/tests/chatgpt-session.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test"; +import { + assertChatGptConversationPage, + chatGptConversationUrl, + CHATGPT_SAVED_CHAT_URL, + CHATGPT_TEMPORARY_CHAT_URL, +} from "../src/chatgpt-session"; + +test("selects the correct ChatGPT conversation surface", async () => { + expect(chatGptConversationUrl("temporary")).toBe(CHATGPT_TEMPORARY_CHAT_URL); + expect(chatGptConversationUrl("saved")).toBe(CHATGPT_SAVED_CHAT_URL); + + await assertChatGptConversationPage({ url: () => CHATGPT_TEMPORARY_CHAT_URL } as never, "temporary"); + await assertChatGptConversationPage({ url: () => "https://chatgpt.com/c/saved-conversation" } as never, "saved"); + await expect(assertChatGptConversationPage( + { url: () => CHATGPT_TEMPORARY_CHAT_URL } as never, + "saved", + )).rejects.toThrow("saved conversation surface"); +}); diff --git a/tests/chatgpt-web-harness.test.ts b/tests/chatgpt-web-harness.test.ts index 0c2913983..bb316f5ed 100644 --- a/tests/chatgpt-web-harness.test.ts +++ b/tests/chatgpt-web-harness.test.ts @@ -12,6 +12,7 @@ import { createChatGptWebAdapter } from "../src/adapters/chatgpt-web/index"; import { chatGptHtmlToMarkdown, ChatGptMarkdownBuffer } from "../src/adapters/chatgpt-web/markdown"; import { CHATGPT_WEB_MODEL_ID, resolveChatGptWebModelMode } from "../src/adapters/chatgpt-web/model"; import { chatGptReadOnlyContextWarning, compileChatGptWebPrompt } from "../src/adapters/chatgpt-web/prompt"; +import { scopeChatGptTurnEnvironment } from "../src/adapters/chatgpt-web/project-boundary"; import { ChatGptTextFeed, ChatGptTraceFeed, ChatGptTurnSessions, chatGptTurnExecutionKey } from "../src/adapters/chatgpt-web/turn-execution"; import { callTurnBroker, TurnBroker, type BrokerToolResult } from "../src/adapters/chatgpt-web/turn-broker"; import { defaultBrokerEndpoint } from "../src/config"; @@ -908,4 +909,59 @@ describe("ChatGPT outer-native harness v3", () => { await broker.close(); } }, 30_000); + + test("keeps a danger-full native task project-bound at the MCP implementation", async () => { + const socketPath = brokerTestEndpoint(`cgw-h3-project-boundary-${process.pid}-${Date.now()}`); + const broker = TurnBroker.forSocket(socketPath); + const nativeEnvironment = extractChatGptTurnEnvironment(parsed(environmentXml)); + nativeEnvironment.sandboxPolicy = { type: "dangerFullAccess" }; + const boundedEnvironment = scopeChatGptTurnEnvironment(nativeEnvironment); + boundedEnvironment.tools = boundedEnvironment.tools.filter(tool => tool.name === "exec"); + const token = await broker.register(boundedEnvironment, 60_000); + const transport = new StdioClientTransport({ + command: process.execPath, + args: ["src/cli.ts", "mcp", "--broker-socket", socketPath], + cwd: process.cwd(), + stderr: "pipe", + }); + const client = new Client({ name: "codex-chatgpt-web-project-boundary-test", version: "1.0.0" }); + const call = (name: string, args: Record) => client.callTool({ name, arguments: args }); + + try { + await client.connect(transport); + const bound = await call("codex_bind_turn", { turn_token: token }); + const structured = bound.structuredContent as { binding_id: string; project_boundary: boolean; sandbox: string }; + expect(structured.project_boundary).toBe(true); + expect(structured.sandbox).toBe("workspaceWrite"); + + const inventory = await call("codex_tool_inventory", { binding_id: structured.binding_id }); + expect(inventory.structuredContent).toMatchObject({ tools: [], total: 0, project_boundary: true }); + + const generic = await call("codex_tool_call", { + binding_id: structured.binding_id, + wire_name: "exec", + input: "text('bypass')", + }); + expect(generic.isError).toBe(true); + expect(JSON.stringify(generic.content)).toContain("Generic outer-tool calls are disabled"); + + const execPromise = call("codex_exec", { binding_id: structured.binding_id, cmd: "pwd" }); + const [execRequest] = await broker.nextToolBatch(token); + expect(execRequest?.input).toContain("/usr/bin/sandbox-exec -p"); + expect(execRequest?.input).toContain(JSON.stringify(tempRoot)); + broker.completeTool(token, execRequest!.callId, toolResult({ output: tempRoot, exit_code: 0 })); + expect((await execPromise).structuredContent).toEqual({ output: tempRoot, exit_code: 0 }); + + const escapedPatch = await call("codex_apply_patch", { + binding_id: structured.binding_id, + patch: "*** Begin Patch\n*** Add File: /tmp/outside.txt\n+no\n*** End Patch", + }); + expect(escapedPatch.isError).toBe(true); + expect(JSON.stringify(escapedPatch.content)).toContain("outside the active Codex project"); + } finally { + await client.close().catch(() => {}); + broker.revoke(token); + await broker.close(); + } + }, 30_000); }); diff --git a/tests/chatgpt-web-models.test.ts b/tests/chatgpt-web-models.test.ts index 758e7b52b..f3e6870f6 100644 --- a/tests/chatgpt-web-models.test.ts +++ b/tests/chatgpt-web-models.test.ts @@ -23,13 +23,17 @@ describe("fixed ChatGPT Web model routes", () => { test("uses unique stable slugs and one explicit adapter effort per model", () => { expect(new Set(CHATGPT_WEB_MODEL_ROUTES.map(route => route.slug)).size).toBe(CHATGPT_WEB_MODEL_ROUTES.length); expect(CHATGPT_WEB_MODEL_ROUTES.map(route => [route.slug, route.codexEffort, route.adapterEffort])).toEqual([ + ["chatgpt-web/temporary", "xhigh", "xhigh"], + ["chatgpt-web/saved", "xhigh", "xhigh"], ["chatgpt-web/light", "low", "low"], ["chatgpt-web/medium", "medium", "medium"], ["chatgpt-web/high", "high", "high"], ["chatgpt-web/extra-high", "xhigh", "xhigh"], ["chatgpt-web/pro", "ultra", "max"], ]); - expect(CHATGPT_WEB_MODEL_ROUTES[0]?.displayName).toBe("ChatGPT Web — Instant"); + expect(CHATGPT_WEB_MODEL_ROUTES[0]?.displayName).toBe("ChatGPT Web — 임시대화 (매우 높음)"); + expect(CHATGPT_WEB_MODEL_ROUTES[0]?.conversationMode).toBe("temporary"); + expect(CHATGPT_WEB_MODEL_ROUTES[1]?.conversationMode).toBe("saved"); }); test("does not expose or resolve Pro without the account capability", () => { @@ -46,9 +50,32 @@ describe("fixed ChatGPT Web model routes", () => { expect(route.slug).toBe("chatgpt-web/high"); expect(request.modelId).toBe(CHATGPT_WEB_BACKEND_MODEL); expect(request.options.reasoning).toBe("high"); + expect(request._chatGptWebConversationMode).toBe("temporary"); expect(request._rawBody).toEqual(rawSnapshot); }); + test("keeps temporary and saved conversation choices separate while forcing Web GPT defaults to Extra High", () => { + const temporary = parsed("chatgpt-web/temporary", "low"); + const saved = parsed("chatgpt-web/saved", "low"); + const config = defaultConfig("browser-only"); + + expect(routeChatGptWebRequest(temporary, config)).toMatchObject({ + slug: "chatgpt-web/temporary", + codexEffort: "xhigh", + conversationMode: "temporary", + }); + expect(temporary.options.reasoning).toBe("xhigh"); + expect(temporary._chatGptWebConversationMode).toBe("temporary"); + + expect(routeChatGptWebRequest(saved, config)).toMatchObject({ + slug: "chatgpt-web/saved", + codexEffort: "xhigh", + conversationMode: "saved", + }); + expect(saved.options.reasoning).toBe("xhigh"); + expect(saved._chatGptWebConversationMode).toBe("saved"); + }); + test("binds the Pro model to the browser Pro effort and fails closed for unknown routes", () => { const config = defaultConfig("full"); config.proAvailable = true; diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index 5b4f6513d..ee8643096 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -91,7 +91,13 @@ describe("native /models augmentation", () => { .slice(0, 5) .map(model => model.slug); - expect(spawnOverrides).toEqual(CHATGPT_WEB_MODEL_ROUTES.map(route => route.slug)); + expect(spawnOverrides).toEqual([ + "chatgpt-web/temporary", + "chatgpt-web/saved", + "chatgpt-web/light", + "chatgpt-web/medium", + "chatgpt-web/high", + ]); }); test("owns only its namespace, is idempotent, and omits account-gated Pro when unavailable", () => { @@ -167,7 +173,7 @@ describe("native /models augmentation", () => { const result = augmentNativeModelCatalog(native, defaultConfig("full")); const web = (result.models as Array>) .filter(model => String(model.slug).startsWith("chatgpt-web/")); - expect(web.length).toBe(4); + expect(web.length).toBe(6); expect(web.every(model => model.shell_type === "shell_command")).toBe(true); expect(web.every(model => model.tool_mode === "code_mode_only")).toBe(true); }); diff --git a/tests/project-boundary.test.ts b/tests/project-boundary.test.ts new file mode 100644 index 000000000..33f88f0d4 --- /dev/null +++ b/tests/project-boundary.test.ts @@ -0,0 +1,86 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ChatGptTurnEnvironment } from "../src/adapters/chatgpt-web/environment"; +import { + assertProjectPatch, + assertProjectPath, + projectBoundCommand, + projectSandboxProfile, + scopeChatGptTurnEnvironment, +} from "../src/adapters/chatgpt-web/project-boundary"; + +const root = join(tmpdir(), `codex-web-project-boundary-${process.pid}-${Date.now()}`); +const outside = join(tmpdir(), `codex-web-project-outside-${process.pid}-${Date.now()}`); +mkdirSync(root, { recursive: true }); +mkdirSync(outside, { recursive: true }); +afterAll(() => { + rmSync(root, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); +}); + +function dangerEnvironment(): ChatGptTurnEnvironment { + return { + cwd: root, + roots: [root], + writableRoots: [root], + sandboxPolicy: { type: "dangerFullAccess" }, + tools: [], + }; +} + +describe("Web GPT project boundary", () => { + test("narrows a broader native task without changing its trusted project roots", () => { + expect(scopeChatGptTurnEnvironment(dangerEnvironment())).toEqual({ + cwd: root, + roots: [root], + writableRoots: [root], + sandboxPolicy: { type: "workspaceWrite", writableRoots: [root], networkAccess: false }, + tools: [], + enforceProjectBoundary: true, + }); + }); + + test("defaults commands to the bound cwd and wraps them in the macOS project sandbox", async () => { + const environment = scopeChatGptTurnEnvironment(dangerEnvironment()); + const bounded = await projectBoundCommand(environment, "pwd", undefined, "darwin"); + expect(bounded.workdir).toBe(root); + expect(bounded.command).toStartWith("/usr/bin/sandbox-exec -p "); + expect(bounded.command).toContain("/bin/zsh -lc 'pwd'"); + expect(projectSandboxProfile(environment)).toContain(`(subpath ${JSON.stringify(root)})`); + expect(projectSandboxProfile(environment)).not.toContain("(allow network-outbound)"); + }); + + test("rejects explicit paths and patch targets outside the active project", async () => { + const environment = scopeChatGptTurnEnvironment(dangerEnvironment()); + await expect(assertProjectPath(environment, outside, "read")).rejects.toThrow("outside the active Codex project"); + await expect(assertProjectPatch(environment, `*** Begin Patch\n*** Update File: ${join(outside, "escape.txt")}\n@@\n-old\n+new\n*** End Patch`)) + .rejects.toThrow("outside the active Codex project"); + }); + + test("rejects a project symlink that resolves outside the project", async () => { + const environment = scopeChatGptTurnEnvironment(dangerEnvironment()); + const outsideFile = join(outside, "secret.txt"); + const link = join(root, "escape-link"); + writeFileSync(outsideFile, "secret"); + try { + symlinkSync(outsideFile, link); + await expect(assertProjectPath(environment, link, "read")).rejects.toThrow("symbolic link"); + } finally { + rmSync(link, { force: true }); + rmSync(outsideFile, { force: true }); + } + }); + + test("keeps an existing native workspace-write boundary unchanged", () => { + const environment: ChatGptTurnEnvironment = { + cwd: root, + roots: [root], + writableRoots: [root], + sandboxPolicy: { type: "workspaceWrite", writableRoots: [root], networkAccess: true }, + tools: [], + }; + expect(scopeChatGptTurnEnvironment(environment)).toBe(environment); + }); +}); diff --git a/tests/server-models.test.ts b/tests/server-models.test.ts index c6d932adf..0b7519f0b 100644 --- a/tests/server-models.test.ts +++ b/tests/server-models.test.ts @@ -45,6 +45,8 @@ test("proxies official /models auth and query, then appends the fixed ChatGPT We }; expect(body.models.map(model => model.slug)).toEqual([ "gpt-5.6-sol", + "chatgpt-web/temporary", + "chatgpt-web/saved", "chatgpt-web/light", "chatgpt-web/medium", "chatgpt-web/high", From 76afdcadf2b9a0df126d138b00c8d129d5e60ec3 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 11/17] 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 a01833456..fd10b9b2d 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 type { ChatGptWebConversationMode } from "../../chatgpt-web-models"; import { createProcessLineWriter } from "./process-line-writer"; @@ -135,6 +136,12 @@ async function run(message: RunMessage): Promise { id: message.id, name: error instanceof Error ? error.name : "Error", message: error instanceof Error ? error.message : String(error), + ...(error instanceof ChatGptWebAdapterError ? { + status: error.status, + errorType: error.errorType, + code: error.code, + retryable: error.retryable, + } : {}), }); } finally { abortControllers.delete(message.id); diff --git a/src/adapters/chatgpt-web/browser-worker.ts b/src/adapters/chatgpt-web/browser-worker.ts index ddee26147..f8160bc4a 100644 --- a/src/adapters/chatgpt-web/browser-worker.ts +++ b/src/adapters/chatgpt-web/browser-worker.ts @@ -28,6 +28,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"; @@ -58,6 +59,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, @@ -519,19 +546,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") { @@ -541,6 +582,7 @@ export class ChatGptBrowserWorker { await page.keyboard.press("Escape"); return mode; } + await throwIfChatGptRateLimitDialog(page); await effortChoice.press("Enter"); const deadline = Date.now() + 40_000; @@ -548,7 +590,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 a72e94a5a..8e2e640bb 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 { chatGptWebLocalToolsAllowed, resolveChatGptWebModelMode, type ChatGptWebCapabilities } from "./model"; @@ -432,6 +433,17 @@ export function createChatGptWebAdapter(provider: CodexProviderConfig): Provider if (session.runtime.mode === "tools") { void session.runtime.token.then(turnToken => broker.revoke(turnToken)).catch(() => {}); } + if (error instanceof ChatGptWebAdapterError) { + emit({ + type: "error", + message: error.message, + status: error.status, + errorType: error.errorType, + code: error.code, + retryable: error.retryable, + }); + return; + } throw error; } finally { clearInterval(heartbeat); diff --git a/src/adapters/chatgpt-web/launcher-helper-client.ts b/src/adapters/chatgpt-web/launcher-helper-client.ts index 67e1c9795..decd87c94 100644 --- a/src/adapters/chatgpt-web/launcher-helper-client.ts +++ b/src/adapters/chatgpt-web/launcher-helper-client.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { createInterface } from "node:readline"; import { notifyLauncherTurn, readLauncherBrowserHostDescriptor } from "../../launcher-browser-host"; +import { ChatGptWebAdapterError } from "./adapter-error"; import type { CompiledChatGptWebPrompt } from "./prompt"; import type { BrowserTurn, ResolvedBrowserConfig } from "./browser-worker"; @@ -16,7 +17,16 @@ type HelperMessage = | { type: "ready" } | { type: "event"; id: string; event: "heartbeat" | "reasoning" | "commentary" | "text"; text?: string; continuation?: boolean } | { type: "result"; id: string; text: string } - | { type: "error"; id: string; name?: string; message: string }; + | { + type: "error"; + id: string; + name?: string; + message: string; + status?: number; + errorType?: string; + code?: string; + retryable?: boolean; + }; function parseHelperMessage(line: string): HelperMessage { const value = JSON.parse(line) as unknown; @@ -59,8 +69,26 @@ function parseHelperMessage(line: string): HelperMessage { if (message.type === "error") { const errorMessage = message.message; const errorName = message.name; + const status = message.status; + const errorType = message.errorType; + const code = message.code; + const retryable = message.retryable; + const structured = status !== undefined + || errorType !== undefined + || code !== undefined + || retryable !== undefined; if (typeof errorMessage !== "string" - || (errorName !== undefined && typeof errorName !== "string")) { + || (errorName !== undefined && typeof errorName !== "string") + || (structured && ( + !Number.isInteger(status) + || (status as number) < 400 + || (status as number) > 599 + || typeof errorType !== "string" + || !errorType + || typeof code !== "string" + || !code + || typeof retryable !== "boolean" + ))) { throw new Error("Launcher browser helper error payload is invalid"); } return { @@ -68,6 +96,12 @@ function parseHelperMessage(line: string): HelperMessage { id: message.id, message: errorMessage, ...(errorName !== undefined ? { name: errorName as string } : {}), + ...(structured ? { + status: status as number, + errorType: errorType as string, + code: code as string, + retryable: retryable as boolean, + } : {}), }; } throw new Error("Launcher browser helper emitted an unknown message type"); @@ -262,9 +296,16 @@ export class LauncherBrowserHelperClient { this.finish(message.id); pending.resolve(message.text); } else if (message.type === "error") { - const error = message.name === "AbortError" - ? new DOMException(message.message, "AbortError") - : new Error(message.message); + const error = message.status !== undefined + ? new ChatGptWebAdapterError(message.message, { + status: message.status, + errorType: message.errorType!, + code: message.code!, + retryable: message.retryable!, + }) + : message.name === "AbortError" + ? new DOMException(message.message, "AbortError") + : new Error(message.message); this.finish(message.id); pending.reject(error); } diff --git a/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 bb316f5ed..37c739370 100644 --- a/tests/chatgpt-web-harness.test.ts +++ b/tests/chatgpt-web-harness.test.ts @@ -427,6 +427,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 8c6bc2401b8de608a4e01047e12f5cc8177f94d1 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 12/17] 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 | 28 +++++++++++-- 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 | 24 ++++++++++- 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, 227 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 942155292..1e592f527 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,8 @@ This source path requires Bun 1.3.11. The command installs locked dependencies a | Mode | Models | Local Codex tools | Extra setup | | --- | --- | --- | --- | -| **Browser-only** | Instant through Pro | No; Codex shows a warning | None | -| **Full harness** | Instant through Pro | Instant–Extra High: yes; Pro: read-only | OpenAI tunnel + ChatGPT connector | +| **Browser-only** | Plus: Instant–High; Pro: adds Extra High and Pro | No; Codex shows a warning | None | +| **Full harness** | Plus: Instant–High; Pro: adds Extra High and Pro | Instant–Extra High: yes; Pro: read-only | OpenAI tunnel + ChatGPT connector | Every picker entry has one fixed ChatGPT mode. Codex still displays its built-in Effort and Speed rows, but changing them cannot silently change the selected browser model. Pro receives the full diff --git a/launcher/package.json b/launcher/package.json index b802eed29..8801184c8 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -1,6 +1,6 @@ { "name": "codex-web-gpt-launcher", - "version": "1.1.1-ko.3", + "version": "1.1.2-ko.1", "private": true, "description": "Desktop control center for Codex ChatGPT Web", "author": "miuuyy; Korean localization by AgenticLab-SH", diff --git a/launcher/src/i18n.ts b/launcher/src/i18n.ts index 3a85a34d0..838ce8c88 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", @@ -205,7 +205,7 @@ const zh: Record = { done: "完成", guideVideo: "指导视频", mcpStepOne: "创建 Tunnel 和 API key", - mcpStepOneBody: "创建 OpenAI Tunnel,复制 Tunnel ID,然后创建一个拥有 Tunnels Read + Use 权限的普通 API key(免费;此密钥仅用于运行 Tunnel)。", + mcpStepOneBody: "创建 OpenAI Tunnel,复制 Tunnel ID,然后创建一个拥有 Tunnels Read + Use 权限的普通 API key(免费;此密钥仅用于运行 Tunnel)。(别忘了创建 ChatGPT 工作区。)", openTunnels: "打开 Tunnels", openKeys: "创建 API key", mcpStepTwo: "连接本地 Harness", diff --git a/launcher/tests/design-contract.test.cjs b/launcher/tests/design-contract.test.cjs index f6f0a87f3..63a0987f4 100644 --- a/launcher/tests/design-contract.test.cjs +++ b/launcher/tests/design-contract.test.cjs @@ -144,6 +144,8 @@ test("MCP guide uses the two optimized recordings in the requested step order", test("MCP copy includes every required account, key, and connector instruction", () => { assert.match(i18nSource, /regular API key with Tunnels Read \+ Use \(free;/); + assert.match(i18nSource, /Don't forget to create a ChatGPT workspace\./); + assert.match(i18nSource, /别忘了创建 ChatGPT 工作区。/); assert.match(i18nSource, /same OpenAI account that will use the ChatGPT plugin/); assert.match(i18nSource, /enable Developer Mode[\s\S]*?choose Tunnel[\s\S]*?set Authentication to None/); assert.match(appSource, //); diff --git a/package.json b/package.json index ff03301fc..399074b19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-chatgpt-web", - "version": "1.1.1-ko.3", + "version": "1.1.2-ko.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 779695f5d..15de9352d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ set -eu REPOSITORY="${CODEX_CHATGPT_WEB_REPOSITORY:-AgenticLab-SH/codex-chatgpt-web}" -VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.1.1-ko.3}" +VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.1.2-ko.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 f8160bc4a..2f2c5cbf6 100644 --- a/src/adapters/chatgpt-web/browser-worker.ts +++ b/src/adapters/chatgpt-web/browser-worker.ts @@ -26,6 +26,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"; @@ -85,6 +86,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, @@ -636,6 +662,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(), @@ -1022,6 +1049,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; @@ -1127,6 +1158,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 0c639a5d3..359d262c6 100644 --- a/src/adapters/chatgpt-web/model.ts +++ b/src/adapters/chatgpt-web/model.ts @@ -46,6 +46,7 @@ export function resolveChatGptWebModelMode( case "high": return { modelId, effort, displayLabel: "High", uiEffortIndex: 2, localTools: capabilities.localToolsEnabled }; case "xhigh": + if (!capabilities.proAvailable) throw new Error("ChatGPT Extra High effort is not available for this account"); return { modelId, effort, displayLabel: "Extra High", uiEffortIndex: 3, localTools: capabilities.localToolsEnabled }; case "max": if (!capabilities.proAvailable) throw new Error("ChatGPT Pro effort is not available for this account"); diff --git a/src/chatgpt-web-models.ts b/src/chatgpt-web-models.ts index 8c135d287..d09ac9a33 100644 --- a/src/chatgpt-web-models.ts +++ b/src/chatgpt-web-models.ts @@ -5,6 +5,26 @@ export type ChatGptWebCodexEffort = "low" | "medium" | "high" | "xhigh" | "ultra export type ChatGptWebAdapterEffort = "low" | "medium" | "high" | "xhigh" | "max"; export type ChatGptWebConversationMode = "temporary" | "saved"; +export const CHATGPT_WEB_PLUS_CONTEXT_WINDOW = 225_000; +export const CHATGPT_WEB_PRO_CONTEXT_WINDOW = 256_000; + +export interface ChatGptWebContextLimits { + contextWindow: number; + autoCompactTokenLimit: number; +} + +/** Resolve the authenticated account's product limit for one visible ChatGPT mode. */ +export function resolveChatGptWebContextLimits( + proAvailable: boolean, +): ChatGptWebContextLimits { + const contextWindow = proAvailable ? CHATGPT_WEB_PRO_CONTEXT_WINDOW : CHATGPT_WEB_PLUS_CONTEXT_WINDOW; + return { + contextWindow, + // Leave ten percent for Codex to submit and receive the compact checkpoint before the hard cap. + autoCompactTokenLimit: Math.floor(contextWindow * 0.9), + }; +} + export interface ChatGptWebModelRoute { slug: string; displayName: string; @@ -28,7 +48,7 @@ export const CHATGPT_WEB_MODEL_ROUTES: readonly ChatGptWebModelRoute[] = [ description: "기록에 남기지 않는 ChatGPT Web 임시대화를 사용합니다. 기본 추론 수준은 매우 높음입니다.", codexEffort: "xhigh", adapterEffort: "xhigh", - requiresPro: false, + requiresPro: true, conversationMode: "temporary", }, { @@ -37,7 +57,7 @@ export const CHATGPT_WEB_MODEL_ROUTES: readonly ChatGptWebModelRoute[] = [ description: "ChatGPT 대화 기록에 남는 일반 대화를 새로 만듭니다. 기본 추론 수준은 매우 높음입니다.", codexEffort: "xhigh", adapterEffort: "xhigh", - requiresPro: false, + requiresPro: true, conversationMode: "saved", }, { @@ -73,7 +93,7 @@ export const CHATGPT_WEB_MODEL_ROUTES: readonly ChatGptWebModelRoute[] = [ description: "ChatGPT Web 매우 높은 추론 모드를 임시대화로 사용합니다.", codexEffort: "xhigh", adapterEffort: "xhigh", - requiresPro: false, + requiresPro: true, conversationMode: "temporary", }, { @@ -103,7 +123,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 fb3478277..eacfba870 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -42,8 +42,8 @@ Usage: codex-chatgpt-web uninstall --yes Setup options: - --browser-only Fixed Instant–Pro models, full context/images, no local tools or tunnel - --full Fixed Instant–Extra High tool models plus read-only Pro + --browser-only Account-eligible Web models, full context/images, no local tools or tunnel + --full Account-eligible Web models with tools; Pro remains read-only --port NUMBER Loopback Responses port (default: 17841) --chrome PATH Google Chrome executable --browser-host-descriptor PATH diff --git a/src/codex-integration.ts b/src/codex-integration.ts index 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 ee43631e7..ac2a01258 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.1.1-ko.3"; +export const VERSION = "1.1.2-ko.1"; 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 37c739370..7b2a5ecdd 100644 --- a/tests/chatgpt-web-harness.test.ts +++ b/tests/chatgpt-web-harness.test.ts @@ -357,6 +357,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"); }); @@ -464,6 +468,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 f3e6870f6..9ddaa451b 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"; @@ -36,12 +37,30 @@ describe("fixed ChatGPT Web model routes", () => { expect(CHATGPT_WEB_MODEL_ROUTES[1]?.conversationMode).toBe("saved"); }); - test("does not expose or resolve Pro without the account capability", () => { - expect(availableChatGptWebModelRoutes(false).map(route => route.slug)).not.toContain("chatgpt-web/pro"); + 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("ChatGPT Web — 매우 높음 (임시대화) 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); @@ -58,6 +77,7 @@ describe("fixed ChatGPT Web model routes", () => { const temporary = parsed("chatgpt-web/temporary", "low"); const saved = parsed("chatgpt-web/saved", "low"); const config = defaultConfig("browser-only"); + config.proAvailable = true; expect(routeChatGptWebRequest(temporary, config)).toMatchObject({ slug: "chatgpt-web/temporary", 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 ee8643096..f22ec3fb3 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, @@ -100,7 +99,7 @@ describe("native /models augmentation", () => { ]); }); - 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(); @@ -118,6 +117,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", () => { @@ -140,9 +147,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); } }); @@ -173,7 +181,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(6); + 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 ab383267d..cd1e71ff1 100644 --- a/tests/model-contract.test.ts +++ b/tests/model-contract.test.ts @@ -31,7 +31,7 @@ test("the browser adapter maps fixed routed efforts to the visible ChatGPT modes }); }); -test("capabilities gate tools and Pro explicitly without changing the selected model", () => { +test("capabilities gate tools and Pro-only efforts explicitly without changing the selected model", () => { expect(resolveChatGptWebModelMode(CHATGPT_WEB_MODEL_ID, "high", { localToolsEnabled: false, proAvailable: true, @@ -40,6 +40,10 @@ test("capabilities gate tools and Pro explicitly without changing the selected m localToolsEnabled: false, proAvailable: false, })).toThrow("Pro effort is not available"); + expect(() => resolveChatGptWebModelMode(CHATGPT_WEB_MODEL_ID, "xhigh", { + localToolsEnabled: true, + proAvailable: false, + })).toThrow("Extra High effort is not available"); expect(() => resolveChatGptWebModelMode("unknown", "high", { localToolsEnabled: false, proAvailable: true, diff --git a/tests/server-compaction.test.ts b/tests/server-compaction.test.ts index d2d719663..f98ba3a8a 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", "ChatGPT Web — 매우 높음 (임시대화)"], + ["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 0b7519f0b..7490728f7 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 () => { @@ -55,9 +54,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 b90b39e03e9f911c6f1ef569a86b87326a737e71 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 13/17] 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 d83a99d08..c366dcec1 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" }); }); @@ -1404,6 +1417,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 ed58592fd..bd7744492 100644 --- a/launcher/tests/browser-host.test.cjs +++ b/launcher/tests/browser-host.test.cjs @@ -13,8 +13,33 @@ const { BrowserHost, CHATGPT_VIEWPORT_CSS, isTemporaryChatUrl, + initializationNavigationWasSuperseded, } = require("../electron/browser-host.cjs"); +test("only a proven replacement navigation suppresses initial ERR_ABORTED noise", () => { + const aborted = Object.assign(new Error("ERR_ABORTED (-3) loading 'about:blank'"), { code: "ERR_ABORTED" }); + assert.equal(initializationNavigationWasSuperseded( + aborted, + "about:blank#codex-web-gpt-browser-host", + "https://chatgpt.com/?temporary-chat=true", + ), true); + assert.equal(initializationNavigationWasSuperseded( + aborted, + "about:blank#codex-web-gpt-browser-host", + "about:blank#codex-web-gpt-browser-host", + ), false); + assert.equal(initializationNavigationWasSuperseded( + aborted, + "about:blank#codex-web-gpt-browser-host", + "about:blank", + ), false); + assert.equal(initializationNavigationWasSuperseded( + new Error("net::ERR_FAILED"), + "about:blank#codex-web-gpt-browser-host", + "https://chatgpt.com/?temporary-chat=true", + ), false); +}); + function createContents() { const calls = []; const history = { diff --git a/src/adapters/chatgpt-web/browser-worker.ts b/src/adapters/chatgpt-web/browser-worker.ts index 2f2c5cbf6..cdf27564b 100644 --- a/src/adapters/chatgpt-web/browser-worker.ts +++ b/src/adapters/chatgpt-web/browser-worker.ts @@ -50,6 +50,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. @@ -99,6 +100,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, @@ -866,20 +901,6 @@ export class ChatGptBrowserWorker { throw new Error("ChatGPT accepted the prompt attachments but did not make the message ready to send"); } - private async handleToolConfirmation(page: Page): Promise { - const heading = page.getByText(`Allow ChatGPT to use ${this.config.appName}?`, { exact: true }).last(); - if (!await heading.isVisible().catch(() => false)) return false; - if (!this.config.autoApproveToolCalls) { - throw new Error( - `ChatGPT is waiting for confirmation to use ${this.config.appName}; set chatgptWeb.autoApproveToolCalls=true to authorize per-call "Allow once" clicks`, - ); - } - const allowOnce = page.getByRole("button", { name: "Allow once", exact: true }).last(); - await allowOnce.waitFor({ state: "visible", timeout: 10_000 }); - await allowOnce.press("Enter"); - return true; - } - private async responseDomSnapshot(responseTurn: Locator): Promise { const snapshot = await responseTurn.evaluate((element, completionActionSelector) => { const root = element as HTMLElement; @@ -1160,7 +1181,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 71411b347..d2fbdc3e1 100644 --- a/src/adapters/chatgpt-web/environment.ts +++ b/src/adapters/chatgpt-web/environment.ts @@ -138,7 +138,12 @@ function workspaceMetadataEnvironmentBeforeUser( const trimmed = text.trim(); if (!/^[\s\S]*<\/environment_context>$/.test(trimmed)) continue; - const cwdMatches = [...trimmed.matchAll(/([^<]+)<\/cwd>/g)].map(match => decodeXmlText(match[1]!.trim())); + let cwdMatches: string[]; + try { + cwdMatches = selectedEnvironmentCwdMatches(trimmed).map(value => decodeXmlText(value.trim())); + } catch { + continue; + } if (cwdMatches.length !== 1 || !isAbsolute(cwdMatches[0]!)) continue; const rootMatches = [...trimmed.matchAll(/[\s\S]*?<\/workspace_roots>/g)] .flatMap(section => [...section[0].matchAll(/([^<]+)<\/root>/g)].map(match => decodeXmlText(match[1]!.trim()))); @@ -223,6 +228,22 @@ function decodeXmlText(value: string): string { .replaceAll("'", "'"); } +function selectedEnvironmentCwdMatches(text: string): string[] { + const environments = [...text.matchAll(/]*)>([\s\S]*?)<\/environment>/gi)]; + if (environments.length === 0) { + return [...text.matchAll(/([^<]+)<\/cwd>/gi)].map(match => match[1] ?? ""); + } + const primary = environments.filter(match => /\bprimary=["']true["']/i.test(match[1] ?? "")); + if (primary.length !== 1) { + throw new Error("ChatGPT web turn requires exactly one primary Codex environment"); + } + const cwdMatches = [...primary[0]![2]!.matchAll(/([^<]+)<\/cwd>/gi)].map(match => match[1] ?? ""); + if (cwdMatches.length !== 1) { + throw new Error("ChatGPT web primary Codex environment requires exactly one cwd"); + } + return cwdMatches; +} + function uniqueAbsolutePaths(values: string[], field: string): string[] { const decoded = values.map(value => decodeXmlText(value.trim())); if (decoded.length === 0) throw new MissingTrustedCodexEnvironmentError(field); @@ -241,7 +262,7 @@ function matchesPath(root: string, path: string): boolean { export function extractChatGptTurnEnvironment(parsed: CodexParsedRequest): ChatGptTurnEnvironment { const text = trustedEnvironmentText(parsed); - const cwdMatches = [...text.matchAll(/([^<]+)<\/cwd>/g)].map(match => match[1] ?? ""); + const cwdMatches = selectedEnvironmentCwdMatches(text); const cwdCandidates = uniqueAbsolutePaths(cwdMatches, "cwd"); if (cwdCandidates.length !== 1) throw new Error("ChatGPT web turn has conflicting trusted Codex cwd values"); const cwd = cwdCandidates[0]!; diff --git a/src/adapters/chatgpt-web/turn-execution.ts b/src/adapters/chatgpt-web/turn-execution.ts index 2f548a53a..9f8d613ab 100644 --- a/src/adapters/chatgpt-web/turn-execution.ts +++ b/src/adapters/chatgpt-web/turn-execution.ts @@ -129,6 +129,9 @@ export function chatGptTurnExecutionKey(parsed: CodexParsedRequest): string { turnId: identity.turnId, purpose: parsed._compactionRequest ? "compaction" : "response", conversationMode: parsed._chatGptWebConversationMode ?? "temporary", + userInputs: parsed.context.messages + .filter(message => message.role === "user") + .map(message => message.content), }; return createHash("sha256").update(JSON.stringify({ modelId: parsed.modelId, 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 7b2a5ecdd..c914190e6 100644 --- a/tests/chatgpt-web-harness.test.ts +++ b/tests/chatgpt-web-harness.test.ts @@ -279,6 +279,13 @@ describe("ChatGPT outer-native harness v3", () => { timestamp: Date.now(), }); expect(chatGptTurnExecutionKey(first)).toBe(chatGptTurnExecutionKey(second)); + const steered = structuredClone(second); + steered.context.messages.push({ + role: "user", + content: "Stop and review the implementation before continuing", + timestamp: Date.now(), + }); + expect(chatGptTurnExecutionKey(steered)).not.toBe(chatGptTurnExecutionKey(second)); const compact = structuredClone(first); compact._compactionRequest = true; expect(chatGptTurnExecutionKey(compact)).not.toBe(chatGptTurnExecutionKey(first)); 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 9436b4d817fe3e2f32713c6445239ed88da090e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=83=81=ED=9B=88?= Date: Tue, 4 Aug 2026 21:09:26 +0900 Subject: [PATCH 14/17] Harden Web GPT project boundary and document secure tunnel --- README.ko-KR.md | 24 ++++++++++++++++---- src/adapters/chatgpt-web/project-boundary.ts | 7 +++--- tests/chatgpt-web-models.test.ts | 2 +- tests/server-compaction.test.ts | 2 +- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/README.ko-KR.md b/README.ko-KR.md index 2c543245c..8945dad05 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -6,16 +6,18 @@ Codex의 기본 모델 선택기에서 ChatGPT Web의 임시대화·저장 대 바꿨습니다. 이 포크의 런타임은 upstream -[`miuuyy/codex-chatgpt-web`](https://github.com/miuuyy/codex-chatgpt-web) v1.0.1의 -커밋 `4ad7abe428fe2226fa05038a36e1682de1670fcc`를 기반으로 합니다. 원 저작자 표시와 MIT +[`miuuyy/codex-chatgpt-web`](https://github.com/miuuyy/codex-chatgpt-web) v1.1.2의 +커밋 `2e79cb15a6266916ef2298bda7cc94ead4f32af3`까지 반영합니다. 원 저작자 표시와 MIT 라이선스를 유지합니다. ## 권장 사용 방식 - 기본값은 브라우저 전용 모드입니다. 로컬 도구 없이 ChatGPT Web 모델로 계획·검토·조사를 수행할 때 적합합니다. -- 전체 하네스 모드는 명시적으로 필요한 경우에만 사용하세요. MCP 커넥터와 OpenAI Tunnel - 설정이 필요하며, 쓰기 작업은 워크스페이스 정책과 수동 승인을 따라야 합니다. +- 전체 하네스 모드는 명시적으로 필요한 경우에만 사용하세요. MCP 커넥터와 OpenAI Secure MCP + Tunnel 설정이 필요하며, 쓰기 작업은 워크스페이스 정책과 수동 승인을 따라야 합니다. +- Web GPT가 전체 하네스를 사용하더라도 현재 Codex 작업의 프로젝트 루트만 전달됩니다. 외부 경로, + 심볼릭 링크 탈출, 범용 도구 우회와 네트워크 접근은 별도 프로젝트 경계에서 차단됩니다. - Pro는 깊은 계획과 검토에 적합하지만 현재 이 브리지의 로컬 MCP 도구를 직접 호출하지 못합니다. - 공유 계정, 브라우저 프로필 공유, 과도한 동시 요청, 제한 우회는 사용하지 마세요. @@ -55,6 +57,20 @@ irm https://github.com/AgenticLab-SH/codex-chatgpt-web/releases/latest/download/ 브라우저 창은 닫아도 됩니다. 설정의 백그라운드 실행이 켜져 있으면 런처와 내장 브라우저 프로세스는 계속 동작합니다. 다만 런처를 완전히 종료하면 브리지도 멈춥니다. +## MCP와 자동 시작 + +이 포크는 로컬 MCP를 외부에 공개하는 임의의 공개 터널 대신 OpenAI Secure MCP Tunnel을 +사용합니다. 런처가 macOS 로그인 항목으로 실행되면 다음 순서를 자동으로 관리합니다. + +1. 로컬 MCP 서버와 Responses 브리지를 시작합니다. +2. 저장된 최소 권한 런타임 키로 `tunnel-client`를 실행합니다. +3. Tunnel과 로컬 MCP가 준비된 뒤 Web GPT 요청을 받습니다. + +따라서 매 작업마다 Tunnel이나 ChatGPT 앱을 다시 만들 필요는 없습니다. 다만 Mac이 꺼져 있거나 +런처를 완전히 종료하면 로컬 파일 도구는 사용할 수 없습니다. ChatGPT Business에서 게시한 앱의 +도구 이름·입력 스키마를 바꾼 경우에는 Business 정책상 기존 앱을 수정하는 대신 다시 게시해야 할 +수 있으므로, 현재의 범용 7개 도구 표면을 안정적으로 유지합니다. + ## 소스에서 실행 ```bash diff --git a/src/adapters/chatgpt-web/project-boundary.ts b/src/adapters/chatgpt-web/project-boundary.ts index 97db5eb79..e71055d32 100644 --- a/src/adapters/chatgpt-web/project-boundary.ts +++ b/src/adapters/chatgpt-web/project-boundary.ts @@ -125,8 +125,8 @@ function readOnlyToolRoots(): string[] { } export function projectSandboxProfile(environment: ChatGptTurnEnvironment): string { - const readRoots = uniquePaths([...readOnlyToolRoots(), ...environment.roots, tmpdir(), "/private/tmp", "/private/var/folders"]); - const writeRoots = uniquePaths([...environment.writableRoots, tmpdir(), "/private/tmp", "/private/var/folders"]); + const readRoots = uniquePaths([...readOnlyToolRoots(), ...environment.roots, tmpdir(), "/private/tmp"]); + const writeRoots = uniquePaths([...environment.writableRoots, tmpdir(), "/private/tmp"]); return [ "(version 1)", "(deny default)", @@ -151,7 +151,8 @@ export async function projectBoundCommand( workdir: string | undefined, platform = process.platform, ): Promise<{ command: string; workdir: string }> { - const boundedWorkdir = await assertProjectPath(environment, workdir?.trim() || environment.cwd, "read"); + const requestedWorkdir = workdir && workdir.trim() ? workdir : environment.cwd; + const boundedWorkdir = await assertProjectPath(environment, requestedWorkdir, "read"); if (!environment.enforceProjectBoundary) return { command, workdir: boundedWorkdir }; if (platform !== "darwin") { throw new Error("Project-bound Web GPT shell execution is currently supported on macOS only"); diff --git a/tests/chatgpt-web-models.test.ts b/tests/chatgpt-web-models.test.ts index 9ddaa451b..8bde5a504 100644 --- a/tests/chatgpt-web-models.test.ts +++ b/tests/chatgpt-web-models.test.ts @@ -47,7 +47,7 @@ describe("fixed ChatGPT Web model routes", () => { expect(() => requireChatGptWebModelRoute("chatgpt-web/extra-high", false)) .toThrow("ChatGPT Web — 매우 높음 (임시대화) is not available for this account"); expect(() => requireChatGptWebModelRoute("chatgpt-web/pro", false)) - .toThrow("Pro is not available for this account"); + .toThrow("ChatGPT Web — Pro (임시대화) is not available for this account"); }); test("uses one account-specific context limit for every Web mode with ten-percent compact headroom", () => { diff --git a/tests/server-compaction.test.ts b/tests/server-compaction.test.ts index f98ba3a8a..bea7ffd4f 100644 --- a/tests/server-compaction.test.ts +++ b/tests/server-compaction.test.ts @@ -116,7 +116,7 @@ test("rejects an unknown routed compact model instead of treating it as ChatGPT 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", "ChatGPT Web — 매우 높음 (임시대화)"], - ["chatgpt-web/pro", "Pro"], + ["chatgpt-web/pro", "ChatGPT Web — Pro (임시대화)"], ] as const) { const response = await responseRequest(new Request("http://127.0.0.1:17841/v1/responses", { method: "POST", From 93c41957f9af9ef67b01592fc261bb6b8abc285b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=83=81=ED=9B=88?= Date: Tue, 4 Aug 2026 22:57:03 +0900 Subject: [PATCH 15/17] Route Web GPT through OCX and fix untimed MCP bindings --- launcher/package.json | 2 +- package.json | 2 +- scripts/install.sh | 2 +- src/adapters/chatgpt-web/mcp-server.ts | 29 +++++++++++++++++--------- src/chatgpt-web-models.ts | 14 +++++++++++-- src/version.ts | 2 +- tests/chatgpt-web-harness.test.ts | 3 ++- tests/chatgpt-web-models.test.ts | 15 +++++++++++++ 8 files changed, 52 insertions(+), 17 deletions(-) diff --git a/launcher/package.json b/launcher/package.json index 8801184c8..7bae6ad11 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -1,6 +1,6 @@ { "name": "codex-web-gpt-launcher", - "version": "1.1.2-ko.1", + "version": "1.1.2-ko.3", "private": true, "description": "Desktop control center for Codex ChatGPT Web", "author": "miuuyy; Korean localization by AgenticLab-SH", diff --git a/package.json b/package.json index 399074b19..a92fa05a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-chatgpt-web", - "version": "1.1.2-ko.1", + "version": "1.1.2-ko.3", "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 15de9352d..6b9f683d8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ set -eu REPOSITORY="${CODEX_CHATGPT_WEB_REPOSITORY:-AgenticLab-SH/codex-chatgpt-web}" -VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.1.2-ko.1}" +VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.1.2-ko.3}" 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/mcp-server.ts b/src/adapters/chatgpt-web/mcp-server.ts index e4b58fd62..baadbbf29 100644 --- a/src/adapters/chatgpt-web/mcp-server.ts +++ b/src/adapters/chatgpt-web/mcp-server.ts @@ -9,11 +9,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."); @@ -71,8 +71,13 @@ function namedTool(environment: ChatGptTurnEnvironment, requestedWireName: strin return tool; } -function invocationTimeout(environment: ChatGptTurnEnvironment & { expiresAt: number }): number { - return Math.max(1, environment.expiresAt - Date.now()); +function invocationTimeout(environment: ChatGptTurnEnvironment & { expiresAt?: number }): number { + // An omitted Web turn timeout means the binding follows the outer Codex turn lifetime. The + // broker revokes it when that turn ends; keep one generous transport guard so a broken socket + // still fails instead of manufacturing an Invalid Date or a five-second tool timeout. + return environment.expiresAt === undefined + ? 30 * 60_000 + : Math.max(1, environment.expiresAt - Date.now()); } function asMcpResult(value: BrokerToolResult) { @@ -125,15 +130,17 @@ function execGatewayProgram( export async function runChatGptMcpServer(options: { brokerSocketPath: string }): Promise { const server = new McpServer({ name: "codex-native", version: "3.0.0" }); - const environment = async (bindingId: string): Promise => { + const environment = async (bindingId: string): Promise => { const resolved = await callTurnBroker(options.brokerSocketPath, { method: "resolve", bindingId }); - if (resolved.environment.expiresAt <= Date.now()) throw new Error("Codex turn binding expired"); + if (resolved.environment.expiresAt !== undefined && resolved.environment.expiresAt <= Date.now()) { + throw new Error("Codex turn binding expired"); + } return resolved.environment; }; const invoke = async ( bindingId: string, - bound: ChatGptTurnEnvironment & { expiresAt: number }, + bound: ChatGptTurnEnvironment & { expiresAt?: number }, tool: CodexTool, payload: { arguments?: Record; input?: string }, ) => { @@ -149,7 +156,7 @@ export async function runChatGptMcpServer(options: { brokerSocketPath: string }) const invokeNative = ( bindingId: string, - bound: ChatGptTurnEnvironment & { expiresAt: number }, + bound: ChatGptTurnEnvironment & { expiresAt?: number }, tool: CodexTool, payload: { arguments?: Record; input?: string }, ) => { @@ -161,7 +168,7 @@ export async function runChatGptMcpServer(options: { brokerSocketPath: string }) const invokeNestedNative = ( bindingId: string, - bound: ChatGptTurnEnvironment & { expiresAt: number }, + bound: ChatGptTurnEnvironment & { expiresAt?: number }, nestedToolName: string, freeform: boolean, payload: { arguments?: Record; input?: string }, @@ -197,7 +204,9 @@ export async function runChatGptMcpServer(options: { brokerSocketPath: string }) writable_roots: claimed.environment.writableRoots, sandbox: claimed.environment.sandboxPolicy.type, project_boundary: claimed.environment.enforceProjectBoundary === true, - 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/chatgpt-web-models.ts b/src/chatgpt-web-models.ts index d09ac9a33..a4c2809d8 100644 --- a/src/chatgpt-web-models.ts +++ b/src/chatgpt-web-models.ts @@ -27,6 +27,8 @@ export function resolveChatGptWebContextLimits( export interface ChatGptWebModelRoute { slug: string; + /** Bare model id used when OpenCodex routes this model through its dedicated local provider. */ + ocxModelId: string; displayName: string; description: string; codexEffort: ChatGptWebCodexEffort; @@ -44,6 +46,7 @@ export interface ChatGptWebModelRoute { export const CHATGPT_WEB_MODEL_ROUTES: readonly ChatGptWebModelRoute[] = [ { slug: "chatgpt-web/temporary", + ocxModelId: "webgpt-temporary", displayName: "ChatGPT Web — 임시대화 (매우 높음)", description: "기록에 남기지 않는 ChatGPT Web 임시대화를 사용합니다. 기본 추론 수준은 매우 높음입니다.", codexEffort: "xhigh", @@ -53,6 +56,7 @@ export const CHATGPT_WEB_MODEL_ROUTES: readonly ChatGptWebModelRoute[] = [ }, { slug: "chatgpt-web/saved", + ocxModelId: "webgpt-saved", displayName: "ChatGPT Web — 저장 대화 (매우 높음)", description: "ChatGPT 대화 기록에 남는 일반 대화를 새로 만듭니다. 기본 추론 수준은 매우 높음입니다.", codexEffort: "xhigh", @@ -62,6 +66,7 @@ export const CHATGPT_WEB_MODEL_ROUTES: readonly ChatGptWebModelRoute[] = [ }, { slug: "chatgpt-web/light", + ocxModelId: "webgpt-light", displayName: "ChatGPT Web — 즉시 (임시대화)", description: "ChatGPT Web 즉시 모드를 임시대화로 사용합니다.", codexEffort: "low", @@ -71,6 +76,7 @@ export const CHATGPT_WEB_MODEL_ROUTES: readonly ChatGptWebModelRoute[] = [ }, { slug: "chatgpt-web/medium", + ocxModelId: "webgpt-medium", displayName: "ChatGPT Web — 중간 (임시대화)", description: "ChatGPT Web 중간 추론 모드를 임시대화로 사용합니다.", codexEffort: "medium", @@ -80,6 +86,7 @@ export const CHATGPT_WEB_MODEL_ROUTES: readonly ChatGptWebModelRoute[] = [ }, { slug: "chatgpt-web/high", + ocxModelId: "webgpt-high", displayName: "ChatGPT Web — 높음 (임시대화)", description: "ChatGPT Web 높은 추론 모드를 임시대화로 사용합니다.", codexEffort: "high", @@ -89,6 +96,7 @@ export const CHATGPT_WEB_MODEL_ROUTES: readonly ChatGptWebModelRoute[] = [ }, { slug: "chatgpt-web/extra-high", + ocxModelId: "webgpt-extra-high", displayName: "ChatGPT Web — 매우 높음 (임시대화)", description: "ChatGPT Web 매우 높은 추론 모드를 임시대화로 사용합니다.", codexEffort: "xhigh", @@ -98,6 +106,7 @@ export const CHATGPT_WEB_MODEL_ROUTES: readonly ChatGptWebModelRoute[] = [ }, { slug: "chatgpt-web/pro", + ocxModelId: "webgpt-pro", displayName: "ChatGPT Web — Pro (임시대화)", description: "계정에 Pro 권한이 있을 때만 사용할 수 있는 임시대화 모드입니다. 이 모드에서는 로컬 도구를 사용할 수 없습니다.", codexEffort: "ultra", @@ -108,9 +117,10 @@ export const CHATGPT_WEB_MODEL_ROUTES: readonly ChatGptWebModelRoute[] = [ ]; const routesBySlug = new Map(CHATGPT_WEB_MODEL_ROUTES.map(route => [route.slug, route])); +const routesByOcxModelId = new Map(CHATGPT_WEB_MODEL_ROUTES.map(route => [route.ocxModelId, route])); export function isChatGptWebModelSlug(modelId: string): boolean { - return modelId.startsWith(CHATGPT_WEB_MODEL_PREFIX); + return modelId.startsWith(CHATGPT_WEB_MODEL_PREFIX) || routesByOcxModelId.has(modelId); } export function availableChatGptWebModelRoutes(proAvailable: boolean): readonly ChatGptWebModelRoute[] { @@ -120,7 +130,7 @@ export function availableChatGptWebModelRoutes(proAvailable: boolean): readonly } export function requireChatGptWebModelRoute(modelId: string, proAvailable: boolean): ChatGptWebModelRoute { - const route = routesBySlug.get(modelId); + const route = routesBySlug.get(modelId) ?? routesByOcxModelId.get(modelId); if (!route) throw new Error(`ChatGPT web model is not enabled: ${modelId}`); if (route.requiresPro && !proAvailable) { throw new Error(`${route.displayName} is not available for this account`); diff --git a/src/version.ts b/src/version.ts index ac2a01258..869e9a96a 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.1.2-ko.1"; +export const VERSION = "1.1.2-ko.3"; diff --git a/tests/chatgpt-web-harness.test.ts b/tests/chatgpt-web-harness.test.ts index c914190e6..0d0dda9f6 100644 --- a/tests/chatgpt-web-harness.test.ts +++ b/tests/chatgpt-web-harness.test.ts @@ -916,7 +916,7 @@ describe("ChatGPT outer-native harness v3", () => { gatewayOnlyEnvironment.tools = gatewayOnlyEnvironment.tools.filter(tool => ( tool.name === "exec" || tool.name === "search_openai_docs" )); - const token = await broker.register(gatewayOnlyEnvironment, 60_000); + const token = await broker.register(gatewayOnlyEnvironment); const transport = new StdioClientTransport({ command: process.execPath, args: ["src/cli.ts", "mcp", "--broker-socket", socketPath], @@ -943,6 +943,7 @@ describe("ChatGPT outer-native harness v3", () => { const bindingId = (bound.structuredContent as { binding_id?: string } | undefined)?.binding_id; expect(bindingId).toStartWith("binding_"); expect((bound.structuredContent as { execution: string }).execution).toBe("outer_codex_native"); + expect((bound.structuredContent as { expires_at: string | null }).expires_at).toBeNull(); expect((bound.structuredContent as { outer_tool_gateway: string }).outer_tool_gateway).toBe("exec"); expect((bound.structuredContent as { command_tool: string }).command_tool).toBe("exec_command"); diff --git a/tests/chatgpt-web-models.test.ts b/tests/chatgpt-web-models.test.ts index 8bde5a504..6509cf8c0 100644 --- a/tests/chatgpt-web-models.test.ts +++ b/tests/chatgpt-web-models.test.ts @@ -23,6 +23,7 @@ function parsed(modelId: string, reasoning = "medium"): CodexParsedRequest { describe("fixed ChatGPT Web model routes", () => { test("uses unique stable slugs and one explicit adapter effort per model", () => { expect(new Set(CHATGPT_WEB_MODEL_ROUTES.map(route => route.slug)).size).toBe(CHATGPT_WEB_MODEL_ROUTES.length); + expect(new Set(CHATGPT_WEB_MODEL_ROUTES.map(route => route.ocxModelId)).size).toBe(CHATGPT_WEB_MODEL_ROUTES.length); expect(CHATGPT_WEB_MODEL_ROUTES.map(route => [route.slug, route.codexEffort, route.adapterEffort])).toEqual([ ["chatgpt-web/temporary", "xhigh", "xhigh"], ["chatgpt-web/saved", "xhigh", "xhigh"], @@ -37,6 +38,20 @@ describe("fixed ChatGPT Web model routes", () => { expect(CHATGPT_WEB_MODEL_ROUTES[1]?.conversationMode).toBe("saved"); }); + test("accepts collision-resistant OpenCodex aliases without changing the authoritative Web route", () => { + const config = defaultConfig("browser-only"); + config.proAvailable = true; + const request = parsed("webgpt-temporary", "low"); + + expect(routeChatGptWebRequest(request, config)).toMatchObject({ + slug: "chatgpt-web/temporary", + ocxModelId: "webgpt-temporary", + adapterEffort: "xhigh", + conversationMode: "temporary", + }); + expect(request.modelId).toBe(CHATGPT_WEB_BACKEND_MODEL); + }); + test("exposes only Plus-eligible routes without the Pro account capability", () => { expect(availableChatGptWebModelRoutes(false).map(route => route.slug)).toEqual([ "chatgpt-web/light", From 6022f8c90e6b1c2f503968922f2ba46262f45db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=83=81=ED=9B=88?= Date: Tue, 4 Aug 2026 23:11:32 +0900 Subject: [PATCH 16/17] Fix cross-platform project boundary test --- tests/chatgpt-web-harness.test.ts | 8 ++++++++ tests/project-boundary.test.ts | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/tests/chatgpt-web-harness.test.ts b/tests/chatgpt-web-harness.test.ts index 0d0dda9f6..c21fae9f2 100644 --- a/tests/chatgpt-web-harness.test.ts +++ b/tests/chatgpt-web-harness.test.ts @@ -1019,6 +1019,14 @@ describe("ChatGPT outer-native harness v3", () => { expect(JSON.stringify(generic.content)).toContain("Generic outer-tool calls are disabled"); const execPromise = call("codex_exec", { binding_id: structured.binding_id, cmd: "pwd" }); + if (process.platform !== "darwin") { + const unsupported = await execPromise; + expect(unsupported.isError).toBe(true); + expect(JSON.stringify(unsupported.content)).toContain( + "Project-bound Web GPT shell execution is currently supported on macOS only", + ); + return; + } const [execRequest] = await broker.nextToolBatch(token); expect(execRequest?.input).toContain("/usr/bin/sandbox-exec -p"); expect(execRequest?.input).toContain(JSON.stringify(tempRoot)); diff --git a/tests/project-boundary.test.ts b/tests/project-boundary.test.ts index 33f88f0d4..5493cfa32 100644 --- a/tests/project-boundary.test.ts +++ b/tests/project-boundary.test.ts @@ -52,6 +52,14 @@ describe("Web GPT project boundary", () => { expect(projectSandboxProfile(environment)).not.toContain("(allow network-outbound)"); }); + test("fails closed when the project shell sandbox is unavailable", async () => { + const environment = scopeChatGptTurnEnvironment(dangerEnvironment()); + await expect(projectBoundCommand(environment, "pwd", undefined, "linux")) + .rejects.toThrow("currently supported on macOS only"); + await expect(projectBoundCommand(environment, "pwd", undefined, "win32")) + .rejects.toThrow("currently supported on macOS only"); + }); + test("rejects explicit paths and patch targets outside the active project", async () => { const environment = scopeChatGptTurnEnvironment(dangerEnvironment()); await expect(assertProjectPath(environment, outside, "read")).rejects.toThrow("outside the active Codex project"); From 00c8d56fa03c638f4e2e59cf058135ba549ff840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=83=81=ED=9B=88?= Date: Wed, 5 Aug 2026 01:54:54 +0900 Subject: [PATCH 17/17] =?UTF-8?q?Web=20GPT=20=EB=8C=80=ED=99=94=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=EC=99=80=20=EC=B6=94=EB=A1=A0=20=EC=88=98?= =?UTF-8?q?=EC=A4=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.ko-KR.md | 3 +- docs/architecture.md | 6 +- launcher/package.json | 2 +- package.json | 2 +- scripts/install.sh | 2 +- scripts/smoke-release.ts | 2 +- src/chatgpt-web-models.ts | 122 +++++++++++++++++++++---------- src/version.ts | 2 +- tests/chatgpt-web-models.test.ts | 79 ++++++++++++-------- tests/model-catalog.test.ts | 25 +++---- tests/server-compaction.test.ts | 4 +- tests/server-models.test.ts | 17 +++-- 12 files changed, 167 insertions(+), 99 deletions(-) diff --git a/README.ko-KR.md b/README.ko-KR.md index 8945dad05..d6144a0f2 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -24,7 +24,8 @@ Codex의 기본 모델 선택기에서 ChatGPT Web의 임시대화·저장 대 ## 모델과 라우팅 -- `ChatGPT Web — 임시대화 (매우 높음)`은 대화 기록에 남지 않는 Temporary Chat으로 실행됩니다. +- 모델 선택은 `Web / 임시|저장 / 낮음|중간|높음|매우 높음`처럼 대화 보존 방식과 추론 수준을 함께 표시합니다. +- `Web / 임시 / ...`는 대화 기록에 남지 않는 Temporary Chat으로 실행되고, `Web / 저장 / ...`는 새 일반 대화로 저장됩니다. - `ChatGPT Web — 저장 대화 (매우 높음)`은 일반 ChatGPT 대화를 만들어 ChatGPT 기록에 남깁니다. - Web GPT 두 항목은 선택한 노력 수준이 낮게 들어와도 브리지에서 `매우 높음`으로 고정합니다. - `gpt-5.6-sol`은 Codex 기본 설정에서 `높음`으로 두며, Web GPT 브리지가 브라우저를 열지 않고 diff --git a/docs/architecture.md b/docs/architecture.md index df16a9384..88b98eb70 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,9 +20,9 @@ launcher-owned codex-chatgpt-web daemon ### `browser-only` -- Exposes Instant (`chatgpt-web/light`), Medium, High, and Extra High; each model advertises exactly one - immutable Codex effort matching its ChatGPT browser mode. `chatgpt-web/pro` is appended only when - the authenticated account exposes Pro. +- Exposes the conversation mode and effort as independent axes: temporary or saved, each combined with + Low, Medium, High, and Extra High. Pro is likewise exposed per conversation mode only when the + authenticated account exposes it. Legacy route ids remain accepted as hidden compatibility aliases. - Sends the complete Codex context and image attachments to a fresh ChatGPT Temporary Chat. - Never starts the broker, tunnel, or MCP server. - Emits a nonfatal Codex commentary warning that local tools are unavailable for the selected model. diff --git a/launcher/package.json b/launcher/package.json index 7bae6ad11..036efd802 100644 --- a/launcher/package.json +++ b/launcher/package.json @@ -1,6 +1,6 @@ { "name": "codex-web-gpt-launcher", - "version": "1.1.2-ko.3", + "version": "1.1.2-ko.4", "private": true, "description": "Desktop control center for Codex ChatGPT Web", "author": "miuuyy; Korean localization by AgenticLab-SH", diff --git a/package.json b/package.json index a92fa05a8..ab3554fb3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-chatgpt-web", - "version": "1.1.2-ko.3", + "version": "1.1.2-ko.4", "private": true, "description": "A focused local Responses bridge that runs Codex tasks through a user-authenticated ChatGPT web session.", "repository": { diff --git a/scripts/install.sh b/scripts/install.sh index 6b9f683d8..5323be5f8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ set -eu REPOSITORY="${CODEX_CHATGPT_WEB_REPOSITORY:-AgenticLab-SH/codex-chatgpt-web}" -VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.1.2-ko.3}" +VERSION="${CODEX_CHATGPT_WEB_VERSION:-1.1.2-ko.4}" BIN_DIR="${CODEX_CHATGPT_WEB_BIN_DIR:-$HOME/.local/bin}" LIB_DIR="${CODEX_CHATGPT_WEB_LIB_DIR:-$HOME/.local/lib/codex-chatgpt-web}" DOC_DIR="${CODEX_CHATGPT_WEB_DOC_DIR:-$HOME/.local/share/doc/codex-chatgpt-web}" diff --git a/scripts/smoke-release.ts b/scripts/smoke-release.ts index d13f6db57..fbccf0a68 100644 --- a/scripts/smoke-release.ts +++ b/scripts/smoke-release.ts @@ -119,7 +119,7 @@ try { const rejectedWhileDraining = await fetch(`http://127.0.0.1:${port}/v1/responses`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "chatgpt-web/high", reasoning: { effort: "high" }, input: "test", stream: false }), + body: JSON.stringify({ model: "chatgpt-web/temporary/high", reasoning: { effort: "high" }, input: "test", stream: false }), }); if (rejectedWhileDraining.status !== 503) { throw new Error(`daemon accepted a new turn while draining: HTTP ${rejectedWhileDraining.status}`); diff --git a/src/chatgpt-web-models.ts b/src/chatgpt-web-models.ts index a4c2809d8..449f6f994 100644 --- a/src/chatgpt-web-models.ts +++ b/src/chatgpt-web-models.ts @@ -45,82 +45,128 @@ export interface ChatGptWebModelRoute { */ export const CHATGPT_WEB_MODEL_ROUTES: readonly ChatGptWebModelRoute[] = [ { - slug: "chatgpt-web/temporary", - ocxModelId: "webgpt-temporary", - displayName: "ChatGPT Web — 임시대화 (매우 높음)", - description: "기록에 남기지 않는 ChatGPT Web 임시대화를 사용합니다. 기본 추론 수준은 매우 높음입니다.", - codexEffort: "xhigh", - adapterEffort: "xhigh", - requiresPro: true, + slug: "chatgpt-web/temporary/low", + ocxModelId: "webgpt-temporary-low", + displayName: "Web / 임시 / 낮음", + description: "기록에 남지 않는 임시대화를 낮은 추론 수준으로 실행합니다.", + codexEffort: "low", + adapterEffort: "low", + requiresPro: false, + conversationMode: "temporary", + }, + { + slug: "chatgpt-web/temporary/medium", + ocxModelId: "webgpt-temporary-medium", + displayName: "Web / 임시 / 중간", + description: "기록에 남지 않는 임시대화를 중간 추론 수준으로 실행합니다.", + codexEffort: "medium", + adapterEffort: "medium", + requiresPro: false, + conversationMode: "temporary", + }, + { + slug: "chatgpt-web/temporary/high", + ocxModelId: "webgpt-temporary-high", + displayName: "Web / 임시 / 높음", + description: "기록에 남지 않는 임시대화를 높은 추론 수준으로 실행합니다.", + codexEffort: "high", + adapterEffort: "high", + requiresPro: false, conversationMode: "temporary", }, { - slug: "chatgpt-web/saved", - ocxModelId: "webgpt-saved", - displayName: "ChatGPT Web — 저장 대화 (매우 높음)", - description: "ChatGPT 대화 기록에 남는 일반 대화를 새로 만듭니다. 기본 추론 수준은 매우 높음입니다.", + slug: "chatgpt-web/temporary/extra-high", + ocxModelId: "webgpt-temporary-extra-high", + displayName: "Web / 임시 / 매우 높음", + description: "기록에 남지 않는 임시대화를 매우 높은 추론 수준으로 실행합니다.", codexEffort: "xhigh", adapterEffort: "xhigh", requiresPro: true, - conversationMode: "saved", + conversationMode: "temporary", }, { - slug: "chatgpt-web/light", - ocxModelId: "webgpt-light", - displayName: "ChatGPT Web — 즉시 (임시대화)", - description: "ChatGPT Web 즉시 모드를 임시대화로 사용합니다.", + slug: "chatgpt-web/saved/low", + ocxModelId: "webgpt-saved-low", + displayName: "Web / 저장 / 낮음", + description: "기록에 남는 새 대화를 낮은 추론 수준으로 실행합니다.", codexEffort: "low", adapterEffort: "low", requiresPro: false, - conversationMode: "temporary", + conversationMode: "saved", }, { - slug: "chatgpt-web/medium", - ocxModelId: "webgpt-medium", - displayName: "ChatGPT Web — 중간 (임시대화)", - description: "ChatGPT Web 중간 추론 모드를 임시대화로 사용합니다.", + slug: "chatgpt-web/saved/medium", + ocxModelId: "webgpt-saved-medium", + displayName: "Web / 저장 / 중간", + description: "기록에 남는 새 대화를 중간 추론 수준으로 실행합니다.", codexEffort: "medium", adapterEffort: "medium", requiresPro: false, - conversationMode: "temporary", + conversationMode: "saved", }, { - slug: "chatgpt-web/high", - ocxModelId: "webgpt-high", - displayName: "ChatGPT Web — 높음 (임시대화)", - description: "ChatGPT Web 높은 추론 모드를 임시대화로 사용합니다.", + slug: "chatgpt-web/saved/high", + ocxModelId: "webgpt-saved-high", + displayName: "Web / 저장 / 높음", + description: "기록에 남는 새 대화를 높은 추론 수준으로 실행합니다.", codexEffort: "high", adapterEffort: "high", requiresPro: false, - conversationMode: "temporary", + conversationMode: "saved", }, { - slug: "chatgpt-web/extra-high", - ocxModelId: "webgpt-extra-high", - displayName: "ChatGPT Web — 매우 높음 (임시대화)", - description: "ChatGPT Web 매우 높은 추론 모드를 임시대화로 사용합니다.", + slug: "chatgpt-web/saved/extra-high", + ocxModelId: "webgpt-saved-extra-high", + displayName: "Web / 저장 / 매우 높음", + description: "기록에 남는 새 대화를 매우 높은 추론 수준으로 실행합니다.", codexEffort: "xhigh", adapterEffort: "xhigh", requiresPro: true, - conversationMode: "temporary", + conversationMode: "saved", }, { - slug: "chatgpt-web/pro", - ocxModelId: "webgpt-pro", - displayName: "ChatGPT Web — Pro (임시대화)", + slug: "chatgpt-web/temporary/pro", + ocxModelId: "webgpt-temporary-pro", + displayName: "Web / 임시 / Pro", description: "계정에 Pro 권한이 있을 때만 사용할 수 있는 임시대화 모드입니다. 이 모드에서는 로컬 도구를 사용할 수 없습니다.", codexEffort: "ultra", adapterEffort: "max", requiresPro: true, conversationMode: "temporary", }, + { + slug: "chatgpt-web/saved/pro", + ocxModelId: "webgpt-saved-pro", + displayName: "Web / 저장 / Pro", + description: "계정에 Pro 권한이 있을 때만 사용할 수 있는 저장 대화 모드입니다. 이 모드에서는 로컬 도구를 사용할 수 없습니다.", + codexEffort: "ultra", + adapterEffort: "max", + requiresPro: true, + conversationMode: "saved", + }, ]; const routesBySlug = new Map(CHATGPT_WEB_MODEL_ROUTES.map(route => [route.slug, route])); const routesByOcxModelId = new Map(CHATGPT_WEB_MODEL_ROUTES.map(route => [route.ocxModelId, route])); +const legacyRouteAliases = new Map([ + ["chatgpt-web/temporary", "chatgpt-web/temporary/extra-high"], + ["webgpt-temporary", "chatgpt-web/temporary/extra-high"], + ["chatgpt-web/saved", "chatgpt-web/saved/extra-high"], + ["webgpt-saved", "chatgpt-web/saved/extra-high"], + ["chatgpt-web/light", "chatgpt-web/temporary/low"], + ["webgpt-light", "chatgpt-web/temporary/low"], + ["chatgpt-web/medium", "chatgpt-web/temporary/medium"], + ["webgpt-medium", "chatgpt-web/temporary/medium"], + ["chatgpt-web/high", "chatgpt-web/temporary/high"], + ["webgpt-high", "chatgpt-web/temporary/high"], + ["chatgpt-web/extra-high", "chatgpt-web/temporary/extra-high"], + ["webgpt-extra-high", "chatgpt-web/temporary/extra-high"], + ["chatgpt-web/pro", "chatgpt-web/temporary/pro"], + ["webgpt-pro", "chatgpt-web/temporary/pro"], +]); export function isChatGptWebModelSlug(modelId: string): boolean { - return modelId.startsWith(CHATGPT_WEB_MODEL_PREFIX) || routesByOcxModelId.has(modelId); + return modelId.startsWith(CHATGPT_WEB_MODEL_PREFIX) || routesByOcxModelId.has(modelId) || legacyRouteAliases.has(modelId); } export function availableChatGptWebModelRoutes(proAvailable: boolean): readonly ChatGptWebModelRoute[] { @@ -130,7 +176,9 @@ export function availableChatGptWebModelRoutes(proAvailable: boolean): readonly } export function requireChatGptWebModelRoute(modelId: string, proAvailable: boolean): ChatGptWebModelRoute { - const route = routesBySlug.get(modelId) ?? routesByOcxModelId.get(modelId); + const canonicalSlug = legacyRouteAliases.get(modelId); + const route = routesBySlug.get(modelId) ?? routesByOcxModelId.get(modelId) + ?? (canonicalSlug ? routesBySlug.get(canonicalSlug) : undefined); if (!route) throw new Error(`ChatGPT web model is not enabled: ${modelId}`); if (route.requiresPro && !proAvailable) { throw new Error(`${route.displayName} is not available for this account`); diff --git a/src/version.ts b/src/version.ts index 869e9a96a..2afe39aae 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.1.2-ko.3"; +export const VERSION = "1.1.2-ko.4"; diff --git a/tests/chatgpt-web-models.test.ts b/tests/chatgpt-web-models.test.ts index 6509cf8c0..5cec31f26 100644 --- a/tests/chatgpt-web-models.test.ts +++ b/tests/chatgpt-web-models.test.ts @@ -25,44 +25,60 @@ describe("fixed ChatGPT Web model routes", () => { expect(new Set(CHATGPT_WEB_MODEL_ROUTES.map(route => route.slug)).size).toBe(CHATGPT_WEB_MODEL_ROUTES.length); expect(new Set(CHATGPT_WEB_MODEL_ROUTES.map(route => route.ocxModelId)).size).toBe(CHATGPT_WEB_MODEL_ROUTES.length); expect(CHATGPT_WEB_MODEL_ROUTES.map(route => [route.slug, route.codexEffort, route.adapterEffort])).toEqual([ - ["chatgpt-web/temporary", "xhigh", "xhigh"], - ["chatgpt-web/saved", "xhigh", "xhigh"], - ["chatgpt-web/light", "low", "low"], - ["chatgpt-web/medium", "medium", "medium"], - ["chatgpt-web/high", "high", "high"], - ["chatgpt-web/extra-high", "xhigh", "xhigh"], - ["chatgpt-web/pro", "ultra", "max"], + ["chatgpt-web/temporary/low", "low", "low"], + ["chatgpt-web/temporary/medium", "medium", "medium"], + ["chatgpt-web/temporary/high", "high", "high"], + ["chatgpt-web/temporary/extra-high", "xhigh", "xhigh"], + ["chatgpt-web/saved/low", "low", "low"], + ["chatgpt-web/saved/medium", "medium", "medium"], + ["chatgpt-web/saved/high", "high", "high"], + ["chatgpt-web/saved/extra-high", "xhigh", "xhigh"], + ["chatgpt-web/temporary/pro", "ultra", "max"], + ["chatgpt-web/saved/pro", "ultra", "max"], ]); - expect(CHATGPT_WEB_MODEL_ROUTES[0]?.displayName).toBe("ChatGPT Web — 임시대화 (매우 높음)"); + expect(CHATGPT_WEB_MODEL_ROUTES[0]?.displayName).toBe("Web / 임시 / 낮음"); expect(CHATGPT_WEB_MODEL_ROUTES[0]?.conversationMode).toBe("temporary"); - expect(CHATGPT_WEB_MODEL_ROUTES[1]?.conversationMode).toBe("saved"); + expect(CHATGPT_WEB_MODEL_ROUTES[4]?.conversationMode).toBe("saved"); }); test("accepts collision-resistant OpenCodex aliases without changing the authoritative Web route", () => { const config = defaultConfig("browser-only"); config.proAvailable = true; - const request = parsed("webgpt-temporary", "low"); + const request = parsed("webgpt-temporary-high", "low"); expect(routeChatGptWebRequest(request, config)).toMatchObject({ - slug: "chatgpt-web/temporary", - ocxModelId: "webgpt-temporary", - adapterEffort: "xhigh", + slug: "chatgpt-web/temporary/high", + ocxModelId: "webgpt-temporary-high", + adapterEffort: "high", conversationMode: "temporary", }); expect(request.modelId).toBe(CHATGPT_WEB_BACKEND_MODEL); }); + test("keeps legacy model ids as hidden compatibility aliases", () => { + const config = defaultConfig("full"); + config.proAvailable = true; + expect(requireChatGptWebModelRoute("webgpt-high", true).slug).toBe("chatgpt-web/temporary/high"); + expect(requireChatGptWebModelRoute("chatgpt-web/saved", true).slug) + .toBe("chatgpt-web/saved/extra-high"); + expect(routeChatGptWebRequest(parsed("webgpt-pro"), config).slug) + .toBe("chatgpt-web/temporary/pro"); + }); + test("exposes only Plus-eligible routes without the Pro account capability", () => { expect(availableChatGptWebModelRoutes(false).map(route => route.slug)).toEqual([ - "chatgpt-web/light", - "chatgpt-web/medium", - "chatgpt-web/high", + "chatgpt-web/temporary/low", + "chatgpt-web/temporary/medium", + "chatgpt-web/temporary/high", + "chatgpt-web/saved/low", + "chatgpt-web/saved/medium", + "chatgpt-web/saved/high", ]); expect(availableChatGptWebModelRoutes(true)).toEqual(CHATGPT_WEB_MODEL_ROUTES); - expect(() => requireChatGptWebModelRoute("chatgpt-web/extra-high", false)) - .toThrow("ChatGPT Web — 매우 높음 (임시대화) is not available for this account"); - expect(() => requireChatGptWebModelRoute("chatgpt-web/pro", false)) - .toThrow("ChatGPT Web — Pro (임시대화) is not available for this account"); + expect(() => requireChatGptWebModelRoute("chatgpt-web/temporary/extra-high", false)) + .toThrow("Web / 임시 / 매우 높음 is not available for this account"); + expect(() => requireChatGptWebModelRoute("chatgpt-web/saved/pro", false)) + .toThrow("Web / 저장 / Pro is not available for this account"); }); test("uses one account-specific context limit for every Web mode with ten-percent compact headroom", () => { @@ -77,33 +93,33 @@ describe("fixed ChatGPT Web model routes", () => { }); test("binds the selected model authoritatively and ignores a conflicting request effort", () => { - const request = parsed("chatgpt-web/high", "low"); + const request = parsed("chatgpt-web/saved/high", "low"); const rawSnapshot = structuredClone(request._rawBody); const route = routeChatGptWebRequest(request, defaultConfig("browser-only")); - expect(route.slug).toBe("chatgpt-web/high"); + expect(route.slug).toBe("chatgpt-web/saved/high"); expect(request.modelId).toBe(CHATGPT_WEB_BACKEND_MODEL); expect(request.options.reasoning).toBe("high"); - expect(request._chatGptWebConversationMode).toBe("temporary"); + expect(request._chatGptWebConversationMode).toBe("saved"); expect(request._rawBody).toEqual(rawSnapshot); }); - test("keeps temporary and saved conversation choices separate while forcing Web GPT defaults to Extra High", () => { - const temporary = parsed("chatgpt-web/temporary", "low"); - const saved = parsed("chatgpt-web/saved", "low"); + test("combines temporary and saved conversations with the selected effort independently", () => { + const temporary = parsed("chatgpt-web/temporary/high", "low"); + const saved = parsed("chatgpt-web/saved/extra-high", "low"); const config = defaultConfig("browser-only"); config.proAvailable = true; expect(routeChatGptWebRequest(temporary, config)).toMatchObject({ - slug: "chatgpt-web/temporary", - codexEffort: "xhigh", + slug: "chatgpt-web/temporary/high", + codexEffort: "high", conversationMode: "temporary", }); - expect(temporary.options.reasoning).toBe("xhigh"); + expect(temporary.options.reasoning).toBe("high"); expect(temporary._chatGptWebConversationMode).toBe("temporary"); expect(routeChatGptWebRequest(saved, config)).toMatchObject({ - slug: "chatgpt-web/saved", + slug: "chatgpt-web/saved/extra-high", codexEffort: "xhigh", conversationMode: "saved", }); @@ -114,9 +130,10 @@ describe("fixed ChatGPT Web model routes", () => { test("binds the Pro model to the browser Pro effort and fails closed for unknown routes", () => { const config = defaultConfig("full"); config.proAvailable = true; - const request = parsed("chatgpt-web/pro", "low"); + const request = parsed("chatgpt-web/saved/pro", "low"); expect(routeChatGptWebRequest(request, config).adapterEffort).toBe("max"); expect(request.options.reasoning).toBe("max"); + expect(request._chatGptWebConversationMode).toBe("saved"); expect(() => routeChatGptWebRequest(parsed("chatgpt-web/not-enabled"), config)) .toThrow("model is not enabled"); }); diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index f22ec3fb3..3232a17dd 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -91,11 +91,11 @@ describe("native /models augmentation", () => { .map(model => model.slug); expect(spawnOverrides).toEqual([ - "chatgpt-web/temporary", - "chatgpt-web/saved", - "chatgpt-web/light", - "chatgpt-web/medium", - "chatgpt-web/high", + "chatgpt-web/temporary/low", + "chatgpt-web/temporary/medium", + "chatgpt-web/temporary/high", + "chatgpt-web/temporary/extra-high", + "chatgpt-web/saved/low", ]); }); @@ -105,7 +105,7 @@ describe("native /models augmentation", () => { const polluted = source(); (polluted.models as unknown[]).push( { slug: "chatgpt-web/gpt-5.6-sol", display_name: "legacy generic route" }, - { slug: "chatgpt-web/pro", display_name: "stale Pro route" }, + { slug: "chatgpt-web/pro", display_name: "stale legacy Pro route" }, ); const first = augmentNativeModelCatalog(polluted, config); const second = augmentNativeModelCatalog(first, config); @@ -120,11 +120,10 @@ describe("native /models augmentation", () => { 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 }, - ]); + }))).toEqual(Array.from({ length: 6 }, () => ({ + contextWindow: 225_000, + autoCompactTokenLimit: 202_500, + }))); }); test("honors an explicit Codex context override without replacing or reordering native models", () => { @@ -133,7 +132,7 @@ describe("native /models augmentation", () => { // model_context_window is one top-level Codex setting, so it must not depend on which model // the config's `model` line happens to name - that line can hold a ChatGPT Web slug. const result = augmentNativeModelCatalog(native, defaultConfig("full"), { - model: "chatgpt-web/medium", + model: "chatgpt-web/temporary/medium", contextWindow: 371_851, }); const models = result.models as Array>; @@ -181,7 +180,7 @@ describe("native /models augmentation", () => { const result = augmentNativeModelCatalog(native, defaultConfig("full")); const web = (result.models as Array>) .filter(model => String(model.slug).startsWith("chatgpt-web/")); - expect(web.length).toBe(3); + expect(web.length).toBe(6); expect(web.every(model => model.shell_type === "shell_command")).toBe(true); expect(web.every(model => model.tool_mode === "code_mode_only")).toBe(true); }); diff --git a/tests/server-compaction.test.ts b/tests/server-compaction.test.ts index bea7ffd4f..a1e41bfd6 100644 --- a/tests/server-compaction.test.ts +++ b/tests/server-compaction.test.ts @@ -115,8 +115,8 @@ test("rejects an unknown routed compact model instead of treating it as ChatGPT 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", "ChatGPT Web — 매우 높음 (임시대화)"], - ["chatgpt-web/pro", "ChatGPT Web — Pro (임시대화)"], + ["chatgpt-web/temporary/extra-high", "Web / 임시 / 매우 높음"], + ["chatgpt-web/saved/pro", "Web / 저장 / Pro"], ] as const) { const response = await responseRequest(new Request("http://127.0.0.1:17841/v1/responses", { method: "POST", diff --git a/tests/server-models.test.ts b/tests/server-models.test.ts index 7490728f7..b3e146cdb 100644 --- a/tests/server-models.test.ts +++ b/tests/server-models.test.ts @@ -44,13 +44,16 @@ test("proxies official /models auth and query, then appends the fixed ChatGPT We }; expect(body.models.map(model => model.slug)).toEqual([ "gpt-5.6-sol", - "chatgpt-web/temporary", - "chatgpt-web/saved", - "chatgpt-web/light", - "chatgpt-web/medium", - "chatgpt-web/high", - "chatgpt-web/extra-high", - "chatgpt-web/pro", + "chatgpt-web/temporary/low", + "chatgpt-web/temporary/medium", + "chatgpt-web/temporary/high", + "chatgpt-web/temporary/extra-high", + "chatgpt-web/saved/low", + "chatgpt-web/saved/medium", + "chatgpt-web/saved/high", + "chatgpt-web/saved/extra-high", + "chatgpt-web/temporary/pro", + "chatgpt-web/saved/pro", ]); expect(body.models[0]!.max_context_window).toBe(371_851); for (const model of body.models.slice(1)) {