diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 37eeef494..7528e10fe 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,9 +17,21 @@ 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
+ bun-version: 1.3.14
- run: bun install --frozen-lockfile
- run: bun install --frozen-lockfile
working-directory: launcher
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 28e7c2a70..8e10af5d8 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
- bun-version: 1.3.11
+ bun-version: 1.3.14
- run: bun install --frozen-lockfile
- run: bun install --frozen-lockfile
working-directory: launcher
@@ -73,7 +73,7 @@ jobs:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
- bun-version: 1.3.11
+ bun-version: 1.3.14
- run: bun install --frozen-lockfile
- run: bun install --frozen-lockfile
working-directory: launcher
@@ -91,7 +91,7 @@ jobs:
cp LICENSE release-assets/LICENSE
cp LICENSES/NOTICE.md release-assets/NOTICE.md
cp LICENSES/OpenCodex-MIT.txt release-assets/OpenCodex-MIT.txt
- cp LICENSES/Bun-1.3.11.md release-assets/Bun-1.3.11.md
+ cp LICENSES/Bun-1.3.14.md release-assets/Bun-1.3.14.md
cp dist/THIRD_PARTY_NOTICES.txt release-assets/THIRD_PARTY_NOTICES.txt
cp scripts/install.sh release-assets/install.sh
cp scripts/install-launcher.sh release-assets/install-launcher.sh
diff --git a/.gitleaks.toml b/.gitleaks.toml
new file mode 100644
index 000000000..b16702bb2
--- /dev/null
+++ b/.gitleaks.toml
@@ -0,0 +1,9 @@
+[extend]
+useDefault = true
+
+[[allowlists]]
+description = "Deterministic fake turn tokens used by the prompt contract tests"
+regexes = [
+ '''turn_12345678901234567890123456789012''',
+ '''turn_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA''',
+]
diff --git a/LICENSES/Bun-1.3.11.md b/LICENSES/Bun-1.3.14.md
similarity index 82%
rename from LICENSES/Bun-1.3.11.md
rename to LICENSES/Bun-1.3.14.md
index dbe4cfbec..42c24aaa0 100644
--- a/LICENSES/Bun-1.3.11.md
+++ b/LICENSES/Bun-1.3.14.md
@@ -24,21 +24,29 @@ Bun statically links these libraries:
| [`brotli`](https://github.com/google/brotli) | MIT |
| [`libarchive`](https://github.com/libarchive/libarchive) | [several licenses](https://github.com/libarchive/libarchive/blob/master/COPYING) |
| [`lol-html`](https://github.com/cloudflare/lol-html/tree/master/c-api) | BSD 3-Clause |
+| [`ls-hpack`](https://github.com/litespeedtech/ls-hpack) | MIT |
+| [`ls-qpack`](https://github.com/litespeedtech/ls-qpack) | MIT |
+| [`lsquic`](https://github.com/litespeedtech/lsquic) | MIT (portions derived from [Chromium proto-quic](https://github.com/litespeedtech/lsquic/blob/master/LICENSE.chrome), BSD 3-Clause) |
| [`mimalloc`](https://github.com/microsoft/mimalloc) | MIT |
| [`picohttp`](https://github.com/h2o/picohttpparser) | dual-licensed under the Perl License or the MIT License |
| [`zstd`](https://github.com/facebook/zstd) | dual-licensed under the BSD License or GPLv2 license |
| [`simdutf`](https://github.com/simdutf/simdutf) | Apache 2.0 |
| [`tinycc`](https://github.com/tinycc/tinycc) | LGPL v2.1 |
| [`uSockets`](https://github.com/uNetworking/uSockets) | Apache 2.0 |
-| [`zlib-cloudflare`](https://github.com/cloudflare/zlib) | zlib |
+| [`zlib-ng`](https://github.com/zlib-ng/zlib-ng) | zlib |
| [`c-ares`](https://github.com/c-ares/c-ares) | MIT licensed |
| [`libicu`](https://github.com/unicode-org/icu) 72 | [license here](https://github.com/unicode-org/icu/blob/main/icu4c/LICENSE) |
| [`libbase64`](https://github.com/aklomp/base64/blob/master/LICENSE) | BSD 2-Clause |
| [`libuv`](https://github.com/libuv/libuv) (on Windows) | MIT |
| [`libdeflate`](https://github.com/ebiggers/libdeflate) | MIT |
+| [`libjpeg-turbo`](https://github.com/libjpeg-turbo/libjpeg-turbo) | [BSD 3-Clause / IJG / zlib](https://github.com/libjpeg-turbo/libjpeg-turbo/blob/main/LICENSE.md) |
+| [`libspng`](https://github.com/randy408/libspng) | BSD 2-Clause |
+| [`libwebp`](https://github.com/webmproject/libwebp) | BSD 3-Clause |
+| [`highway`](https://github.com/google/highway) | Apache 2.0 |
| [`uucode`](https://github.com/jacobsandlund/uucode) | MIT |
| A fork of [`uWebsockets`](https://github.com/jarred-sumner/uwebsockets) | Apache 2.0 licensed |
| Parts of [Tigerbeetle's IO code](https://github.com/tigerbeetle/tigerbeetle/blob/532c8b70b9142c17e07737ab6d3da68d7500cbca/src/io/windows.zig#L1) | Apache 2.0 licensed |
+| `__cxa_thread_atexit` fallback from [LLVM libc++abi](https://github.com/llvm/llvm-project/blob/llvmorg-19.1.0/libcxxabi/src/cxa_thread_atexit.cpp) | Apache 2.0 with LLVM exception |
## Polyfills
diff --git a/LICENSES/tiktoken-MIT.txt b/LICENSES/tiktoken-MIT.txt
new file mode 100644
index 000000000..83ed1036f
--- /dev/null
+++ b/LICENSES/tiktoken-MIT.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 OpenAI, Shantanu Jain
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.ko-KR.md b/README.ko-KR.md
index ce34cacac..40e61aae2 100644
--- a/README.ko-KR.md
+++ b/README.ko-KR.md
@@ -1,26 +1,53 @@
# Codex용 ChatGPT Web
-Codex의 기본 모델 선택기에서 ChatGPT Web의 Instant, Medium, High, Extra High, Pro를 선택해
-사용하는 로컬 Responses 브리지입니다. 이 포크는 런처의 기본 언어를 한국어로 바꾸고,
-한국어 UI를 추가했으며, GitHub/X 페이지를 반드시 열어야 했던 시작 절차를 선택 사항으로
-바꿨습니다.
-
-이 포크의 런타임은 upstream
-[`miuuyy/codex-chatgpt-web`](https://github.com/miuuyy/codex-chatgpt-web) v1.0.1의
-커밋 `4ad7abe428fe2226fa05038a36e1682de1670fcc`를 기반으로 합니다. 원 저작자 표시와 MIT
-라이선스를 유지합니다.
-
-## 권장 사용 방식
+Codex Desktop의 모델 선택기에서 ChatGPT Web을 직접 모델 백엔드로 사용하는 로컬
+Responses 브리지입니다. Codex는 작업 화면, 스레드 기록, 승인, 도구 하네스를 유지하고
+모델 추론만 로그인된 ChatGPT Web 계정에서 수행합니다.
+
+이 AICC 포크는 upstream
+[`miuuyy/codex-chatgpt-web`](https://github.com/miuuyy/codex-chatgpt-web) v2.0.0
+커밋 `8ae34eb`을 기준으로 하며, 원 저작자 표시와 MIT 라이선스를 유지합니다.
+
+## 모델 선택기
+
+일반 계정에서 다음 8개 항목을 제공합니다.
+
+- `Web / 임시 / 낮음`
+- `Web / 임시 / 중간`
+- `Web / 임시 / 높음`
+- `Web / 임시 / 매우 높음`
+- `Web / 저장 / 낮음`
+- `Web / 저장 / 중간`
+- `Web / 저장 / 높음`
+- `Web / 저장 / 매우 높음`
+
+계정에서 Pro가 실제로 확인된 경우에만 `Web / 임시 / Pro`와
+`Web / 저장 / Pro`가 추가됩니다. 매우 높음(xhigh)은 Pro 전용이 아닙니다.
+임시·저장 방식과 추론 수준은 모델 이름 하나에 함께 고정되므로 Codex의 별도
+추론 강도 행이 선택한 Web 모드를 조용히 바꾸지 않습니다.
+
+현재 구현은 안정적인 복구와 컨텍스트 일관성을 위해 Codex의 매 turn을 새 ChatGPT
+대화로 실행합니다. 임시는 ChatGPT 기록에 남지 않고, 저장은 일반 ChatGPT 대화로
+남습니다. 같은 Codex 스레드의 누적 문맥은 Codex가 정본으로 보유하고 매 turn에
+검증된 컨텍스트 패킷으로 다시 전달합니다. ChatGPT 대화 URL을 재사용하는 연속 모드는
+아직 제공하지 않습니다.
+
+## 세 가지 독립 경로
+
+```text
+Web 모델 -> 17841 -> ChatGPT Web 추론
+Web 이외 모델 -> 17841 -> 설치 전의 loopback upstream(이 Mac에서는 OCX 10100)
+codex-native 프로필 -> 공식 Codex endpoint
+```
-- 기본값은 브라우저 전용 모드입니다. 로컬 도구 없이 ChatGPT Web 모델로 계획·검토·조사를
- 수행할 때 적합합니다.
-- 전체 하네스 모드는 명시적으로 필요한 경우에만 사용하세요. MCP 커넥터와 OpenAI Tunnel
- 설정이 필요하며, 쓰기 작업은 워크스페이스 정책과 수동 승인을 따라야 합니다.
-- Pro는 깊은 계획과 검토에 적합하지만 현재 이 브리지의 로컬 MCP 도구를 직접 호출하지
- 못합니다.
-- 공유 계정, 브라우저 프로필 공유, 과도한 동시 요청, 제한 우회는 사용하지 마세요.
+초기 설치 시 기존 Codex 설정이 loopback upstream과 모델 catalog를 사용 중이면
+브리지가 이를 자동 보존합니다. 따라서 Web 모델 요청은 OCX 모델 추론을 호출하지 않고,
+Web 이외 모델만 OCX로 전달됩니다. OCX가 중단돼도 Web 분기는 계속 동작하지만, 앞단
+브리지 17841이 중단되면 해당 브리지를 쓰는 Desktop 요청은 모두 실패합니다.
+순수 Native 복구는 별도 `codex-native` 프로필 또는 안전한 시점의 `ocx restore`로
+수행합니다.
-## 설치 및 시작
+## 설치
macOS 또는 Linux:
@@ -36,25 +63,75 @@ irm https://github.com/AgenticLab-SH/codex-chatgpt-web/releases/latest/download/
앱에서 다음 순서로 진행합니다.
-1. 내장 브라우저에서 ChatGPT에 로그인합니다.
-2. 브라우저 점검을 실행합니다.
-3. `모델 설치`를 누르고 Codex를 한 번 다시 시작합니다.
-4. Codex 모델 선택기에서 `ChatGPT Web — ...` 모델을 선택합니다.
+1. 앱 전용 내장 브라우저에 사용할 ChatGPT 계정으로 로그인합니다.
+2. 브라우저 동작 시험을 실행합니다.
+3. 모델 연결을 실행합니다. 기존 Codex route가 있으면 복구 journal을 만든 뒤
+ `--replace-codex-route`와 같은 가역 교체를 수행합니다.
+4. Codex Desktop을 한 번 완전히 다시 열어 모델 catalog를 갱신합니다.
+
+앱은 로그인 시 자동 시작하고 창을 닫아도 백그라운드에서 브리지를 유지할 수 있습니다.
+Mac이 꺼졌거나 앱을 완전히 종료하면 Web 모델은 동작하지 않습니다.
+
+## 브라우저 전용과 전체 하네스
+
+| 모드 | ChatGPT Web 추론 | 로컬 파일·터미널·도구 | 추가 설정 |
+| --- | --- | --- | --- |
+| 브라우저 전용 | 가능 | 불가 | 내장 브라우저 로그인 |
+| 전체 하네스 | 가능 | 현재 Codex 프로젝트 경계에서 가능 | Secure MCP Tunnel과 ChatGPT 앱 |
+
+전체 하네스는 현재 Codex turn이 광고한 도구 중 명시적으로 허용된 도구만 ChatGPT에
+노출합니다. 전용 파일·패치·명령·이미지 도구는 검증된 프로젝트 경계를 강제하며,
+Browser·Chrome·Computer Use 및 다른 MCP 도구는 allowlist와 현재 하네스의 실제
+가용성에 따라 제공됩니다. 범용 `node_repl`처럼 프로젝트 경계를 우회할 수 있는
+원시 gateway는 직접 노출하지 않습니다.
+
+Secure MCP Tunnel은 Mac에서 OpenAI로 나가는 연결만 만들며 공인 IP, 인바운드 포트,
+공유기 포트 포워딩을 요구하지 않습니다. Tunnel용 일반 API 키는 Tunnel Read+Use
+권한에만 사용되며 모델 API 호출을 발생시키지 않습니다. 다만 ChatGPT 구독과
+워크스페이스 정책은 그대로 적용됩니다.
+
+## AICC Workspace와의 차이
+
+`AICC Workspace`는 외부 ChatGPT 웹이나 휴대폰 브라우저에서 이 Mac의 등록 프로젝트를
+다루는 별도 Business 앱입니다.
+
+```text
+외부 ChatGPT Web -> Secure MCP Tunnel -> AICC Workspace MCP -> 등록 프로젝트
+```
+
+Codex 모델 선택기의 Web GPT 브리지와 AICC Workspace는 서로 다른 경로입니다.
+AICC Workspace는 Codex 작업을 위임하거나 OCX/로컬 Codex 모델 토큰을 사용하지 않습니다.
+Mac, 로컬 MCP, Tunnel이 실행 중이어야 하며 모바일 네이티브 앱의 전체 custom MCP 지원은
+공식 보장 범위가 아니므로 모바일 웹에서 먼저 검증해야 합니다.
+
+## 컨텍스트·동시성·보안
+
+- 동시에 최대 5개의 Web browser turn을 허용하며 완료된 turn의 탭은 즉시 해제합니다.
+- Codex의 native compaction 요청을 지원하고, Web 응답의 provider-local ID를 다른
+ backend로 넘길 때 제거합니다.
+- 프로젝트 밖 경로, 심볼릭 링크 탈출, 임의 절대 경로와 미허용 도구 호출은 실패로 끝납니다.
+- 승인 프롬프트는 기본적으로 자동 클릭하지 않습니다. 명시적 opt-in에서도 영구 허용이
+ 아닌 1회 허용만 처리합니다.
+- ChatGPT UI가 바뀌면 selector 검증이 명시적으로 실패하며 다른 계정이나 모델로 조용히
+ 우회하지 않습니다.
-브라우저 창은 닫아도 됩니다. 설정의 백그라운드 실행이 켜져 있으면 런처와 내장 브라우저
-프로세스는 계속 동작합니다. 다만 런처를 완전히 종료하면 브리지도 멈춥니다.
+Temporary Chat도 OpenAI 서버에서 처리됩니다. 자세한 내용은
+[Temporary Chat 정책](https://help.openai.com/en/articles/8914046-temporary-chat-faq)을
+확인하세요.
-## 소스에서 실행
+## 소스 개발
```bash
git clone https://github.com/AgenticLab-SH/codex-chatgpt-web.git
cd codex-chatgpt-web
+bun run verify
bun run app
+bun run app:package
```
-Bun 1.3.11이 필요합니다. 전체 아키텍처와 보안 경계는 [영문 README](README.md),
-[아키텍처](docs/architecture.md), [보안 모델](docs/security-model.md)을 확인하세요.
+고정 개발 런타임은 Bun 1.3.14입니다. 전체 경계는
+[아키텍처](docs/architecture.md), [보안 모델](docs/security-model.md),
+[기여 안내](CONTRIBUTING.md)를 확인하세요.
-이 프로젝트는 OpenAI의 공식 제품이 아니며 OpenAI의 보증을 받지 않습니다. ChatGPT UI
-자동화는 화면 변경으로 중단될 수 있고, 프롬프트는 사용 중인 계정과 워크스페이스 정책에
-따라 OpenAI에서 처리됩니다.
+이 프로젝트는 OpenAI의 공식 제품이 아니며 OpenAI의 보증을 받지 않습니다. 본인 계정과
+허용된 워크스페이스에서만 사용하고 OpenAI 약관과 조직 정책을 준수하세요.
diff --git a/README.md b/README.md
index 1d53a444b..b4bfb066a 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
ChatGPT Web for Codex
- Use ChatGPT Web (including Pro) as native Codex models.
- Change the model tier, save your workflow.
+ Use ChatGPT Web as a model backend inside the native Codex task harness.
+ Korean-first AICC fork with reversible routing and project-bound tools.
@@ -15,65 +15,54 @@
-
-Pick **ChatGPT Web — Instant**, **Medium**, **High**, **Extra High**, or **Pro** in Codex's native
-model picker. The bridge sends the complete Codex task context to a fresh ChatGPT Temporary Chat,
-attaches images, and streams visible reasoning, tool activity, and Markdown back into the same
-Codex task.
+Codex keeps the native task UI, history, approvals, tracing, compaction, and tool lifecycle.
+Only the selected Web model's inference is performed by the signed-in ChatGPT Web account.
-> This fork adds a Korean-first launcher and removes the mandatory social-link gate. The runtime is
-> based on upstream [`miuuyy/codex-chatgpt-web`](https://github.com/miuuyy/codex-chatgpt-web) v1.0.1
-> at commit `4ad7abe428fe2226fa05038a36e1682de1670fcc`; upstream attribution and the MIT license are preserved.
+This fork is based on upstream
+[`miuuyy/codex-chatgpt-web`](https://github.com/miuuyy/codex-chatgpt-web) v2.0.0
+at commit `8ae34eb`. The upstream attribution and MIT license are preserved.
-
-
-
+```text
+Codex task -> local Responses bridge (17841) -> embedded ChatGPT Web
+ | non-Web model
+ +-----------------------------> preserved loopback upstream
+```
+
+## Model picker
+
+Eight non-Pro entries are always available after account verification:
+
+- `Web / Temporary / Low`, `Medium`, `High`, `Extra High`
+- `Web / Saved / Low`, `Medium`, `High`, `Extra High`
+
+The Korean launcher displays these as `Web / 임시|저장 / 낮음|중간|높음|매우 높음`.
+Extra High (xhigh) is not Pro-only. Two Pro entries are added only when the signed-in account
+actually exposes Pro. Conversation retention and reasoning level are fixed in the model slug, so
+Codex's separate effort row cannot silently change the selected browser mode.
+
+Each Codex turn currently uses a fresh ChatGPT conversation. Temporary turns do not appear in
+normal ChatGPT history; Saved turns do. Codex remains the durable history source and sends a
+verified accumulated context packet for each turn. Reusing one ChatGPT conversation URL across
+multiple Codex turns is intentionally not implemented yet.
+
+## Independent routes
+
+On first setup the bridge detects and preserves an existing loopback Codex upstream and model
+catalog. On this AICC workstation the resulting topology is:
```text
-Codex task ──Responses + SSE──▶ codex-chatgpt-web ──embedded browser──▶ ChatGPT
- ▲ │ │
- └──────── native UI, context, images, tracing, and tool lifecycle ──────┘
+Web models -> 17841 -> ChatGPT Web inference
+Non-Web models -> 17841 -> OCX 10100
+codex-native profile -> official Codex endpoint
```
-Codex keeps the native task, context lifecycle, UI, and tool harness. The local Responses bridge
-routes only the selected model turn through a fresh ChatGPT Temporary Chat; in full mode, MCP
-connects ChatGPT back to the tools of that same Codex task.
-
-## Highlights
-
-- **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.
-- **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.
-- **Local-first task sessions.** Codex remains the source of truth for task history on your
- computer. Every browser turn starts in a fresh ChatGPT Temporary Chat and receives the complete
- accumulated Codex context, so browser chats are not reused across tasks or added to normal
- ChatGPT history.
-- **The full Codex harness over MCP.** In full mode, Instant through Extra High can use the active
- Codex task's filesystem, shell, images, approvals, and configured tools/apps through MCP. Calls
- and real results stay inside the same browser response—nothing is simulated as text.
-- **Pro stays useful.** Pro is the one exception: ChatGPT's current Pro mode does not expose the
- custom MCP connector this bridge needs. Its native capabilities, including web search and
- research, remain available. Gather local workspace context with Instant through Extra High,
- switch to Pro, and Pro receives the complete accumulated Codex task for deeper analysis.
-- **Fail-closed and manually tested.** Model selection, long inline context, images, streaming,
- visible trace, compaction, native tool rounds, cancellation, and Pro were exercised end-to-end on
- macOS and Windows 11. UI drift and missing capabilities produce explicit errors rather than
- silent fallbacks.
-
-Temporary Chat is a ChatGPT privacy mode, not anonymity or local-only inference: prompts are still
-processed by OpenAI and are subject to the account's settings and OpenAI's
-[Temporary Chat policy](https://help.openai.com/en/articles/8914046-temporary-chat-faq). This project
-is unofficial; users remain responsible for complying with applicable OpenAI terms and workspace
-policies.
-
-## Quick start
-
-Install the desktop launcher:
+A Web request does not invoke OCX model inference. If OCX stops, the Web branch remains usable.
+If the 17841 front door stops, every Desktop request configured through that front door fails.
+Native recovery remains a separate profile or a deliberate `ocx restore` at a safe restart point.
+
+## Install
**macOS or Linux**
@@ -87,100 +76,73 @@ curl -fsSL https://github.com/AgenticLab-SH/codex-chatgpt-web/releases/latest/do
irm https://github.com/AgenticLab-SH/codex-chatgpt-web/releases/latest/download/install-launcher.ps1 | iex
```
-Then complete the three checks in the app:
+Then:
-1. Sign in to ChatGPT in the embedded browser.
+1. Sign in with the intended ChatGPT account inside the app-owned browser profile.
2. Run the browser smoke test.
-3. Press **Install models**, restart Codex once, and select a **ChatGPT Web — …** model.
+3. Install the models. Existing Codex routing is journaled and replaced reversibly.
+4. Fully restart Codex Desktop once to refresh its model catalog.
-Pro appears only when the signed-in account exposes it. The separate **MCP** page is optional and
-guides the full-harness setup without terminal commands.
+The launcher can start at login and keep the bridge alive after its window closes. Web models are
+unavailable when the Mac is off or the launcher is fully quit.
-A packaged browser-only install needs no Google Chrome, model API key, system Node/Bun, or separate
-browser download.
+## Browser-only and full harness
-**Run from source**
+| Mode | ChatGPT Web inference | Local files, shell, and tools | Extra setup |
+| --- | --- | --- | --- |
+| Browser-only | Yes | No | Embedded browser login |
+| Full harness | Yes | Within the current Codex project boundary | Secure MCP Tunnel + ChatGPT app |
-```bash
-git clone https://github.com/AgenticLab-SH/codex-chatgpt-web.git && \
-cd codex-chatgpt-web && \
-bun run app
+Full mode exposes dedicated project-bound file, patch, command, and image tools. Additional
+Browser, Chrome, Computer Use, MCP, and app tools are available only when the active Codex harness
+advertises them and the configured allowlist permits them. Raw gateways that could bypass the
+project boundary, such as unrestricted `node_repl`, are not exposed directly.
+
+The official Secure MCP Tunnel is outbound-only. It does not require a public IP, inbound port, or
+router forwarding. A regular API key with Tunnel Read+Use permission authenticates the tunnel
+control plane; it does not create model API calls. ChatGPT subscription and workspace policies still
+apply.
+
+## AICC Workspace is separate
+
+The `AICC Workspace` Business app is a separate path for using registered projects from external
+ChatGPT Web, including a phone browser while the Mac, local MCP server, and tunnel are running:
+
+```text
+External ChatGPT Web -> Secure MCP Tunnel -> AICC Workspace MCP -> registered project
```
-This source path requires Bun 1.3.11. The command installs locked dependencies and opens the app.
+It does not delegate to Codex tasks and does not consume local Codex or OCX model tokens. Full
+custom MCP behavior in native mobile apps is not an official guarantee; verify the mobile web
+surface first.
-## Modes
+## Reliability and security
-| 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 |
-
-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
-context already collected by Codex, but ChatGPT Pro cannot initiate local MCP/tool calls.
-
-## Full harness
-
-Full mode connects ChatGPT's tool calls back to the current Codex task through the official
-[OpenAI tunnel-client](https://github.com/openai/tunnel-client). The tunnel is outbound: it does
-not expose a public IP, open an inbound port, or require router forwarding.
-
-1. Finish the required launcher setup.
-2. Open **MCP** in the launcher. Create the Tunnel and a regular API key on the same OpenAI account
- that will use the ChatGPT connector; creating the key is free and does not consume model API
- credits.
-3. Paste the Tunnel ID and API key, then press **Connect harness**.
-4. Enable **Developer Mode** in ChatGPT settings. Create a connector using **Tunnel**, select that
- exact Tunnel, set **Authentication** to **None**, and name it exactly `Codex Native`.
-5. Scan its tools, choose the intended action permissions, and run **Verify runtime**. Verification
- types and accepts the full `@Codex Native` mention, then confirms the connector pill.
-
-Write/modify actions require a ChatGPT workspace and admin policy that permit them. OpenAI
-currently documents those actions for Business and Enterprise/Edu workspaces; personal Pro is
-limited to read/fetch MCP permissions. See
-[developer mode and MCP apps](https://help.openai.com/en/articles/12584461-developer-mode-and-mcp-apps-in-chatgpt).
-Unexpected approval prompts fail closed unless `--auto-approve-tool-calls` is explicitly enabled;
-that option clicks **Allow once**, never a permanent grant.
-
-## Operations
-
-Use **Activity** for structured local logs and **Settings → Run doctor** for end-to-end health
-checks. Use **Settings → Cancel retained browser turn** if a stopped task leaves ChatGPT working,
-and **Settings → Remove Codex integration** before deleting the launcher so the previous Codex
-route is restored.
-
-## Limitations and security
-
-- This is unofficial browser automation, not an OpenAI API. ChatGPT UI changes can break selectors;
- drift fails explicitly instead of silently switching model or transport.
-- Browser state is a sensitive login artifact, and the loopback listener is reachable by processes
- running as the same local user. Never share the launcher profile; use a trusted workstation.
-- Release packages currently target macOS 13+ (arm64/x64), Windows x64, and Linux x64. The browser
- flow is manually exercised end-to-end on macOS and Windows 11; runtime, tests, and native
- packaging are gated on all three operating systems in CI.
-- Until platform signing credentials are configured for a release, macOS Gatekeeper or Windows
- SmartScreen may show an unknown-publisher warning. The one-command installers verify the
- published SHA-256 manifest before installation.
-
-Read the complete [architecture](docs/architecture.md) and
-[security model](docs/security-model.md) before enabling full mode. Report vulnerabilities through
-[SECURITY.md](SECURITY.md).
+- Up to five browser turns may run concurrently; completed turn tabs are released immediately.
+- Native Codex compaction requests are supported.
+- Project traversal, symlink escape, arbitrary absolute paths, and unallowlisted tool calls fail
+ closed.
+- Approval prompts are not auto-clicked by default. The explicit opt-in handles one-time approval,
+ not permanent grants.
+- ChatGPT UI drift produces an explicit selector/capability error instead of switching accounts,
+ models, or transports silently.
+- Temporary Chat is still processed by OpenAI. See the
+ [Temporary Chat FAQ](https://help.openai.com/en/articles/8914046-temporary-chat-faq).
## Development
```bash
-bun run app
+git clone https://github.com/AgenticLab-SH/codex-chatgpt-web.git
+cd codex-chatgpt-web
bun run verify
+bun run app
bun run app:package
```
-- [Architecture](docs/architecture.md)
-- [Security model](docs/security-model.md)
-- [Contributing](CONTRIBUTING.md)
-
-## Disclaimer
+Development requires Bun 1.3.14. See the
+[architecture](docs/architecture.md), [security model](docs/security-model.md), and
+[contributing guide](CONTRIBUTING.md).
-This is independent software and is not affiliated with or endorsed by OpenAI. Use it only with
-your own account and in accordance with applicable [Terms of Use](https://openai.com/policies/terms-of-use/)
-and workspace policies; it does not bypass authentication or access controls.
+This project is independent software and is not affiliated with or endorsed by OpenAI. Use it only
+with your own authorized account and workspace, in accordance with applicable OpenAI terms and
+organization policies.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index c36439128..8f79c11a1 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 仍然是电脑上任务历史的真实来源。每个浏览器轮次都会从一个
@@ -63,7 +64,8 @@ Codex 任务的工具。
## 快速开始
-安装桌面启动器:
+安装或更新桌面启动器。若要更新或修复现有安装,请先退出启动器,然后再次运行同一条命令;它会
+替换应用程序和内置运行时,同时保留 ChatGPT 配置文件和启动器配置。
**macOS 或 Linux**
@@ -96,7 +98,7 @@ cd codex-chatgpt-web && \
bun run app
```
-源码方式需要 Bun 1.3.11。该命令会安装锁定版本的依赖并打开应用。
+源码方式需要 Bun 1.3.14。该命令会安装锁定版本的依赖并打开应用。
## 模式
diff --git a/bun.lock b/bun.lock
index 577ced4dd..8493ac92a 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,16 +5,17 @@
"": {
"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",
+ "tiktoken": "1.0.22",
"turndown": "7.2.0",
"turndown-plugin-gfm": "1.0.2",
"zod": "4.4.3",
},
"devDependencies": {
- "@types/bun": "1.3.11",
+ "@types/bun": "1.3.14",
"@types/turndown": "5.0.5",
"typescript": "5.9.3",
},
@@ -22,6 +23,8 @@
},
"overrides": {
"@hono/node-server": "2.0.12",
+ "fast-uri": "3.1.5",
+ "hono": "4.12.34",
"zod-to-json-schema": "3.25.1",
},
"packages": {
@@ -29,9 +32,9 @@
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
- "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
+ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="],
- "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
+ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
@@ -45,7 +48,7 @@
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
- "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
+ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
@@ -99,7 +102,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 +124,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=="],
@@ -211,6 +214,8 @@
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
+ "tiktoken": ["tiktoken@1.0.22", "", {}, "sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA=="],
+
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
diff --git a/docs/architecture.md b/docs/architecture.md
index 9450278b6..cf693d4e7 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -7,7 +7,7 @@ Codex app / CLI
launcher-owned codex-chatgpt-web daemon
├─ official /models passthrough + fixed ChatGPT Web models
├─ native Responses passthrough or ChatGPT Responses/SSE bridge
- ├─ ChatGPT browser worker (embedded Electron surface, one turn at a time)
+ ├─ ChatGPT browser worker (up to five task-bound Electron tabs)
├─ capability broker (full mode only)
└─ stdio MCP server
▲
@@ -20,9 +20,9 @@ launcher-owned codex-chatgpt-web daemon
### `browser-only`
-- Exposes Instant (`chatgpt-web/light`), Medium, High, and Extra High; each model advertises exactly one
- immutable Codex effort matching its ChatGPT browser mode. `chatgpt-web/pro` is appended only when
- the authenticated account exposes Pro.
+- Exposes the conversation mode and effort as independent axes: temporary or saved, each combined with
+ Low, Medium, High, and Extra High. Pro is likewise exposed per conversation mode only when the
+ authenticated account exposes it. Legacy route ids remain accepted as hidden compatibility aliases.
- Sends the complete Codex context and image attachments to a fresh ChatGPT Temporary Chat.
- Never starts the broker, tunnel, or MCP server.
- Emits a nonfatal Codex commentary warning that local tools are unavailable for the selected model.
@@ -37,23 +37,31 @@ 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
JSONL file, upload a synthetic context document, include prompt hashes, or truncate the envelope.
Attachment acceptance and send readiness are verified before the turn begins.
-ChatGPT owns context compaction inside that browser response. The appended models intentionally
-advertise no Codex context window or auto-compaction threshold, and routed compaction v1/v2 calls
-fail explicitly instead of opening a second summarizer turn. A prompt-level checkpoint marker is
-translated into a visible Codex trace item; tool-capable turns re-bind the same capability after
-that checkpoint. Visible ChatGPT status rows become reasoning summaries, while stable prose between
-rows becomes native Codex commentary.
+The appended models advertise the authenticated account's context window and a ten-percent
+auto-compaction reserve. Usage is counted with the GPT-5 tokenizer plus fixed platform/image
+reserves, rather than inferred from character length. The ChatGPT composer also has an independent
+inline-size boundary: usage accounting asks Codex to compact before that boundary, and a prompt
+that still exceeds the proven hard ceiling fails explicitly before any browser turn opens.
+
+Routed compaction v1/v2 runs as a dedicated read-only browser summarization turn with no broker or
+local tools, then returns the native replacement-history shape expected by Codex. A prompt-level
+checkpoint marker is translated into a visible Codex trace item; tool-capable turns re-bind the
+same capability after that checkpoint. Visible ChatGPT status rows become reasoning summaries,
+while stable prose between rows becomes native Codex commentary.
## Installation and service lifecycle
@@ -104,8 +112,8 @@ launcher error.
- Store browser state and tunnel credentials under the application home with mode `0600`.
- Protect lifecycle control endpoints with a random application-owned bearer token.
- Never place secret values in command-line arguments, logs, generated profiles, or Git.
-- Serialize browser turns and reject unsupported models explicitly. The selected routed model fixes
- the adapter effort; a conflicting request effort cannot change it.
+- Limit browser turns to five independent task-bound tabs and reject unsupported models explicitly.
+ The selected routed model fixes the adapter effort; a conflicting request effort cannot change it.
- Do not retry or switch modes to evade product usage limits.
See the complete [security model](security-model.md).
diff --git a/docs/security-model.md b/docs/security-model.md
index cc5cfed41..188106114 100644
--- a/docs/security-model.md
+++ b/docs/security-model.md
@@ -66,9 +66,11 @@ transport, or returns a fabricated success.
### Cross-turn data leakage
-Browser turns are serialized. Every outer Codex turn navigates to a fresh Temporary Chat page and
-closes the prior page. Tool calls for that turn remain in the same ChatGPT response. The bounded
-local continuation cache is private, expires, and exists only to implement Codex
+Browser turns use at most five independent task-bound tabs in one private login partition. Every
+outer Codex task owns a fresh Temporary Chat document and an exact launcher surface lease; chats are
+never reused across tasks. Closing a running tab destroys its page and terminates that turn. The
+five-tab limit bounds parallel account traffic. Tool calls remain in the same ChatGPT response. The
+bounded local continuation cache is private, expires, and exists only to implement Codex
`previous_response_id` replay. ChatGPT Web context compaction remains inside the active browser
response; the bridge does not fabricate or install a Codex history checkpoint.
diff --git a/launcher/electron/browser-host.cjs b/launcher/electron/browser-host.cjs
index 9ebabecff..4d521ac39 100644
--- a/launcher/electron/browser-host.cjs
+++ b/launcher/electron/browser-host.cjs
@@ -6,6 +6,7 @@ const { writePrivateFileAtomic } = require("./atomic-file.cjs");
const { verifyConnectorWithBrowserHelper } = require("./browser-helper-verifier.cjs");
const { processRunning } = require("./process-tree.cjs");
const {
+ dispatchTrustedClick,
dispatchTrustedKey,
evaluatePage,
} = require("./cdp-input.cjs");
@@ -25,7 +26,11 @@ 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 CHATGPT_BACKEND_REQUEST_FILTER = { urls: [`${CHATGPT_ORIGIN}/backend-api/*`] };
+const CLOUDFLARE_CHALLENGE_RECOVERY_DELAY_MS = 500;
+const CLOUDFLARE_CHALLENGE_RECOVERY_SETTLE_MS = 1_000;
const COMPOSER_SELECTOR = [
'[data-testid="prompt-textarea"]',
"#prompt-textarea",
@@ -114,6 +119,58 @@ function isTemporaryChatUrl(value) {
&& parsed.searchParams.get("temporary-chat") === "true";
}
+function isTemporaryChatAuthRedirect(value) {
+ let parsed;
+ try {
+ parsed = new URL(value);
+ } catch {
+ return false;
+ }
+ if (parsed.origin !== CHATGPT_ORIGIN || parsed.pathname !== "/auth/login") return false;
+ const next = parsed.searchParams.get("next");
+ if (!next) return false;
+ try {
+ return isTemporaryChatUrl(new URL(next, CHATGPT_ORIGIN).toString());
+ } catch {
+ return false;
+ }
+}
+
+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) || isTemporaryChatAuthRedirect(currentUrl));
+}
+
+function isChatGptBackendUrl(value) {
+ let parsed;
+ try {
+ parsed = new URL(value);
+ } catch {
+ return false;
+ }
+ return parsed.origin === CHATGPT_ORIGIN && parsed.pathname.startsWith("/backend-api/");
+}
+
+function responseHeaderIncludes(responseHeaders, name, expectedValue) {
+ const expected = expectedValue.toLowerCase();
+ return Object.entries(responseHeaders || {}).some(([headerName, rawValues]) => {
+ if (headerName.toLowerCase() !== name.toLowerCase()) return false;
+ const values = Array.isArray(rawValues) ? rawValues : [rawValues];
+ return values.some(value => String(value)
+ .split(",")
+ .some(candidate => candidate.trim().toLowerCase() === expected));
+ });
+}
+
+function isChatGptCloudflareChallengeResponse(details) {
+ return details?.statusCode === 403
+ && isChatGptBackendUrl(details.url)
+ && responseHeaderIncludes(details.responseHeaders, "cf-mitigated", "challenge");
+}
+
class BrowserHost {
constructor({ window, descriptorPath, cdpPort, control, helper, logger, publishState }) {
this.window = window;
@@ -123,16 +180,22 @@ class BrowserHost {
this.helper = helper;
this.logger = logger;
this.publishState = publishState;
+ this.dispatchTrustedClick = dispatchTrustedClick;
this.dispatchTrustedKey = dispatchTrustedKey;
this.evaluatePage = evaluatePage;
this.verifyConnectorWithBrowserHelper = verifyConnectorWithBrowserHelper;
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.cloudflareChallengeRecovery = null;
+ this.cloudflareChallengeRecoveryArmed = true;
+ this.cloudflareChallengeRecoveryDelayMs = CLOUDFLARE_CHALLENGE_RECOVERY_DELAY_MS;
+ this.cloudflareChallengeRecoverySettleMs = CLOUDFLARE_CHALLENGE_RECOVERY_SETTLE_MS;
this.viewportCssKey = null;
this.authView = null;
this.boundsReady = false;
@@ -162,14 +225,160 @@ class BrowserHost {
window.contentView.addChildView(this.view);
this.view.setBounds(this.bounds);
this.view.setVisible(false);
+ this.bindChatGptBackendRecovery();
this.bindWebContents();
- void this.view.webContents.loadURL(IDLE_BROWSER_URL).catch((error) => {
+ void this.view.webContents.loadURL(IDLE_BROWSER_URL).catch(async (error) => {
+ // Electron may reject the superseded navigation before getURL() reflects
+ // ChatGPT's authentication redirect. Give that committed replacement a
+ // bounded settle window, then still fail closed for every other URL.
+ await sleep(250);
+ const currentUrl = this.view.webContents.getURL();
+ if (initializationNavigationWasSuperseded(error, IDLE_BROWSER_URL, currentUrl)) {
+ this.logger.info("browser.initialization_superseded", { url: currentUrl });
+ return;
+ }
this.logger.error("browser.initialization_failed", { message: error instanceof Error ? error.message : String(error) });
this.setState({ status: "error", message: "Embedded browser failed to initialize" });
});
this.writeDescriptor();
}
+ get activeTraceId() {
+ return [...this.turnTabs.values()].find((tab) => tab.status === "running")?.traceId || null;
+ }
+
+ tabSnapshot(tab) {
+ return {
+ id: tab.id,
+ traceId: tab.traceId,
+ title: tab.label,
+ status: tab.status,
+ loading: tab.loading === true,
+ active: this.selectedTabId === tab.id,
+ closable: true,
+ };
+ }
+
+ selectedTurnTab() {
+ return this.turnTabs.get(this.selectedTabId) || null;
+ }
+
+ createTurnTab(traceId, helperPid) {
+ if (this.turnTabs.size >= MAX_BROWSER_TABS) {
+ throw new Error(
+ `ChatGPT Web already has ${MAX_BROWSER_TABS} browser tabs; close one before starting another turn to avoid excessive parallel traffic on the ChatGPT account`,
+ );
+ }
+ const id = randomBytes(12).toString("base64url");
+ const surfaceId = randomBytes(24).toString("base64url");
+ const ordinal = Array.from({ length: MAX_BROWSER_TABS }, (_unused, index) => index + 1)
+ .find(candidate => ![...this.turnTabs.values()].some(tab => tab.ordinal === candidate));
+ if (!ordinal) throw new Error("ChatGPT Web browser tab allocation is inconsistent");
+ const view = new WebContentsView({
+ webPreferences: {
+ partition: CHATGPT_PARTITION,
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
+ spellcheck: true,
+ backgroundThrottling: false,
+ },
+ });
+ const tab = {
+ id,
+ surfaceId,
+ traceId,
+ helperPid,
+ view,
+ status: "running",
+ ordinal,
+ label: `ChatGPT ${ordinal}`,
+ pageTitle: "ChatGPT",
+ url: IDLE_BROWSER_URL,
+ loading: true,
+ message: "ChatGPT is working",
+ };
+ this.turnTabs.set(id, tab);
+ this.window.contentView.addChildView(view);
+ view.setBounds(this.bounds);
+ view.setVisible(false);
+ this.bindTurnContents(tab);
+ void view.webContents.loadURL(IDLE_BROWSER_URL).catch((error) => {
+ tab.status = "error";
+ tab.loading = false;
+ tab.message = error instanceof Error ? error.message : String(error);
+ this.publishState?.(this.snapshot());
+ });
+ return tab;
+ }
+
+ bindTurnContents(tab) {
+ const contents = tab.view.webContents;
+ contents.setWindowOpenHandler(({ url }) => {
+ let parsed;
+ try { parsed = new URL(url); } catch { return { action: "deny" }; }
+ if (parsed.protocol === "https:" || parsed.protocol === "http:") void shell.openExternal(parsed.toString());
+ return { action: "deny" };
+ });
+ contents.on("did-start-loading", () => {
+ tab.loading = true;
+ this.publishState?.(this.snapshot());
+ });
+ contents.on("did-stop-loading", () => {
+ tab.loading = false;
+ tab.url = contents.getURL();
+ this.publishState?.(this.snapshot());
+ });
+ contents.on("did-finish-load", () => {
+ tab.url = contents.getURL();
+ tab.loading = false;
+ void contents.insertCSS(CHATGPT_VIEWPORT_CSS).catch(() => {});
+ const encoded = JSON.stringify(tab.surfaceId);
+ void contents.executeJavaScript(`(() => {
+ Object.defineProperty(globalThis, "__CODEX_WEB_GPT_SURFACE_ID__", {
+ value: ${encoded}, configurable: true, enumerable: false, writable: false,
+ });
+ document.documentElement.dataset.codexWebGptSurface = ${encoded};
+ })()`, true).then(
+ () => this.publishState?.(this.snapshot()),
+ (error) => {
+ tab.status = "error";
+ tab.message = `Browser ownership failed: ${error instanceof Error ? error.message : String(error)}`;
+ this.publishState?.(this.snapshot());
+ },
+ );
+ });
+ contents.on("page-title-updated", (_event, title) => {
+ if (typeof title === "string" && title.trim()) tab.pageTitle = title.trim();
+ this.publishState?.(this.snapshot());
+ });
+ contents.on("did-navigate-in-page", (_event, url, mainFrame) => {
+ if (mainFrame) tab.url = url;
+ this.publishState?.(this.snapshot());
+ });
+ contents.on("did-fail-load", (_event, errorCode, errorDescription, url, mainFrame) => {
+ if (!mainFrame || errorCode === -3) return;
+ tab.status = "error";
+ tab.loading = false;
+ tab.url = url;
+ tab.message = errorDescription;
+ this.logger.error("browser.tab_navigation_failed", {
+ tabId: tab.id,
+ traceId: tab.traceId,
+ errorCode,
+ errorDescription,
+ url,
+ });
+ this.publishState?.(this.snapshot());
+ });
+ contents.on("render-process-gone", (_event, details) => {
+ tab.status = "error";
+ tab.loading = false;
+ tab.message = `Browser renderer stopped: ${details.reason}`;
+ this.publishState?.(this.snapshot());
+ });
+ }
+
bindWebContents() {
const contents = this.view.webContents;
contents.setWindowOpenHandler(({ url }) => {
@@ -225,13 +434,116 @@ class BrowserHost {
});
}
+ bindChatGptBackendRecovery() {
+ this.view.webContents.session.webRequest.onCompleted(
+ CHATGPT_BACKEND_REQUEST_FILTER,
+ details => this.handleChatGptBackendResponse(details),
+ );
+ }
+
+ handleChatGptBackendResponse(details) {
+ const contents = this.view?.webContents;
+ if (!contents || contents.isDestroyed() || details?.webContentsId !== contents.id) return false;
+ if (!isChatGptBackendUrl(details.url)) return false;
+
+ if (details.statusCode >= 200 && details.statusCode < 400) {
+ this.cloudflareChallengeRecoveryArmed = true;
+ return false;
+ }
+ if (!isChatGptCloudflareChallengeResponse(details)) return false;
+ if (this.cloudflareChallengeRecovery) {
+ this.cloudflareChallengeRecoveryArmed = false;
+ return true;
+ }
+ if (this.activeTraceId || this.manualOperation) {
+ this.logger.warn("browser.cloudflare_challenge_not_reloaded", {
+ reason: this.activeTraceId ? "turn-active" : "manual-operation-active",
+ url: details.url,
+ });
+ return true;
+ }
+ if (!this.cloudflareChallengeRecoveryArmed) {
+ this.logger.warn("browser.cloudflare_challenge_persisted", { url: details.url });
+ return true;
+ }
+ this.cloudflareChallengeRecoveryArmed = false;
+ this.logger.warn("browser.cloudflare_challenge_detected", { url: details.url });
+ const recovery = this.reloadHomeAfterCloudflareChallenge();
+ const tracked = recovery
+ .catch((error) => {
+ const message = error instanceof Error ? error.message : String(error);
+ this.logger.error("browser.cloudflare_challenge_recovery_failed", { message });
+ this.setState({ status: "error", message, loading: false });
+ })
+ .finally(() => {
+ if (this.cloudflareChallengeRecovery === tracked) this.cloudflareChallengeRecovery = null;
+ });
+ this.cloudflareChallengeRecovery = tracked;
+ return true;
+ }
+
+ async reloadHomeAfterCloudflareChallenge() {
+ const contents = this.view.webContents;
+ this.setState({
+ status: "loading",
+ message: "Refreshing ChatGPT security check",
+ loading: true,
+ });
+ await sleep(this.cloudflareChallengeRecoveryDelayMs);
+ if (contents.isDestroyed()) throw new Error("ChatGPT browser closed during security-check recovery");
+ const url = contents.getURL();
+ if (!url.startsWith(CHATGPT_ORIGIN)) {
+ throw new Error("ChatGPT security-check recovery lost its owned browser page");
+ }
+
+ // Only responses from this new document may prove that the challenge cleared.
+ this.cloudflareChallengeRecoveryArmed = false;
+ await contents.loadURL(url);
+ await sleep(this.cloudflareChallengeRecoverySettleMs);
+ if (!this.cloudflareChallengeRecoveryArmed) {
+ throw new Error("ChatGPT security check is still blocking backend requests. Reload ChatGPT and retry.");
+ }
+ await this.probeAuthentication();
+ this.logger.info("browser.cloudflare_challenge_recovered", { url });
+ }
+
snapshot() {
const contents = this.activeView()?.webContents;
- return readBrowserNavigationState(contents, {
- ...this.state,
+ const selected = this.selectedTurnTab();
+ const homeTab = {
+ id: "home",
+ traceId: null,
+ title: this.state.title || "ChatGPT",
+ status: this.state.status,
+ loading: this.state.loading === true,
+ active: this.selectedTabId === "home",
+ closable: false,
+ };
+ const state = selected
+ ? {
+ ...this.state,
+ status: selected.status,
+ message: selected.message,
+ url: selected.url,
+ title: selected.pageTitle,
+ loading: selected.loading,
+ }
+ : this.state;
+ return {
+ ...readBrowserNavigationState(contents, {
+ ...state,
visible: this.visible,
surfaceActive: this.surfaceActive,
- });
+ }),
+ activeTabId: this.selectedTabId,
+ tabs: this.turnTabs.size > 0
+ ? [
+ ...(this.selectedTabId === "home" ? [homeTab] : []),
+ ...[...this.turnTabs.values()].map((tab) => this.tabSnapshot(tab)),
+ ]
+ : [homeTab],
+ maxTabs: MAX_BROWSER_TABS,
+ };
}
setState(patch) {
@@ -249,6 +561,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 +571,62 @@ class BrowserHost {
}
activeView() {
- return this.authView || this.view;
+ return this.authView || this.selectedTurnTab()?.view || this.view;
+ }
+
+ activateHomeSurface() {
+ this.selectedTabId = "home";
+ this.syncViewVisibility();
+ if (this.visible && this.surfaceActive) this.activeView().webContents.focus();
+ this.publishState?.(this.snapshot());
+ this.writeDescriptor();
}
syncViewVisibility() {
const visible = browserViewVisible(this.visible, this.surfaceActive, this.boundsReady);
- this.view.setVisible(visible && !this.authView);
+ const selected = this.selectedTurnTab();
+ this.view.setVisible(visible && !this.authView && !selected);
+ for (const tab of this.turnTabs.values()) {
+ tab.view.setVisible(visible && !this.authView && selected?.id === tab.id);
+ }
this.authView?.setVisible(visible);
}
+ selectTab(tabId) {
+ if (tabId !== "home" && !this.turnTabs.has(tabId)) throw new Error("Browser tab does not exist");
+ if (this.authView) this.closeAuthView(this.authView, true);
+ this.selectedTabId = tabId;
+ this.syncViewVisibility();
+ if (this.visible && this.surfaceActive) this.activeView().webContents.focus();
+ this.publishState?.(this.snapshot());
+ this.writeDescriptor();
+ return this.snapshot();
+ }
+
+ removeTurnTab(tab, abortRunning) {
+ this.turnTabs.delete(tab.id);
+ if (abortRunning && tab.status === "running") {
+ this.closedTurnOwners.set(tab.traceId, tab.helperPid);
+ tab.status = "aborted";
+ }
+ try { this.window.contentView.removeChildView(tab.view); } catch {}
+ if (!tab.view.webContents.isDestroyed()) tab.view.webContents.close();
+ if (this.selectedTabId === tab.id) {
+ this.selectedTabId = [...this.turnTabs.keys()].at(-1) || "home";
+ }
+ this.syncViewVisibility();
+ this.publishState?.(this.snapshot());
+ this.writeDescriptor();
+ }
+
+ closeTab(tabId) {
+ const tab = this.turnTabs.get(tabId);
+ if (!tab) throw new Error("Browser tab does not exist");
+ this.removeTurnTab(tab, true);
+ this.logger.info("browser.tab_closed", { tabId, traceId: tab.traceId, status: tab.status });
+ return this.snapshot();
+ }
+
createAuthView(options = {}) {
this.closeAuthView(this.authView, true);
const authView = new WebContentsView({
@@ -370,7 +730,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 +777,73 @@ 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) {
+ if (processRunning(existing.helperPid)) {
+ throw new Error(`ChatGPT browser turn ${traceId} is owned by another helper process`);
+ }
+ this.logger.warn("browser.stale_turn_owner_replaced", {
+ tabId: existing.id,
+ traceId,
+ previousHelperPid: existing.helperPid,
+ helperPid,
+ evidence: "previous helper exited",
+ });
}
- this.logger.warn("browser.stale_turn_replaced", {
- previousTraceId: this.activeTraceId,
- previousHelperPid: this.activeHelperPid,
- traceId,
- helperPid,
- evidence: sameSerializedHelper ? "same serialized helper" : "previous helper exited",
- });
+ existing.helperPid = helperPid;
+ existing.status = "running";
+ existing.loading = true;
+ existing.message = "ChatGPT is working";
+ if (!existing.view.webContents.isDestroyed()) {
+ existing.view.webContents.setBackgroundThrottling(false);
+ }
+ this.selectedTabId = existing.id;
+ if (reveal) this.show();
+ else this.syncViewVisibility();
+ this.publishState?.(this.snapshot());
+ this.writeDescriptor();
+ this.logger.info("browser.tab_reused", { tabId: existing.id, traceId });
+ return { surfaceId: existing.surfaceId, tabId: existing.id };
}
- this.activeTraceId = traceId;
- this.activeHelperPid = helperPid;
- this.view.webContents.setBackgroundThrottling(false);
+ const tab = this.createTurnTab(traceId, helperPid);
+ this.selectedTabId = tab.id;
if (reveal) this.show();
- this.setState({ status: "running", message: "ChatGPT is working", authenticated: true });
+ else this.syncViewVisibility();
+ this.publishState?.(this.snapshot());
+ this.logger.info("browser.tab_created", { tabId: tab.id, traceId, tabCount: this.turnTabs.size });
+ return { surfaceId: tab.surfaceId, tabId: tab.id };
}
async endTurn(traceId, helperPid, status, hideAfterTurn, message) {
- if (this.activeTraceId !== traceId) {
- throw new Error(`Browser turn ownership mismatch: expected ${this.activeTraceId || "none"}, received ${traceId}`);
+ const tab = [...this.turnTabs.values()].find((candidate) => candidate.traceId === traceId);
+ if (!tab) {
+ const closedOwner = this.closedTurnOwners.get(traceId);
+ if (closedOwner === helperPid) {
+ this.closedTurnOwners.delete(traceId);
+ return;
+ }
+ throw new Error(`Browser turn ownership mismatch: no browser tab owns ${traceId}`);
}
- if (this.activeHelperPid !== helperPid) {
+ if (tab.helperPid !== helperPid) {
throw new Error(
- `Browser helper ownership mismatch: expected ${this.activeHelperPid || "none"}, received ${helperPid}`,
+ `Browser helper ownership mismatch: expected ${tab.helperPid}, received ${helperPid}`,
);
}
- this.activeTraceId = null;
- this.activeHelperPid = null;
- this.view.webContents.setBackgroundThrottling(true);
- if (hideAfterTurn) this.hide();
+ tab.status = status === "completed" ? "ready" : status === "aborted" ? "aborted" : "error";
+ tab.message = status === "completed" ? "Task completed" : message || `ChatGPT turn ${status}`;
+ tab.loading = false;
+ if (!tab.view.webContents.isDestroyed()) tab.view.webContents.setBackgroundThrottling(true);
if (status === "completed") {
- this.setState({ status: "ready", message: "No active task", authenticated: true });
- try {
- await this.returnToIdle();
- } catch (error) {
- const detail = error instanceof Error ? error.message : String(error);
- this.logger.error("browser.idle_cleanup_failed", { message: detail });
- this.setState({ status: "error", message: `Browser cleanup failed: ${detail}`, authenticated: true });
- }
- } else {
- this.setState({ status: "error", message: message || `ChatGPT turn ${status}`, authenticated: true });
+ this.logger.info("browser.tab_completed", { tabId: tab.id, traceId });
}
+ // A browser tab represents an active Codex turn, not durable task history. Retaining terminal
+ // tabs leaked one slot per response/compaction until the five-tab safety limit made later
+ // turns fail. The result already lives in Codex; release the browser document on every
+ // terminal path while leaving other concurrently running tabs untouched.
+ this.removeTurnTab(tab, false);
+ if (hideAfterTurn && !this.activeTraceId) this.hide();
+ this.logger.info("browser.tab_released", { tabId: tab.id, traceId, status: tab.status });
}
async returnToIdle() {
@@ -479,10 +860,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;
}
@@ -503,6 +886,29 @@ class BrowserHost {
return tracked;
}
+ async logout() {
+ return await this.withManualOperation("ChatGPT logout", async () => {
+ if (this.authView) this.closeAuthView(this.authView, true, false);
+ const contents = this.view.webContents;
+ await contents.session.clearStorageData();
+ this.setState({
+ authenticated: false,
+ loading: true,
+ message: "Signing out of ChatGPT",
+ status: "loading",
+ });
+ await contents.loadURL(TEMPORARY_CHAT_URL);
+ const browser = await this.probeAuthentication();
+ if (browser.authenticated) {
+ throw new Error("ChatGPT session remained authenticated after local session data was cleared");
+ }
+ this.activateHomeSurface();
+ this.show();
+ this.logger.info("browser.logout_completed");
+ return this.snapshot();
+ });
+ }
+
async refreshAuthentication() {
return await this.withManualOperation("session refresh", async () => {
this.setState({ status: "loading", message: "Checking saved ChatGPT session" });
@@ -544,6 +950,9 @@ class BrowserHost {
}
}
if (result.composer) {
+ if (this.authView && !this.authView.webContents.isDestroyed()) {
+ this.closeAuthView(this.authView, true, false);
+ }
const wasAuthenticated = this.state.authenticated;
const availability = this.activeTraceId
? { status: "running", message: "ChatGPT is working" }
@@ -668,6 +1077,19 @@ class BrowserHost {
}
}
+ async clickTrustedBrowserPoint(point) {
+ try {
+ await this.dispatchTrustedClick({
+ debuggerClient: this.view.webContents.debugger,
+ point,
+ });
+ } catch (error) {
+ throw new Error(
+ `ChatGPT trusted browser click failed: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ }
+
async evaluateBrowserPage(expression) {
const contents = this.view.webContents;
try {
@@ -830,10 +1252,12 @@ class BrowserHost {
url: location.href,
};
}
+ const rect = control.getBoundingClientRect();
return {
found: true,
label: normalize(control.innerText || control.textContent),
expanded: control.getAttribute('aria-expanded'),
+ point: { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 },
composer: Boolean(composer),
form: true,
readyState: document.readyState,
@@ -842,26 +1266,6 @@ class BrowserHost {
})()`);
}
- async focusEffortControl() {
- return await this.evaluateBrowserPage(`(() => {
- /* effort-control-focus */
- const visible = (element) => {
- const style = getComputedStyle(element);
- const rect = element.getBoundingClientRect();
- return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
- };
- const composer = ${visibleElementScript(COMPOSER_SELECTOR)};
- const form = composer?.closest('form');
- const controls = Array.from(form?.querySelectorAll(
- 'button[aria-haspopup="menu"][data-tone="neutral"]'
- ) || []).filter(visible);
- const control = controls.at(-1);
- if (!control) return false;
- control.focus({ preventScroll: true });
- return document.activeElement === control;
- })()`);
- }
-
async waitForEffortControl(timeoutMs, pollMs) {
const deadline = Date.now() + timeoutMs;
let control;
@@ -908,66 +1312,42 @@ class BrowserHost {
if (!candidate || !target) {
return { open: Boolean(candidate), count: candidate?.items.length || 0, target: null };
}
+ const rect = target.getBoundingClientRect();
return {
open: true,
count: candidate.items.length,
target: {
label: normalize(target.innerText || target.textContent),
checked: target.getAttribute('aria-checked'),
+ point: { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 },
},
};
})()`);
}
- async focusEffortMenuItem(targetIndex) {
- return await this.evaluateBrowserPage(`(() => {
- /* effort-menu-focus */
- const targetIndex = ${targetIndex};
- const visible = (element) => {
- const style = getComputedStyle(element);
- const rect = element.getBoundingClientRect();
- return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
- };
- const composer = ${visibleElementScript(COMPOSER_SELECTOR)};
- const control = Array.from(composer?.closest('form')?.querySelectorAll(
- 'button[aria-haspopup="menu"][data-tone="neutral"]'
- ) || []).filter(visible).at(-1);
- const controlledId = control?.getAttribute('aria-controls');
- const controlled = controlledId ? document.getElementById(controlledId) : null;
- const roots = [
- ...(controlled ? [controlled] : []),
- ...Array.from(document.querySelectorAll(${JSON.stringify(EFFORT_MENU_SELECTOR)})),
- ];
- const candidates = [...new Set(roots)].filter(visible).map((menu) => (
- Array.from(menu.querySelectorAll('[role="menuitemradio"]')).filter(visible)
- )).filter(items => items.length > 0)
- .sort((left, right) => right.length - left.length);
- const target = candidates[0]?.[targetIndex];
- if (!target) return false;
- target.focus({ preventScroll: true });
- return document.activeElement === target;
- })()`);
- }
-
async openEffortMenu(targetIndex, timeoutMs, pollMs, knownControl) {
- const control = knownControl?.found ? knownControl : await this.readEffortControl();
+ let control = knownControl?.found ? knownControl : await this.readEffortControl();
if (!control.found) {
throw new Error("ChatGPT effort control disappeared before its menu could open");
}
if (control.expanded !== "true") {
- if (!await this.focusEffortControl()) {
- throw new Error("ChatGPT effort control could not receive focus");
+ // Re-resolve immediately before activation so the click is derived from the current
+ // composer-owned semantic control, never from a stale or hard-coded viewport coordinate.
+ control = await this.readEffortControl();
+ if (!control.found || !control.point) {
+ throw new Error("ChatGPT effort control disappeared before activation");
}
- await this.pressTrustedBrowserKey("Enter");
+ await this.clickTrustedBrowserPoint(control.point);
}
return await this.waitForEffortMenu(targetIndex, timeoutMs, pollMs);
}
- async chooseEffortMenuItem(targetIndex) {
- if (!await this.focusEffortMenuItem(targetIndex)) {
- throw new Error(`ChatGPT effort item index ${targetIndex} could not receive focus`);
+ async chooseEffortMenuItem(targetIndex, knownMenu) {
+ const menu = knownMenu?.target ? knownMenu : await this.readEffortMenu(targetIndex);
+ if (!menu.target?.point) {
+ throw new Error(`ChatGPT effort item index ${targetIndex} disappeared before activation`);
}
- await this.pressTrustedBrowserKey("Enter");
+ await this.clickTrustedBrowserPoint(menu.target.point);
}
async waitForEffortMenu(targetIndex, timeoutMs, pollMs) {
@@ -1005,7 +1385,7 @@ class BrowserHost {
this.pressBrowserKey("Escape");
return { effort: "High", changed: false };
}
- await this.chooseEffortMenuItem(targetIndex);
+ await this.chooseEffortMenuItem(targetIndex, menu);
const deadline = Date.now() + confirmTimeoutMs;
let confirmed = menu;
@@ -1116,6 +1496,7 @@ class BrowserHost {
if (this.manualOperation) {
throw new Error(`ChatGPT browser is already busy with ${this.manualOperation}`);
}
+ this.activateHomeSurface();
this.manualOperation = name;
const contents = this.view?.webContents;
if (contents && !contents.isDestroyed()) contents.setBackgroundThrottling(false);
@@ -1160,6 +1541,11 @@ class BrowserHost {
if (current.pid === process.pid) fs.rmSync(this.descriptorPath, { force: true });
} catch {}
this.closeAuthView(this.authView, true);
+ for (const tab of this.turnTabs.values()) {
+ try { this.window.contentView.removeChildView(tab.view); } catch {}
+ if (!tab.view.webContents.isDestroyed()) tab.view.webContents.close();
+ }
+ this.turnTabs.clear();
if (this.view && !this.view.webContents.isDestroyed()) this.view.webContents.close();
}
}
@@ -1169,6 +1555,9 @@ module.exports = {
BrowserHost,
CHATGPT_VIEWPORT_CSS,
IDLE_BROWSER_URL,
+ initializationNavigationWasSuperseded,
+ isTemporaryChatAuthRedirect,
+ isChatGptCloudflareChallengeResponse,
isTemporaryChatUrl,
TEMPORARY_CHAT_URL,
};
diff --git a/launcher/electron/cdp-input.cjs b/launcher/electron/cdp-input.cjs
index 11ef30e6a..c25618940 100644
--- a/launcher/electron/cdp-input.cjs
+++ b/launcher/electron/cdp-input.cjs
@@ -35,6 +35,32 @@ async function dispatchTrustedKey({ debuggerClient, key }) {
});
}
+async function dispatchTrustedClick({ debuggerClient, point }) {
+ if (!Number.isFinite(point?.x) || !Number.isFinite(point?.y)) {
+ throw new Error("CDP click point is invalid");
+ }
+ const position = { x: point.x, y: point.y };
+ await withWebContentsDebugger(debuggerClient, async (sendCommand) => {
+ await sendCommand("Input.dispatchMouseEvent", {
+ ...position,
+ type: "mouseMoved",
+ button: "none",
+ });
+ await sendCommand("Input.dispatchMouseEvent", {
+ ...position,
+ type: "mousePressed",
+ button: "left",
+ clickCount: 1,
+ });
+ await sendCommand("Input.dispatchMouseEvent", {
+ ...position,
+ type: "mouseReleased",
+ button: "left",
+ clickCount: 1,
+ });
+ });
+}
+
async function evaluatePage({ debuggerClient, expression }) {
if (typeof expression !== "string" || !expression.trim()) {
throw new Error("CDP evaluation expression is required");
@@ -56,6 +82,7 @@ async function evaluatePage({ debuggerClient, expression }) {
}
module.exports = {
+ dispatchTrustedClick,
dispatchTrustedKey,
evaluatePage,
withWebContentsDebugger,
diff --git a/launcher/electron/control-server.cjs b/launcher/electron/control-server.cjs
index 8aaae46b9..d4c29635e 100644
--- a/launcher/electron/control-server.cjs
+++ b/launcher/electron/control-server.cjs
@@ -113,8 +113,10 @@ class BrowserControlServer {
}
const preferences = this.getPreferences();
if (request.url === "/v1/turn/start") {
- host.beginTurn(body.traceId, preferences.showBrowserDuringTurns === true, body.helperPid);
+ const lease = host.beginTurn(body.traceId, preferences.showBrowserDuringTurns === true, body.helperPid);
this.logger.info("browser.turn_started", { traceId: body.traceId });
+ writeJson(response, 200, { ok: true, ...lease });
+ return;
} else {
if (!['completed', 'failed', 'aborted'].includes(body.status)) throw new Error("turn status is invalid");
await host.endTurn(
diff --git a/launcher/electron/main.cjs b/launcher/electron/main.cjs
index a0f30ba1d..0e9ad2d98 100644
--- a/launcher/electron/main.cjs
+++ b/launcher/electron/main.cjs
@@ -27,7 +27,13 @@ const {
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 { runtimeBundlePaths } = require("./runtime-command.cjs");
+const { createUpdateController } = require("./update.cjs");
+const {
+ createStateStore,
+ nextSessionRefreshReminderAt,
+ validateSidebarState,
+} = require("./state.cjs");
const {
MIN_WINDOW_BOUNDS,
readWindowState,
@@ -86,6 +92,7 @@ let cdpPort = 0;
let lastOperation = null;
let catalogVerificationTimer = null;
let catalogVerificationInFlight = false;
+let updateController = null;
function findFreePort() {
return new Promise((resolve, reject) => {
@@ -154,6 +161,28 @@ function startCatalogVerificationMonitor({ logger, stateStore }) {
void check();
}
+async function restoreCodexRouteAfterRuntimeFailure({ logger, stateStore }) {
+ try {
+ const route = await runtimeHost.restoreBridgeRoute("runtime-start-fail-safe");
+ if (!route.installed || route.active) return { restored: false };
+ const state = stateStore.update({
+ bridgeEnabled: false,
+ codexCatalogVerified: false,
+ codexRestartRequired: true,
+ });
+ send("launcher:state-changed", state);
+ stopCatalogVerificationMonitor();
+ logger.warn("bridge.route_restored_after_runtime_failure", {
+ changed: route.changed === true,
+ });
+ return { restored: true };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ logger.error("bridge.route_restore_after_runtime_failure_failed", { message });
+ return { restored: false, error: message };
+ }
+}
+
function trayImage() {
if (process.platform !== "darwin") {
return nativeImage.createFromPath(APP_ICON_PATH).resize({ width: 18, height: 18 });
@@ -354,6 +383,7 @@ function registerIpc({ logger, stateStore }) {
version: app.getVersion(),
smokePassed: smokePassedThisSession || smokePassedForCurrentVersion(stateStore.read()),
operation: lastOperation,
+ update: updateController?.getState() ?? { status: "disabled" },
}));
handle("launcher:set-language", (_event, language) => {
@@ -390,7 +420,27 @@ 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-login", () => browserHost.openLogin());
+ handle("launcher:browser-tab-select", (_event, tabId) => browserHost.selectTab(tabId));
+ handle("launcher:browser-tab-close", (_event, tabId) => browserHost.closeTab(tabId));
+ handle("launcher:browser-login", async () => {
+ const browser = await browserHost.openLogin();
+ if (browser.authenticated) {
+ const state = stateStore.update({ sessionRefreshReminderAt: nextSessionRefreshReminderAt() });
+ send("launcher:state-changed", state);
+ }
+ return browser;
+ });
+ handle("launcher:browser-logout", async () => {
+ const browser = await browserHost.logout();
+ const state = stateStore.update({ sessionRefreshReminderAt: nextSessionRefreshReminderAt() });
+ send("launcher:state-changed", state);
+ return { browser, state };
+ });
+ handle("launcher:session-reminder-dismiss", () => {
+ const state = stateStore.update({ sessionRefreshReminderAt: nextSessionRefreshReminderAt() });
+ send("launcher:state-changed", state);
+ return state;
+ });
handle("launcher:browser-smoke", async () => {
const result = await browserHost.smokeTest();
stateStore.update({ browserSmokePassed: true, browserSmokeVersion: app.getVersion() });
@@ -540,6 +590,16 @@ function registerIpc({ logger, stateStore }) {
if (error) throw new Error(`Could not open the launcher log directory: ${error}`);
return logger.filePath;
});
+ handle("launcher:update-install", async () => {
+ if (!updateController) throw new Error("Launcher updates are unavailable");
+ const launch = await updateController.beginInstall();
+ const result = await requestQuit();
+ if (!result.ok) {
+ updateController.cancelInstall(launch);
+ throw new Error(result.message);
+ }
+ return true;
+ });
handle("launcher:window-state", (event) => {
const window = BrowserWindow.fromWebContents(event.sender);
return windowStateSnapshot(window);
@@ -554,7 +614,9 @@ function registerIpc({ logger, stateStore }) {
}
async function requestQuit() {
- if (shutdownInProgress || exitCommitted) return;
+ if (shutdownInProgress || exitCommitted) {
+ return { ok: false, message: "Launcher shutdown is already in progress" };
+ }
shutdownInProgress = true;
try {
const activeOperation = runtimeHost?.currentOperation();
@@ -568,11 +630,13 @@ async function requestQuit() {
await browserControl?.close();
exitCommitted = true;
app.quit();
+ return { ok: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
quitting = false;
showMainWindow();
publishOperation({ name: "launcher-quit", status: "failed", message });
+ return { ok: false, message };
} finally {
shutdownInProgress = false;
}
@@ -608,6 +672,9 @@ async function start() {
};
const stateStore = createStateStore(path.join(app.getPath("userData"), "launcher-state.json"));
+ if (stateStore.read().sessionRefreshReminderAt === null) {
+ stateStore.update({ sessionRefreshReminderAt: nextSessionRefreshReminderAt() });
+ }
const persistedState = stateStore.read();
if (persistedState.coreSetupComplete === true && persistedState.codexCatalogVerified === undefined) {
stateStore.update({
@@ -666,6 +733,20 @@ async function start() {
publishOperation,
supervisor: runtimeSupervisor,
});
+ const updaterRuntimeRoot = runtimeRootProvider();
+ updateController = createUpdateController({
+ currentVersion: app.getVersion(),
+ platform: process.platform,
+ arch: process.arch,
+ packaged: app.isPackaged,
+ executablePath: process.execPath,
+ runtimeExecutable: updaterRuntimeRoot
+ ? runtimeBundlePaths(updaterRuntimeRoot, process.platform).executable
+ : null,
+ logsDirectory: app.getPath("logs"),
+ publish: (state) => send("launcher:update-state", state),
+ logger,
+ });
registerIpc({ logger, stateStore });
const trayAvailable = createTray(logger, stateStore);
if (startHidden && !trayAvailable) mainWindow.once("ready-to-show", () => showMainWindow());
@@ -678,6 +759,7 @@ async function start() {
});
}
await loadRenderer(mainWindow);
+ if (!launcherSmokeTest) void updateController.checkOnce();
if (launcherSmokeTest) {
const smokeRuntimeRoot = runtimeRootProvider();
if (app.isPackaged && !smokeRuntimeRoot) {
@@ -717,6 +799,27 @@ async function start() {
return;
}
void (async () => {
+ const upgrade = await runtimeHost.upgradeManagedRuntime();
+ if (upgrade.updated) {
+ const state = stateStore.update({
+ bridgeEnabled: upgrade.bridgeEnabled,
+ coreSetupComplete: true,
+ codexCatalogVerified: false,
+ codexRestartRequired: true,
+ ...(upgrade.mode === "browser-only" ? {
+ mcpRuntimeInstalled: false,
+ mcpSetupComplete: false,
+ mcpGuideStep: 0,
+ } : {}),
+ });
+ send("launcher:state-changed", state);
+ logger.info("runtime.release_upgraded", {
+ fromVersion: upgrade.fromVersion,
+ toVersion: upgrade.toVersion,
+ mode: upgrade.mode,
+ bridgeEnabled: upgrade.bridgeEnabled,
+ });
+ }
try {
const route = await runtimeHost.bridgeStatus();
if (route.installed) {
@@ -735,7 +838,7 @@ async function start() {
});
}
return runtimeSupervisor.startIfConfigured();
- })().then((runtime) => {
+ })().then(async (runtime) => {
if (runtime.status === "bridge-disabled") {
stopCatalogVerificationMonitor();
return;
@@ -759,6 +862,7 @@ async function start() {
return;
}
if (runtime.status === "not-configured") {
+ const routeRecovery = await restoreCodexRouteAfterRuntimeFailure({ logger, stateStore });
const current = stateStore.read();
if (current.coreSetupComplete || current.mcpRuntimeInstalled || current.mcpSetupComplete) {
const state = stateStore.update({
@@ -770,23 +874,42 @@ async function start() {
});
send("launcher:state-changed", state);
}
+ if (routeRecovery.error) {
+ publishOperation({
+ name: "runtime-start",
+ status: "failed",
+ message: `Local runtime is not configured; restoring the previous Codex route also failed: ${routeRecovery.error}`,
+ });
+ }
return;
}
+ const routeRecovery = await restoreCodexRouteAfterRuntimeFailure({ logger, stateStore });
const state = stateStore.update({ coreSetupComplete: false, codexCatalogVerified: false });
send("launcher:state-changed", state);
if (runtime.status === "external" || runtime.status === "needs-setup") {
+ const detail = runtime.detail || (
+ runtime.status === "external"
+ ? "Another process owns the configured Codex Web GPT runtime"
+ : "The installed runtime configuration must be repaired from Setup"
+ );
publishOperation({
name: "runtime-start",
status: "failed",
- message: runtime.detail || (
- runtime.status === "external"
- ? "Another process owns the configured Codex Web GPT runtime"
- : "The installed runtime configuration must be repaired from Setup"
- ),
+ message: routeRecovery.error
+ ? `${detail}; restoring the previous Codex route also failed: ${routeRecovery.error}`
+ : routeRecovery.restored
+ ? `${detail}; the previous Codex route was restored, restart Codex once`
+ : detail,
});
}
- }).catch((error) => {
- const message = error instanceof Error ? error.message : String(error);
+ }).catch(async (error) => {
+ const primary = error instanceof Error ? error.message : String(error);
+ const routeRecovery = await restoreCodexRouteAfterRuntimeFailure({ logger, stateStore });
+ const message = routeRecovery.error
+ ? `${primary}; restoring the previous Codex route also failed: ${routeRecovery.error}`
+ : routeRecovery.restored
+ ? `${primary}; the previous Codex route was restored, restart Codex once`
+ : primary;
logger.error("runtime.startup_failed", { message });
const state = stateStore.update({ coreSetupComplete: false, codexCatalogVerified: false });
send("launcher:state-changed", state);
diff --git a/launcher/electron/preload.cjs b/launcher/electron/preload.cjs
index a2af47ac1..a6689ac3f 100644
--- a/launcher/electron/preload.cjs
+++ b/launcher/electron/preload.cjs
@@ -17,7 +17,11 @@ 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"),
+ logoutChatGpt: () => ipcRenderer.invoke("launcher:browser-logout"),
+ dismissSessionReminder: () => ipcRenderer.invoke("launcher:session-reminder-dismiss"),
smokeTest: () => ipcRenderer.invoke("launcher:browser-smoke"),
verifyMcp: () => ipcRenderer.invoke("launcher:mcp-verify"),
doctor: () => ipcRenderer.invoke("launcher:doctor"),
@@ -32,6 +36,7 @@ contextBridge.exposeInMainWorld("codexWebLauncher", {
setSidebarState: (state) => ipcRenderer.invoke("launcher:sidebar-state", state),
logs: (limit) => ipcRenderer.invoke("launcher:logs", limit),
openLogs: () => ipcRenderer.invoke("launcher:open-logs"),
+ installUpdate: () => ipcRenderer.invoke("launcher:update-install"),
windowState: () => ipcRenderer.invoke("launcher:window-state"),
windowControl: (action) => ipcRenderer.send("launcher:window-control", action),
onWindowStateChanged: (listener) => subscription("launcher:window-state-changed", listener),
@@ -39,4 +44,5 @@ contextBridge.exposeInMainWorld("codexWebLauncher", {
onBrowserState: (listener) => subscription("launcher:browser-state", listener),
onOperation: (listener) => subscription("launcher:operation", listener),
onLog: (listener) => subscription("launcher:log", listener),
+ onUpdateState: (listener) => subscription("launcher:update-state", listener),
});
diff --git a/launcher/electron/runtime-install.cjs b/launcher/electron/runtime-install.cjs
index 48a840493..8b3fee28e 100644
--- a/launcher/electron/runtime-install.cjs
+++ b/launcher/electron/runtime-install.cjs
@@ -3,14 +3,16 @@ const path = require("node:path");
const { renameAtomicFile } = require("./atomic-file.cjs");
const { runtimeBundlePaths } = require("./runtime-command.cjs");
-function validateRuntimeBundle(runtimeRoot, { version, platform, arch }) {
+function validateRuntimeBundle(runtimeRoot, { version, platform, arch, bundleId }) {
const manifestPath = path.join(runtimeRoot, "manifest.json");
if (!fs.existsSync(manifestPath)) throw new Error(`Runtime manifest is missing: ${manifestPath}`);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (manifest.schemaVersion !== 1
|| manifest.appVersion !== version
|| manifest.platform !== platform
- || manifest.arch !== arch) {
+ || manifest.arch !== arch
+ || !/^[a-f0-9]{64}$/.test(manifest.bundleId)
+ || (bundleId && manifest.bundleId !== bundleId)) {
throw new Error(
`Runtime bundle identity mismatch: expected ${version} ${platform}/${arch}, received ${JSON.stringify(manifest)}`,
);
@@ -36,6 +38,8 @@ function ensurePackagedRuntime({ app, coreHome, resourcesPath }) {
};
const source = path.join(resourcesPath, "runtime");
validateRuntimeBundle(source, identity);
+ const sourceManifest = JSON.parse(fs.readFileSync(path.join(source, "manifest.json"), "utf8"));
+ const expectedIdentity = { ...identity, bundleId: sourceManifest.bundleId };
const versionsRoot = path.join(coreHome, "versions");
const destination = path.join(
versionsRoot,
@@ -43,7 +47,7 @@ function ensurePackagedRuntime({ app, coreHome, resourcesPath }) {
);
if (fs.existsSync(destination)) {
try {
- return validateRuntimeBundle(destination, identity);
+ return validateRuntimeBundle(destination, expectedIdentity);
} catch {
// A terminated installer or external cleanup can leave a version directory present but
// incomplete. Rebuild the launcher-owned bundle transactionally from the signed package.
@@ -56,14 +60,14 @@ function ensurePackagedRuntime({ app, coreHome, resourcesPath }) {
let previousMoved = false;
try {
fs.cpSync(source, temporary, { recursive: true, errorOnExist: true, force: false });
- validateRuntimeBundle(temporary, identity);
+ validateRuntimeBundle(temporary, expectedIdentity);
if (fs.existsSync(destination)) {
renameAtomicFile(destination, previous);
previousMoved = true;
}
try {
renameAtomicFile(temporary, destination);
- validateRuntimeBundle(destination, identity);
+ validateRuntimeBundle(destination, expectedIdentity);
} catch (error) {
fs.rmSync(destination, { recursive: true, force: true });
if (previousMoved) {
@@ -91,7 +95,7 @@ function ensurePackagedRuntime({ app, coreHome, resourcesPath }) {
}
}
try { fs.chmodSync(destination, 0o700); } catch {}
- return validateRuntimeBundle(destination, identity);
+ return validateRuntimeBundle(destination, expectedIdentity);
}
module.exports = {
diff --git a/launcher/electron/runtime-supervisor.cjs b/launcher/electron/runtime-supervisor.cjs
index 0b35a56f9..c8aeb1e84 100644
--- a/launcher/electron/runtime-supervisor.cjs
+++ b/launcher/electron/runtime-supervisor.cjs
@@ -96,6 +96,12 @@ function tunnelRuntimeStopped(health) {
|| (health?.state === "stopped" && health?.processRunning === false);
}
+function runtimeOwnershipMayBeLive(state) {
+ if (!state) return false;
+ if (processRunning(state.daemonPid) || processRunning(state.tunnelPid)) return true;
+ return ["starting", "ready", "degraded", "stopping"].includes(state.status);
+}
+
function conciseTunnelLog(value) {
if (typeof value !== "string" || !value.trim()) return undefined;
const tail = value.trim().split(/\r?\n/).slice(-3).join(" | ");
@@ -143,13 +149,18 @@ function tunnelCommandQuoted(value) {
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
}
-function managedTunnelMcpCommand(config) {
- return [...config.runtimeCommand, "mcp", "--broker-socket", config.brokerSocketPath]
+function managedTunnelMcpCommand(invocation) {
+ if (!invocation
+ || typeof invocation.executable !== "string"
+ || !Array.isArray(invocation.args)) {
+ throw new Error("Launcher tunnel MCP command requires an explicit runtime invocation");
+ }
+ return [invocation.executable, ...invocation.args]
.map(tunnelCommandQuoted)
.join(" ");
}
-function managedTunnelConnectArgs(config) {
+function managedTunnelConnectArgs(config, invocation) {
const tunnel = config.tunnel;
if (!tunnel) throw new Error("launcher-owned tunnel has no runtime configuration");
return [
@@ -160,7 +171,7 @@ function managedTunnelConnectArgs(config) {
"--tunnel-client-bin", tunnel.binaryPath,
"--tunnel-id", tunnel.tunnelId,
"--runtime-api-key", `file:${tunnel.runtimeKeyFile}`,
- "--mcp-command", managedTunnelMcpCommand(config),
+ "--mcp-command", managedTunnelMcpCommand(invocation),
"--json",
];
}
@@ -513,11 +524,14 @@ class RuntimeSupervisor {
async readTunnelHealth(config) {
const tunnel = config.tunnel;
+ // `runtimes status` performs an optional control-plane lookup when the saved runtime key is
+ // available. The cleanup dry run is the official local-only inventory and never removes
+ // entries without `--apply`, so proxy or control-plane failures cannot block supervision.
const result = await this.runTunnelCommand(
config,
- ["runtimes", "status", tunnel.alias, "--json"],
+ ["runtimes", "cleanup", "--json"],
5_000,
- "Tunnel health probe",
+ "Local tunnel inventory probe",
);
if (result.code !== 0) {
return {
@@ -526,60 +540,67 @@ class RuntimeSupervisor {
state: undefined,
processRunning: undefined,
healthy: undefined,
- absent: tunnelRuntimeAbsent(result.output),
- statusKnown: tunnelRuntimeAbsent(result.output),
+ absent: false,
+ statusKnown: false,
detail: tunnelControlDiagnostic(result),
};
}
try {
const parsed = JSON.parse(result.output);
- const healthBaseUrl = loopbackHealthBaseURL(parsed.health_url ?? parsed.healthUrl);
+ if (!Array.isArray(parsed.entries)) throw new Error("local inventory has no entries array");
+ const entry = parsed.entries.find(candidate => candidate?.alias === tunnel.alias);
+ if (!entry) {
+ return {
+ ready: false,
+ pid: null,
+ state: "stopped",
+ processRunning: false,
+ healthy: false,
+ absent: true,
+ statusKnown: true,
+ detail: `alias=${tunnel.alias}; local_inventory=absent`,
+ };
+ }
+ const runtimeState = entry.runtime_state;
+ if (!["stopped", "starting", "healthy", "ready"].includes(runtimeState)) {
+ throw new Error(`local inventory reported unsupported runtime_state=${String(runtimeState)}`);
+ }
+ const liveRuntime = entry.live_runtime && typeof entry.live_runtime === "object"
+ ? entry.live_runtime
+ : {};
+ const healthBaseUrl = loopbackHealthBaseURL(liveRuntime.base_url);
if (healthBaseUrl) this.tunnelHealthBaseUrl = healthBaseUrl;
- const pid = Number.isInteger(parsed.pid) && parsed.pid > 0
- ? parsed.pid
- : Number.isInteger(parsed.process?.pid) && parsed.process.pid > 0
- ? parsed.process.pid
+ const pid = Number.isInteger(liveRuntime.system?.pid) && liveRuntime.system.pid > 0
+ ? liveRuntime.system.pid
+ : Number.isInteger(liveRuntime.status?.pid) && liveRuntime.status.pid > 0
+ ? liveRuntime.status.pid
: null;
- const runtimeState = parsed.runtime_state ?? parsed.state ?? parsed.status;
- const issues = Array.isArray(parsed.local?.issues)
- ? parsed.local.issues.filter(issue => typeof issue === "string").slice(0, 3)
- : [];
- const logTail = typeof parsed.launch_diagnostics?.log_tail === "string"
- ? parsed.launch_diagnostics.log_tail
- : typeof parsed.local?.log?.tail === "string"
- ? parsed.local.log.tail
- : undefined;
- const conciseLog = conciseTunnelLog(logTail);
+ const processRunning = runtimeState !== "stopped";
+ const healthy = runtimeState === "healthy" || runtimeState === "ready";
+ const ready = runtimeState === "ready";
const detail = [
["state", runtimeState],
- ["process_running", parsed.process_running ?? parsed.processRunning],
- ["healthy", parsed.healthy],
- ["ready", parsed.ready],
- ["health_url", parsed.health_url || parsed.healthUrl ? "present" : "missing"],
+ ["process_running", processRunning],
+ ["healthy", healthy],
+ ["ready", ready],
+ ["classification", entry.classification],
+ ["live_admin", liveRuntime.found === true],
["pid", pid ?? "missing"],
]
.filter(([, value]) => value !== undefined)
.map(([key, value]) => `${key}=${String(value)}`)
- .concat(issues)
- .concat(conciseLog ? [`runtime_log=${conciseLog}`] : [])
.join("; ");
return {
- ready: parsed.process_running === true
- && parsed.healthy === true
- && parsed.ready === true,
+ ready,
pid,
- state: typeof runtimeState === "string" ? runtimeState : undefined,
- processRunning: typeof parsed.process_running === "boolean"
- ? parsed.process_running
- : typeof parsed.processRunning === "boolean"
- ? parsed.processRunning
- : undefined,
- healthy: typeof parsed.healthy === "boolean" ? parsed.healthy : undefined,
+ state: runtimeState,
+ processRunning,
+ healthy,
absent: false,
statusKnown: true,
- detail: redactText(detail || "status JSON did not expose tunnel readiness fields").slice(0, 2_000),
+ detail: redactText(detail).slice(0, 2_000),
};
- } catch {
+ } catch (error) {
return {
ready: false,
pid: null,
@@ -588,7 +609,8 @@ class RuntimeSupervisor {
healthy: undefined,
absent: false,
statusKnown: false,
- detail: `status command returned invalid JSON: ${redactText(result.output || "[empty]").slice(0, 500)}`,
+ detail: `local inventory returned invalid JSON: ${errorMessage(error)};`
+ + ` ${redactText(result.output || "[empty]").slice(0, 500)}`,
};
}
}
@@ -804,9 +826,10 @@ class RuntimeSupervisor {
}
async runTunnelConnectCommand(config) {
+ const invocation = this.runtimeCommand(["mcp", "--broker-socket", config.brokerSocketPath]);
return await this.runTunnelCommand(
config,
- managedTunnelConnectArgs(config),
+ managedTunnelConnectArgs(config, invocation),
TUNNEL_START_TIMEOUT_MS,
"Tunnel managed startup",
);
@@ -956,7 +979,8 @@ class RuntimeSupervisor {
return { status: "not-configured" };
}
if (config.releaseVersion !== this.app.getVersion()) {
- if (await this.proxyHealth(config) || this.readState()) {
+ const ownershipState = this.readState();
+ if (await this.proxyHealth(config) || runtimeOwnershipMayBeLive(ownershipState)) {
try {
const recovered = await this.stopStaleOwnedRuntime(config);
if (!recovered) {
@@ -980,7 +1004,7 @@ class RuntimeSupervisor {
if (!this.daemon && !this.tunnel) {
const healthyRuntime = await this.proxyHealth(config);
const ownershipState = this.readState();
- if (healthyRuntime || ownershipState) {
+ if (healthyRuntime || runtimeOwnershipMayBeLive(ownershipState)) {
try {
const recovered = await this.stopStaleOwnedRuntime(config);
if (!recovered) {
@@ -1621,19 +1645,23 @@ class RuntimeSupervisor {
let drained = false;
let tunnelStopped = false;
try {
- if (config?.mode === "full" && !this.tunnel) {
+ const ownershipState = this.readState();
+ const healthyRuntime = config ? await this.proxyHealth(config) : false;
+ const runtimeMayBeLive = healthyRuntime || runtimeOwnershipMayBeLive(ownershipState);
+ if (config?.mode === "full"
+ && !this.tunnel
+ && (runtimeMayBeLive || !ownershipState)) {
await this.adoptConfiguredTunnelForStop(config);
}
if (!this.daemon && !this.tunnel) {
if (!config) {
- const ownershipState = this.readState();
if (ownershipState && (
processRunning(ownershipState.daemonPid)
|| processRunning(ownershipState.tunnelPid)
)) {
throw new Error("runtime configuration is missing while launcher ownership processes are still alive");
}
- } else if (await this.proxyHealth(config) || this.readState()) {
+ } else if (runtimeMayBeLive) {
const recovered = await this.stopStaleOwnedRuntime(config);
if (!recovered) {
throw new Error("an existing runtime could not be safely recovered");
diff --git a/launcher/electron/runtime.cjs b/launcher/electron/runtime.cjs
index 78f8b6117..854b5eba1 100644
--- a/launcher/electron/runtime.cjs
+++ b/launcher/electron/runtime.cjs
@@ -533,6 +533,31 @@ class RuntimeHost {
return parseBridgeRouteResult(result.stdout, { requireInstalled: true });
}
+ async restoreBridgeRouteWithinOperation(operationName) {
+ const current = await this.bridgeStatus(operationName);
+ if (!current.installed || !current.active) return current;
+ const disconnected = await this.run(operationName, ["route", "disconnect"], {
+ embedded: true,
+ message: "Restoring the previous Codex route",
+ successMessage: "Previous Codex route restored",
+ timeoutMs: 15_000,
+ });
+ return {
+ ...parseBridgeRouteResult(disconnected.stdout, { expectedActive: false }),
+ installed: true,
+ };
+ }
+
+ async restoreBridgeRoute(operationName = "bridge-route-restore") {
+ if (this.currentOperation()) throw new Error(`Another launcher operation is active: ${this.currentOperation()}`);
+ this.lifecycleOperation = operationName;
+ try {
+ return await this.restoreBridgeRouteWithinOperation(operationName);
+ } finally {
+ this.lifecycleOperation = null;
+ }
+ }
+
async setBridgeEnabled(enabled) {
const desired = enabled === true;
const name = desired ? "bridge-connect" : "bridge-disconnect";
@@ -622,26 +647,42 @@ class RuntimeHost {
const previousRuntime = this.runtimeConfigSnapshot();
this.lifecycleOperation = name;
try {
- if (previousRuntime.owner === "external") this.supervisor.prepareExternalMigration();
- else await this.supervisor.stopForSetup();
- return await this.run(name, ["uninstall", "--yes", "--launcher-control"], {
- embedded: true,
- env: this.launcherControlEnvironment(),
- message: "Restoring the previous Codex route",
- successMessage: "Codex Web GPT integration removed",
- timeoutMs: UNINSTALL_TIMEOUT_MS,
- });
- } catch (error) {
- let recoveryError;
try {
- await this.restorePreviousRuntime(previousRuntime, name);
- } catch (caught) {
- recoveryError = caught;
+ if (previousRuntime.owner === "external") this.supervisor.prepareExternalMigration();
+ else await this.supervisor.stopForSetup();
+ } catch (error) {
+ try {
+ await this.restoreBridgeRouteWithinOperation(name);
+ } catch (routeError) {
+ throw new Error(
+ `${error instanceof Error ? error.message : String(error)}; restoring the previous Codex route also failed:`
+ + ` ${routeError instanceof Error ? routeError.message : String(routeError)}`,
+ );
+ }
+ throw new Error(
+ `${error instanceof Error ? error.message : String(error)}; the previous Codex route was restored,`
+ + " but launcher runtime cleanup did not complete",
+ );
+ }
+ try {
+ return await this.run(name, ["uninstall", "--yes", "--launcher-control"], {
+ embedded: true,
+ env: this.launcherControlEnvironment(),
+ message: "Restoring the previous Codex route",
+ successMessage: "Codex Web GPT integration removed",
+ timeoutMs: UNINSTALL_TIMEOUT_MS,
+ });
+ } catch (error) {
+ try {
+ await this.restoreBridgeRouteWithinOperation(name);
+ } catch (routeError) {
+ throw new Error(
+ `${error instanceof Error ? error.message : String(error)}; restoring the previous Codex route also failed:`
+ + ` ${routeError instanceof Error ? routeError.message : String(routeError)}`,
+ );
+ }
+ throw error;
}
- if (!recoveryError) throw error;
- const primary = error instanceof Error ? error.message : String(error);
- const recovery = recoveryError instanceof Error ? recoveryError.message : String(recoveryError);
- throw new Error(`${primary}; restoring the launcher runtime also failed: ${recovery}`);
} finally {
this.lifecycleOperation = null;
}
@@ -667,6 +708,38 @@ class RuntimeHost {
return { ...result, mode };
}
+ async upgradeManagedRuntime() {
+ if (this.currentOperation()) throw new Error(`Another launcher operation is active: ${this.currentOperation()}`);
+ const existing = this.runtimeConfigSnapshot();
+ const currentVersion = this.app.getVersion();
+ if (existing.owner !== "launcher" || existing.config?.releaseVersion === currentVersion) {
+ return { updated: false };
+ }
+ const route = await this.bridgeStatus("runtime-upgrade-route");
+ const args = [
+ "setup",
+ existing.mode === "full" ? "--full" : "--browser-only",
+ "--browser-host-descriptor",
+ this.browserDescriptorPath,
+ "--acknowledge-unofficial",
+ "--restart-service",
+ ];
+ const result = await this.runSetup("runtime-upgrade", args, {
+ message: `Upgrading launcher runtime from ${existing.config.releaseVersion} to ${currentVersion}`,
+ successMessage: `Launcher runtime upgraded to ${currentVersion}`,
+ timeoutMs: existing.mode === "full" ? MCP_SETUP_TIMEOUT_MS : CORE_SETUP_TIMEOUT_MS,
+ });
+ if (!route.active) await this.setBridgeEnabled(false);
+ return {
+ updated: true,
+ mode: existing.mode,
+ bridgeEnabled: route.active,
+ fromVersion: existing.config.releaseVersion,
+ toVersion: currentVersion,
+ stdout: result.stdout,
+ };
+ }
+
setupMcp({ tunnelId = "", runtimeKey = "", replace = false } = {}) {
if (this.currentOperation()) throw new Error(`Another launcher operation is active: ${this.currentOperation()}`);
const reuseSavedCredentials = replace !== true && this.mcpCredentialsConfigured();
diff --git a/launcher/electron/state.cjs b/launcher/electron/state.cjs
index 785af5767..25b15978b 100644
--- a/launcher/electron/state.cjs
+++ b/launcher/electron/state.cjs
@@ -2,6 +2,7 @@ const fs = require("node:fs");
const { writePrivateFileAtomic } = require("./atomic-file.cjs");
const SIDEBAR_MIN_WIDTH = 240;
const SIDEBAR_MAX_WIDTH = 420;
+const SESSION_REFRESH_REMINDER_INTERVAL_MS = 48 * 60 * 60 * 1000;
const DEFAULT_STATE = Object.freeze({
version: 1,
@@ -18,8 +19,14 @@ const DEFAULT_STATE = Object.freeze({
sidebarOpen: true,
sidebarWidth: 252,
mcpGuideStep: 0,
+ sessionRefreshReminderAt: null,
});
+function nextSessionRefreshReminderAt(now = Date.now()) {
+ if (!Number.isFinite(now)) throw new Error("Session refresh reminder time must be finite");
+ return new Date(now + SESSION_REFRESH_REMINDER_INTERVAL_MS).toISOString();
+}
+
function readState(filePath) {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
@@ -53,6 +60,11 @@ function readState(filePath) {
if (!Number.isInteger(state.mcpGuideStep) || state.mcpGuideStep < 0 || state.mcpGuideStep > 2) {
state.mcpGuideStep = DEFAULT_STATE.mcpGuideStep;
}
+ if (state.sessionRefreshReminderAt !== null
+ && (typeof state.sessionRefreshReminderAt !== "string"
+ || !Number.isFinite(Date.parse(state.sessionRefreshReminderAt)))) {
+ state.sessionRefreshReminderAt = DEFAULT_STATE.sessionRefreshReminderAt;
+ }
for (const key of [
"coreSetupComplete",
"codexCatalogVerified",
@@ -98,8 +110,10 @@ function createStateStore(filePath) {
}
module.exports = {
+ SESSION_REFRESH_REMINDER_INTERVAL_MS,
SIDEBAR_MAX_WIDTH,
SIDEBAR_MIN_WIDTH,
createStateStore,
+ nextSessionRefreshReminderAt,
validateSidebarState,
};
diff --git a/launcher/electron/update-worker.cjs b/launcher/electron/update-worker.cjs
new file mode 100644
index 000000000..a8760989e
--- /dev/null
+++ b/launcher/electron/update-worker.cjs
@@ -0,0 +1,147 @@
+const fs = require("node:fs");
+const path = require("node:path");
+const { spawn, spawnSync } = require("node:child_process");
+
+function appendLog(job, message) {
+ try {
+ fs.mkdirSync(path.dirname(job.logPath), { recursive: true, mode: 0o700 });
+ fs.appendFileSync(job.logPath, `${new Date().toISOString()} ${message}\n`, { mode: 0o600 });
+ } catch {}
+}
+
+function processAlive(pid) {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (error) {
+ return error?.code === "EPERM";
+ }
+}
+
+async function waitForParent(pid, timeoutMs = 120_000) {
+ const deadline = Date.now() + timeoutMs;
+ while (processAlive(pid)) {
+ if (Date.now() >= deadline) throw new Error(`Launcher process ${pid} did not exit in time`);
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ }
+}
+
+function launch(bin, args = []) {
+ const child = spawn(bin, args, { detached: true, stdio: "ignore", windowsHide: true });
+ child.unref();
+}
+
+function requireFile(filePath, label) {
+ if (!filePath || !path.isAbsolute(filePath) || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
+ throw new Error(`${label} is missing: ${filePath || "unknown"}`);
+ }
+}
+
+function updateMac(job) {
+ const sourceExecutable = path.join(job.source, "Contents", "MacOS", "Codex Web GPT");
+ requireFile(sourceExecutable, "Staged macOS launcher");
+ const next = `${job.target}.updating-${process.pid}`;
+ const previous = `${job.target}.swap-${process.pid}`;
+ fs.rmSync(next, { recursive: true, force: true });
+ fs.rmSync(previous, { recursive: true, force: true });
+ const copied = spawnSync("/usr/bin/ditto", [job.source, next], { encoding: "utf8", timeout: 180_000 });
+ if (copied.error) throw copied.error;
+ if (copied.status !== 0) throw new Error(`Could not stage the macOS application: ${copied.stderr.trim()}`);
+ requireFile(path.join(next, "Contents", "MacOS", "Codex Web GPT"), "Copied macOS launcher");
+
+ fs.renameSync(job.target, previous);
+ try {
+ fs.renameSync(next, job.target);
+ } catch (error) {
+ fs.renameSync(previous, job.target);
+ throw error;
+ }
+ fs.rmSync(previous, { recursive: true, force: true });
+ launch("/usr/bin/open", [job.target]);
+}
+
+function updateWindows(job) {
+ requireFile(job.source, "Windows installer");
+ const result = spawnSync(job.source, ["/S"], { encoding: "utf8", timeout: 15 * 60_000, windowsHide: true });
+ if (result.error) throw result.error;
+ if (result.status !== 0) throw new Error(`Windows installer exited with code ${result.status}`);
+ requireFile(job.target, "Installed Windows launcher");
+ launch(job.target);
+}
+
+function shellQuote(value) {
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
+}
+
+function installLinuxFile(source, target) {
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
+ const next = `${target}.updating-${process.pid}`;
+ fs.rmSync(next, { force: true });
+ fs.copyFileSync(source, next);
+ fs.chmodSync(next, 0o755);
+ fs.renameSync(next, target);
+}
+
+function updateLinux(job) {
+ requireFile(job.source, "Linux AppImage");
+ const wrapper = job.wrapper && path.isAbsolute(job.wrapper) ? job.wrapper : null;
+ let launchTarget = job.target;
+ if (wrapper) {
+ const versionsRoot = path.dirname(path.dirname(job.target));
+ const nextTarget = path.join(versionsRoot, job.version, path.basename(job.target));
+ installLinuxFile(job.source, nextTarget);
+ const wrapperNext = `${wrapper}.updating-${process.pid}`;
+ fs.writeFileSync(wrapperNext, [
+ "#!/bin/sh",
+ "set -eu",
+ 'export APPIMAGE_EXTRACT_AND_RUN="${APPIMAGE_EXTRACT_AND_RUN:-1}"',
+ `export CODEX_WEB_GPT_LAUNCHER_EXECUTABLE=${shellQuote(wrapper)}`,
+ `export CODEX_WEB_GPT_APPIMAGE=${shellQuote(nextTarget)}`,
+ `exec ${shellQuote(nextTarget)} "$@"`,
+ "",
+ ].join("\n"), { mode: 0o755 });
+ fs.renameSync(wrapperNext, wrapper);
+ if (path.dirname(job.target) !== path.dirname(nextTarget)
+ && path.dirname(path.dirname(job.target)) === versionsRoot) {
+ fs.rmSync(path.dirname(job.target), { recursive: true, force: true });
+ }
+ launchTarget = wrapper;
+ } else {
+ installLinuxFile(job.source, job.target);
+ }
+ launch(launchTarget);
+}
+
+function relaunchExisting(job) {
+ try {
+ if (job.platform === "darwin" && fs.existsSync(job.target)) launch("/usr/bin/open", [job.target]);
+ else if (job.platform === "win32" && fs.existsSync(job.target)) launch(job.target);
+ else if (job.platform === "linux") {
+ const target = job.wrapper && fs.existsSync(job.wrapper) ? job.wrapper : job.target;
+ if (fs.existsSync(target)) launch(target);
+ }
+ } catch {}
+}
+
+async function main() {
+ const jobPath = process.argv[2];
+ if (!jobPath || !path.isAbsolute(jobPath)) throw new Error("Update worker requires an absolute job path");
+ const job = JSON.parse(fs.readFileSync(jobPath, "utf8"));
+ appendLog(job, `waiting for launcher PID ${job.parentPid} before installing v${job.version}`);
+ await waitForParent(job.parentPid);
+ appendLog(job, `installing v${job.version} on ${job.platform}`);
+ try {
+ if (job.platform === "darwin") updateMac(job);
+ else if (job.platform === "win32") updateWindows(job);
+ else if (job.platform === "linux") updateLinux(job);
+ else throw new Error(`Unsupported update platform: ${job.platform}`);
+ appendLog(job, `v${job.version} installed and relaunched`);
+ try { fs.rmSync(job.tempRoot, { recursive: true, force: true }); } catch {}
+ } catch (error) {
+ appendLog(job, `update failed: ${error instanceof Error ? error.stack || error.message : String(error)}`);
+ relaunchExisting(job);
+ throw error;
+ }
+}
+
+void main().catch(() => process.exit(1));
diff --git a/launcher/electron/update.cjs b/launcher/electron/update.cjs
new file mode 100644
index 000000000..58b092d49
--- /dev/null
+++ b/launcher/electron/update.cjs
@@ -0,0 +1,362 @@
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+const https = require("node:https");
+const os = require("node:os");
+const path = require("node:path");
+const { spawn, spawnSync } = require("node:child_process");
+const { pipeline } = require("node:stream/promises");
+
+const REPOSITORY = "AgenticLab-SH/codex-chatgpt-web";
+const RELEASE_API_URL = `https://api.github.com/repos/${REPOSITORY}/releases/latest`;
+const USER_AGENT = "codex-web-gpt-launcher-updater";
+const MAX_REDIRECTS = 5;
+
+function parseVersion(value) {
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(value || "").trim());
+ if (!match) return null;
+ return {
+ major: Number(match[1]),
+ minor: Number(match[2]),
+ patch: Number(match[3]),
+ prerelease: match[4] || null,
+ };
+}
+
+function compareVersions(left, right) {
+ const a = parseVersion(left);
+ const b = parseVersion(right);
+ if (!a || !b) throw new Error(`Invalid release version comparison: ${left} / ${right}`);
+ for (const key of ["major", "minor", "patch"]) {
+ if (a[key] !== b[key]) return a[key] > b[key] ? 1 : -1;
+ }
+ if (a.prerelease === b.prerelease) return 0;
+ if (a.prerelease === null) return 1;
+ if (b.prerelease === null) return -1;
+ return a.prerelease.localeCompare(b.prerelease, "en", { numeric: true });
+}
+
+function releaseVersion(tagName) {
+ const version = String(tagName || "").replace(/^v/, "");
+ if (!parseVersion(version)) throw new Error(`GitHub returned an invalid release tag: ${tagName}`);
+ return version;
+}
+
+function releaseAssetName(version, platform = process.platform, arch = process.arch) {
+ if (platform === "darwin" && ["arm64", "x64"].includes(arch)) {
+ return `codex-web-gpt-${version}-mac-${arch}.zip`;
+ }
+ if (platform === "win32" && arch === "x64") {
+ return `codex-web-gpt-${version}-win-x64.exe`;
+ }
+ if (platform === "linux" && arch === "x64") {
+ return `codex-web-gpt-${version}-linux-x64.AppImage`;
+ }
+ return null;
+}
+
+function expectedChecksum(contents, assetName) {
+ for (const line of String(contents || "").split(/\r?\n/)) {
+ const match = /^([a-fA-F0-9]{64})\s+(.+)$/.exec(line.trim());
+ if (match && match[2] === assetName) return match[1].toLowerCase();
+ }
+ throw new Error(`checksums.txt has no entry for ${assetName}`);
+}
+
+function validateReleaseAssetUrl(raw, version, assetName) {
+ const url = new URL(raw);
+ const expectedPath = `/${REPOSITORY}/releases/download/v${version}/${assetName}`;
+ if (url.protocol !== "https:" || url.hostname !== "github.com" || url.pathname !== expectedPath) {
+ throw new Error(`GitHub returned an unexpected release asset URL for ${assetName}`);
+ }
+ return url.toString();
+}
+
+function request(url, redirects = 0) {
+ return new Promise((resolve, reject) => {
+ if (redirects > MAX_REDIRECTS) {
+ reject(new Error(`Too many redirects while downloading ${url}`));
+ return;
+ }
+ const parsed = new URL(url);
+ if (parsed.protocol !== "https:") {
+ reject(new Error(`Refusing non-HTTPS update URL: ${parsed.protocol}`));
+ return;
+ }
+ const req = https.get(parsed, {
+ headers: {
+ Accept: "application/vnd.github+json",
+ "User-Agent": USER_AGENT,
+ },
+ }, (response) => {
+ if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) {
+ response.resume();
+ const next = new URL(response.headers.location, parsed).toString();
+ request(next, redirects + 1).then(resolve, reject);
+ return;
+ }
+ if (response.statusCode !== 200) {
+ response.resume();
+ reject(new Error(`Update download failed with HTTP ${response.statusCode}`));
+ return;
+ }
+ resolve(response);
+ });
+ req.setTimeout(60_000, () => req.destroy(new Error("Update request timed out")));
+ req.once("error", reject);
+ });
+}
+
+async function downloadText(url, maxBytes = 2 * 1024 * 1024) {
+ const response = await request(url);
+ const chunks = [];
+ let bytes = 0;
+ for await (const chunk of response) {
+ bytes += chunk.length;
+ if (bytes > maxBytes) throw new Error("Update metadata exceeded its size limit");
+ chunks.push(chunk);
+ }
+ return Buffer.concat(chunks).toString("utf8");
+}
+
+async function downloadFile(url, destination) {
+ const response = await request(url);
+ await pipeline(response, fs.createWriteStream(destination, { flags: "wx", mode: 0o600 }));
+}
+
+function sha256(filePath) {
+ const hash = crypto.createHash("sha256");
+ const fd = fs.openSync(filePath, "r");
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
+ try {
+ for (;;) {
+ const count = fs.readSync(fd, buffer, 0, buffer.length, null);
+ if (count === 0) break;
+ hash.update(buffer.subarray(0, count));
+ }
+ } finally {
+ fs.closeSync(fd);
+ }
+ return hash.digest("hex");
+}
+
+function macApplicationPath(executablePath) {
+ const match = /^(.*\.app)[\\/]Contents[\\/]MacOS[\\/][^\\/]+$/.exec(executablePath);
+ if (!match?.[1]) throw new Error(`Could not resolve the macOS application bundle from ${executablePath}`);
+ return match[1];
+}
+
+function findMacApplication(root) {
+ const entries = fs.readdirSync(root, { withFileTypes: true });
+ const appEntry = entries.find((entry) => entry.isDirectory() && entry.name.endsWith(".app"));
+ if (!appEntry) throw new Error("The macOS update archive does not contain an application bundle");
+ const application = path.join(root, appEntry.name);
+ const executable = path.join(application, "Contents", "MacOS", "Codex Web GPT");
+ if (!fs.existsSync(executable) || !fs.statSync(executable).isFile()) {
+ throw new Error("The macOS update archive is incomplete");
+ }
+ return application;
+}
+
+function buildJob({ version, platform, executablePath, assetPath, stagingRoot, tempRoot, logPath }) {
+ if (platform === "darwin") {
+ return {
+ version,
+ platform,
+ parentPid: process.pid,
+ tempRoot,
+ logPath,
+ source: findMacApplication(stagingRoot),
+ target: macApplicationPath(executablePath),
+ };
+ }
+ if (platform === "win32") {
+ return {
+ version,
+ platform,
+ parentPid: process.pid,
+ tempRoot,
+ logPath,
+ source: assetPath,
+ target: executablePath,
+ };
+ }
+ if (platform === "linux") {
+ const target = process.env.CODEX_WEB_GPT_APPIMAGE?.trim()
+ || process.env.APPIMAGE?.trim();
+ if (!target || !path.isAbsolute(target)) {
+ throw new Error("The running Linux AppImage path is unavailable; reinstall with install-launcher.sh");
+ }
+ return {
+ version,
+ platform,
+ parentPid: process.pid,
+ tempRoot,
+ logPath,
+ source: assetPath,
+ target,
+ wrapper: process.env.CODEX_WEB_GPT_LAUNCHER_EXECUTABLE?.trim() || null,
+ };
+ }
+ throw new Error(`Updates are not supported on ${platform}`);
+}
+
+function defaultDependencies() {
+ return {
+ fetchRelease: async () => JSON.parse(await downloadText(RELEASE_API_URL)),
+ downloadText,
+ downloadFile,
+ sha256,
+ extractMac(archive, destination) {
+ fs.mkdirSync(destination, { recursive: true, mode: 0o700 });
+ const result = spawnSync("/usr/bin/ditto", ["-x", "-k", archive, destination], {
+ encoding: "utf8",
+ timeout: 120_000,
+ });
+ if (result.error) throw result.error;
+ if (result.status !== 0) throw new Error(`Could not extract the macOS update: ${result.stderr.trim()}`);
+ },
+ spawnWorker(runtimeExecutable, workerPath, jobPath) {
+ return spawn(runtimeExecutable, [workerPath, jobPath], {
+ detached: true,
+ stdio: "ignore",
+ windowsHide: true,
+ });
+ },
+ };
+}
+
+function createUpdateController({
+ currentVersion,
+ platform,
+ arch,
+ packaged,
+ executablePath,
+ runtimeExecutable,
+ logsDirectory,
+ publish,
+ logger,
+ dependencies = {},
+}) {
+ const deps = { ...defaultDependencies(), ...dependencies };
+ const supportedAsset = releaseAssetName(currentVersion, platform, arch);
+ let state = packaged && supportedAsset ? { status: "idle" } : { status: "disabled" };
+ let checked = false;
+ let pending = null;
+ let candidate = null;
+
+ const transition = (next) => {
+ state = next;
+ publish?.(state);
+ return state;
+ };
+
+ async function checkOnce() {
+ if (state.status === "disabled" || checked) return state;
+ checked = true;
+ transition({ status: "checking" });
+ try {
+ const release = await deps.fetchRelease();
+ const version = releaseVersion(release?.tag_name);
+ if (compareVersions(version, currentVersion) <= 0) {
+ candidate = null;
+ return transition({ status: "up-to-date" });
+ }
+ const assetName = releaseAssetName(version, platform, arch);
+ if (!assetName) return transition({ status: "disabled" });
+ const assets = Array.isArray(release?.assets) ? release.assets : [];
+ const asset = assets.find((item) => item?.name === assetName);
+ const checksums = assets.find((item) => item?.name === "checksums.txt");
+ if (!asset?.browser_download_url || !checksums?.browser_download_url) {
+ throw new Error(`Release v${version} is missing ${assetName} or checksums.txt`);
+ }
+ candidate = {
+ version,
+ assetName,
+ assetUrl: validateReleaseAssetUrl(asset.browser_download_url, version, assetName),
+ checksumsUrl: validateReleaseAssetUrl(checksums.browser_download_url, version, "checksums.txt"),
+ };
+ logger?.info("launcher.update_available", { currentVersion, version, platform, arch });
+ return transition({ status: "available", version });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ logger?.warn("launcher.update_check_failed", { message });
+ return transition({ status: "error", message });
+ }
+ }
+
+ async function beginInstall() {
+ if (pending) throw new Error("An update is already being prepared");
+ if (state.status !== "available" || !candidate) throw new Error("No launcher update is available");
+ const available = candidate;
+ pending = (async () => {
+ transition({ status: "downloading", version: available.version });
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-web-gpt-update-"));
+ try {
+ const checksums = await deps.downloadText(available.checksumsUrl);
+ const expected = expectedChecksum(checksums, available.assetName);
+ const assetPath = path.join(tempRoot, available.assetName);
+ await deps.downloadFile(available.assetUrl, assetPath);
+ const actual = deps.sha256(assetPath);
+ if (actual !== expected) throw new Error(`SHA-256 verification failed for ${available.assetName}`);
+
+ const stagingRoot = path.join(tempRoot, "stage");
+ if (platform === "darwin") deps.extractMac(assetPath, stagingRoot);
+ if (platform === "linux") fs.chmodSync(assetPath, 0o755);
+
+ const workerPath = path.join(tempRoot, "update-worker.cjs");
+ fs.copyFileSync(path.join(__dirname, "update-worker.cjs"), workerPath);
+ const job = buildJob({
+ version: available.version,
+ platform,
+ executablePath,
+ assetPath,
+ stagingRoot,
+ tempRoot,
+ logPath: path.join(logsDirectory, "update-worker.log"),
+ });
+ const jobPath = path.join(tempRoot, "job.json");
+ fs.writeFileSync(jobPath, `${JSON.stringify(job)}\n`, { mode: 0o600 });
+ const child = deps.spawnWorker(runtimeExecutable, workerPath, jobPath);
+ if (!Number.isInteger(child?.pid) || child.pid <= 0) throw new Error("The update worker did not start");
+ child.unref?.();
+ logger?.info("launcher.update_worker_started", { pid: child.pid, version: available.version });
+ transition({ status: "installing", version: available.version });
+ return { child, tempRoot, version: available.version };
+ } catch (error) {
+ fs.rmSync(tempRoot, { recursive: true, force: true });
+ transition({ status: "available", version: available.version });
+ throw error;
+ }
+ })();
+ try {
+ return await pending;
+ } finally {
+ pending = null;
+ }
+ }
+
+ function cancelInstall(launch) {
+ try { launch?.child?.kill(); } catch {}
+ if (launch?.tempRoot) fs.rmSync(launch.tempRoot, { recursive: true, force: true });
+ if (candidate) transition({ status: "available", version: candidate.version });
+ }
+
+ return {
+ getState: () => state,
+ checkOnce,
+ beginInstall,
+ cancelInstall,
+ };
+}
+
+module.exports = {
+ buildJob,
+ compareVersions,
+ createUpdateController,
+ expectedChecksum,
+ macApplicationPath,
+ parseVersion,
+ releaseAssetName,
+ releaseVersion,
+ validateReleaseAssetUrl,
+};
diff --git a/launcher/package.json b/launcher/package.json
index a938cc97a..fe2405287 100644
--- a/launcher/package.json
+++ b/launcher/package.json
@@ -1,6 +1,6 @@
{
"name": "codex-web-gpt-launcher",
- "version": "1.0.1-ko.4",
+ "version": "2.0.0-aicc.1",
"private": true,
"description": "Desktop control center for Codex ChatGPT Web",
"author": "miuuyy; Korean localization by AgenticLab-SH",
diff --git a/launcher/scripts/smoke-package.cjs b/launcher/scripts/smoke-package.cjs
index 7bea273ad..31fff3e25 100644
--- a/launcher/scripts/smoke-package.cjs
+++ b/launcher/scripts/smoke-package.cjs
@@ -105,7 +105,8 @@ try {
);
if (installedManifest.appVersion !== expectedVersion
|| installedManifest.platform !== process.platform
- || installedManifest.arch !== process.arch) {
+ || installedManifest.arch !== process.arch
+ || !/^[a-f0-9]{64}$/.test(installedManifest.bundleId)) {
throw new Error(`Packaged launcher installed the wrong durable runtime: ${JSON.stringify(installedManifest)}`);
}
process.stdout.write(`PACKAGED_LAUNCHER_SMOKE_OK ${process.platform}/${process.arch}\n`);
diff --git a/launcher/src/App.tsx b/launcher/src/App.tsx
index cbabb27bf..766b040a1 100644
--- a/launcher/src/App.tsx
+++ b/launcher/src/App.tsx
@@ -64,12 +64,16 @@ export function App() {
if (next.status === "failed") setError(next.message);
});
const unsubscribeLog = api.onLog((record) => setLogs((current) => [...current.slice(-299), record]));
+ const unsubscribeUpdate = api.onUpdateState((update) => {
+ setSnapshot((current) => current ? { ...current, update } : current);
+ });
return () => {
cancelled = true;
unsubscribeState();
unsubscribeBrowser();
unsubscribeOperation();
unsubscribeLog();
+ unsubscribeUpdate();
};
}, []);
@@ -302,12 +306,17 @@ function LauncherShell({
const [sidebarOpen, setSidebarOpen] = useState(!compactAtMount);
const [compactSidebar, setCompactSidebar] = useState(compactAtMount);
const [browserSlot, setBrowserSlot] = useState(null);
+ const [sessionReminderBusy, setSessionReminderBusy] = useState(false);
+ const [sessionReminderDue, setSessionReminderDue] = useState(false);
const browserSlotRef = useCallback((node: HTMLDivElement | null) => setBrowserSlot(node), []);
const browserSurfaceActive = surface === "browser" && !(compactSidebar && sidebarOpen);
const needsBrowser = browser?.authenticated !== true;
const needsSetup = !needsBrowser
&& (snapshot.state.coreSetupComplete !== true || snapshot.state.codexCatalogVerified !== true);
const mcpOptional = snapshot.state.codexCatalogVerified === true && snapshot.state.mcpSetupComplete !== true;
+ const updateVisible = ["available", "downloading", "installing"].includes(snapshot.update.status);
+ const updateBusy = snapshot.update.status === "downloading" || snapshot.update.status === "installing";
+ const updateVersion = "version" in snapshot.update ? snapshot.update.version : null;
useLayoutEffect(() => {
let cancelled = false;
@@ -355,6 +364,23 @@ function LauncherShell({
return () => media.removeEventListener("change", apply);
}, []);
+ useEffect(() => {
+ const reminderAt = snapshot.state.sessionRefreshReminderAt;
+ const reminderTime = reminderAt === null ? Number.NaN : Date.parse(reminderAt);
+ if (browser?.authenticated !== true || !Number.isFinite(reminderTime)) {
+ setSessionReminderDue(false);
+ return;
+ }
+ const delay = reminderTime - Date.now();
+ if (delay <= 0) {
+ setSessionReminderDue(true);
+ return;
+ }
+ setSessionReminderDue(false);
+ const timer = window.setTimeout(() => setSessionReminderDue(true), delay);
+ return () => window.clearTimeout(timer);
+ }, [browser?.authenticated, snapshot.state.sessionRefreshReminderAt]);
+
const activateBrowser = useCallback(async (show = false) => {
setSurface("browser");
await api!.setBrowserSurfaceActive(true);
@@ -377,6 +403,44 @@ function LauncherShell({
if (compactSidebar) setSidebarOpen(false);
};
+ const installUpdate = async () => {
+ setError(null);
+ try {
+ await api!.installUpdate();
+ } catch (cause) {
+ setError(messageOf(cause));
+ }
+ };
+
+ const dismissSessionReminder = async () => {
+ if (sessionReminderBusy) return;
+ setSessionReminderBusy(true);
+ setError(null);
+ try {
+ updateState(await api!.dismissSessionReminder());
+ } catch (cause) {
+ setError(messageOf(cause));
+ } finally {
+ setSessionReminderBusy(false);
+ }
+ };
+
+ const logoutChatGpt = async () => {
+ if (sessionReminderBusy) return;
+ setSessionReminderBusy(true);
+ setError(null);
+ try {
+ const result = await api!.logoutChatGpt();
+ updateState(result.state);
+ navigateSurface("browser");
+ await api!.setBrowserSurfaceActive(true);
+ } catch (cause) {
+ setError(messageOf(cause));
+ } finally {
+ setSessionReminderBusy(false);
+ }
+ };
+
return (