diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 60dc3937e..7ef4210f2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -39,6 +39,7 @@ jobs:
bash -n scripts/build-rpm.sh
bash -n scripts/build-pacman.sh
bash -n scripts/build-appimage.sh
+ bash -n scripts/ci/download-upstream-dmg.sh
bash -n scripts/ci/update-nix-hashes.sh
bash -n scripts/ci/validate-nix-pins.sh
@@ -143,6 +144,40 @@ jobs:
echo "- Verified generic feature resource, 0640 mode, and runtime dependency."
} >> "$GITHUB_STEP_SUMMARY"
+ - name: Create Codex Micro packaged app fixture
+ env:
+ CODEX_FIXTURE_LINUX_FEATURES_JSON: '["codex-micro"]'
+ run: |
+ set -euo pipefail
+ rm -rf codex-app dist
+ tests/fixtures/create-packaged-app-fixture.sh codex-app
+ printf '%s\n' '{"enabled":["codex-micro"]}' > /tmp/codex-micro-features.json
+
+ - name: Build Codex Micro Debian package
+ env:
+ CODEX_LINUX_FEATURES_CONFIG: /tmp/codex-micro-features.json
+ run: PACKAGE_VERSION="$CI_PACKAGE_VERSION" ./scripts/build-deb.sh
+
+ - name: Inspect Codex Micro Debian package
+ run: |
+ set -euo pipefail
+ deb_file="$(find dist -maxdepth 1 -name 'codex-desktop_*.deb' -print -quit)"
+ test -n "$deb_file"
+ dpkg-deb -c "$deb_file" | tee /tmp/deb-micro-contents.txt >/dev/null
+ dpkg-deb -f "$deb_file" Depends | tee /tmp/deb-micro-depends.txt >/dev/null
+ grep -q './usr/lib/udev/rules.d/70-codex-micro.rules' /tmp/deb-micro-contents.txt
+ grep -q 'libudev1' /tmp/deb-micro-depends.txt
+ grep -q 'libusb-1.0-0' /tmp/deb-micro-depends.txt
+ rule_mode="$(
+ awk '$NF == "./usr/lib/udev/rules.d/70-codex-micro.rules" { print $1 }' \
+ /tmp/deb-micro-contents.txt
+ )"
+ test "$rule_mode" = '-rw-r--r--'
+ {
+ echo "- Codex Micro build: \`$(basename "$deb_file")\`"
+ echo "- Verified udev rule, 0644 mode, and libudev/libusb dependencies."
+ } >> "$GITHUB_STEP_SUMMARY"
+
nix:
name: Nix Package Builds
runs-on: ubuntu-latest
@@ -257,6 +292,7 @@ jobs:
.#codex-desktop-computer-use-ui
.#codex-desktop-remote-mobile-control
.#codex-desktop-computer-use-ui-remote-mobile-control
+ .#checks.x86_64-linux.nix-pipewire-alsa-wrapper
.#checks.x86_64-linux.nix-gsettings-schema-wrapper
.#checks.x86_64-linux.watchdog-linux-features
)
@@ -363,6 +399,41 @@ jobs:
echo "- Verified generic feature resource, 0640 mode, and runtime dependency."
} >> "$GITHUB_STEP_SUMMARY"
+ - name: Create Codex Micro packaged app fixture
+ env:
+ CODEX_FIXTURE_LINUX_FEATURES_JSON: '["codex-micro"]'
+ run: |
+ set -euo pipefail
+ rm -rf codex-app dist
+ tests/fixtures/create-packaged-app-fixture.sh codex-app
+ printf '%s\n' '{"enabled":["codex-micro"]}' > /tmp/codex-micro-features.json
+
+ - name: Build Codex Micro RPM package
+ env:
+ CODEX_LINUX_FEATURES_CONFIG: /tmp/codex-micro-features.json
+ run: PACKAGE_VERSION="$CI_PACKAGE_VERSION" ./scripts/build-rpm.sh
+
+ - name: Inspect Codex Micro RPM package
+ run: |
+ set -euo pipefail
+ rpm_file="$(find dist -maxdepth 1 -name 'codex-desktop-*.rpm' -print -quit)"
+ test -n "$rpm_file"
+ rpm -qlp "$rpm_file" | tee /tmp/rpm-micro-contents.txt >/dev/null
+ rpm -qlvp "$rpm_file" | tee /tmp/rpm-micro-long-contents.txt >/dev/null
+ rpm -qp --requires "$rpm_file" | tee /tmp/rpm-micro-requires.txt >/dev/null
+ grep -q '/usr/lib/udev/rules.d/70-codex-micro.rules' /tmp/rpm-micro-contents.txt
+ grep -q '^libudev\.so\.1' /tmp/rpm-micro-requires.txt
+ grep -q '^libusb-1\.0\.so\.0' /tmp/rpm-micro-requires.txt
+ rule_mode="$(
+ awk '$NF == "/usr/lib/udev/rules.d/70-codex-micro.rules" { print $1 }' \
+ /tmp/rpm-micro-long-contents.txt
+ )"
+ test "$rule_mode" = '-rw-r--r--'
+ {
+ echo "- Codex Micro build: \`$(basename "$rpm_file")\`"
+ echo "- Verified udev rule, 0644 mode, and libudev/libusb dependencies."
+ } >> "$GITHUB_STEP_SUMMARY"
+
package-pacman:
name: Build Pacman Package
runs-on: ubuntu-latest
@@ -426,9 +497,34 @@ jobs:
fixture_mode="$(sed -n "1s/ .*//p" /tmp/pacman-feature-long-contents.txt)"
test "$fixture_mode" = "-rw-r-----"
printf "%s\n" "$(basename "$feature_pkg_file")" > /tmp/pacman-feature-package-name.txt
+
+ rm -rf codex-app dist
+ CODEX_FIXTURE_LINUX_FEATURES_JSON="[\"codex-micro\"]" \
+ tests/fixtures/create-packaged-app-fixture.sh codex-app
+ printf "%s\n" "{\"enabled\":[\"codex-micro\"]}" \
+ > /tmp/codex-micro-features.json
+
+ CODEX_LINUX_FEATURES_CONFIG=/tmp/codex-micro-features.json \
+ PACKAGE_VERSION="$CI_PACKAGE_VERSION" \
+ ./scripts/build-pacman.sh
+
+ micro_pkg_file="$(find dist -maxdepth 1 -name "codex-desktop-*.pkg.tar.*" -print -quit)"
+ test -n "$micro_pkg_file"
+ pacman -Qlp "$micro_pkg_file" | tee /tmp/pacman-micro-contents.txt >/dev/null
+ tar -xOf "$micro_pkg_file" .PKGINFO | tee /tmp/pacman-micro-pkginfo.txt >/dev/null
+ tar -tvf "$micro_pkg_file" \
+ usr/lib/udev/rules.d/70-codex-micro.rules \
+ | tee /tmp/pacman-micro-long-contents.txt >/dev/null
+ grep -q "usr/lib/udev/rules.d/70-codex-micro.rules" /tmp/pacman-micro-contents.txt
+ grep -q "^depend = libusb$" /tmp/pacman-micro-pkginfo.txt
+ grep -q "^depend = systemd-libs$" /tmp/pacman-micro-pkginfo.txt
+ micro_rule_mode="$(sed -n "1s/ .*//p" /tmp/pacman-micro-long-contents.txt)"
+ test "$micro_rule_mode" = "-rw-r--r--"
+ printf "%s\n" "$(basename "$micro_pkg_file")" > /tmp/pacman-micro-package-name.txt
'"'"'
cp /tmp/pacman-package-name.txt /work/.pacman-package-name.txt
cp /tmp/pacman-feature-package-name.txt /work/.pacman-feature-package-name.txt
+ cp /tmp/pacman-micro-package-name.txt /work/.pacman-micro-package-name.txt
'
- name: Write pacman validation summary
@@ -436,6 +532,7 @@ jobs:
set -euo pipefail
pkg_file="$(cat .pacman-package-name.txt)"
feature_pkg_file="$(cat .pacman-feature-package-name.txt)"
+ micro_pkg_file="$(cat .pacman-micro-package-name.txt)"
{
echo "## Pacman Package Validation"
echo ""
@@ -443,4 +540,6 @@ jobs:
echo "- Verified updater binary, user service, update-builder bundle, and packaged runtime helper."
echo "- Feature-enabled build: \`$feature_pkg_file\`"
echo "- Verified generic feature resource, 0640 mode, and runtime dependency."
+ echo "- Codex Micro build: \`$micro_pkg_file\`"
+ echo "- Verified udev rule, 0644 mode, and systemd-libs/libusb dependencies."
} >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/upstream-build-app.yml b/.github/workflows/upstream-build-app.yml
index 013b800b7..beaeef693 100644
--- a/.github/workflows/upstream-build-app.yml
+++ b/.github/workflows/upstream-build-app.yml
@@ -10,12 +10,16 @@ on:
- scripts/patch-linux-window-ui.js
- scripts/patch-linux-window-ui.test.js
- scripts/patches/**
+ - scripts/ci/download-upstream-dmg.sh
- scripts/ci/validate-patch-report.js
- scripts/ci/upstream-dmg-*.js
- scripts/validate-upstream-dmg.js
- scripts/lib/candidate-install.sh
- scripts/lib/bundled-plugins.sh
- scripts/lib/browser-client-node-repl-runtime.test.js
+ - scripts/lib/build-info.js
+ - scripts/lib/build-info.sh
+ - scripts/lib/build-info.test.js
- scripts/lib/patch-browser-client-iab-socket-scope.js
- scripts/lib/patch-validation.js
- scripts/lib/upstream-dmg-acceptance.js
@@ -33,12 +37,16 @@ on:
- scripts/patch-linux-window-ui.js
- scripts/patch-linux-window-ui.test.js
- scripts/patches/**
+ - scripts/ci/download-upstream-dmg.sh
- scripts/ci/validate-patch-report.js
- scripts/ci/upstream-dmg-*.js
- scripts/validate-upstream-dmg.js
- scripts/lib/candidate-install.sh
- scripts/lib/bundled-plugins.sh
- scripts/lib/browser-client-node-repl-runtime.test.js
+ - scripts/lib/build-info.js
+ - scripts/lib/build-info.sh
+ - scripts/lib/build-info.test.js
- scripts/lib/patch-browser-client-iab-socket-scope.js
- scripts/lib/patch-validation.js
- scripts/lib/upstream-dmg-acceptance.js
@@ -124,11 +132,12 @@ jobs:
key: upstream-dmg-${{ env.DMG_CACHE_SCHEMA_VERSION }}-${{ steps.upstream-metadata.outputs.cache_segment }}
- name: Download upstream DMG
- if: steps.dmg-cache.outputs.cache-hit != 'true'
run: |
set -euo pipefail
- mkdir -p "$(dirname "$UPSTREAM_DMG_PATH")"
- curl -fL --retry 3 -o "$UPSTREAM_DMG_PATH" "$UPSTREAM_DMG_URL"
+ scripts/ci/download-upstream-dmg.sh \
+ "$UPSTREAM_DMG_URL" \
+ "$UPSTREAM_DMG_PATH" \
+ --reuse-existing
- name: Record local DMG fingerprint
id: local-dmg
@@ -277,5 +286,6 @@ jobs:
repo: context.repo,
decision,
currentHttpIdentityKey: httpIdentity(currentMetadata)?.key ?? null,
+ scanAll: true,
});
core.info(`Upstream DMG issue reconciliation: ${JSON.stringify(result)}`);
diff --git a/AGENTS.md b/AGENTS.md
index 6678a77fb..ee9385ef1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -110,11 +110,14 @@ Repository governance: [issue and pull request labels](docs/label-governance.md)
- Core patch descriptors are the source of truth for shipped Linux
compatibility patches. Read `scripts/patches/core/README.md` before adding
or moving descriptors.
-- ASAR patches are fail-soft unless intentionally marked `required-upstream`.
- Each patch should be idempotent and report warnings when current upstream
- drift prevents a needle from matching.
-- Patch reports are written for installs/rebuilds. Upstream-build CI fails only
- for required upstream patches that are missing or skipped.
+- ASAR patches are fail-soft unless intentionally marked `required-upstream`,
+ or unless a transactional mutation reports `failed-integrity` because it
+ cannot prove rollback restored the original bytes. Each patch should be
+ idempotent and report warnings when current upstream drift prevents a needle
+ from matching.
+- Patch reports are written for installs/rebuilds. Upstream-build CI fails for
+ required upstream patches that are missing or skipped and for every
+ `failed-integrity` status.
- Do not recreate deleted compatibility barrels such as
`scripts/patches/main-process.js`, `webview-assets.js`, or `shared.js`.
- Feature patching uses only `entrypoints.patchDescriptors`. Removed feature
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1a98197cc..035a4cbdb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
- Linux launcher accepts `--profile NAME` / `--profile=NAME` / `-p NAME` and
applies the named Codex CLI profile to runtime commands and the local
app-server without changing the Desktop sign-in used for remote control.
+- A disabled-by-default `deferred-update-build` Linux feature adds a **Build
+ updates automatically** setting. Turning it off keeps notification and DMG
+ verification active while deferring local package builds until an explicit
+ **Check for updates**.
+- The embedded Computer Use backend is synchronized to standalone v0.4.6 as
+ `0.4.6-linux-alpha1`, including generic X11/EWMH window control, X11
+ `xdotool` keyboard, text, and coordinate-click input, KDE portal scroll
+ polarity, and portal key chords, with generic X11 registered last.
- A shared upstream DMG acceptance profile now produces the same structured
decision for local installs, updater rebuilds, and scheduled CI. Scheduled
rejections create one fingerprinted drift issue and supersede issues for
@@ -42,6 +50,63 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
### Fixed
+- Deferred upstream DMGs are revalidated before a build. A newer candidate
+ supersedes the pending download, and a deleted cached DMG is redownloaded in
+ the same explicit check. Fresh app-launch checks preserve the stable deferred
+ candidate without an upstream DMG request; stale checks use HEAD and reuse a valid
+ unchanged cached DMG, while offline checks leave it pending. The optional
+ state marker retains the existing `update_detected` status so updater 0.10.x
+ can read the state and resume its previous automatic-build behavior. State
+ written by prerelease builds using `update_available` is migrated back to
+ `update_detected` on read.
+- Native X11 coordinate clicks now use one supervised xdotool XTEST command,
+ fall back to ydotool only when xdotool cannot launch, and preserve nested X11
+ session identity instead of importing a host Wayland display.
+- Wrapper update checks no longer offer rebuilds when every change since the
+ installed commit is limited to repository documentation or metadata.
+- The updater feature picker now changes only the enabled feature list, preserving
+ nested feature settings and other local configuration keys across rebuilds.
+- The opt-in Dock icon tweak now targets the current upstream main-process
+ bundle, restoring Linux window, tray, and desktop icon synchronization.
+- The opt-in shallow repository watcher now patches both current app bundles
+ and routes the Linux Parcel working-tree path through the same shallow host,
+ restoring bounded watches on the latest upstream DMG.
+- The opt-in directory-only working-tree watcher now routes the current Linux
+ Parcel working-tree path through its existing bounded directory watcher,
+ restoring the feature on the latest upstream DMG, with byte-verified rollback
+ for its paired bundle writes.
+- Computer Use now supports Plasma 5 and 6 KWin scripting, validates every
+ ydotool 1.0.3+ command shape it emits, and rejects semantically incompatible
+ CLIs even when a daemon socket exists. Hyprland dispatch validation handles
+ exit-zero errors, modifier chords use the v0.4.3 delay, and an xdotool command
+ that starts but fails is never replayed through ydotool.
+- Open Target Discovery now resolves the selected Linux editor or terminal
+ through the current private open-target command path. Command-path drift is
+ reported before the feature changes the main bundle, so enabled-feature
+ acceptance cannot mistake a partially patched bundle for success.
+- Repeated current-DMG patch passes now keep composed native and frameless
+ titlebars, external-open handling, Record & Replay, and Browser Use runtime
+ resolution byte-identical. Complete markers no longer depend on
+ function-local minified aliases, while partial markers remain fail-soft and
+ leave drifted assets untouched.
+- Remote mobile control now patches the current 26.721 dual-gate enablement
+ bridge instead of reporting it as already applied. Startup auto-connects the
+ environment owned by this Desktop without overwriting saved choices for
+ other enrolled hosts.
+- Updater-managed npm Codex CLI installs now serialize across daemon, launcher,
+ and status processes. If npm reports the exact stale Arborist retirement
+ directory failure, automatic paths preserve the working CLI and direct the
+ user to read-only diagnostics. The explicit `repair-cli` command revalidates
+ the condition under the shared lock, records crash-durable quarantines, and
+ retries npm once per explicit invocation without discarding failed recovery
+ state or concurrent updater state. A parent-independent bounded supervisor
+ retains the lock while mutating npm children run without inheriting it,
+ terminates their complete process group, and releases the lock only after
+ cleanup if the updater parent or supervisor exits abruptly or the npm leader
+ leaves a background descendant.
+ Late routine CLI checks revalidate both the repair journal and their original
+ CLI state before persisting a result. Missing-CLI preflight also re-resolves a
+ CLI installed while it waited for the lock before consulting npm.
- Concurrent updater entrypoints now serialize state reloads and cache cleanup
before persisting startup state. A second process can no longer prune an
active rebuild workspace, while forced checks wait for startup maintenance
diff --git a/Cargo.lock b/Cargo.lock
index 3690c9236..572129c0a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -490,7 +490,7 @@ dependencies = [
[[package]]
name = "codex-computer-use-linux"
-version = "0.3.1-linux-alpha1"
+version = "0.4.6-linux-alpha1"
dependencies = [
"anyhow",
"atspi",
@@ -512,6 +512,7 @@ dependencies = [
"tokio-tungstenite",
"wayland-client",
"wayland-protocols",
+ "wayland-protocols-wlr",
"xkeysym",
"zbus",
]
@@ -560,7 +561,7 @@ dependencies = [
[[package]]
name = "codex-update-manager"
-version = "0.10.3"
+version = "0.11.1"
dependencies = [
"anyhow",
"chrono",
@@ -900,11 +901,10 @@ dependencies = [
[[package]]
name = "event-listener"
-version = "5.4.1"
+version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
- "concurrent-queue",
"parking",
"pin-project-lite",
]
@@ -2294,9 +2294,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
-version = "0.103.10"
+version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"aws-lc-rs",
"ring",
diff --git a/README.md b/README.md
index c7563c8ee..fa665dea5 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,10 @@
+
+ English | 简体中文
+
+
Unofficial Linux build wrapper for [OpenAI ChatGPT Desktop](https://chatgpt.com/features/desktop/).
The official ChatGPT app is available for macOS and Windows; this repository
covers Linux by converting the upstream macOS `Codex.dmg` into a runnable Linux
@@ -47,6 +51,7 @@ cd codex-desktop-linux
| Platform | Recommended path | Notes |
|---|---|---|
| Debian, Ubuntu, Pop!_OS, Mint, Elementary | `make bootstrap-native` | Builds and installs a `.deb` |
+| Raspberry Pi 5 (64-bit) | `make bootstrap-native` | Validated on a 16 GB Pi 5; see [Raspberry Pi 5](docs/raspberry-pi-5.md) |
| Fedora | `make bootstrap-native` | Builds and installs an `.rpm` |
| openSUSE | `make bootstrap-native` | Builds and installs an `.rpm` |
| Arch, Manjaro, EndeavourOS | `make bootstrap-native` | Builds and installs a pacman package |
@@ -198,7 +203,7 @@ workarounds.
| Browser annotations | Always | Built into the patched webview | [Architecture](docs/architecture.md) |
| Tray and warm-start handoff | Always | Normal app launch | [Architecture](docs/architecture.md) |
| Multiple app instances | Opt-in | `./codex-app/start.sh --new-instance` | [Build and packaging](docs/build-and-packaging.md#running-the-generated-app) |
-| Linux Computer Use backend | Bundled | MCP backend registers by default | [Linux Computer Use](docs/linux-computer-use.md) |
+| Linux Computer Use backend | Bundled | MCP backend registers by default, including compositor-native and generic X11/EWMH window control | [Linux Computer Use](docs/linux-computer-use.md) |
| Linux Computer Use UI | Opt-in | `CODEX_LINUX_ENABLE_COMPUTER_USE_UI=1` or settings flag | [Linux Computer Use](docs/linux-computer-use.md#enable-the-in-app-ui) |
| Linux Features framework | Opt-in | Edit `linux-features/features.json` | [Linux Features](linux-features/README.md) |
@@ -213,6 +218,7 @@ workarounds.
| Linux AppShots | Opt-in | `appshots` | [Docs](linux-features/appshots/README.md) |
| Authenticated proxy | Opt-in | `authenticated-proxy` | [Docs](linux-features/authenticated-proxy/README.md) |
| Wrapper updater button | Opt-in | `codex-wrapper-updater` | [Docs](linux-features/codex-wrapper-updater/README.md) |
+| Codex Micro (USB-C / Bluetooth) | Opt-in | `codex-micro` | [Docs](linux-features/codex-micro/README.md) |
| Conversation mode | Opt-in | `conversation-mode` | [Docs](linux-features/conversation-mode/README.md) |
| Copilot reasoning effort defaults | Opt-in | `copilot-reasoning-effort` | [Docs](linux-features/copilot-reasoning-effort/README.md) |
| Directory-only working-tree watch | Opt-in | `directory-only-working-tree-watch` | [Docs](linux-features/directory-only-working-tree-watch/README.md) |
@@ -231,9 +237,10 @@ workarounds.
| Read Aloud MCP | Opt-in | `read-aloud-mcp` | [Docs](linux-features/read-aloud-mcp/README.md) |
| Remote Control UI gates | Opt-in | `remote-control-ui` | [Docs](linux-features/remote-control-ui/README.md) |
| Experimental Remote Mobile Control | Opt-in | `remote-mobile-control` | [Docs](linux-features/remote-mobile-control/README.md) |
+| SSH command wrapper | Opt-in | `ssh-command-wrapper` | [Docs](linux-features/ssh-command-wrapper/README.md) |
| Thorium Chrome Plugin Support | Opt-in | `thorium-chrome-plugin` | [Docs](linux-features/thorium-chrome-plugin/README.md) |
| UI tweaks | Opt-in | `ui-tweaks` | [Docs](linux-features/ui-tweaks/README.md) |
-| X11/EWMH Computer Use adapter | Opt-in | `x11-ewmh-computer-use` | [Docs](linux-features/x11-ewmh-computer-use/README.md) |
+| Alternative namespaced X11/EWMH Computer Use tools | Opt-in | `x11-ewmh-computer-use` | [Docs](linux-features/x11-ewmh-computer-use/README.md) |
ChatGPT-account model rollouts remain controlled by OpenAI per account.
Rebuilding this wrapper does not unlock them. API-key-authenticated custom
@@ -420,6 +427,7 @@ Full list: [Troubleshooting](docs/troubleshooting.md).
## Project Docs
- [Native setup](docs/native-setup.md)
+- [Raspberry Pi 5](docs/raspberry-pi-5.md)
- [Nix](docs/nix.md)
- [Linux Computer Use](docs/linux-computer-use.md)
- [Record and Replay on Linux](docs/record-and-replay-linux.md)
diff --git a/README.zh-CN.md b/README.zh-CN.md
new file mode 100644
index 000000000..ae9d66e8c
--- /dev/null
+++ b/README.zh-CN.md
@@ -0,0 +1,328 @@
+ ChatGPT Desktop for Linux
+
+
+
+
+
+
+
+
+ English | 简体中文
+
+
+这是 [OpenAI ChatGPT Desktop](https://chatgpt.com/features/desktop/) 的非官方 Linux 构建封装。官方 ChatGPT 应用提供 macOS 和 Windows 版本;本仓库通过将上游 macOS `Codex.dmg` 转换为可运行的 Linux Electron 应用,为 Linux 提供支持。
+
+本项目可构建原生 `.deb`、`.rpm` 和 `.pkg.tar.zst` 软件包,支持本地自行构建 AppImage 和 Nix,并可安装本地更新管理器,以便在新版上游 DMG 发布后重新构建 Linux 软件包。
+
+
+ 安装 ·
+ 卸载 ·
+ 功能 ·
+ 更新 ·
+ 构建 ·
+ 故障排除 ·
+ 文档 ·
+ Discord
+
+
+发起 Pull Request 前,请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。有关实现细节,请参阅 [AGENTS.md](AGENTS.md)。
+
+## 如何安装
+
+ChatGPT Desktop for Linux 基于上游 `Codex.dmg` 在本地构建:安装程序会下载或复用 DMG,提取 Electron 应用,应用 Linux 兼容性补丁,重新构建原生模块,准备 Linux 运行环境,并将其打包。可选的 Linux 专属集成功能位于 `linux-features/`,除非你在构建前启用,否则默认保持禁用。
+
+要构建原生软件包或 AppImage,请先克隆仓库:
+
+```bash
+git clone https://github.com/ilysenko/codex-desktop-linux.git
+cd codex-desktop-linux
+```
+
+| 支持平台 | 构建命令 | 说明 |
+|---|---|---|
+| Debian、Ubuntu、Pop!_OS、Mint、Elementary | `make bootstrap-native` | 构建并安装 `.deb` |
+| Raspberry Pi 5(64 位) | `make bootstrap-native` | 已在 16 GB Pi 5 上验证;参阅 [Raspberry Pi 5](docs/raspberry-pi-5.md) |
+| Fedora | `make bootstrap-native` | 构建并安装 `.rpm` |
+| openSUSE | `make bootstrap-native` | 构建并安装 `.rpm` |
+| Arch、Manjaro、EndeavourOS | `make bootstrap-native` | 构建并安装 pacman 软件包 |
+| NixOS / Nix | `nix run github:ilysenko/codex-desktop-linux` | 参阅 [Nix 文档](docs/nix.md) |
+| 不可变桌面 / 其他发行版 | `make build-app && make appimage` | 本地自行构建;不含内置更新器 |
+
+推荐的安装方式:
+
+```bash
+make bootstrap-native
+```
+
+如果依赖已安装可直接执行以下命令:
+
+```bash
+make install-native
+```
+
+`make bootstrap-native` 会安装构建依赖,验证缓存的上游 `Codex.dmg`,仅在文件缺失或过期时下载,构建 `codex-app/`,为你的发行版打包,并从 `dist/` 安装最新产物。
+
+如果你要在 Fedora 上手动安装依赖:
+
+```bash
+# Fedora 41+
+sudo dnf install python3 7zip curl unzip rpm-build make gcc-c++ @development-tools
+
+# Fedora < 41
+sudo dnf install python3 p7zip p7zip-plugins curl unzip rpm-build make gcc-c++
+sudo dnf groupinstall 'Development Tools'
+```
+
+如需引导式的首次运行清单和可选功能选择器:
+
+```bash
+make setup-native
+```
+
+有关向导、非交互式功能选择、清理流程和 `PACKAGE_WITH_UPDATER=0`,请参阅[原生安装](docs/native-setup.md)。
+
+## 卸载
+
+先关闭 ChatGPT Desktop,再使用对应发行版的包管理器卸载软件包:
+
+```bash
+# Debian / Ubuntu
+sudo apt remove codex-desktop
+
+# Fedora
+sudo dnf remove codex-desktop
+
+# openSUSE
+sudo zypper remove codex-desktop
+
+# Arch / Manjaro
+sudo pacman -R codex-desktop
+```
+
+软件包卸载时,如安装了 `codex-update-manager.service`,会自动停止并禁用它。若旧版软件包或手动安装遗留了该服务,请使用以下命令显式禁用:
+
+```bash
+systemctl --user disable --now codex-update-manager.service
+```
+
+AppImage 构建不会被本仓库安装到系统范围;请删除你创建的 AppImage 文件。仅在仓库中生成的应用可以在工作副本中通过以下命令删除:
+
+```bash
+rm -rf codex-app
+```
+
+`nix run github:ilysenko/codex-desktop-linux` 是临时运行方式。若你通过 Nix profile、Home Manager 或 NixOS 模块安装了 flake,请删除相应的 profile 或配置,并重新构建你的 profile / 系统。
+
+重新安装会保留用户数据。若只想移除此封装的本地应用状态、日志、启动器标志和更新器状态,请删除以下路径。
+
+如果启用了远程移动控制,`~/.config/codex-desktop` 可能包含私有目录 `remote-control-device-keys/`。删除它或整个 `codex-desktop` 目录前,请在 Codex 设置/连接或 ChatGPT 中撤销已配对设备。对于由功能拥有的数据,优先使用[原生安装](docs/native-setup.md#feature-cleanup)中的清理流程。
+
+```bash
+rm -rf \
+ ~/.config/codex-desktop \
+ ~/.local/state/codex-desktop \
+ ~/.cache/codex-desktop \
+ ~/.config/codex-update-manager \
+ ~/.local/state/codex-update-manager \
+ ~/.cache/codex-update-manager
+```
+
+除非你还希望删除 Codex CLI 配置和项目状态,否则不要移除 `~/.codex`。
+
+## 安装前须知
+
+生成的应用和原生软件包内置受管理的 Linux Node.js 运行环境。对于普通安装、Browser Use、Codex CLI 的安装/更新或本地自动更新重建,不需要发行版提供的 `nodejs` / `npm` 软件包。
+
+运行时仍需要 Codex CLI。首次启动可使用内置的 `npm` 安装或更新 `@openai/codex`,你也可以自行管理 CLI。若通过 npm 手动安装 CLI,请使用 `npm i -g --include=optional @openai/codex` 包含可选依赖,从而安装 Linux 平台二进制文件。启动器不会按版本选择已安装的 CLI;它会先使用显式的 `CODEX_CLI_PATH`,再按常规查找顺序搜索,并记录解析出的 CLI 路径和尽力获取的版本,便于发现 GUI 的 PATH 问题。希望固定特定二进制文件时,请设置 `CODEX_CLI_PATH=/path/to/codex`。
+
+本地 AppImage 构建可选择性内嵌该 CLI 及对应的 Linux 平台软件包。运行 `make appimage` 时,将 `CODEX_CLI_BUNDLE_SOURCE` 设置为已安装的 `node_modules/@openai/codex` 目录;显式的 `CODEX_CLI_PATH` 在运行时仍然优先。请参阅[构建与打包](docs/build-and-packaging.md#appimage-local-self-build)。
+
+支持 X11 和 Wayland 会话。启动器在 Wayland 上会优先使用 XWayland(若可用),以获得更好的 Electron 弹出窗口定位;否则回退至 Electron 的自动 Wayland 处理。GPU、Vulkan 和 `/tmp noexec` 的解决方法请参阅[故障排除](docs/troubleshooting.md)。
+
+## 功能矩阵
+
+### 核心与平台支持
+
+| 功能 | 默认状态 | 启用 / 使用方式 | 文档 |
+|---|---|---|---|
+| 标准 ChatGPT Desktop UI | 始终启用 | 安装或运行生成的应用 | 本 README |
+| 受管理的 Linux Node.js 运行环境 | 始终启用 | 构建/安装时内置 | [构建与打包](docs/build-and-packaging.md) |
+| 原生软件包 | 始终启用 | `make package && make install` | [构建与打包](docs/build-and-packaging.md) |
+| 自动更新管理器 | 原生软件包 | 除非 `PACKAGE_WITH_UPDATER=0`,否则随包提供 | [更新器](docs/updater.md) |
+| AppImage 自行构建 | 手动 | `make build-app && make appimage` | [构建与打包](docs/build-and-packaging.md#appimage-local-self-build) |
+| Nix flake | 手动 | `nix run github:ilysenko/codex-desktop-linux` | [Nix](docs/nix.md) |
+| GUI 安装提示 | 若已安装 | 使用 `kdialog` / `zenity`,随后回退至终端 | [原生安装](docs/native-setup.md) |
+| Linux 文件管理器集成 | 始终启用 | 内置于核心 Linux 补丁 | [架构](docs/architecture.md) |
+| Chrome 插件原生宿主 | 始终启用 | 随内置插件安装 | [架构](docs/architecture.md) |
+| 可移植的上游插件 | 上游提供时 | 自动准备 Sites、Deep Research 和 Visualize;上游分批发布仍然适用 | [架构](docs/architecture.md#bundled-plugins) |
+| 浏览器标注 | 始终启用 | 内置于已修补的 webview | [架构](docs/architecture.md) |
+| 托盘与热启动交接 | 始终启用 | 正常启动应用 | [架构](docs/architecture.md) |
+| 多应用实例 | 可选 | `./codex-app/start.sh --new-instance` | [构建与打包](docs/build-and-packaging.md#running-the-generated-app) |
+| Linux Computer Use 后端 | 内置 | 默认注册 MCP 后端,包括合成器原生和通用 X11/EWMH 窗口控制 | [Linux Computer Use](docs/linux-computer-use.md) |
+| Linux Computer Use UI | 可选 | `CODEX_LINUX_ENABLE_COMPUTER_USE_UI=1` 或设置标志 | [Linux Computer Use](docs/linux-computer-use.md#enable-the-in-app-ui) |
+| Linux 功能框架 | 可选 | 编辑 `linux-features/features.json` | [Linux 功能](linux-features/README.md) |
+
+### 可选 Linux 功能
+
+| 功能 | 默认状态 / 状态 | 启用 / 使用方式 | 文档 |
+|---|---|---|---|
+| 录制与回放(alpha) | 可选 alpha | `record-and-replay` | [文档](linux-features/record-and-replay/README.md) |
+| Agent 工作区 | 可选 | `agent-workspace` | [文档](linux-features/agent-workspace/README.md) |
+| API 密钥模型可见性 | 可选 | `api-key-model-visibility` | [文档](linux-features/api-key-model-visibility/README.md) |
+| API 密钥服务层级 | 可选 | `api-key-service-tier` | [文档](linux-features/api-key-service-tier/README.md) |
+| Linux AppShots | 可选 | `appshots` | [文档](linux-features/appshots/README.md) |
+| 已认证代理 | 可选 | `authenticated-proxy` | [文档](linux-features/authenticated-proxy/README.md) |
+| 封装更新器按钮 | 可选 | `codex-wrapper-updater` | [文档](linux-features/codex-wrapper-updater/README.md) |
+| Codex Micro(USB-C / 蓝牙) | 可选 | `codex-micro` | [文档](linux-features/codex-micro/README.md) |
+| 对话模式 | 可选 | `conversation-mode` | [文档](linux-features/conversation-mode/README.md) |
+| Copilot 推理强度默认值 | 可选 | `copilot-reasoning-effort` | [文档](linux-features/copilot-reasoning-effort/README.md) |
+| 仅目录的工作树监测 | 可选 | `directory-only-working-tree-watch` | [文档](linux-features/directory-only-working-tree-watch/README.md) |
+| Linux 功能示例 | 开发者示例 | `example-feature` | [文档](linux-features/example-feature/README.md) |
+| 无边框标题栏 | 可选 | `frameless-titlebar` | [文档](linux-features/frameless-titlebar/README.md) |
+| 全局听写 | 可选 | `global-dictation` | [文档](linux-features/global-dictation/README.md) |
+| MCP 辅助进程回收器 | 可选 | `mcp-helper-reaper` | [文档](linux-features/mcp-helper-reaper/README.md) |
+| Browser Use node_repl 回收器 | 可选 | `node-repl-reaper` | [文档](linux-features/node-repl-reaper/README.md) |
+| Omarchy 主题 | 可选 | `omarchy-theme` | [文档](linux-features/omarchy-theme/README.md) |
+| 打开目标发现 | 可选 | `open-target-discovery` | [文档](linux-features/open-target-discovery/README.md) |
+| 持久状态面板 | 可选 | `persistent-status-panel` | [文档](linux-features/persistent-status-panel/README.md) |
+| 宠物叠加层 | 可选 | `pet-overlay` | [文档](linux-features/pet-overlay/README.md) |
+| 项目组“最近更新”排序 | 可选 | `project-group-last-updated-sort` | [文档](linux-features/project-group-last-updated-sort/README.md) |
+| 项目任务“创建时间”排序 | 可选 | `project-task-sort` | [文档](linux-features/project-task-sort/README.md) |
+| 朗读按钮 | 可选 | `read-aloud` | [文档](linux-features/read-aloud/README.md) |
+| 朗读 MCP | 可选 | `read-aloud-mcp` | [文档](linux-features/read-aloud-mcp/README.md) |
+| 远程控制 UI 开关 | 可选 | `remote-control-ui` | [文档](linux-features/remote-control-ui/README.md) |
+| 实验性远程移动控制 | 可选 | `remote-mobile-control` | [文档](linux-features/remote-mobile-control/README.md) |
+| SSH 命令封装器 | 可选 | `ssh-command-wrapper` | [文档](linux-features/ssh-command-wrapper/README.md) |
+| Thorium Chrome 插件支持 | 可选 | `thorium-chrome-plugin` | [文档](linux-features/thorium-chrome-plugin/README.md) |
+| UI 微调 | 可选 | `ui-tweaks` | [文档](linux-features/ui-tweaks/README.md) |
+| 可替代的带命名空间 X11/EWMH Computer Use 工具 | 可选 | `x11-ewmh-computer-use` | [文档](linux-features/x11-ewmh-computer-use/README.md) |
+
+ChatGPT 账户模型的分批开放仍由 OpenAI 按账户控制。重新构建此封装不会解锁这些功能。使用 API 密钥认证的自定义提供商可通过 `api-key-model-visibility` 选择显示其 CLI 模型目录。
+
+## 可选 Linux 功能
+
+可选的 Linux 专属集成功能位于 `linux-features/`,默认处于禁用状态。它们可以添加 ASAR 补丁、准备资源、运行时钩子、打包钩子或旧版构建/安装钩子,而无需改变核心构建流程。
+
+在构建前启用受跟踪或本地功能:
+
+```bash
+cp linux-features/features.example.json linux-features/features.json
+```
+
+```json
+{
+ "enabled": [
+ "read-aloud",
+ "open-target-discovery"
+ ]
+}
+```
+
+私有的用户本地功能可以放在被 git 忽略的 `linux-features/local//` 目录中,并使用相同的 `feature.json` 约定。修改功能选择后请重新构建:
+
+```bash
+make install-native
+```
+
+完整约定请参阅 [linux-features/README.md](linux-features/README.md) 和[Linux 功能架构](docs/linux-features-architecture.md)。
+
+## 更新
+
+默认的原生软件包会安装 `codex-update-manager`,这是一个 `systemd --user` 服务,用于检查更新的上游 DMG,重新构建本地原生软件包,并在 ChatGPT Desktop 退出后安装。最终安装使用 `pkexec`。精简的窗口管理器会话需要图形化 polkit 认证代理才能使用应用内安装按钮;否则更新器会保留已准备好的软件包,并报告终端命令 `sudo /usr/bin/codex-update-manager ... --path ...`。
+
+手动更新软件包:
+
+```bash
+PACKAGE_WITH_UPDATER=0 make package
+make install
+```
+
+从受信任的工作副本手动重建:
+
+```bash
+PACKAGE_WITH_UPDATER=0 make update-native
+```
+
+AppImage 构建和仅在仓库中生成的应用不包含原生软件包更新器。请参阅[更新器](docs/updater.md)。
+
+## 构建、打包与运行
+
+生成本地 Electron 应用:
+
+```bash
+make build-app-fresh
+make run-app
+```
+
+使用本地 DMG:
+
+```bash
+make build-app DMG=/path/to/Codex.dmg
+```
+
+本地构建采用事务方式:候选应用必须通过与定时 GitHub 工作流相同的[上游 DMG 验收配置](docs/upstream-dmg-acceptance.md),才会替换工作中的 `codex-app/`。只检查已配置的 Linux 功能;已启用功能发生漂移时,当前应用会保持安装状态,直到该功能被禁用或修复。
+
+构建并安装软件包:
+
+```bash
+make package
+make install
+```
+
+构建特定产物:
+
+```bash
+make deb
+make rpm
+make pacman
+make appimage
+```
+
+打包脚本只会重新打包已生成的 `codex-app/`,它们不会自行下载或提取 DMG。请参阅[构建与打包](docs/build-and-packaging.md)。
+
+## 故障排除
+
+| 问题 | 首先尝试 |
+|---|---|
+| `/tmp` 挂载为 `noexec` | 将 `TMPDIR` 和 `XDG_CACHE_HOME` 设置为 `$HOME` 下可执行的目录 |
+| 空白窗口或启动画面卡住 | 检查 `~/.cache/codex-desktop/launcher.log`,以及端口 `5175` 是否已被使用 |
+| `CODEX_CLI_PATH` 或 CLI 安装错误 | 检查 `~/.cache/codex-desktop/launcher.log`,设置 `CODEX_CLI_PATH=/path/to/codex` 固定二进制文件,或使用可选依赖手动安装 `@openai/codex` |
+| Wayland / GPU / Vulkan 卡住 | 尝试 `CODEX_LINUX_RENDERING_MODE=wayland-gpu ./codex-app/start.sh` 或持久化启动标志 |
+| UI 过大或模糊(HiDPI / 分数缩放) | 尝试 `CODEX_FORCE_DEVICE_SCALE_FACTOR=1 ./codex-app/start.sh` 或 `CODEX_OZONE_PLATFORM=x11 ./codex-app/start.sh`;参阅 `./codex-app/start.sh --diagnose-scaling` |
+| 调整尺寸时出现残影或陈旧帧 | 尝试 `CODEX_ELECTRON_DISABLE_GPU_COMPOSITING=1 ./codex-app/start.sh` 或 `--disable-gpu-compositing` |
+| Computer Use UI 被隐藏 | 启用 UI 可选功能;账户/服务器端的分批开放仍可能隐藏上游控制的部分 |
+| Computer Use 没有输入后端 | 检查 `/dev/uinput`、portal 支持,或 `ydotoold` / `ydotool.service` |
+| 更新器似乎卡住 | 检查 `codex-update-manager status --json` 和服务日志 |
+
+完整列表:[故障排除](docs/troubleshooting.md)。
+
+## 项目文档
+
+- [原生安装](docs/native-setup.md)
+- [Raspberry Pi 5](docs/raspberry-pi-5.md)
+- [Nix](docs/nix.md)
+- [Linux Computer Use](docs/linux-computer-use.md)
+- [Linux 上的录制与回放](docs/record-and-replay-linux.md)
+- [更新器](docs/updater.md)
+- [构建与打包](docs/build-and-packaging.md)
+- [故障排除](docs/troubleshooting.md)
+- [架构](docs/architecture.md)
+- [应用启动 Shell 中的 GitHub CLI 认证](docs/github-cli-auth.md)
+- [Linux 功能架构](docs/linux-features-architecture.md)
+- [Wayland 输入焦点调查](docs/wayland-input-focus-investigation.md)
+- [Webview 服务器评估](docs/webview-server-evaluation.md)
+- [启动器性能说明](docs/launcher-performance.md)
+
+## 免责声明
+
+这是一个非官方社区项目,与 OpenAI 没有隶属关系。ChatGPT Desktop、OpenAI 服务、商标、上游应用代码、二进制文件和资产仍归 OpenAI 或其各自所有者所有。
+
+本仓库中的 MIT 许可证仅适用于此封装的源代码、打包脚本、文档和 Linux 兼容层代码。它不授予对 OpenAI 软件或服务的任何权利。
+
+本仓库不分发 OpenAI 软件或修改后的 OpenAI 应用二进制文件。用户必须通过 OpenAI 官方渠道获得自己已获授权的 Codex Desktop 副本。构建过程会在用户自己的副本上进行本地 Linux 兼容性转换,使其可以在 Linux 上运行。实际上,它自动化了用户在自己副本上执行的转换过程。
+
+使用 ChatGPT Desktop 仍须遵守 OpenAI 的适用条款和服务器端功能可用性。
+
+## 许可证
+
+MIT
diff --git a/computer-use-linux/Cargo.toml b/computer-use-linux/Cargo.toml
index ca1434592..e1a8d9083 100644
--- a/computer-use-linux/Cargo.toml
+++ b/computer-use-linux/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "codex-computer-use-linux"
-version = "0.3.1-linux-alpha1"
+version = "0.4.6-linux-alpha1"
edition = "2021"
[[bin]]
@@ -43,5 +43,6 @@ tokio = { version = "1.51.1", features = ["io-util", "macros", "net", "process",
tokio-tungstenite = { version = "0.29.0", default-features = false, features = ["handshake"] }
wayland-client = "0.31.11"
wayland-protocols = { version = "0.32.9", features = ["client", "staging"] }
+wayland-protocols-wlr = { version = "0.3.12", features = ["client"] }
xkeysym = "0.2.1"
zbus = "5.14.0"
diff --git a/computer-use-linux/src/abs_pointer.rs b/computer-use-linux/src/abs_pointer.rs
index 4e580f151..6129b0fe2 100644
--- a/computer-use-linux/src/abs_pointer.rs
+++ b/computer-use-linux/src/abs_pointer.rs
@@ -15,7 +15,7 @@
use std::thread::sleep;
use std::time::Duration;
-use anyhow::{Context, Result};
+use anyhow::{anyhow, Context, Result};
use evdev::{
uinput::VirtualDevice, AbsInfo, AbsoluteAxisCode, AttributeSet, EventType, InputEvent, KeyCode,
PropType, UinputAbsSetup,
@@ -85,11 +85,16 @@ impl AbsPointer {
sleep(Duration::from_millis(30));
let code = button.key_code();
for _ in 0..count.max(1) {
- self.device
- .emit(&[InputEvent::new_now(EventType::KEY.0, code, 1)])?;
+ if let Err(error) = self
+ .device
+ .emit(&[InputEvent::new_now(EventType::KEY.0, code, 1)])
+ {
+ return Err(self.release_after_error(code, error.into()));
+ }
sleep(Duration::from_millis(30));
- self.device
- .emit(&[InputEvent::new_now(EventType::KEY.0, code, 0)])?;
+ if let Err(error) = self.release_button(code) {
+ return Err(self.release_after_error(code, error));
+ }
sleep(Duration::from_millis(40));
}
Ok(())
@@ -105,15 +110,37 @@ impl AbsPointer {
let code = button.key_code();
self.move_to(start.0, start.1)?;
sleep(Duration::from_millis(30));
- self.device
- .emit(&[InputEvent::new_now(EventType::KEY.0, code, 1)])?;
+ if let Err(error) = self
+ .device
+ .emit(&[InputEvent::new_now(EventType::KEY.0, code, 1)])
+ {
+ return Err(self.release_after_error(code, error.into()));
+ }
sleep(Duration::from_millis(40));
- self.move_to(end.0, end.1)?;
+ if let Err(error) = self.move_to(end.0, end.1) {
+ return Err(self.release_after_error(code, error));
+ }
sleep(Duration::from_millis(40));
- self.device
- .emit(&[InputEvent::new_now(EventType::KEY.0, code, 0)])?;
+ if let Err(error) = self.release_button(code) {
+ return Err(self.release_after_error(code, error));
+ }
Ok(())
}
+
+ fn release_button(&mut self, code: u16) -> Result<()> {
+ self.device
+ .emit(&[InputEvent::new_now(EventType::KEY.0, code, 0)])
+ .context("failed to emit absolute pointer button release")
+ }
+
+ fn release_after_error(&mut self, code: u16, error: anyhow::Error) -> anyhow::Error {
+ match self.release_button(code) {
+ Ok(()) => anyhow!("{error:#}; sent a best-effort button release"),
+ Err(release_error) => {
+ anyhow!("{error:#}; best-effort button release also failed: {release_error:#}")
+ }
+ }
+ }
}
/// Pointer buttons we can synthesize.
diff --git a/computer-use-linux/src/bin/codex-chrome-extension-host.rs b/computer-use-linux/src/bin/codex-chrome-extension-host.rs
index a7c32f9f3..339be9e17 100644
--- a/computer-use-linux/src/bin/codex-chrome-extension-host.rs
+++ b/computer-use-linux/src/bin/codex-chrome-extension-host.rs
@@ -14,7 +14,11 @@ use std::{
},
path::{Path, PathBuf},
process,
- sync::{Arc, Mutex},
+ sync::{
+ atomic::{AtomicUsize, Ordering},
+ mpsc::{sync_channel, Receiver, SyncSender, TrySendError},
+ Arc, Mutex, Weak,
+ },
thread,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
@@ -38,18 +42,73 @@ const MAX_ACCEPTED_FRAME_BYTES: usize = 64 * 1024 * 1024;
const PENDING_REQUEST_TTL: Duration = Duration::from_secs(10 * 60);
/// Bounds unanswered correlations independently in each bridge direction.
const MAX_PENDING_REQUESTS_PER_DIRECTION: usize = 1024;
+/// Prevents one stalled browser client from exhausting the shared request pool.
+const MAX_PENDING_REQUESTS_PER_CLIENT_PER_DIRECTION: usize = 256;
/// Bounds retained string IDs to about 4 MiB per direction at the entry cap.
const MAX_PENDING_REQUEST_ID_STRING_BYTES: usize = 4 * 1024;
+/// Bounds simultaneously connected Codex browser clients and their I/O threads.
+const MAX_CONNECTED_CLIENTS: usize = 64;
+/// Retains a small secondary item bound for streams of tiny messages.
+const CLIENT_WRITE_QUEUE_MAX_MESSAGES: usize = 64;
+/// Bounds each client's queued and in-flight serialized frame bytes.
+const CLIENT_WRITE_QUEUE_MAX_BYTES: usize = MAX_ACCEPTED_FRAME_BYTES + std::mem::size_of::();
+/// Bounds retained browser-session routing state.
+const MAX_TRACKED_SESSIONS: usize = 1024;
+const MAX_SESSION_ID_BYTES: usize = 1024;
const PENDING_REQUEST_LIMIT_ERROR_CODE: i64 = -32001;
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
type SharedState = Arc>;
type SharedChromeWriter = Arc>>;
-type SharedClientWriter = Arc>;
+type SharedClientFrame = Arc<[u8]>;
-#[derive(Clone)]
struct Client {
- writer: SharedClientWriter,
+ sender: SyncSender,
+ queued_bytes: Arc,
+ max_queued_bytes: usize,
+ shutdown: UnixStream,
+}
+
+impl Client {
+ fn new(
+ sender: SyncSender,
+ queued_bytes: Arc,
+ shutdown: UnixStream,
+ ) -> Self {
+ Self {
+ sender,
+ queued_bytes,
+ max_queued_bytes: CLIENT_WRITE_QUEUE_MAX_BYTES,
+ shutdown,
+ }
+ }
+
+ #[cfg(test)]
+ fn with_max_queued_bytes(
+ sender: SyncSender,
+ queued_bytes: Arc,
+ max_queued_bytes: usize,
+ shutdown: UnixStream,
+ ) -> Self {
+ Self {
+ sender,
+ queued_bytes,
+ max_queued_bytes,
+ shutdown,
+ }
+ }
+}
+
+struct QueuedClientFrame {
+ bytes: SharedClientFrame,
+ queued_bytes: Arc,
+}
+
+impl Drop for QueuedClientFrame {
+ fn drop(&mut self) {
+ self.queued_bytes
+ .fetch_sub(self.bytes.len(), Ordering::AcqRel);
+ }
}
struct PendingChromeRequest {
@@ -63,6 +122,7 @@ struct PendingChromeRequest {
struct PendingClientRequest {
client_id: usize,
chrome_request_id: Value,
+ fanout_group: Option,
created_at: Instant,
}
@@ -77,7 +137,7 @@ impl ChromeClientRouteError {
match self {
Self::NoClients => "No Codex browser client is connected",
Self::MultipleClients => {
- "Multiple Codex browser clients are connected; Chrome requests require exactly one"
+ "Multiple Codex browser clients are connected; Chrome request is not scoped to a known browser session"
}
}
}
@@ -88,6 +148,7 @@ struct HostState {
rollout_tracker: RolloutTracker,
extension_id: Option,
clients: HashMap,
+ session_owners: HashMap,
pending_chrome_requests: HashMap,
pending_client_requests: HashMap,
next_client_id: usize,
@@ -108,6 +169,7 @@ impl HostState {
rollout_tracker,
extension_id,
clients: HashMap::new(),
+ session_owners: HashMap::new(),
pending_chrome_requests: HashMap::new(),
pending_client_requests: HashMap::new(),
next_client_id: 1,
@@ -117,26 +179,56 @@ impl HostState {
}
}
- fn replace_with_client(&mut self, writer: SharedClientWriter) -> (usize, Vec<(usize, Client)>) {
- let evicted_clients = self.clients.drain().collect::>();
- if !evicted_clients.is_empty() {
- self.pending_chrome_requests.clear();
- self.pending_client_requests.clear();
+ fn add_client(&mut self, client: Client) -> Option {
+ if self.clients.len() >= MAX_CONNECTED_CLIENTS {
+ return None;
}
- let id = self.next_client_id;
- self.next_client_id += 1;
- self.clients.insert(id, Client { writer });
- (id, evicted_clients)
+ let mut id = self.next_client_id.max(1);
+ while self.clients.contains_key(&id) {
+ id = id.checked_add(1).unwrap_or(1);
+ }
+ self.next_client_id = id.checked_add(1).unwrap_or(1);
+ self.clients.insert(id, client);
+ Some(id)
}
- fn remove_client(&mut self, client_id: usize) {
- self.clients.remove(&client_id);
+ fn remove_client(&mut self, client_id: usize) -> bool {
+ let Some(client) = self.clients.remove(&client_id) else {
+ return false;
+ };
+ let _ = client.shutdown.shutdown(Shutdown::Both);
+ self.session_owners
+ .retain(|_, owner_client_id| *owner_client_id != client_id);
remove_pending_requests_for_client(
&mut self.pending_chrome_requests,
&mut self.pending_client_requests,
client_id,
);
+ true
+ }
+
+ fn track_session_owner(&mut self, client_id: usize, message: &Value) {
+ let Some(session_id) = session_id_from_message(message) else {
+ return;
+ };
+ if !self.clients.contains_key(&client_id) {
+ return;
+ }
+ if !self.session_owners.contains_key(session_id)
+ && self.session_owners.len() >= MAX_TRACKED_SESSIONS
+ {
+ return;
+ }
+
+ self.session_owners
+ .insert(session_id.to_string(), client_id);
+ }
+
+ fn session_owner(&self, message: &Value) -> Option {
+ let session_id = session_id_from_message(message)?;
+ let client_id = *self.session_owners.get(session_id)?;
+ self.clients.contains_key(&client_id).then_some(client_id)
}
fn prune_expired_pending_requests(&mut self, now: Instant) {
@@ -148,6 +240,20 @@ impl HostState {
});
}
+ fn pending_chrome_request_count(&self, client_id: usize) -> usize {
+ self.pending_chrome_requests
+ .values()
+ .filter(|pending| pending.client_id == client_id)
+ .count()
+ }
+
+ fn pending_client_request_count(&self, client_id: usize) -> usize {
+ self.pending_client_requests
+ .values()
+ .filter(|pending| pending.client_id == client_id)
+ .count()
+ }
+
fn send_chrome(&self, message: &Value) {
let mut stdout = self.stdout.lock().expect("stdout mutex poisoned");
if let Err(error) = write_frame(&mut *stdout, message) {
@@ -156,24 +262,96 @@ impl HostState {
}
}
- fn send_client(&self, client_id: usize, message: &Value) {
+ fn send_client(&mut self, client_id: usize, message: &Value) -> bool {
+ if !self.clients.contains_key(&client_id) {
+ return false;
+ }
+
+ let frame = match serialize_frame(message) {
+ Ok(frame) => frame,
+ Err(error) => {
+ log(&format!("client frame serialization error: {error}"));
+ self.remove_client(client_id);
+ return false;
+ }
+ };
+ self.send_client_frame(client_id, frame)
+ }
+
+ fn send_client_frame(&mut self, client_id: usize, frame: SharedClientFrame) -> bool {
let Some(client) = self.clients.get(&client_id) else {
- return;
+ return false;
};
+ let sender = client.sender.clone();
+ let queued_bytes = Arc::clone(&client.queued_bytes);
+ let max_queued_bytes = client.max_queued_bytes;
- let mut writer = client.writer.lock().expect("client writer mutex poisoned");
- if let Err(error) = write_frame(&mut *writer, message) {
- log(&format!("client socket write error: {error}"));
+ if !try_reserve_queue_bytes(&queued_bytes, frame.len(), max_queued_bytes) {
+ log(&format!(
+ "disconnecting browser client {client_id}: outbound byte limit exceeded"
+ ));
+ self.remove_client(client_id);
+ return false;
+ }
+
+ let queued = QueuedClientFrame {
+ bytes: frame,
+ queued_bytes,
+ };
+
+ match sender.try_send(queued) {
+ Ok(()) => true,
+ Err(TrySendError::Full(queued)) => {
+ drop(queued);
+ log(&format!(
+ "disconnecting browser client {client_id}: outbound queue is full"
+ ));
+ self.remove_client(client_id);
+ false
+ }
+ Err(TrySendError::Disconnected(queued)) => {
+ drop(queued);
+ log(&format!(
+ "disconnecting browser client {client_id}: writer is unavailable"
+ ));
+ self.remove_client(client_id);
+ false
+ }
}
}
- fn broadcast_clients(&self, message: &Value) {
+ fn broadcast_clients(&mut self, message: &Value) {
+ let frame = match serialize_frame(message) {
+ Ok(frame) => frame,
+ Err(error) => {
+ log(&format!("client frame serialization error: {error}"));
+ return;
+ }
+ };
for client_id in self.clients.keys().copied().collect::>() {
+ self.send_client_frame(client_id, Arc::clone(&frame));
+ }
+ }
+
+ fn send_chrome_notification(&mut self, message: &Value) {
+ if let Some(client_id) = self.session_owner(message) {
self.send_client(client_id, message);
+ } else {
+ self.broadcast_clients(message);
}
}
}
+fn try_reserve_queue_bytes(counter: &AtomicUsize, bytes: usize, max_bytes: usize) -> bool {
+ counter
+ .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
+ current
+ .checked_add(bytes)
+ .filter(|total| *total <= max_bytes)
+ })
+ .is_ok()
+}
+
#[derive(Clone)]
struct RolloutTracker {
inner: Arc>,
@@ -483,35 +661,39 @@ fn accept_clients(listener: UnixListener, state: SharedState) {
}
let writer = match stream.try_clone() {
- Ok(stream) => Arc::new(Mutex::new(stream)),
+ Ok(stream) => stream,
+ Err(error) => {
+ log(&format!("client socket clone error: {error}"));
+ continue;
+ }
+ };
+ let shutdown = match stream.try_clone() {
+ Ok(stream) => stream,
Err(error) => {
log(&format!("client socket clone error: {error}"));
continue;
}
};
+ let (sender, receiver) = sync_channel::(CLIENT_WRITE_QUEUE_MAX_MESSAGES);
+ let queued_bytes = Arc::new(AtomicUsize::new(0));
- let (client_id, evicted_clients) = {
+ let client_id = {
let mut state = state.lock().expect("host state mutex poisoned");
- state.replace_with_client(writer)
+ state.add_client(Client::new(sender, queued_bytes, shutdown))
};
- for (evicted_id, evicted_client) in evicted_clients {
+ let Some(client_id) = client_id else {
log(&format!(
- "evicting stale browser client {evicted_id} after a newer client connected"
+ "rejecting browser client because the {MAX_CONNECTED_CLIENTS}-client limit was reached"
));
- close_client_socket(&evicted_client);
- }
+ let _ = stream.shutdown(Shutdown::Both);
+ continue;
+ };
- let state = Arc::clone(&state);
- thread::spawn(move || read_client_messages(state, client_id, stream));
- }
-}
+ let writer_state = Arc::downgrade(&state);
+ thread::spawn(move || write_client_messages(writer_state, client_id, writer, receiver));
-fn close_client_socket(client: &Client) {
- match client.writer.lock() {
- Ok(writer) => {
- let _ = writer.shutdown(Shutdown::Both);
- }
- Err(error) => log(&format!("client socket close lock error: {error}")),
+ let reader_state = Arc::clone(&state);
+ thread::spawn(move || read_client_messages(reader_state, client_id, stream));
}
}
@@ -578,6 +760,28 @@ fn read_client_messages(state: SharedState, client_id: usize, stream: UnixStream
}
}
+ disconnect_client(&state, client_id);
+}
+
+fn write_client_messages(
+ state: Weak>,
+ client_id: usize,
+ mut stream: UnixStream,
+ receiver: Receiver,
+) {
+ while let Ok(frame) = receiver.recv() {
+ if let Err(error) = write_serialized_frame(&mut stream, &frame.bytes) {
+ log(&format!("client socket write error: {error}"));
+ break;
+ }
+ }
+
+ if let Some(state) = state.upgrade() {
+ disconnect_client(&state, client_id);
+ }
+}
+
+fn disconnect_client(state: &SharedState, client_id: usize) {
let mut state = state.lock().expect("host state mutex poisoned");
state.remove_client(client_id);
}
@@ -604,6 +808,28 @@ fn handle_client_message(state: &SharedState, client_id: usize, message: Value)
return;
}
state.pending_client_requests.remove(id);
+ if let Some(group) = pending.fanout_group {
+ if is_successful_heartbeat_response(&message) {
+ state
+ .pending_client_requests
+ .retain(|_, sibling| sibling.fanout_group != Some(group));
+ state.send_chrome(&with_id(message, pending.chrome_request_id));
+ } else if !state
+ .pending_client_requests
+ .values()
+ .any(|sibling| sibling.fanout_group == Some(group))
+ {
+ state.send_chrome(&json!({
+ "jsonrpc": "2.0",
+ "id": pending.chrome_request_id,
+ "error": {
+ "code": -32000,
+ "message": "No browser client returned a valid heartbeat response"
+ }
+ }));
+ }
+ return;
+ }
state.send_chrome(&with_id(message, pending.chrome_request_id));
return;
@@ -629,7 +855,7 @@ fn handle_client_message(state: &SharedState, client_id: usize, message: Value)
let Some(id) = message.get("id").cloned() else {
return;
};
- let state = state.lock().expect("host state mutex poisoned");
+ let mut state = state.lock().expect("host state mutex poisoned");
state.send_client(
client_id,
&json!({ "jsonrpc": "2.0", "id": id, "result": "pong" }),
@@ -640,7 +866,7 @@ fn handle_client_message(state: &SharedState, client_id: usize, message: Value)
let client_request_id = match bounded_pending_request_id(&message) {
Ok(id) => id,
Err(error) => {
- let state = state.lock().expect("host state mutex poisoned");
+ let mut state = state.lock().expect("host state mutex poisoned");
if state.clients.contains_key(&client_id) {
state.send_client(client_id, &invalid_request_id_error(error));
}
@@ -653,6 +879,19 @@ fn handle_client_message(state: &SharedState, client_id: usize, message: Value)
if !state.clients.contains_key(&client_id) {
return;
}
+ state.track_session_owner(client_id, &message);
+ if state.pending_chrome_request_count(client_id)
+ >= MAX_PENDING_REQUESTS_PER_CLIENT_PER_DIRECTION
+ {
+ state.send_client(
+ client_id,
+ &pending_request_limit_error(
+ client_request_id,
+ "Too many pending requests from this browser client to Chrome",
+ ),
+ );
+ return;
+ }
if state.pending_chrome_requests.len() >= MAX_PENDING_REQUESTS_PER_DIRECTION {
state.send_client(
client_id,
@@ -712,10 +951,9 @@ fn handle_chrome_message(state: &SharedState, message: Value) {
// failure.
if pending.fallback_extension_info && is_missing_chrome_runtime_get_version_error(&message)
{
- state.send_client(
- pending.client_id,
- &extension_info_response(pending.client_request_id, state.extension_id.as_deref()),
- );
+ let response =
+ extension_info_response(pending.client_request_id, state.extension_id.as_deref());
+ state.send_client(pending.client_id, &response);
return;
}
@@ -727,8 +965,8 @@ fn handle_chrome_message(state: &SharedState, message: Value) {
}
if !is_request(&message) {
- let state = state.lock().expect("host state mutex poisoned");
- state.broadcast_clients(&message);
+ let mut state = state.lock().expect("host state mutex poisoned");
+ state.send_chrome_notification(&message);
return;
}
@@ -741,7 +979,14 @@ fn handle_chrome_message(state: &SharedState, message: Value) {
}
};
let mut state = state.lock().expect("host state mutex poisoned");
- let client_id = match select_single_client_id(&state.clients) {
+ if message.get("method").and_then(Value::as_str) == Some("ping")
+ && state.session_owner(&message).is_none()
+ {
+ forward_chrome_heartbeat(&mut state, message, chrome_request_id);
+ return;
+ }
+
+ let client_id = match select_client_id_for_chrome_request(&state, &message) {
Ok(client_id) => client_id,
Err(error) => {
state.send_chrome(&json!({
@@ -757,6 +1002,15 @@ fn handle_chrome_message(state: &SharedState, message: Value) {
};
let client_request_id = format!("chrome-{}-{}", process::id(), state.next_client_request_id);
+ if state.pending_client_request_count(client_id)
+ >= MAX_PENDING_REQUESTS_PER_CLIENT_PER_DIRECTION
+ {
+ state.send_chrome(&pending_request_limit_error(
+ chrome_request_id,
+ "Too many pending Chrome requests to this browser client",
+ ));
+ return;
+ }
if state.pending_client_requests.len() >= MAX_PENDING_REQUESTS_PER_DIRECTION {
state.send_chrome(&pending_request_limit_error(
chrome_request_id,
@@ -769,14 +1023,104 @@ fn handle_chrome_message(state: &SharedState, message: Value) {
client_request_id.clone(),
PendingClientRequest {
client_id,
- chrome_request_id,
+ chrome_request_id: chrome_request_id.clone(),
+ fanout_group: None,
created_at: Instant::now(),
},
);
- state.send_client(
+ if !state.send_client(
client_id,
&with_id(message, Value::String(client_request_id)),
- );
+ ) {
+ state.send_chrome(&json!({
+ "jsonrpc": "2.0",
+ "id": chrome_request_id,
+ "error": {
+ "code": -32000,
+ "message": "Browser client disconnected before the request could be forwarded"
+ }
+ }));
+ }
+}
+
+fn select_client_id_for_chrome_request(
+ state: &HostState,
+ message: &Value,
+) -> std::result::Result {
+ if let Some(client_id) = state.session_owner(message) {
+ return Ok(client_id);
+ }
+
+ select_single_client_id(&state.clients)
+}
+
+fn forward_chrome_heartbeat(state: &mut HostState, message: Value, chrome_request_id: Value) {
+ if state.clients.is_empty() {
+ state.send_chrome(&json!({
+ "jsonrpc": "2.0",
+ "id": chrome_request_id,
+ "error": {
+ "code": -32000,
+ "message": ChromeClientRouteError::NoClients.message()
+ }
+ }));
+ return;
+ }
+
+ let remaining_global_capacity =
+ MAX_PENDING_REQUESTS_PER_DIRECTION.saturating_sub(state.pending_client_requests.len());
+ let mut client_ids = state
+ .clients
+ .keys()
+ .copied()
+ .filter(|client_id| {
+ state.pending_client_request_count(*client_id)
+ < MAX_PENDING_REQUESTS_PER_CLIENT_PER_DIRECTION
+ })
+ .collect::>();
+ client_ids.sort_unstable_by_key(|client_id| {
+ (state.pending_client_request_count(*client_id), *client_id)
+ });
+ client_ids.truncate(remaining_global_capacity);
+
+ if client_ids.is_empty() {
+ state.send_chrome(&pending_request_limit_error(
+ chrome_request_id,
+ "Too many pending Chrome requests to connected browser clients",
+ ));
+ return;
+ }
+
+ let fanout_group = state.next_client_request_id;
+ let mut sent = false;
+ let mut message = message;
+ for client_id in client_ids {
+ let client_request_id =
+ format!("chrome-{}-{}", process::id(), state.next_client_request_id);
+ state.next_client_request_id += 1;
+ state.pending_client_requests.insert(
+ client_request_id.clone(),
+ PendingClientRequest {
+ client_id,
+ chrome_request_id: chrome_request_id.clone(),
+ fanout_group: Some(fanout_group),
+ created_at: Instant::now(),
+ },
+ );
+ set_message_id(&mut message, Value::String(client_request_id));
+ sent |= state.send_client(client_id, &message);
+ }
+
+ if !sent {
+ state.send_chrome(&json!({
+ "jsonrpc": "2.0",
+ "id": chrome_request_id,
+ "error": {
+ "code": -32000,
+ "message": "No writable Codex browser client is connected"
+ }
+ }));
+ }
}
fn select_single_client_id(
@@ -841,15 +1185,25 @@ fn is_response(message: &Value) -> bool {
message.get("id").is_some() && message.get("method").and_then(Value::as_str).is_none()
}
+fn is_successful_heartbeat_response(message: &Value) -> bool {
+ message.get("jsonrpc").and_then(Value::as_str) == Some("2.0")
+ && message.get("result").and_then(Value::as_str) == Some("pong")
+ && message.get("error").is_none()
+}
+
fn message_id_as_str(message: &Value) -> Option<&str> {
message.get("id").and_then(Value::as_str)
}
fn with_id(mut message: Value, id: Value) -> Value {
+ set_message_id(&mut message, id);
+ message
+}
+
+fn set_message_id(message: &mut Value, id: Value) {
if let Value::Object(ref mut object) = message {
object.insert("id".to_string(), id);
}
- message
}
fn is_missing_chrome_runtime_get_version_error(message: &Value) -> bool {
@@ -891,11 +1245,16 @@ fn extension_info_response(id: Value, extension_id: Option<&str>) -> Value {
fn session_turn_from_message(message: &Value) -> Option<(String, String)> {
let params = message.get("params")?;
- let session_id = non_empty_string(params.get("session_id")?)?;
+ let session_id = session_id_from_message(message)?;
let turn_id = non_empty_string(params.get("turn_id")?)?;
Some((session_id.to_string(), turn_id.to_string()))
}
+fn session_id_from_message(message: &Value) -> Option<&str> {
+ let session_id = non_empty_string(message.get("params")?.get("session_id")?)?;
+ (session_id.len() <= MAX_SESSION_ID_BYTES).then_some(session_id)
+}
+
fn non_empty_string(value: &Value) -> Option<&str> {
let value = value.as_str()?.trim();
(!value.is_empty()).then_some(value)
@@ -1026,16 +1385,27 @@ fn read_frame(reader: &mut impl Read) -> io::Result