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 @@ Join the Discord community

+

+ 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

+ +

+ CI + 上游应用构建 + 加入 Discord 社区 +

+ +

+ 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> { } fn write_frame(writer: &mut impl Write, message: &Value) -> io::Result<()> { - let body = serde_json::to_vec(message).map_err(io::Error::other)?; - if body.len() > u32::MAX as usize { + let frame = serialize_frame(message)?; + write_serialized_frame(writer, &frame) +} + +fn serialize_frame(message: &Value) -> io::Result { + let mut frame = vec![0_u8; std::mem::size_of::()]; + serde_json::to_writer(&mut frame, message).map_err(io::Error::other)?; + let body_len = frame.len() - std::mem::size_of::(); + if body_len > u32::MAX as usize { return Err(io::Error::new( ErrorKind::InvalidInput, "message too large for 4-byte length prefix", )); } - writer.write_all(&(body.len() as u32).to_ne_bytes())?; - writer.write_all(&body)?; + frame[..std::mem::size_of::()].copy_from_slice(&(body_len as u32).to_ne_bytes()); + Ok(frame.into()) +} + +fn write_serialized_frame(writer: &mut impl Write, frame: &[u8]) -> io::Result<()> { + writer.write_all(frame)?; writer.flush() } @@ -1386,12 +1756,12 @@ while True: } #[test] - fn replacing_browser_client_evicts_stale_clients_and_pending_requests() { + fn adding_browser_client_preserves_existing_clients_and_pending_requests() { let mut state = test_host_state(); - let (first_client_id, evicted_clients) = - state.replace_with_client(test_client().writer.clone()); - assert!(evicted_clients.is_empty()); + let first_client_id = state + .add_client(test_client()) + .expect("first client should be accepted"); assert!(state.clients.contains_key(&first_client_id)); state.pending_chrome_requests.insert( @@ -1408,24 +1778,35 @@ while True: PendingClientRequest { client_id: first_client_id, chrome_request_id: json!("chrome-request-1"), + fanout_group: None, created_at: Instant::now(), }, ); - let (second_client_id, evicted_clients) = - state.replace_with_client(test_client().writer.clone()); + let second_client_id = state + .add_client(test_client()) + .expect("second client should be accepted"); assert_ne!(first_client_id, second_client_id); - assert_eq!(evicted_clients.len(), 1); - assert_eq!(evicted_clients[0].0, first_client_id); - assert!(!state.clients.contains_key(&first_client_id)); + assert!(state.clients.contains_key(&first_client_id)); assert!(state.clients.contains_key(&second_client_id)); - assert!(state.pending_chrome_requests.is_empty()); - assert!(state.pending_client_requests.is_empty()); + assert!(state.pending_chrome_requests.contains_key("chrome-request")); + assert!(state.pending_client_requests.contains_key("client-request")); + } + + #[test] + fn connected_browser_clients_are_bounded() { + let mut state = test_host_state(); + for _ in 0..MAX_CONNECTED_CLIENTS { + assert!(state.add_client(test_client()).is_some()); + } + + assert_eq!(state.clients.len(), MAX_CONNECTED_CLIENTS); + assert!(state.add_client(test_client()).is_none()); } #[test] - fn evicted_client_requests_are_ignored() { + fn unknown_client_requests_are_ignored() { let state = Arc::new(Mutex::new(test_host_state())); handle_client_message( @@ -1440,84 +1821,403 @@ while True: } #[test] - fn forwards_client_raw_cdp_call_requests_to_chrome_without_filtering() { - let (mut host_state, output) = test_host_state_with_output(); - host_state.clients.insert(1, test_client()); + fn interleaved_requests_return_to_the_originating_clients() { + let (client_one_writer, mut client_one_reader) = UnixStream::pair().unwrap(); + let (client_two_writer, mut client_two_reader) = UnixStream::pair().unwrap(); + let (mut host_state, chrome_output) = test_host_state_with_output(); + host_state + .clients + .insert(1, queued_test_client(client_one_writer)); + host_state + .clients + .insert(2, queued_test_client(client_two_writer)); let state = Arc::new(Mutex::new(host_state)); - let request = json!({ - "jsonrpc": "2.0", - "id": "client-cdp-call-1", - "method": "tab_cdp_call", - "params": { - "browser_id": "browser-1", - "tab_id": "42", - "method": "Runtime.evaluate", + + handle_client_message( + &state, + 1, + json!({ + "jsonrpc": "2.0", + "id": "client-one-request", + "method": "getTabs", "params": { - "expression": "document.title", - "returnByValue": true - }, - "target": { - "target_id": "target-1" - }, - "timeout_ms": 5000 - } - }); + "session_id": "session-one", + "turn_id": "turn-one" + } + }), + ); + handle_client_message( + &state, + 2, + json!({ + "jsonrpc": "2.0", + "id": "client-two-request", + "method": "getTabs", + "params": { + "session_id": "session-two", + "turn_id": "turn-two" + } + }), + ); - handle_client_message(&state, 1, request.clone()); + let forwarded = read_captured_messages(&chrome_output); + assert_eq!(forwarded.len(), 2); + let first_chrome_id = forwarded[0]["id"].clone(); + let second_chrome_id = forwarded[1]["id"].clone(); + assert_eq!(forwarded[0]["params"]["session_id"], "session-one"); + assert_eq!(forwarded[1]["params"]["session_id"], "session-two"); - let chrome_id = format!("linux-{}-1", process::id()); - let forwarded = read_captured_message(&output); - assert_eq!(forwarded["id"], chrome_id); - assert_eq!(forwarded["method"], "tab_cdp_call"); - assert_eq!(forwarded["params"], request["params"]); + handle_chrome_message( + &state, + json!({ "jsonrpc": "2.0", "id": second_chrome_id, "result": "two" }), + ); + handle_chrome_message( + &state, + json!({ "jsonrpc": "2.0", "id": first_chrome_id, "result": "one" }), + ); + assert_eq!( + read_frame(&mut client_one_reader).unwrap().unwrap(), + json!({ "jsonrpc": "2.0", "id": "client-one-request", "result": "one" }) + ); + assert_eq!( + read_frame(&mut client_two_reader).unwrap().unwrap(), + json!({ "jsonrpc": "2.0", "id": "client-two-request", "result": "two" }) + ); let state = state.lock().unwrap(); - let pending = state.pending_chrome_requests.get(&chrome_id).unwrap(); - assert_eq!(pending.client_id, 1); - assert_eq!(pending.client_request_id, json!("client-cdp-call-1")); - assert!(!pending.fallback_extension_info); + assert_eq!(state.session_owners.get("session-one"), Some(&1)); + assert_eq!(state.session_owners.get("session-two"), Some(&2)); + assert!(state.pending_chrome_requests.is_empty()); } #[test] - fn forwards_client_raw_cdp_event_requests_to_chrome_without_filtering() { - let (mut host_state, output) = test_host_state_with_output(); - host_state.clients.insert(1, test_client()); + fn session_scoped_chrome_messages_route_to_the_owning_client() { + let (client_one_writer, client_one_reader) = UnixStream::pair().unwrap(); + let (client_two_writer, mut client_two_reader) = UnixStream::pair().unwrap(); + client_one_reader.set_nonblocking(true).unwrap(); + let mut host_state = test_host_state(); + host_state + .clients + .insert(1, queued_test_client(client_one_writer)); + host_state + .clients + .insert(2, queued_test_client(client_two_writer)); + host_state + .session_owners + .insert("session-two".to_string(), 2); let state = Arc::new(Mutex::new(host_state)); - let request = json!({ + let notification = json!({ "jsonrpc": "2.0", - "id": "client-cdp-events-1", - "method": "tab_cdp_events", + "method": "onPageEvent", "params": { - "after_sequence": 7, - "browser_id": "browser-1", - "limit": 25, - "methods": ["Runtime.consoleAPICalled", "Target.attachedToTarget"], - "tab_id": "42", - "target": { - "session_id": "session-1" - }, - "timeout_ms": 500 + "session_id": "session-two", + "type": "navigation" } }); - handle_client_message(&state, 1, request.clone()); + handle_chrome_message(&state, notification.clone()); - let forwarded = read_captured_message(&output); - assert_eq!(forwarded["id"], format!("linux-{}-1", process::id())); - assert_eq!(forwarded["method"], "tab_cdp_events"); - assert_eq!(forwarded["params"], request["params"]); - } + assert_eq!( + read_frame(&mut client_two_reader).unwrap().unwrap(), + notification + ); + let mut client_one_reader = client_one_reader; + assert_eq!( + read_frame(&mut client_one_reader).unwrap_err().kind(), + ErrorKind::WouldBlock + ); + } + + #[test] + fn unscoped_chrome_notifications_are_broadcast_to_all_clients() { + let (client_one_writer, mut client_one_reader) = UnixStream::pair().unwrap(); + let (client_two_writer, mut client_two_reader) = UnixStream::pair().unwrap(); + let mut host_state = test_host_state(); + host_state + .clients + .insert(1, queued_test_client(client_one_writer)); + host_state + .clients + .insert(2, queued_test_client(client_two_writer)); + let state = Arc::new(Mutex::new(host_state)); + let notification = json!({ + "jsonrpc": "2.0", + "method": "onCDPEvent", + "params": { + "source": { "tabId": 42 }, + "method": "Runtime.consoleAPICalled" + } + }); + + handle_chrome_message(&state, notification.clone()); + + assert_eq!( + read_frame(&mut client_one_reader).unwrap().unwrap(), + notification + ); + assert_eq!( + read_frame(&mut client_two_reader).unwrap().unwrap(), + notification + ); + } + + #[test] + fn session_scoped_chrome_request_returns_through_the_owning_client() { + let (client_one_writer, client_one_reader) = UnixStream::pair().unwrap(); + let (client_two_writer, mut client_two_reader) = UnixStream::pair().unwrap(); + client_one_reader.set_nonblocking(true).unwrap(); + let (mut host_state, chrome_output) = test_host_state_with_output(); + host_state + .clients + .insert(1, queued_test_client(client_one_writer)); + host_state + .clients + .insert(2, queued_test_client(client_two_writer)); + host_state + .session_owners + .insert("session-two".to_string(), 2); + let state = Arc::new(Mutex::new(host_state)); + + handle_chrome_message( + &state, + json!({ + "jsonrpc": "2.0", + "id": "chrome-session-request", + "method": "sessionCommand", + "params": { "session_id": "session-two" } + }), + ); + + let forwarded = read_frame(&mut client_two_reader).unwrap().unwrap(); + assert_eq!(forwarded["method"], "sessionCommand"); + let forwarded_id = forwarded["id"].clone(); + handle_client_message( + &state, + 2, + json!({ "jsonrpc": "2.0", "id": forwarded_id, "result": "done" }), + ); + + assert_eq!( + read_captured_message(&chrome_output), + json!({ + "jsonrpc": "2.0", + "id": "chrome-session-request", + "result": "done" + }) + ); + let mut client_one_reader = client_one_reader; + assert_eq!( + read_frame(&mut client_one_reader).unwrap_err().kind(), + ErrorKind::WouldBlock + ); + assert!(state.lock().unwrap().pending_client_requests.is_empty()); + } + + #[test] + fn chrome_heartbeat_uses_the_first_healthy_client_response() { + let (client_one_writer, mut client_one_reader) = UnixStream::pair().unwrap(); + let (client_two_writer, mut client_two_reader) = UnixStream::pair().unwrap(); + let (mut host_state, chrome_output) = test_host_state_with_output(); + host_state + .clients + .insert(1, queued_test_client(client_one_writer)); + host_state + .clients + .insert(2, queued_test_client(client_two_writer)); + let state = Arc::new(Mutex::new(host_state)); + + handle_chrome_message( + &state, + json!({ "jsonrpc": "2.0", "id": "heartbeat", "method": "ping" }), + ); + + let client_one_request = read_frame(&mut client_one_reader).unwrap().unwrap(); + let client_two_request = read_frame(&mut client_two_reader).unwrap().unwrap(); + assert_eq!(client_one_request["method"], "ping"); + assert_eq!(client_two_request["method"], "ping"); + handle_client_message( + &state, + 2, + json!({ "jsonrpc": "2.0", "id": client_two_request["id"], "result": "pong" }), + ); + + assert_eq!( + read_captured_message(&chrome_output), + json!({ "jsonrpc": "2.0", "id": "heartbeat", "result": "pong" }) + ); + handle_client_message( + &state, + 1, + json!({ "jsonrpc": "2.0", "id": client_one_request["id"], "result": "late" }), + ); + assert_eq!(read_captured_messages(&chrome_output).len(), 1); + let state = state.lock().unwrap(); + assert_eq!(state.clients.len(), 2); + assert!(state.pending_client_requests.is_empty()); + } + + #[test] + fn chrome_heartbeat_waits_for_a_healthy_response_after_invalid_responses() { + let (client_one_writer, mut client_one_reader) = UnixStream::pair().unwrap(); + let (client_two_writer, mut client_two_reader) = UnixStream::pair().unwrap(); + let (client_three_writer, mut client_three_reader) = UnixStream::pair().unwrap(); + let (mut host_state, chrome_output) = test_host_state_with_output(); + host_state + .clients + .insert(1, queued_test_client(client_one_writer)); + host_state + .clients + .insert(2, queued_test_client(client_two_writer)); + host_state + .clients + .insert(3, queued_test_client(client_three_writer)); + let state = Arc::new(Mutex::new(host_state)); + + handle_chrome_message( + &state, + json!({ "jsonrpc": "2.0", "id": "heartbeat", "method": "ping" }), + ); + + let client_one_request = read_frame(&mut client_one_reader).unwrap().unwrap(); + let client_two_request = read_frame(&mut client_two_reader).unwrap().unwrap(); + let client_three_request = read_frame(&mut client_three_reader).unwrap().unwrap(); + + handle_client_message( + &state, + 1, + json!({ + "jsonrpc": "2.0", + "id": client_one_request["id"], + "error": { "code": -32000, "message": "not ready" } + }), + ); + assert!(chrome_output.lock().unwrap().is_empty()); + assert_eq!(state.lock().unwrap().pending_client_requests.len(), 2); + + handle_client_message( + &state, + 2, + json!({ + "id": client_two_request["id"], + "result": "unexpected" + }), + ); + assert!(chrome_output.lock().unwrap().is_empty()); + assert_eq!(state.lock().unwrap().pending_client_requests.len(), 1); + + handle_client_message( + &state, + 3, + json!({ + "jsonrpc": "2.0", + "id": client_three_request["id"], + "result": "pong" + }), + ); + + assert_eq!( + read_captured_message(&chrome_output), + json!({ "jsonrpc": "2.0", "id": "heartbeat", "result": "pong" }) + ); + let state = state.lock().unwrap(); + assert_eq!(state.clients.len(), 3); + assert!(state.pending_client_requests.is_empty()); + } + + #[test] + fn ambiguous_unscoped_chrome_request_fails_without_evicting_clients() { + let (mut host_state, chrome_output) = test_host_state_with_output(); + host_state.clients.insert(1, test_client()); + host_state.clients.insert(2, test_client()); + let state = Arc::new(Mutex::new(host_state)); + + handle_chrome_message( + &state, + json!({ "jsonrpc": "2.0", "id": "ambiguous", "method": "browserCommand" }), + ); + + let response = read_captured_message(&chrome_output); + assert_eq!(response["id"], "ambiguous"); + assert_eq!(response["error"]["code"], -32000); + let state = state.lock().unwrap(); + assert_eq!(state.clients.len(), 2); + assert!(state.pending_client_requests.is_empty()); + } + + #[test] + fn forwards_client_raw_cdp_call_requests_to_chrome_without_filtering() { + let (mut host_state, output) = test_host_state_with_output(); + host_state.clients.insert(1, test_client()); + let state = Arc::new(Mutex::new(host_state)); + let request = json!({ + "jsonrpc": "2.0", + "id": "client-cdp-call-1", + "method": "tab_cdp_call", + "params": { + "browser_id": "browser-1", + "tab_id": "42", + "method": "Runtime.evaluate", + "params": { + "expression": "document.title", + "returnByValue": true + }, + "target": { + "target_id": "target-1" + }, + "timeout_ms": 5000 + } + }); + + handle_client_message(&state, 1, request.clone()); + + let chrome_id = format!("linux-{}-1", process::id()); + let forwarded = read_captured_message(&output); + assert_eq!(forwarded["id"], chrome_id); + assert_eq!(forwarded["method"], "tab_cdp_call"); + assert_eq!(forwarded["params"], request["params"]); + + let state = state.lock().unwrap(); + let pending = state.pending_chrome_requests.get(&chrome_id).unwrap(); + assert_eq!(pending.client_id, 1); + assert_eq!(pending.client_request_id, json!("client-cdp-call-1")); + assert!(!pending.fallback_extension_info); + } + + #[test] + fn forwards_client_raw_cdp_event_requests_to_chrome_without_filtering() { + let (mut host_state, output) = test_host_state_with_output(); + host_state.clients.insert(1, test_client()); + let state = Arc::new(Mutex::new(host_state)); + let request = json!({ + "jsonrpc": "2.0", + "id": "client-cdp-events-1", + "method": "tab_cdp_events", + "params": { + "after_sequence": 7, + "browser_id": "browser-1", + "limit": 25, + "methods": ["Runtime.consoleAPICalled", "Target.attachedToTarget"], + "tab_id": "42", + "target": { + "session_id": "session-1" + }, + "timeout_ms": 500 + } + }); + + handle_client_message(&state, 1, request.clone()); + + let forwarded = read_captured_message(&output); + assert_eq!(forwarded["id"], format!("linux-{}-1", process::id())); + assert_eq!(forwarded["method"], "tab_cdp_events"); + assert_eq!(forwarded["params"], request["params"]); + } #[test] fn forwards_chrome_raw_cdp_responses_to_the_requesting_client() { let (client_writer, mut client_reader) = UnixStream::pair().unwrap(); let mut state = test_host_state(); - state.clients.insert( - 1, - Client { - writer: Arc::new(Mutex::new(client_writer)), - }, - ); + state.clients.insert(1, queued_test_client(client_writer)); state.pending_chrome_requests.insert( "linux-1-1".to_string(), PendingChromeRequest { @@ -1553,12 +2253,7 @@ while True: fn get_info_falls_back_when_runtime_get_version_is_missing() { let (client_writer, mut client_reader) = UnixStream::pair().unwrap(); let mut state = test_host_state(); - state.clients.insert( - 1, - Client { - writer: Arc::new(Mutex::new(client_writer)), - }, - ); + state.clients.insert(1, queued_test_client(client_writer)); state.pending_chrome_requests.insert( "linux-1-1".to_string(), PendingChromeRequest { @@ -1622,6 +2317,7 @@ while True: PendingClientRequest { client_id: 3, chrome_request_id: json!("expired-chrome-id"), + fanout_group: None, created_at: expired_at, }, ); @@ -1630,6 +2326,7 @@ while True: PendingClientRequest { client_id: 4, chrome_request_id: json!("live-chrome-id"), + fanout_group: None, created_at: now, }, ); @@ -1673,12 +2370,7 @@ while True: fn oversized_client_request_id_is_rejected_without_retention() { let (client_writer, mut client_reader) = UnixStream::pair().unwrap(); let (mut state, chrome_output) = test_host_state_with_output(); - state.clients.insert( - 1, - Client { - writer: Arc::new(Mutex::new(client_writer)), - }, - ); + state.clients.insert(1, queued_test_client(client_writer)); let state = Arc::new(Mutex::new(state)); handle_client_message( @@ -1720,17 +2412,256 @@ while True: } #[test] - fn full_chrome_request_map_returns_correlated_error_to_client() { - let (client_writer, mut client_reader) = UnixStream::pair().unwrap(); - let (mut state, chrome_output) = test_host_state_with_output(); + fn client_writer_byte_limit_does_not_block_another_client() { + let first_message = json!({ "jsonrpc": "2.0", "method": "fill-byte-budget" }); + let first_frame = serialize_frame(&first_message).unwrap(); + let (stalled_sender, stalled_receiver) = sync_channel(CLIENT_WRITE_QUEUE_MAX_MESSAGES); + let stalled_queued_bytes = Arc::new(AtomicUsize::new(0)); + let (stalled_shutdown, _stalled_peer) = UnixStream::pair().unwrap(); + + let (healthy_sender, healthy_receiver) = sync_channel(CLIENT_WRITE_QUEUE_MAX_MESSAGES); + let healthy_queued_bytes = Arc::new(AtomicUsize::new(0)); + let (healthy_shutdown, _healthy_peer) = UnixStream::pair().unwrap(); + let mut state = test_host_state(); state.clients.insert( 1, - Client { - writer: Arc::new(Mutex::new(client_writer)), - }, + Client::with_max_queued_bytes( + stalled_sender, + Arc::clone(&stalled_queued_bytes), + first_frame.len(), + stalled_shutdown, + ), + ); + state.clients.insert( + 2, + Client::new( + healthy_sender, + Arc::clone(&healthy_queued_bytes), + healthy_shutdown, + ), + ); + assert!(state.send_client(1, &first_message)); + assert_eq!( + stalled_queued_bytes.load(Ordering::Acquire), + first_frame.len() + ); + + let message = json!({ "jsonrpc": "2.0", "method": "healthy" }); + + state.broadcast_clients(&message); + + let healthy_frame = healthy_receiver + .recv_timeout(Duration::from_secs(1)) + .unwrap(); + assert_eq!( + healthy_frame.bytes.as_ref(), + serialize_frame(&message).unwrap().as_ref() + ); + let stalled_frame = stalled_receiver + .recv_timeout(Duration::from_secs(1)) + .unwrap(); + assert_eq!(stalled_frame.bytes.as_ref(), first_frame.as_ref()); + assert!(!state.clients.contains_key(&1)); + assert!(state.clients.contains_key(&2)); + + drop(stalled_frame); + drop(healthy_frame); + assert_eq!(stalled_queued_bytes.load(Ordering::Acquire), 0); + assert_eq!(healthy_queued_bytes.load(Ordering::Acquire), 0); + } + + #[test] + fn full_client_writer_message_queue_does_not_block_another_client() { + let (stalled_sender, stalled_receiver) = sync_channel(CLIENT_WRITE_QUEUE_MAX_MESSAGES); + let stalled_queued_bytes = Arc::new(AtomicUsize::new(0)); + let (stalled_shutdown, _stalled_peer) = UnixStream::pair().unwrap(); + let (healthy_sender, healthy_receiver) = sync_channel(CLIENT_WRITE_QUEUE_MAX_MESSAGES); + let healthy_queued_bytes = Arc::new(AtomicUsize::new(0)); + let (healthy_shutdown, _healthy_peer) = UnixStream::pair().unwrap(); + let mut state = test_host_state(); + state.clients.insert( + 1, + Client::new( + stalled_sender, + Arc::clone(&stalled_queued_bytes), + stalled_shutdown, + ), ); + state.clients.insert( + 2, + Client::new( + healthy_sender, + Arc::clone(&healthy_queued_bytes), + healthy_shutdown, + ), + ); + for index in 0..CLIENT_WRITE_QUEUE_MAX_MESSAGES { + assert!(state.send_client(1, &json!(index))); + } + + let message = json!({ "jsonrpc": "2.0", "method": "healthy" }); + assert!(!state.send_client(1, &message)); + assert!(state.send_client(2, &message)); + + let healthy_frame = healthy_receiver + .recv_timeout(Duration::from_secs(1)) + .unwrap(); + assert_eq!( + healthy_frame.bytes.as_ref(), + serialize_frame(&message).unwrap().as_ref() + ); + assert_eq!( + stalled_receiver.try_iter().count(), + CLIENT_WRITE_QUEUE_MAX_MESSAGES + ); + assert!(!state.clients.contains_key(&1)); + assert!(state.clients.contains_key(&2)); + + drop(healthy_frame); + assert_eq!(stalled_queued_bytes.load(Ordering::Acquire), 0); + assert_eq!(healthy_queued_bytes.load(Ordering::Acquire), 0); + } + + #[test] + fn broadcast_reuses_one_serialized_frame_for_all_clients() { + let (client_one_sender, client_one_receiver) = + sync_channel(CLIENT_WRITE_QUEUE_MAX_MESSAGES); + let client_one_queued_bytes = Arc::new(AtomicUsize::new(0)); + let (client_one_shutdown, _client_one_peer) = UnixStream::pair().unwrap(); + let (client_two_sender, client_two_receiver) = + sync_channel(CLIENT_WRITE_QUEUE_MAX_MESSAGES); + let client_two_queued_bytes = Arc::new(AtomicUsize::new(0)); + let (client_two_shutdown, _client_two_peer) = UnixStream::pair().unwrap(); + let mut state = test_host_state(); + state.clients.insert( + 1, + Client::new( + client_one_sender, + Arc::clone(&client_one_queued_bytes), + client_one_shutdown, + ), + ); + state.clients.insert( + 2, + Client::new( + client_two_sender, + Arc::clone(&client_two_queued_bytes), + client_two_shutdown, + ), + ); + let message = json!({ "jsonrpc": "2.0", "method": "shared" }); + + state.broadcast_clients(&message); + + let client_one_frame = client_one_receiver + .recv_timeout(Duration::from_secs(1)) + .unwrap(); + let client_two_frame = client_two_receiver + .recv_timeout(Duration::from_secs(1)) + .unwrap(); + assert!(Arc::ptr_eq( + &client_one_frame.bytes, + &client_two_frame.bytes + )); + assert_eq!( + client_one_frame.bytes.as_ref(), + serialize_frame(&message).unwrap().as_ref() + ); + assert_eq!( + client_one_queued_bytes.load(Ordering::Acquire), + client_one_frame.bytes.len() + ); + assert_eq!( + client_two_queued_bytes.load(Ordering::Acquire), + client_two_frame.bytes.len() + ); + + drop(client_one_frame); + drop(client_two_frame); + assert_eq!(client_one_queued_bytes.load(Ordering::Acquire), 0); + assert_eq!(client_two_queued_bytes.load(Ordering::Acquire), 0); + } + + #[test] + fn stalled_client_cannot_exhaust_chrome_request_capacity_for_another_client() { + let (client_two_writer, _client_two_reader) = UnixStream::pair().unwrap(); + let (mut state, chrome_output) = test_host_state_with_output(); + state.clients.insert(1, test_client()); + state + .clients + .insert(2, queued_test_client(client_two_writer)); let now = Instant::now(); - for index in 0..MAX_PENDING_REQUESTS_PER_DIRECTION { + for index in 0..MAX_PENDING_REQUESTS_PER_CLIENT_PER_DIRECTION { + state.pending_chrome_requests.insert( + format!("stalled-{index}"), + PendingChromeRequest { + client_id: 1, + client_request_id: json!(index), + fallback_extension_info: false, + created_at: now, + }, + ); + } + let state = Arc::new(Mutex::new(state)); + + handle_client_message( + &state, + 2, + json!({ "jsonrpc": "2.0", "id": "healthy", "method": "getTabs" }), + ); + + let forwarded = read_captured_message(&chrome_output); + assert_eq!(forwarded["method"], "getTabs"); + assert_eq!(state.lock().unwrap().pending_chrome_request_count(2), 1); + } + + #[test] + fn stalled_client_cannot_exhaust_client_request_capacity_for_another_client() { + let (client_two_writer, mut client_two_reader) = UnixStream::pair().unwrap(); + let (mut state, _chrome_output) = test_host_state_with_output(); + state.clients.insert(1, test_client()); + state + .clients + .insert(2, queued_test_client(client_two_writer)); + state + .session_owners + .insert("healthy-session".to_string(), 2); + let now = Instant::now(); + for index in 0..MAX_PENDING_REQUESTS_PER_CLIENT_PER_DIRECTION { + state.pending_client_requests.insert( + format!("stalled-{index}"), + PendingClientRequest { + client_id: 1, + chrome_request_id: json!(index), + fanout_group: None, + created_at: now, + }, + ); + } + let state = Arc::new(Mutex::new(state)); + + handle_chrome_message( + &state, + json!({ + "jsonrpc": "2.0", + "id": "healthy", + "method": "sessionCommand", + "params": { "session_id": "healthy-session" } + }), + ); + + let forwarded = read_frame(&mut client_two_reader).unwrap().unwrap(); + assert_eq!(forwarded["method"], "sessionCommand"); + assert_eq!(state.lock().unwrap().pending_client_request_count(2), 1); + } + + #[test] + fn client_to_chrome_per_client_limit_returns_correlated_error() { + let (client_writer, mut client_reader) = UnixStream::pair().unwrap(); + let (mut state, chrome_output) = test_host_state_with_output(); + state.clients.insert(1, queued_test_client(client_writer)); + let now = Instant::now(); + for index in 0..MAX_PENDING_REQUESTS_PER_CLIENT_PER_DIRECTION { state.pending_chrome_requests.insert( format!("pending-{index}"), PendingChromeRequest { @@ -1754,24 +2685,67 @@ while True: assert_eq!(response["error"]["code"], PENDING_REQUEST_LIMIT_ERROR_CODE); assert!(chrome_output.lock().unwrap().is_empty()); let state = state.lock().unwrap(); + assert_eq!( + state.pending_chrome_requests.len(), + MAX_PENDING_REQUESTS_PER_CLIENT_PER_DIRECTION + ); + assert_eq!(state.next_chrome_id, 1); + } + + #[test] + fn client_to_chrome_global_limit_returns_correlated_error() { + let (client_writer, mut client_reader) = UnixStream::pair().unwrap(); + let (mut state, chrome_output) = test_host_state_with_output(); + for client_id in 1..=4 { + state.clients.insert(client_id, test_client()); + } + state.clients.insert(5, queued_test_client(client_writer)); + let now = Instant::now(); + for index in 0..MAX_PENDING_REQUESTS_PER_DIRECTION { + let client_id = index / MAX_PENDING_REQUESTS_PER_CLIENT_PER_DIRECTION + 1; + state.pending_chrome_requests.insert( + format!("pending-{index}"), + PendingChromeRequest { + client_id, + client_request_id: json!(index), + fallback_extension_info: false, + created_at: now, + }, + ); + } + let state = Arc::new(Mutex::new(state)); + + handle_client_message( + &state, + 5, + json!({ "jsonrpc": "2.0", "id": "global-over-cap", "method": "getTabs" }), + ); + + let response = read_frame(&mut client_reader).unwrap().unwrap(); + assert_eq!(response["id"], "global-over-cap"); + assert_eq!(response["error"]["code"], PENDING_REQUEST_LIMIT_ERROR_CODE); + assert!(chrome_output.lock().unwrap().is_empty()); + let state = state.lock().unwrap(); assert_eq!( state.pending_chrome_requests.len(), MAX_PENDING_REQUESTS_PER_DIRECTION ); + assert_eq!(state.pending_chrome_request_count(5), 0); assert_eq!(state.next_chrome_id, 1); } #[test] - fn full_client_request_map_returns_correlated_error_to_chrome() { + fn chrome_to_client_per_client_limit_returns_correlated_error() { let (mut state, chrome_output) = test_host_state_with_output(); state.clients.insert(1, test_client()); let now = Instant::now(); - for index in 0..MAX_PENDING_REQUESTS_PER_DIRECTION { + for index in 0..MAX_PENDING_REQUESTS_PER_CLIENT_PER_DIRECTION { state.pending_client_requests.insert( format!("pending-{index}"), PendingClientRequest { client_id: 1, chrome_request_id: json!(index), + fanout_group: None, created_at: now, }, ); @@ -1787,10 +2761,54 @@ while True: assert_eq!(response["id"], "chrome-over-cap"); assert_eq!(response["error"]["code"], PENDING_REQUEST_LIMIT_ERROR_CODE); let state = state.lock().unwrap(); + assert_eq!( + state.pending_client_requests.len(), + MAX_PENDING_REQUESTS_PER_CLIENT_PER_DIRECTION + ); + assert_eq!(state.next_client_request_id, 1); + } + + #[test] + fn chrome_to_client_global_limit_returns_correlated_error() { + let (mut state, chrome_output) = test_host_state_with_output(); + for client_id in 1..=5 { + state.clients.insert(client_id, test_client()); + } + state.session_owners.insert("target-session".to_string(), 5); + let now = Instant::now(); + for index in 0..MAX_PENDING_REQUESTS_PER_DIRECTION { + let client_id = index / MAX_PENDING_REQUESTS_PER_CLIENT_PER_DIRECTION + 1; + state.pending_client_requests.insert( + format!("pending-{index}"), + PendingClientRequest { + client_id, + chrome_request_id: json!(index), + fanout_group: None, + created_at: now, + }, + ); + } + let state = Arc::new(Mutex::new(state)); + + handle_chrome_message( + &state, + json!({ + "jsonrpc": "2.0", + "id": "chrome-global-over-cap", + "method": "sessionCommand", + "params": { "session_id": "target-session" } + }), + ); + + let response = read_captured_message(&chrome_output); + assert_eq!(response["id"], "chrome-global-over-cap"); + assert_eq!(response["error"]["code"], PENDING_REQUEST_LIMIT_ERROR_CODE); + let state = state.lock().unwrap(); assert_eq!( state.pending_client_requests.len(), MAX_PENDING_REQUESTS_PER_DIRECTION ); + assert_eq!(state.pending_client_request_count(5), 0); assert_eq!(state.next_client_request_id, 1); } @@ -1822,6 +2840,7 @@ while True: PendingClientRequest { client_id: 1, chrome_request_id: json!("client-request-1"), + fanout_group: None, created_at: Instant::now(), }, ), @@ -1830,6 +2849,7 @@ while True: PendingClientRequest { client_id: 2, chrome_request_id: json!("client-request-2"), + fanout_group: None, created_at: Instant::now(), }, ), @@ -1843,11 +2863,49 @@ while True: assert!(!pending_client.contains_key("drop")); } + #[test] + fn disconnect_cleanup_preserves_other_clients_and_session_routes() { + let mut state = test_host_state(); + state.clients.insert(1, test_client()); + state.clients.insert(2, test_client()); + state.session_owners.insert("session-one".to_string(), 1); + state.session_owners.insert("session-two".to_string(), 2); + state.pending_chrome_requests.insert( + "drop".to_string(), + PendingChromeRequest { + client_id: 1, + client_request_id: json!("client-request"), + fallback_extension_info: false, + created_at: Instant::now(), + }, + ); + + state.remove_client(1); + + assert!(!state.clients.contains_key(&1)); + assert!(state.clients.contains_key(&2)); + assert!(!state.session_owners.contains_key("session-one")); + assert_eq!(state.session_owners.get("session-two"), Some(&2)); + assert!(state.pending_chrome_requests.is_empty()); + } + fn test_client() -> Client { let (stream, _peer) = UnixStream::pair().unwrap(); - Client { - writer: Arc::new(Mutex::new(stream)), - } + queued_test_client(stream) + } + + fn queued_test_client(mut stream: UnixStream) -> Client { + let shutdown = stream.try_clone().unwrap(); + let (sender, receiver) = sync_channel::(CLIENT_WRITE_QUEUE_MAX_MESSAGES); + let queued_bytes = Arc::new(AtomicUsize::new(0)); + thread::spawn(move || { + while let Ok(frame) = receiver.recv() { + if write_serialized_frame(&mut stream, &frame.bytes).is_err() { + break; + } + } + }); + Client::new(sender, queued_bytes, shutdown) } fn test_host_state() -> HostState { @@ -1891,9 +2949,20 @@ while True: } fn read_captured_message(output: &Arc>>) -> Value { + read_captured_messages(output) + .into_iter() + .next() + .expect("one captured message") + } + + fn read_captured_messages(output: &Arc>>) -> Vec { let data = output.lock().unwrap().clone(); let mut cursor = io::Cursor::new(data); - read_frame(&mut cursor).unwrap().unwrap() + let mut messages = Vec::new(); + while let Some(message) = read_frame(&mut cursor).unwrap() { + messages.push(message); + } + messages } fn process_is_live(pid: libc::pid_t) -> bool { diff --git a/computer-use-linux/src/bin/codex-computer-use-cosmic.rs b/computer-use-linux/src/bin/codex-computer-use-cosmic.rs index e302e3b2f..029d1b45a 100644 --- a/computer-use-linux/src/bin/codex-computer-use-cosmic.rs +++ b/computer-use-linux/src/bin/codex-computer-use-cosmic.rs @@ -16,8 +16,11 @@ use wayland_client::{ use wayland_protocols::ext::foreign_toplevel_list::v1::client::{ ext_foreign_toplevel_handle_v1, ext_foreign_toplevel_list_v1, }; +use wayland_protocols_wlr::output_management::v1::client::{ + zwlr_output_head_v1, zwlr_output_manager_v1, zwlr_output_mode_v1, +}; -const HELP: &str = "codex-computer-use-cosmic\n\nUsage:\n codex-computer-use-cosmic probe\n codex-computer-use-cosmic list-windows\n codex-computer-use-cosmic focused-window\n codex-computer-use-cosmic activate-window --window-id "; +const HELP: &str = "codex-computer-use-cosmic\n\nUsage:\n codex-computer-use-cosmic probe\n codex-computer-use-cosmic list-windows\n codex-computer-use-cosmic focused-window\n codex-computer-use-cosmic monitor-layout\n codex-computer-use-cosmic activate-window --window-id "; const BACKEND: &str = "cosmic-wayland"; const ACTIVATION_STATE_TTL: Duration = Duration::from_secs(5); @@ -64,6 +67,31 @@ struct ActivationState { timestamp_ms: u64, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct MonitorInfo { + x: i32, + y: i32, + width: i32, + height: i32, + scale: f64, +} + +#[derive(Debug, Default)] +struct OutputHeadState { + enabled: bool, + finished: bool, + position: Option<(i32, i32)>, + transform: Option, + scale: Option, + current_mode: Option, +} + +#[derive(Debug, Default)] +struct OutputModeState { + finished: bool, + size: Option<(i32, i32)>, +} + #[derive(Debug, Clone, Default)] struct ToplevelRecord { foreign: Option, @@ -104,6 +132,11 @@ struct AppData { records: Vec, by_foreign_id: HashMap, by_cosmic_id: HashMap, + output_manager: Option, + output_layout_ready: bool, + output_manager_finished: bool, + output_heads: HashMap, + output_modes: HashMap, } fn main() -> Result<()> { @@ -111,6 +144,7 @@ fn main() -> Result<()> { Command::Probe => print_json(&probe()?), Command::ListWindows => print_json(&collect_windows()?), Command::FocusedWindow => print_json(&focused_window()?), + Command::MonitorLayout => print_json(&monitor_layout()?), Command::ActivateWindow { window_id } => print_json(&activate_window(window_id)?), } } @@ -120,6 +154,7 @@ enum Command { Probe, ListWindows, FocusedWindow, + MonitorLayout, ActivateWindow { window_id: u64 }, } @@ -129,6 +164,7 @@ impl Command { [command] if command == "probe" => Ok(Self::Probe), [command] if command == "list-windows" => Ok(Self::ListWindows), [command] if command == "focused-window" => Ok(Self::FocusedWindow), + [command] if command == "monitor-layout" => Ok(Self::MonitorLayout), [command, flag, value] if command == "activate-window" && flag == "--window-id" => { Ok(Self::ActivateWindow { window_id: value @@ -144,7 +180,7 @@ impl Command { println!("{HELP}"); std::process::exit(0); } - _ => bail!("unknown arguments. Expected one of: probe, list-windows, focused-window, activate-window --window-id "), + _ => bail!("unknown arguments. Expected one of: probe, list-windows, focused-window, monitor-layout, activate-window --window-id "), } } } @@ -205,6 +241,10 @@ fn focused_window() -> Result> { Ok(window) } +fn monitor_layout() -> Result> { + Snapshot::collect()?.monitor_layout() +} + fn activate_window(window_id: u64) -> Result { let mut snapshot = Snapshot::collect()?; snapshot.activate(window_id)?; @@ -236,6 +276,9 @@ impl Snapshot { snapshot.app_data.toplevel_manager = globals .bind::(&qh, 1..=4, ()) .ok(); + snapshot.app_data.output_manager = globals + .bind::(&qh, 1..=4, ()) + .ok(); globals.contents().with_list(|entries| { for global in entries { if global.interface == "wl_seat" { @@ -295,6 +338,17 @@ impl Snapshot { }) } + fn monitor_layout(&self) -> Result> { + if self.app_data.output_manager.is_none() { + bail!("COSMIC output management protocol is unavailable"); + } + if self.app_data.output_manager_finished || !self.app_data.output_layout_ready { + bail!("COSMIC output management did not finish an atomic layout snapshot"); + } + monitor_layout_from_state(&self.app_data.output_heads, &self.app_data.output_modes) + .ok_or_else(|| anyhow!("COSMIC output management returned an incomplete layout")) + } + fn activate(&mut self, window_id: u64) -> Result<()> { if !self.can_activate_windows() { bail!("COSMIC activation capability is unavailable"); @@ -333,6 +387,145 @@ impl Snapshot { } } +fn monitor_layout_from_state( + heads: &HashMap, + modes: &HashMap, +) -> Option> { + let mut layout = heads + .values() + .filter(|head| head.enabled && !head.finished) + .map(|head| { + let (x, y) = head.position?; + let scale = head.scale?; + let mode = modes.get(&head.current_mode?)?; + if mode.finished || !scale.is_finite() || scale <= 0.0 { + return None; + } + let (mut width, mut height) = mode.size?; + if head.transform? % 2 == 1 { + std::mem::swap(&mut width, &mut height); + } + if width <= 0 || height <= 0 { + return None; + } + let width = (f64::from(width) / scale).round() as i32; + let height = (f64::from(height) / scale).round() as i32; + (width > 0 && height > 0).then_some(MonitorInfo { + x, + y, + width, + height, + scale, + }) + }) + .collect::>>()?; + layout.sort_by_key(|monitor| (monitor.x, monitor.y, monitor.width, monitor.height)); + (!layout.is_empty()).then_some(layout) +} + +impl Dispatch for AppData { + fn event( + app_data: &mut Self, + _manager: &zwlr_output_manager_v1::ZwlrOutputManagerV1, + event: zwlr_output_manager_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + match event { + zwlr_output_manager_v1::Event::Head { head } => { + app_data.output_layout_ready = false; + app_data + .output_heads + .entry(head.id().protocol_id()) + .or_default(); + } + zwlr_output_manager_v1::Event::Done { .. } => { + app_data.output_layout_ready = true; + } + zwlr_output_manager_v1::Event::Finished => { + app_data.output_layout_ready = false; + app_data.output_manager_finished = true; + } + _ => unreachable!(), + } + } + + event_created_child!( + AppData, + zwlr_output_manager_v1::ZwlrOutputManagerV1, + [ + zwlr_output_manager_v1::EVT_HEAD_OPCODE => (zwlr_output_head_v1::ZwlrOutputHeadV1, ()), + ] + ); +} + +impl Dispatch for AppData { + fn event( + app_data: &mut Self, + head: &zwlr_output_head_v1::ZwlrOutputHeadV1, + event: zwlr_output_head_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + app_data.output_layout_ready = false; + let head_id = head.id().protocol_id(); + let state = app_data.output_heads.entry(head_id).or_default(); + match event { + zwlr_output_head_v1::Event::Mode { mode } => { + app_data + .output_modes + .entry(mode.id().protocol_id()) + .or_default(); + } + zwlr_output_head_v1::Event::Enabled { enabled } => state.enabled = enabled != 0, + zwlr_output_head_v1::Event::CurrentMode { mode } => { + state.current_mode = Some(mode.id().protocol_id()); + } + zwlr_output_head_v1::Event::Position { x, y } => state.position = Some((x, y)), + zwlr_output_head_v1::Event::Transform { transform } => { + state.transform = Some(transform.into()); + } + zwlr_output_head_v1::Event::Scale { scale } => state.scale = Some(scale), + zwlr_output_head_v1::Event::Finished => state.finished = true, + _ => {} + } + } + + event_created_child!( + AppData, + zwlr_output_head_v1::ZwlrOutputHeadV1, + [ + zwlr_output_head_v1::EVT_MODE_OPCODE => (zwlr_output_mode_v1::ZwlrOutputModeV1, ()), + ] + ); +} + +impl Dispatch for AppData { + fn event( + app_data: &mut Self, + mode: &zwlr_output_mode_v1::ZwlrOutputModeV1, + event: zwlr_output_mode_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + app_data.output_layout_ready = false; + let state = app_data + .output_modes + .entry(mode.id().protocol_id()) + .or_default(); + match event { + zwlr_output_mode_v1::Event::Size { width, height } => { + state.size = Some((width, height)); + } + zwlr_output_mode_v1::Event::Finished => state.finished = true, + _ => {} + } + } +} + impl Dispatch for AppData { fn event( app_data: &mut Self, @@ -652,6 +845,111 @@ mod tests { assert!(error.contains("invalid window id")); } + #[test] + fn parses_monitor_layout_command() { + assert!(matches!( + Command::parse(vec!["monitor-layout".to_string()]).unwrap(), + Command::MonitorLayout + )); + } + + #[test] + fn monitor_layout_uses_fractional_scale_and_transform() { + let heads = HashMap::from([ + ( + 1, + OutputHeadState { + enabled: true, + position: Some((-1080, 0)), + transform: Some(1), + scale: Some(2.0), + current_mode: Some(11), + ..Default::default() + }, + ), + ( + 2, + OutputHeadState { + enabled: true, + position: Some((0, 0)), + transform: Some(0), + scale: Some(1.25), + current_mode: Some(22), + ..Default::default() + }, + ), + ]); + let modes = HashMap::from([ + ( + 11, + OutputModeState { + size: Some((3840, 2160)), + ..Default::default() + }, + ), + ( + 22, + OutputModeState { + size: Some((2560, 1440)), + ..Default::default() + }, + ), + ]); + + assert_eq!( + monitor_layout_from_state(&heads, &modes).unwrap(), + vec![ + MonitorInfo { + x: -1080, + y: 0, + width: 1080, + height: 1920, + scale: 2.0, + }, + MonitorInfo { + x: 0, + y: 0, + width: 2048, + height: 1152, + scale: 1.25, + }, + ] + ); + } + + #[test] + fn monitor_layout_rejects_an_incomplete_enabled_head() { + let heads = HashMap::from([ + ( + 1, + OutputHeadState { + enabled: true, + position: Some((0, 0)), + transform: Some(0), + scale: Some(1.0), + current_mode: Some(11), + ..Default::default() + }, + ), + ( + 2, + OutputHeadState { + enabled: true, + ..Default::default() + }, + ), + ]); + let modes = HashMap::from([( + 11, + OutputModeState { + size: Some((1920, 1080)), + ..Default::default() + }, + )]); + + assert!(monitor_layout_from_state(&heads, &modes).is_none()); + } + #[test] fn stable_window_id_is_stable() { assert_eq!(stable_window_id("window-1"), stable_window_id("window-1")); diff --git a/computer-use-linux/src/command_runner.rs b/computer-use-linux/src/command_runner.rs new file mode 100644 index 000000000..a6a7567b9 --- /dev/null +++ b/computer-use-linux/src/command_runner.rs @@ -0,0 +1,638 @@ +use anyhow::{anyhow, Context, Result}; +use std::{ + io::{self, Read}, + os::{fd::AsRawFd, unix::process::CommandExt as _}, + process::{Command as StdCommand, Output, Stdio}, + thread, + time::Instant as StdInstant, +}; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWriteExt}, + process::{Child, Command}, + sync::oneshot, + task::JoinHandle, + time::{Duration, Instant}, +}; + +const COMMAND_TIMEOUT: Duration = Duration::from_secs(2); +const REAP_TIMEOUT: Duration = Duration::from_secs(1); +const MAX_COMMAND_OUTPUT_BYTES: usize = 8 * 1024 * 1024; +const BLOCKING_POLL_INTERVAL: Duration = Duration::from_millis(10); +const MAX_BLOCKING_OUTPUT_BYTES: usize = 1024 * 1024; +const MAX_BLOCKING_DRAIN_BYTES: usize = 64 * 1024; + +pub(crate) async fn output(command: Command, action: &str) -> Result { + output_with_timeout(command, action, COMMAND_TIMEOUT).await +} + +pub(crate) async fn output_with_timeout( + command: Command, + action: &str, + timeout: Duration, +) -> Result { + output_with_input(command, action, timeout, None).await +} + +pub(crate) async fn output_with_stdin( + command: Command, + action: &str, + timeout: Duration, + input: Vec, +) -> Result { + output_with_input(command, action, timeout, Some(input)).await +} + +pub(crate) fn output_blocking_with_timeout( + command: &mut StdCommand, + action: &str, + timeout: Duration, +) -> Result { + command + .process_group(0) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command + .spawn() + .with_context(|| format!("failed to {action}"))?; + let pgid = i32::try_from(child.id()).context("child process id did not fit in i32")?; + let mut stdout = child + .stdout + .take() + .context("command stdout was not piped")?; + let mut stderr = child + .stderr + .take() + .context("command stderr was not piped")?; + if let Err(error) = + set_nonblocking(stdout.as_raw_fd()).and_then(|()| set_nonblocking(stderr.as_raw_fd())) + { + terminate_blocking_process(&mut child, pgid); + return Err(error).with_context(|| format!("failed to configure {action} output pipes")); + } + + let deadline = StdInstant::now() + timeout; + let mut stdout_bytes = Vec::new(); + let mut stderr_bytes = Vec::new(); + let mut stdout_eof = false; + let mut stderr_eof = false; + loop { + if !stdout_eof { + match drain_nonblocking(&mut stdout, &mut stdout_bytes) { + Ok(eof) => stdout_eof = eof, + Err(error) => { + terminate_blocking_process(&mut child, pgid); + return Err(error) + .with_context(|| format!("failed to collect {action} stdout")); + } + } + } + if !stderr_eof { + match drain_nonblocking(&mut stderr, &mut stderr_bytes) { + Ok(eof) => stderr_eof = eof, + Err(error) => { + terminate_blocking_process(&mut child, pgid); + return Err(error) + .with_context(|| format!("failed to collect {action} stderr")); + } + } + } + if stdout_eof && stderr_eof { + match child.try_wait() { + Ok(Some(status)) => { + return Ok(Output { + status, + stdout: stdout_bytes, + stderr: stderr_bytes, + }); + } + Ok(None) => {} + Err(error) => { + terminate_blocking_process(&mut child, pgid); + return Err(error).with_context(|| format!("failed to wait for {action}")); + } + } + } + if StdInstant::now() >= deadline { + terminate_blocking_process(&mut child, pgid); + return Err(timeout_error(action, timeout)); + } + thread::sleep(BLOCKING_POLL_INTERVAL); + } +} + +async fn output_with_input( + mut command: Command, + action: &str, + timeout: Duration, + input: Option>, +) -> Result { + command + .kill_on_drop(true) + .process_group(0) + .stdin(if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let child = command + .spawn() + .with_context(|| format!("failed to {action}"))?; + supervise_child(child, action, timeout, input).await +} + +pub(crate) async fn output_child(child: Child, action: &str, timeout: Duration) -> Result { + supervise_child(child, action, timeout, None).await +} + +async fn supervise_child( + mut child: Child, + action: &str, + timeout: Duration, + input: Option>, +) -> Result { + let pid = child + .id() + .and_then(|pid| i32::try_from(pid).ok()) + .context("spawned command did not have a usable process id")?; + let mut process_group = ProcessGroupGuard::new(pid); + let stdout = child + .stdout + .take() + .context("command stdout was not piped")?; + let stderr = child + .stderr + .take() + .context("command stderr was not piped")?; + let stdout_reader = tokio::spawn(read_pipe(stdout)); + let stderr_reader = tokio::spawn(read_pipe(stderr)); + let stdin_writer = match input { + Some(input) => { + let mut stdin = child.stdin.take().context("command stdin was not piped")?; + Some(tokio::spawn(async move { stdin.write_all(&input).await })) + } + None => None, + }; + + let (cancel_tx, cancel_rx) = oneshot::channel(); + let (result_tx, result_rx) = oneshot::channel(); + let deadline = Instant::now() + timeout; + let action = action.to_string(); + tokio::spawn(async move { + supervise_command( + child, + &mut process_group, + stdout_reader, + stderr_reader, + stdin_writer, + cancel_rx, + deadline, + timeout, + &action, + result_tx, + ) + .await; + }); + + let mut cancellation = CancelOnDrop(Some(cancel_tx)); + let result = result_rx + .await + .context("command supervisor stopped before returning a result")?; + cancellation.0 = None; + result +} + +#[allow(clippy::too_many_arguments)] +async fn supervise_command( + mut child: Child, + process_group: &mut ProcessGroupGuard, + mut stdout_reader: JoinHandle>>, + mut stderr_reader: JoinHandle>>, + mut stdin_writer: Option>>, + mut cancel_rx: oneshot::Receiver<()>, + deadline: Instant, + timeout: Duration, + action: &str, + result_tx: oneshot::Sender>, +) { + enum Completion { + Finished(Result), + Timeout, + Cancelled, + } + + let completion = { + // Keep the leader waitable until every inherited output descriptor is + // closed, so its process-group id cannot be reused during cleanup. + let command_completion = async { + let stdin = async { + match stdin_writer.as_mut() { + Some(writer) => writer + .await + .context("command stdin writer task failed")? + .context("failed to write command stdin"), + None => Ok(()), + } + }; + let stdout = async { + (&mut stdout_reader) + .await + .context("command stdout reader task failed")? + .context("failed to read command stdout") + }; + let stderr = async { + (&mut stderr_reader) + .await + .context("command stderr reader task failed")? + .context("failed to read command stderr") + }; + let ((), stdout, stderr) = tokio::try_join!(stdin, stdout, stderr)?; + let status = child.wait().await.context("failed to wait for command")?; + Ok(Output { + status, + stdout, + stderr, + }) + }; + tokio::pin!(command_completion); + tokio::select! { + result = &mut command_completion => Completion::Finished(result), + _ = tokio::time::sleep_until(deadline) => Completion::Timeout, + _ = &mut cancel_rx => Completion::Cancelled, + } + }; + + let result = match completion { + Completion::Finished(Ok(output)) => { + process_group.disarm(); + Ok(output) + } + Completion::Finished(Err(error)) => { + terminate_command( + &mut child, + process_group, + &mut stdout_reader, + &mut stderr_reader, + stdin_writer.as_mut(), + ) + .await; + Err(error).with_context(|| format!("failed to {action}")) + } + Completion::Timeout => { + terminate_command( + &mut child, + process_group, + &mut stdout_reader, + &mut stderr_reader, + stdin_writer.as_mut(), + ) + .await; + Err(timeout_error(action, timeout)) + } + Completion::Cancelled => { + terminate_command( + &mut child, + process_group, + &mut stdout_reader, + &mut stderr_reader, + stdin_writer.as_mut(), + ) + .await; + Err(anyhow!("cancelled while trying to {action}")) + } + }; + let _ = result_tx.send(result); +} + +async fn read_pipe(mut pipe: impl AsyncRead + Unpin) -> std::io::Result> { + let mut output = Vec::new(); + let mut buffer = [0_u8; 8192]; + loop { + let length = pipe.read(&mut buffer).await?; + if length == 0 { + break; + } + if output.len().saturating_add(length) > MAX_COMMAND_OUTPUT_BYTES { + return Err(std::io::Error::other(format!( + "command output exceeded {MAX_COMMAND_OUTPUT_BYTES} bytes" + ))); + } + output.extend_from_slice(&buffer[..length]); + } + Ok(output) +} + +fn set_nonblocking(fd: std::os::fd::RawFd) -> io::Result<()> { + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + if flags < 0 { + return Err(io::Error::last_os_error()); + } + if unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +fn drain_nonblocking(reader: &mut impl Read, output: &mut Vec) -> io::Result { + let mut buffer = [0_u8; 8192]; + let mut drained = 0; + loop { + match reader.read(&mut buffer) { + Ok(0) => return Ok(true), + Ok(length) => { + if output.len().saturating_add(length) > MAX_BLOCKING_OUTPUT_BYTES { + return Err(io::Error::other(format!( + "command output exceeded {MAX_BLOCKING_OUTPUT_BYTES} bytes" + ))); + } + output.extend_from_slice(&buffer[..length]); + drained += length; + if drained >= MAX_BLOCKING_DRAIN_BYTES { + return Ok(false); + } + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => return Ok(false), + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => return Err(error), + } + } +} + +fn terminate_blocking_process(child: &mut std::process::Child, pgid: i32) { + unsafe { + libc::kill(-pgid, libc::SIGKILL); + } + let _ = child.kill(); + let deadline = StdInstant::now() + REAP_TIMEOUT; + loop { + match child.try_wait() { + Ok(Some(_)) | Err(_) => return, + Ok(None) if StdInstant::now() < deadline => thread::sleep(BLOCKING_POLL_INTERVAL), + Ok(None) => return, + } + } +} + +async fn terminate_command( + child: &mut Child, + process_group: &mut ProcessGroupGuard, + stdout_reader: &mut JoinHandle>>, + stderr_reader: &mut JoinHandle>>, + stdin_writer: Option<&mut JoinHandle>>, +) { + process_group.kill(); + let _ = child.start_kill(); + let _ = tokio::time::timeout(REAP_TIMEOUT, child.wait()).await; + stdout_reader.abort(); + stderr_reader.abort(); + if let Some(stdin_writer) = stdin_writer { + stdin_writer.abort(); + } + process_group.disarm(); +} + +fn timeout_error(action: &str, timeout: Duration) -> anyhow::Error { + anyhow!( + "timed out after {} ms while trying to {action}", + timeout.as_millis() + ) +} + +struct ProcessGroupGuard { + pgid: i32, + armed: bool, +} + +impl ProcessGroupGuard { + fn new(pgid: i32) -> Self { + Self { pgid, armed: true } + } + + fn kill(&self) { + if self.armed { + unsafe { + libc::kill(-self.pgid, libc::SIGKILL); + } + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for ProcessGroupGuard { + fn drop(&mut self) { + self.kill(); + } +} + +struct CancelOnDrop(Option>); + +impl Drop for CancelOnDrop { + fn drop(&mut self) { + if let Some(cancel) = self.0.take() { + let _ = cancel.send(()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{fs, path::PathBuf, time::Instant}; + + #[tokio::test] + async fn timeout_kills_the_child_process() { + let pid_path = temporary_pid_path("leader"); + let mut command = Command::new("sh"); + command.args([ + "-c", + &format!("printf %s $$ > '{}'; exec sleep 60", pid_path.display()), + ]); + let started = Instant::now(); + + let error = output_with_timeout(command, "run test child", Duration::from_millis(20)) + .await + .unwrap_err(); + + assert!(error.to_string().contains("timed out")); + assert!(started.elapsed() < Duration::from_millis(500)); + let pid = wait_for_pid(&pid_path).await; + wait_for_process_exit(pid).await; + let _ = fs::remove_file(pid_path); + } + + #[tokio::test] + async fn timeout_kills_descendants_that_hold_output_pipes() { + let leader_path = temporary_pid_path("group-leader"); + let descendant_path = temporary_pid_path("group-descendant"); + let mut command = Command::new("sh"); + command.args([ + "-c", + &format!( + "printf %s $$ > '{}'; sleep 60 & printf %s $! > '{}'; wait", + leader_path.display(), + descendant_path.display() + ), + ]); + + let error = output_with_timeout(command, "run process tree", Duration::from_millis(100)) + .await + .unwrap_err(); + + assert!(error.to_string().contains("timed out")); + let leader = wait_for_pid(&leader_path).await; + let descendant = wait_for_pid(&descendant_path).await; + wait_for_process_exit(leader).await; + wait_for_process_exit(descendant).await; + let _ = fs::remove_file(leader_path); + let _ = fs::remove_file(descendant_path); + } + + #[tokio::test] + async fn caller_cancellation_kills_the_process_group() { + let leader_path = temporary_pid_path("cancel-leader"); + let descendant_path = temporary_pid_path("cancel-descendant"); + let mut command = Command::new("sh"); + command.args([ + "-c", + &format!( + "printf %s $$ > '{}'; sleep 60 & printf %s $! > '{}'; wait", + leader_path.display(), + descendant_path.display() + ), + ]); + let task = tokio::spawn(output_with_timeout( + command, + "run cancellable process tree", + Duration::from_secs(60), + )); + let leader = wait_for_pid(&leader_path).await; + let descendant = wait_for_pid(&descendant_path).await; + + task.abort(); + let _ = task.await; + + wait_for_process_exit(leader).await; + wait_for_process_exit(descendant).await; + let _ = fs::remove_file(leader_path); + let _ = fs::remove_file(descendant_path); + } + + #[tokio::test] + async fn cancellation_after_leader_exit_kills_pipe_holding_descendant() { + let descendant_path = temporary_pid_path("post-exit-descendant"); + let mut command = Command::new("sh"); + command.args([ + "-c", + &format!( + "sleep 60 & printf %s $! > '{}'; exit 0", + descendant_path.display() + ), + ]); + let task = tokio::spawn(output_with_timeout( + command, + "collect descendant output", + Duration::from_secs(60), + )); + let descendant = wait_for_pid(&descendant_path).await; + tokio::time::sleep(Duration::from_millis(20)).await; + assert!( + !task.is_finished(), + "descendant should still hold output pipes" + ); + + task.abort(); + let _ = task.await; + + wait_for_process_exit(descendant).await; + let _ = fs::remove_file(descendant_path); + } + + #[tokio::test] + async fn output_with_stdin_writes_and_closes_input() { + let mut command = Command::new("sh"); + command.args(["-c", "cat"]); + + let output = output_with_stdin( + command, + "echo stdin", + Duration::from_secs(1), + b"portal input".to_vec(), + ) + .await + .expect("stdin command should complete"); + + assert!(output.status.success()); + assert_eq!(output.stdout, b"portal input"); + } + + #[tokio::test] + async fn command_output_is_bounded() { + let mut command = Command::new("sh"); + command.args(["-c", "head -c 9000000 /dev/zero"]); + + let error = + output_with_timeout(command, "capture excessive output", Duration::from_secs(5)) + .await + .unwrap_err(); + + assert!(format!("{error:#}").contains("output exceeded")); + } + + #[tokio::test] + async fn command_output_drains_large_stdout_and_stderr() { + let mut command = Command::new("sh"); + command.args([ + "-c", + "yes stdout | head -c 200000; yes stderr | head -c 200000 >&2", + ]); + + let output = output_with_timeout(command, "collect noisy output", Duration::from_secs(5)) + .await + .expect("noisy command should complete"); + + assert!(output.status.success()); + assert!(output.stdout.len() >= 200_000); + assert!(output.stderr.len() >= 200_000); + } + + fn temporary_pid_path(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "computer-use-linux-command-runner-{label}-{}-{}.pid", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } + + async fn wait_for_pid(path: &PathBuf) -> u32 { + for _ in 0..100 { + if let Ok(value) = fs::read_to_string(path) { + if let Ok(pid) = value.parse() { + return pid; + } + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + panic!("child did not record its pid") + } + + async fn wait_for_process_exit(pid: u32) { + for _ in 0..100 { + if !PathBuf::from(format!("/proc/{pid}")).exists() { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + unsafe { + libc::kill(pid as i32, libc::SIGKILL); + } + panic!("process {pid} was not killed and reaped") + } +} diff --git a/computer-use-linux/src/cosmic_helper.rs b/computer-use-linux/src/cosmic_helper.rs index 40bb0553d..89e52fe15 100644 --- a/computer-use-linux/src/cosmic_helper.rs +++ b/computer-use-linux/src/cosmic_helper.rs @@ -1,12 +1,16 @@ +use crate::command_runner; use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; use std::{ env, path::{Path, PathBuf}, - process::Command, + process::Command as StdCommand, + time::Duration, }; +use tokio::process::Command; pub const COSMIC_HELPER_BINARY: &str = "codex-computer-use-cosmic"; +const PROBE_TIMEOUT: Duration = Duration::from_secs(2); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CosmicHelperProbe { @@ -22,6 +26,15 @@ pub struct CosmicHelperActivation { pub detail: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CosmicMonitor { + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, + pub scale: f64, +} + pub fn resolve_helper_binary() -> Result { if let Some(path) = env_var("CODEX_COMPUTER_USE_COSMIC_HELPER") .or_else(|| env_var("COMPUTER_USE_LINUX_COSMIC_HELPER")) @@ -54,16 +67,20 @@ pub fn probe() -> Result { run_json_command(["probe"]) } -pub fn list_windows_json() -> Result { - run_text_command(["list-windows"]) +pub async fn list_windows_json() -> Result { + run_text_command_async(["list-windows"]).await } -pub fn focused_window_json() -> Result { - run_text_command(["focused-window"]) +pub async fn focused_window_json() -> Result { + run_text_command_async(["focused-window"]).await } -pub fn activate_window(window_id: u64) -> Result { - run_json_command(["activate-window", "--window-id", &window_id.to_string()]) +pub async fn monitor_layout() -> Result> { + run_json_command_async(["monitor-layout"]).await +} + +pub async fn activate_window(window_id: u64) -> Result { + run_json_command_async(["activate-window", "--window-id", &window_id.to_string()]).await } fn run_json_command(args: I) -> Result @@ -77,12 +94,23 @@ where .with_context(|| format!("failed to parse {COSMIC_HELPER_BINARY} JSON output")) } -fn run_text_command(args: I) -> Result +async fn run_json_command_async(args: I) -> Result +where + T: for<'de> Deserialize<'de>, + I: IntoIterator, + S: AsRef, +{ + let output = run_command_async(args).await?; + serde_json::from_str(&output) + .with_context(|| format!("failed to parse {COSMIC_HELPER_BINARY} JSON output")) +} + +async fn run_text_command_async(args: I) -> Result where I: IntoIterator, S: AsRef, { - run_command(args) + run_command_async(args).await } fn run_command(args: I) -> Result @@ -95,10 +123,46 @@ where .into_iter() .map(|arg| arg.as_ref().to_string()) .collect::>(); - let output = Command::new(&helper) - .args(&args) - .output() - .with_context(|| format!("failed to run {}", helper.display()))?; + let mut command = StdCommand::new(&helper); + command.args(&args); + let output = command_runner::output_blocking_with_timeout( + &mut command, + &format!("run {}", helper.display()), + PROBE_TIMEOUT, + )?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = if !stderr.is_empty() { stderr } else { stdout }; + bail!( + "{} {} failed{}", + helper.display(), + args.join(" "), + if detail.is_empty() { + String::new() + } else { + format!(": {detail}") + } + ); + } + String::from_utf8(output.stdout) + .map(|text| text.trim().to_string()) + .context("helper output was not valid UTF-8") +} + +async fn run_command_async(args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let helper = resolve_helper_binary()?; + let args = args + .into_iter() + .map(|arg| arg.as_ref().to_string()) + .collect::>(); + let mut command = Command::new(&helper); + command.args(&args); + let output = command_runner::output(command, &format!("run {}", helper.display())).await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); diff --git a/computer-use-linux/src/diagnostics.rs b/computer-use-linux/src/diagnostics.rs index 41e5221df..469374f56 100644 --- a/computer-use-linux/src/diagnostics.rs +++ b/computer-use-linux/src/diagnostics.rs @@ -1,6 +1,6 @@ use crate::windowing::registry::{ self, COSMIC_WAYLAND_BACKEND, GNOME_SHELL_EXTENSION_BACKEND, GNOME_SHELL_INTROSPECT_BACKEND, - HYPRLAND_BACKEND, KWIN_BACKEND, NIRI_BACKEND, + HYPRLAND_BACKEND, I3_BACKEND, KWIN_BACKEND, NIRI_BACKEND, X11_BACKEND, }; use crate::ydotool; use schemars::JsonSchema; @@ -9,10 +9,7 @@ use std::{ collections::{BTreeMap, HashMap}, env, fs, fs::OpenOptions, - os::unix::{ - fs::MetadataExt, - net::{UnixDatagram, UnixStream}, - }, + os::unix::{fs::MetadataExt, net::UnixDatagram}, path::{Path, PathBuf}, process::Command, }; @@ -31,6 +28,26 @@ const DESKTOP_ENV_KEYS: &[&str] = &[ "XDG_RUNTIME_DIR", "XDG_SESSION_TYPE", ]; +const FORCE_YDOTOOL_KEYBOARD_ENV_KEYS: &[&str] = &[ + "COMPUTER_USE_LINUX_FORCE_YDOTOOL_KEYBOARD", + "CODEX_COMPUTER_USE_FORCE_YDOTOOL_KEYBOARD", +]; +const FORCE_YDOTOOL_POINTER_ENV_KEYS: &[&str] = &[ + "COMPUTER_USE_LINUX_FORCE_YDOTOOL_POINTER", + "CODEX_COMPUTER_USE_FORCE_YDOTOOL_POINTER", +]; +const FORCE_XDOTOOL_KEYBOARD_ENV_KEYS: &[&str] = &[ + "COMPUTER_USE_LINUX_FORCE_XDOTOOL_KEYBOARD", + "CODEX_COMPUTER_USE_FORCE_XDOTOOL_KEYBOARD", +]; +const FORCE_PORTAL_KEYBOARD_ENV_KEYS: &[&str] = &[ + "COMPUTER_USE_LINUX_FORCE_PORTAL_KEYBOARD", + "CODEX_COMPUTER_USE_FORCE_PORTAL_KEYBOARD", +]; +const FORCE_PORTAL_POINTER_ENV_KEYS: &[&str] = &[ + "COMPUTER_USE_LINUX_FORCE_PORTAL_POINTER", + "CODEX_COMPUTER_USE_FORCE_PORTAL_POINTER", +]; #[derive(Debug, Clone, Serialize, JsonSchema)] pub struct DoctorReport { @@ -126,6 +143,9 @@ pub struct InputReport { pub ydotoold: Check, pub ydotool_socket: Check, pub uinput: Check, + /// X11 XTEST keyboard backend. Preferred over ydotool on X11 sessions, + /// where raw evdev scancodes are re-mapped by the active XKB layout. + pub xdotool: Check, } #[derive(Debug, Clone, Serialize, JsonSchema)] @@ -209,12 +229,27 @@ fn capability_map( if input.uinput.ok { input_backends.push("abs_pointer".to_string()); } - if portals.remote_desktop.ok { + let force_ydotool = env_flag_enabled_any(FORCE_YDOTOOL_KEYBOARD_ENV_KEYS); + let force_xdotool = env_flag_enabled_any(FORCE_XDOTOOL_KEYBOARD_ENV_KEYS); + let portal_available = portal_input_available(platform, portals); + let portal_forced_for_all_input = force_portal_for_all_input( + env_flag_enabled_any(FORCE_PORTAL_POINTER_ENV_KEYS), + env_flag_enabled_any(FORCE_PORTAL_KEYBOARD_ENV_KEYS), + env_flag_enabled_any(FORCE_YDOTOOL_POINTER_ENV_KEYS), + force_ydotool, + ); + if should_advertise_xdotool(platform, input, force_ydotool, force_xdotool) { + input_backends.push("xdotool".to_string()); + } + if portal_available && portal_forced_for_all_input { input_backends.push("portal".to_string()); } - if input.ydotool.ok && input.ydotoold.ok && input.ydotool_socket.ok { + if input.ydotool.ok && input.ydotool_socket.ok { input_backends.push("ydotool".to_string()); } + if portal_available && !portal_forced_for_all_input { + input_backends.push("portal".to_string()); + } let mut screenshot_backends = Vec::new(); if platform.gnome_shell_version.ok { @@ -232,12 +267,25 @@ fn capability_map( } let mut window_backends = Vec::new(); + let x11_available = windowing + .backends + .get(X11_BACKEND) + .is_some_and(|check| check.ok); + let prefer_x11_over_introspect = windowing.gnome_shell_introspect.ok + && x11_available + && registry::backend_can_exact_focus(X11_BACKEND); if windowing.codex_gnome_shell_extension.ok { window_backends.push("gnome_shell_extension".to_string()); } + if prefer_x11_over_introspect { + window_backends.push(X11_BACKEND.to_string()); + } if windowing.gnome_shell_introspect.ok { window_backends.push("gnome_introspect".to_string()); } + if windowing.cosmic_helper.ok { + window_backends.push("cosmic".to_string()); + } if windowing.kwin.ok { window_backends.push("kwin".to_string()); } @@ -247,8 +295,18 @@ fn capability_map( if windowing.niri.ok { window_backends.push(NIRI_BACKEND.to_string()); } - if windowing.cosmic_helper.ok { - window_backends.push("cosmic".to_string()); + // i3 and the generic X11/EWMH backend have no dedicated WindowingReport + // field; read them from the probe map (tried last) so the capability list + // matches the backends the registry will actually use. + if windowing + .backends + .get(I3_BACKEND) + .is_some_and(|check| check.ok) + { + window_backends.push(I3_BACKEND.to_string()); + } + if x11_available && !prefer_x11_over_introspect { + window_backends.push(X11_BACKEND.to_string()); } let mut accessibility_backends = Vec::new(); @@ -351,19 +409,50 @@ fn hydrate_desktop_env_from_systemd_user() { } fn hydrate_desktop_env_from_map(process_env: &HashMap) { - for key in DESKTOP_ENV_KEYS { - if env_var(key).is_some() { - continue; - } - if let Some(value) = process_env - .get(*key) - .filter(|value| !value.trim().is_empty()) - { - env::set_var(key, value); - } + let current_env = DESKTOP_ENV_KEYS + .iter() + .filter_map(|key| env_var(key).map(|value| ((*key).to_string(), value))) + .collect(); + for (key, value) in desktop_env_hydration_updates(¤t_env, process_env) { + env::set_var(key, value); } } +fn desktop_env_hydration_updates( + current_env: &HashMap, + source_env: &HashMap, +) -> Vec<(&'static str, String)> { + // A nested X11 desktop can share a user manager with a Wayland host. + // Preserve its complete process-local session instead of grafting the + // host's WAYLAND_DISPLAY onto it. + let preserve_native_x11 = current_env + .get("XDG_SESSION_TYPE") + .is_some_and(|value| value.trim().eq_ignore_ascii_case("x11")) + && current_env + .get("DISPLAY") + .is_some_and(|value| !value.trim().is_empty()) + && !current_env + .get("WAYLAND_DISPLAY") + .is_some_and(|value| !value.trim().is_empty()); + + DESKTOP_ENV_KEYS + .iter() + .filter_map(|key| { + if current_env + .get(*key) + .is_some_and(|value| !value.trim().is_empty()) + || preserve_native_x11 && *key == "WAYLAND_DISPLAY" + { + return None; + } + source_env + .get(*key) + .filter(|value| !value.trim().is_empty()) + .map(|value| (*key, value.clone())) + }) + .collect() +} + fn desktop_process_environments() -> Vec> { let mut environments = Vec::new(); let mut visited_pids = Vec::new(); @@ -594,7 +683,9 @@ fn windowing_report(platform: &PlatformReport) -> WindowingReport { let can_focus_apps = probes.iter().any(|probe| probe.can_focus_apps); let can_focus_windows = probes.iter().any(|probe| probe.can_focus_windows); let note = if can_list_windows { - if cosmic_helper.ok && is_cosmic_wayland_platform(platform) { + if !can_focus_windows { + "A window listing backend is available for list_windows, but focused-window and targeted-input verification are unavailable (for example wmctrl is present but xprop is missing on X11)." + } else if cosmic_helper.ok && is_cosmic_wayland_platform(platform) { "A COSMIC Wayland window backend is available for list_windows, focused_window, and targeted input verification." } else if kwin.ok { "A KWin/Plasma window backend is available for list_windows, focused_window, and targeted input verification." @@ -603,7 +694,7 @@ fn windowing_report(platform: &PlatformReport) -> WindowingReport { } else if niri.ok { "A Niri window backend is available for list_windows, focused_window, and targeted input verification." } else { - "A GNOME window listing backend is available for list_windows, focused_window, and targeted input verification." + "A window listing backend is available for list_windows, focused_window, and targeted input verification." } } else { "Window listing is unavailable or denied. Computer Use can still use screenshots, AT-SPI, and global ydotool input, but targeted window input cannot be verified. On GNOME, run setup_window_targeting to install the optional GNOME Shell extension backend. On COSMIC, ensure the bundled COSMIC helper is present and can connect to the session. On KDE/Plasma, ensure KWin exposes org.kde.KWin scripting on the session bus. On Hyprland, ensure hyprctl is available in the session. On Niri, ensure NIRI_SOCKET is available and niri msg can reach the active compositor." @@ -637,12 +728,13 @@ fn check_from_backend_probe(probe: ®istry::BackendProbe) -> Check { fn input_report() -> InputReport { InputReport { ydotool: match ydotool::ensure_supported() { - Ok(detail) => Check::ok(detail), + Ok(support) => Check::ok(support.detail), Err(detail) => Check::fail(detail), }, ydotoold: process_check("ydotoold"), ydotool_socket: ydotool_socket_check(), uinput: read_write_path_check(Path::new("/dev/uinput")), + xdotool: command_path_check("xdotool"), } } @@ -658,7 +750,7 @@ fn readiness_report( let can_query_windows = windowing.can_list_windows; let can_focus_apps = windowing.can_focus_apps; let can_focus_windows = windowing.can_focus_windows; - let can_send_development_input = can_send_development_input(portals, input); + let can_send_development_input = can_send_development_input(platform, portals, input); if !can_build_accessibility_tree { blockers.push( @@ -685,7 +777,7 @@ fn readiness_report( if !can_send_development_input { blockers.push( - "Development input is unavailable; enable read/write /dev/uinput, XDG RemoteDesktop portal input, or ydotool with a connectable ydotoold socket." + "Development input is unavailable; enable read/write /dev/uinput, XDG RemoteDesktop portal input on Wayland, xdotool with DISPLAY on X11, or ydotool with a connectable ydotoold socket." .to_string(), ); } @@ -705,7 +797,7 @@ fn readiness_report( } else if !can_focus_windows { "Enable an exact-focus window backend before using window_id, title, or terminal-targeted input.".to_string() } else if !can_send_development_input { - "Enable a supported input backend: grant read/write /dev/uinput, enable the XDG RemoteDesktop portal, or start ydotoold with a socket accessible to this desktop user." + "Enable a supported input backend: grant read/write /dev/uinput, enable the XDG RemoteDesktop portal on Wayland, install xdotool for X11, or start ydotoold with a socket accessible to this desktop user." .to_string() } else { "Computer Use is ready: AT-SPI tree support, window targeting, and a Linux input backend are available." @@ -724,10 +816,21 @@ fn readiness_report( } } -fn can_send_development_input(portals: &PortalReport, input: &InputReport) -> bool { +fn can_send_development_input( + platform: &PlatformReport, + portals: &PortalReport, + input: &InputReport, +) -> bool { + let force_ydotool = env_flag_enabled_any(FORCE_YDOTOOL_KEYBOARD_ENV_KEYS); + let force_xdotool = env_flag_enabled_any(FORCE_XDOTOOL_KEYBOARD_ENV_KEYS); input.uinput.ok - || portals.remote_desktop.ok - || input.ydotool.ok && input.ydotoold.ok && input.ydotool_socket.ok + || portal_input_available(platform, portals) + || should_advertise_xdotool(platform, input, force_ydotool, force_xdotool) + || input.ydotool.ok && input.ydotool_socket.ok +} + +fn portal_input_available(platform: &PlatformReport, portals: &PortalReport) -> bool { + platform_is_wayland(platform) && portals.remote_desktop.ok } fn is_cosmic_wayland_platform(platform: &PlatformReport) -> bool { @@ -809,6 +912,52 @@ fn user_id() -> Option { .filter(|value| !value.is_empty()) } +fn command_path_check(command: &str) -> Check { + command_check("sh", &["-c", &format!("command -v {command}")]) +} + +fn platform_is_wayland(platform: &PlatformReport) -> bool { + match platform.xdg_session_type.as_deref() { + Some(value) => value.eq_ignore_ascii_case("wayland"), + None => platform + .wayland_display + .as_deref() + .is_some_and(|display| !display.trim().is_empty()), + } +} + +fn should_advertise_xdotool( + platform: &PlatformReport, + input: &InputReport, + force_ydotool: bool, + force_xdotool: bool, +) -> bool { + !force_ydotool + && input.xdotool.ok + && platform + .display + .as_deref() + .is_some_and(|display| !display.trim().is_empty()) + && (force_xdotool || !platform_is_wayland(platform)) +} + +fn env_flag_enabled_any(keys: &[&str]) -> bool { + keys.iter() + .any(|key| env::var(key).ok().as_deref() == Some("1")) +} + +fn force_portal_for_all_input( + force_portal_pointer: bool, + force_portal_keyboard: bool, + force_ydotool_pointer: bool, + force_ydotool_keyboard: bool, +) -> bool { + force_portal_pointer + && force_portal_keyboard + && !force_ydotool_pointer + && !force_ydotool_keyboard +} + fn process_check(process_name: &str) -> Check { command_check("pgrep", &["-a", process_name]) } @@ -826,20 +975,9 @@ fn socket_connect_result(path: &Path) -> std::result::Result<(), String> { return Err(format!("missing: {}", path.display())); } - match UnixStream::connect(path) { - Ok(_) => Ok(()), - Err(stream_error) => { - match UnixDatagram::unbound().and_then(|socket| socket.connect(path)) { - Ok(()) => Ok(()), - Err(datagram_error) => Err(format!( - "{}: stream: {}; datagram: {}", - path.display(), - stream_error, - datagram_error - )), - } - } - } + UnixDatagram::unbound() + .and_then(|socket| socket.connect(path)) + .map_err(|error| format!("{}: datagram: {error}", path.display())) } fn read_write_path_check(path: &Path) -> Check { @@ -1092,6 +1230,7 @@ mod tests { ydotoold, ydotool_socket, uinput, + xdotool: Check::fail("missing xdotool"), } } @@ -1164,6 +1303,40 @@ mod tests { assert!(DESKTOP_ENV_KEYS.contains(&"NIRI_SOCKET")); } + #[test] + fn desktop_env_hydration_preserves_explicit_native_x11() { + let current_env = HashMap::from([ + ("DISPLAY".to_string(), ":90".to_string()), + ("XDG_SESSION_TYPE".to_string(), "x11".to_string()), + ]); + let host_env = HashMap::from([ + ("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string()), + ( + "XDG_CURRENT_DESKTOP".to_string(), + "ubuntu:GNOME".to_string(), + ), + ]); + + let updates = desktop_env_hydration_updates(¤t_env, &host_env); + + assert!(!updates.iter().any(|(key, _)| *key == "WAYLAND_DISPLAY")); + assert!(updates + .iter() + .any(|(key, value)| { *key == "XDG_CURRENT_DESKTOP" && value == "ubuntu:GNOME" })); + } + + #[test] + fn desktop_env_hydration_still_imports_wayland_for_incomplete_sessions() { + let current_env = HashMap::new(); + let host_env = HashMap::from([("WAYLAND_DISPLAY".to_string(), "wayland-0".to_string())]); + + let updates = desktop_env_hydration_updates(¤t_env, &host_env); + + assert!(updates + .iter() + .any(|(key, value)| *key == "WAYLAND_DISPLAY" && value == "wayland-0")); + } + #[test] fn graphical_process_env_requires_display() { let with_display = HashMap::from([("DISPLAY".to_string(), ":0".to_string())]); @@ -1176,6 +1349,145 @@ mod tests { assert!(!process_env_has_graphical_display(&without_display)); } + #[test] + fn capabilities_prefer_xdotool_before_ydotool_on_x11() { + let mut platform = platform_report(); + platform.xdg_session_type = Some("x11".to_string()); + platform.wayland_display = None; + platform.display = Some(":0".to_string()); + let input = InputReport { + ydotool: Check::ok("ydotool"), + ydotoold: Check::ok("ydotoold"), + ydotool_socket: Check::ok("connectable"), + uinput: Check::fail("missing"), + xdotool: Check::ok("xdotool"), + }; + + let capabilities = capability_map( + &platform, + &portal_report(Check::fail("missing")), + &accessibility_report(Check::fail("missing"), Check::fail("missing")), + &windowing_report(false, false), + &input, + ); + + assert_eq!(capabilities.input, ["xdotool", "ydotool"]); + assert_eq!(capabilities.preferred.input.as_deref(), Some("xdotool")); + } + + #[test] + fn x11_diagnostics_ignore_portal_and_accept_xdotool() { + let mut platform = platform_report(); + platform.xdg_session_type = Some("x11".to_string()); + platform.wayland_display = None; + platform.display = Some(":0".to_string()); + let portals = portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop")); + let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true")); + let windowing = windowing_report(true, true); + let mut input = input_report(false); + input.xdotool = Check::ok("xdotool"); + + let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input); + let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input); + + assert_eq!(capabilities.input, ["xdotool"]); + assert_eq!(capabilities.preferred.input.as_deref(), Some("xdotool")); + assert!(readiness.can_send_development_input); + } + + #[test] + fn wayland_diagnostics_prefer_ydotool_before_portal() { + let platform = platform_report(); + let portals = portal_report(Check::ok("org.freedesktop.portal.RemoteDesktop")); + let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true")); + let windowing = windowing_report(true, true); + let input = input_report_parts( + Check::ok("ydotool"), + Check::ok("ydotoold"), + Check::ok("connectable"), + Check::fail("missing uinput"), + ); + + let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input); + + assert_eq!(capabilities.input, ["ydotool", "portal"]); + assert_eq!(capabilities.preferred.input.as_deref(), Some("ydotool")); + } + + #[test] + fn portal_force_order_requires_both_input_modalities() { + assert!(force_portal_for_all_input(true, true, false, false)); + assert!(!force_portal_for_all_input(true, false, false, false)); + assert!(!force_portal_for_all_input(true, true, true, false)); + assert!(!force_portal_for_all_input(true, true, false, true)); + assert_eq!( + FORCE_PORTAL_POINTER_ENV_KEYS, + [ + "COMPUTER_USE_LINUX_FORCE_PORTAL_POINTER", + "CODEX_COMPUTER_USE_FORCE_PORTAL_POINTER" + ] + ); + assert_eq!( + FORCE_PORTAL_KEYBOARD_ENV_KEYS, + [ + "COMPUTER_USE_LINUX_FORCE_PORTAL_KEYBOARD", + "CODEX_COMPUTER_USE_FORCE_PORTAL_KEYBOARD" + ] + ); + } + + #[test] + fn capabilities_require_display_to_advertise_xdotool() { + let mut platform = platform_report(); + platform.xdg_session_type = Some("x11".to_string()); + platform.wayland_display = None; + platform.display = None; + let input = InputReport { + ydotool: Check::ok("ydotool"), + ydotoold: Check::ok("ydotoold"), + ydotool_socket: Check::ok("connectable"), + uinput: Check::fail("missing"), + xdotool: Check::ok("xdotool"), + }; + + let capabilities = capability_map( + &platform, + &portal_report(Check::fail("missing")), + &accessibility_report(Check::fail("missing"), Check::fail("missing")), + &windowing_report(false, false), + &input, + ); + + assert_eq!(capabilities.input, ["ydotool"]); + assert_eq!(capabilities.preferred.input.as_deref(), Some("ydotool")); + } + + #[test] + fn xdotool_diagnostics_force_precedence_matches_runtime() { + let mut platform = platform_report(); + platform.xdg_session_type = Some("wayland".to_string()); + platform.display = Some(":0".to_string()); + let mut input = input_report(false); + input.xdotool = Check::ok("xdotool"); + + assert!(should_advertise_xdotool(&platform, &input, false, true)); + assert!(!should_advertise_xdotool(&platform, &input, true, true)); + assert_eq!( + FORCE_XDOTOOL_KEYBOARD_ENV_KEYS, + [ + "COMPUTER_USE_LINUX_FORCE_XDOTOOL_KEYBOARD", + "CODEX_COMPUTER_USE_FORCE_XDOTOOL_KEYBOARD" + ] + ); + assert_eq!( + FORCE_YDOTOOL_KEYBOARD_ENV_KEYS, + [ + "COMPUTER_USE_LINUX_FORCE_YDOTOOL_KEYBOARD", + "CODEX_COMPUTER_USE_FORCE_YDOTOOL_KEYBOARD" + ] + ); + } + #[test] fn readiness_requires_exact_window_focus_for_targeted_input() { let platform = platform_report(); @@ -1317,6 +1629,29 @@ mod tests { assert!(readiness.blockers.is_empty()); } + #[test] + fn readiness_uses_connectable_ydotool_socket_when_process_probe_fails() { + let platform = platform_report(); + let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true")); + let windowing = windowing_report(true, true); + let input = input_report_parts( + Check::ok("ydotool"), + Check::fail("ydotoold process name not found"), + Check::ok("connectable: /run/user/1000/.ydotool_socket"), + Check::fail("/dev/uinput: Permission denied"), + ); + let portals = portal_report(Check::fail("missing")); + + let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input); + let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input); + + assert!(capabilities + .input + .iter() + .any(|backend| backend == "ydotool")); + assert!(readiness.can_send_development_input); + } + #[test] fn readiness_accepts_direct_uinput_without_connectable_ydotool_socket() { let platform = platform_report(); @@ -1396,7 +1731,7 @@ mod tests { } #[test] - fn ydotool_socket_check_requires_a_connectable_socket() { + fn ydotool_socket_check_rejects_legacy_stream_socket() { let dir = std::env::temp_dir().join(format!( "codex-computer-use-diagnostics-{}", std::process::id() @@ -1409,7 +1744,7 @@ mod tests { let check = socket_connect_check(&socket); - assert!(check.ok, "{check:?}"); + assert!(!check.ok, "{check:?}"); drop(listener); let _ = std::fs::remove_dir_all(&dir); } diff --git a/computer-use-linux/src/lib.rs b/computer-use-linux/src/lib.rs index eb2493a0e..80068ce38 100644 --- a/computer-use-linux/src/lib.rs +++ b/computer-use-linux/src/lib.rs @@ -1,5 +1,6 @@ pub mod abs_pointer; pub mod atspi_tree; +mod command_runner; pub mod cosmic_helper; pub mod diagnostics; pub mod gnome_extension; diff --git a/computer-use-linux/src/main.rs b/computer-use-linux/src/main.rs index cb637e59c..78917bed9 100644 --- a/computer-use-linux/src/main.rs +++ b/computer-use-linux/src/main.rs @@ -7,6 +7,7 @@ static GLOBAL: MiMalloc = MiMalloc; mod abs_pointer; mod atspi_tree; +mod command_runner; mod cosmic_helper; mod diagnostics; mod gnome_extension; diff --git a/computer-use-linux/src/remote_desktop.rs b/computer-use-linux/src/remote_desktop.rs index b8030e82a..a7caccecc 100644 --- a/computer-use-linux/src/remote_desktop.rs +++ b/computer-use-linux/src/remote_desktop.rs @@ -1,7 +1,16 @@ -use crate::diagnostics::hydrate_session_bus_env; +use crate::{command_runner, diagnostics::hydrate_session_bus_env}; use anyhow::{bail, Context, Result}; use futures_util::StreamExt; -use std::{collections::HashMap, time::Duration}; +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, OnceLock, + }, + time::Duration, +}; +use tokio::process::Command; +use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard}; use xkeysym::Keysym; use zbus::{ proxy::SignalStream, @@ -14,7 +23,11 @@ const PORTAL_DESKTOP_PATH: &str = "/org/freedesktop/portal/desktop"; const PORTAL_REMOTE_DESKTOP_INTERFACE: &str = "org.freedesktop.portal.RemoteDesktop"; const PORTAL_SCREENCAST_INTERFACE: &str = "org.freedesktop.portal.ScreenCast"; const PORTAL_REQUEST_INTERFACE: &str = "org.freedesktop.portal.Request"; +const PORTAL_SESSION_INTERFACE: &str = "org.freedesktop.portal.Session"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(120); +const PORTAL_CALL_TIMEOUT: Duration = Duration::from_secs(5); +const RELEASE_TIMEOUT: Duration = Duration::from_secs(1); +const INPUT_TIMEOUT: Duration = Duration::from_secs(3); const DEVICE_KEYBOARD: u32 = 1; const DEVICE_POINTER: u32 = 2; @@ -43,12 +56,30 @@ pub struct PortalPointerSession { connection: Connection, session_handle: OwnedObjectPath, streams: Vec, + desktop_layout: Option>, + input_lock: Arc>, + valid: Arc, } #[derive(Clone)] pub struct PortalKeyboardSession { connection: Connection, session_handle: OwnedObjectPath, + input_lock: Arc>, + valid: Arc, +} + +#[derive(Clone)] +pub(crate) struct InputOperationGuard { + _guard: Arc>, +} + +impl InputOperationGuard { + pub(crate) fn new(guard: OwnedMutexGuard<()>) -> Self { + Self { + _guard: Arc::new(guard), + } + } } #[derive(Debug, Clone)] @@ -58,6 +89,45 @@ struct PortalStream { size: Option<(i32, i32)>, } +#[derive(Debug, Clone)] +struct LogicalMonitor { + x: i32, + y: i32, + width: i32, + height: i32, + scale: f64, +} + +#[derive(Debug, Clone, Copy)] +enum PressedKey { + Keysym(i32), + Keycode(i32), +} + +struct PointerReleaseGuard { + connection: Connection, + session_handle: OwnedObjectPath, + button: Option, + input_guard: Option>, + operation_guard: Option, + valid: Arc, +} + +struct KeyboardReleaseGuard { + connection: Connection, + session_handle: OwnedObjectPath, + pressed: Vec, + input_guard: Option>, + operation_guard: Option, + valid: Arc, +} + +struct PortalSessionCleanup { + connection: Connection, + session_handle: OwnedObjectPath, + armed: bool, +} + #[derive(Debug, Clone, Copy)] pub enum PointerButton { Left, @@ -84,6 +154,7 @@ pub async fn start_portal_pointer_session() -> Result { .await .context("failed to connect to session bus for remote desktop portal")?; let session_handle = create_remote_desktop_session(&connection).await?; + let mut cleanup = PortalSessionCleanup::new(connection.clone(), session_handle.clone()); select_pointer_devices(&connection, &session_handle).await?; select_monitor_sources(&connection, &session_handle).await?; let (devices, streams) = start_remote_desktop_session(&connection, &session_handle).await?; @@ -95,10 +166,16 @@ pub async fn start_portal_pointer_session() -> Result { bail!("remote desktop portal session started without any monitor streams"); } + let desktop_layout = logical_desktop_layout().await; + cleanup.disarm(); + Ok(PortalPointerSession { connection, session_handle, streams, + desktop_layout, + input_lock: portal_input_lock(), + valid: Arc::new(AtomicBool::new(true)), }) } @@ -109,16 +186,354 @@ pub async fn start_portal_keyboard_session() -> Result { .await .context("failed to connect to session bus for remote desktop portal")?; let session_handle = create_remote_desktop_session(&connection).await?; + let mut cleanup = PortalSessionCleanup::new(connection.clone(), session_handle.clone()); select_keyboard_devices(&connection, &session_handle).await?; let (devices, _) = start_remote_desktop_session(&connection, &session_handle).await?; if devices & DEVICE_KEYBOARD == 0 { bail!("remote desktop portal session started without keyboard access"); } + cleanup.disarm(); Ok(PortalKeyboardSession { connection, session_handle, + input_lock: portal_input_lock(), + valid: Arc::new(AtomicBool::new(true)), + }) +} + +fn portal_input_lock() -> Arc> { + static INPUT_LOCK: OnceLock>> = OnceLock::new(); + Arc::clone(INPUT_LOCK.get_or_init(|| Arc::new(AsyncMutex::new(())))) +} + +async fn logical_desktop_layout() -> Option> { + if env_token_contains("XDG_CURRENT_DESKTOP", "gnome") { + if let Some(layout) = crate::windowing::backends::gnome::extension_monitor_layout() + .await + .ok() + .filter(|monitors| !monitors.is_empty()) + .map(|monitors| { + monitors + .into_iter() + .map(|monitor| LogicalMonitor { + x: monitor.x, + y: monitor.y, + width: monitor.width, + height: monitor.height, + scale: monitor.scale, + }) + .collect::>() + }) + { + if layout.iter().all(|monitor| { + monitor.width > 0 + && monitor.height > 0 + && monitor.scale.is_finite() + && monitor.scale >= 0.0 + }) { + return Some(layout); + } + } + } + + if env_token_contains("XDG_CURRENT_DESKTOP", "hyprland") { + let mut command = Command::new("hyprctl"); + command.args(["monitors", "-j"]); + if let Ok(output) = command_runner::output(command, "query Hyprland monitor layout").await { + if output.status.success() { + if let Some(layout) = parse_hyprland_monitor_layout(&output.stdout) { + return Some(layout); + } + } + } + } + + if env_token_contains("XDG_CURRENT_DESKTOP", "kde") + || env_token_contains("XDG_CURRENT_DESKTOP", "plasma") + { + let mut command = Command::new("kscreen-doctor"); + command.arg("-j"); + if let Ok(output) = command_runner::output(command, "query KDE monitor layout").await { + if output.status.success() { + if let Some(layout) = parse_kscreen_monitor_layout(&output.stdout) { + return Some(layout); + } + } + } + } + + if env_token_contains("XDG_CURRENT_DESKTOP", "sway") || std::env::var_os("SWAYSOCK").is_some() { + let mut command = Command::new("swaymsg"); + command.args(["-t", "get_outputs", "-r"]); + if let Ok(output) = command_runner::output(command, "query Sway output layout").await { + if output.status.success() { + if let Some(layout) = parse_sway_monitor_layout(&output.stdout) { + return Some(layout); + } + } + } + } + + if env_token_contains("XDG_CURRENT_DESKTOP", "cosmic") { + if let Ok(monitors) = crate::cosmic_helper::monitor_layout().await { + let layout = monitors + .into_iter() + .map(|monitor| LogicalMonitor { + x: monitor.x, + y: monitor.y, + width: monitor.width, + height: monitor.height, + scale: monitor.scale, + }) + .collect::>(); + if !layout.is_empty() + && layout.iter().all(|monitor| { + monitor.width > 0 + && monitor.height > 0 + && monitor.scale.is_finite() + && monitor.scale > 0.0 + }) + { + return Some(layout); + } + } + } + + if env_token_contains("XDG_CURRENT_DESKTOP", "niri") { + let mut command = Command::new("niri"); + command.args(["msg", "--json", "outputs"]); + if let Ok(output) = command_runner::output(command, "query Niri output layout").await { + if output.status.success() { + if let Some(layout) = parse_niri_monitor_layout(&output.stdout) { + return Some(layout); + } + } + } + } + + let mut command = Command::new("xrandr"); + command.arg("--listactivemonitors"); + let output = command_runner::output(command, "query XRandR monitor layout") + .await + .ok()?; + output + .status + .success() + .then(|| parse_xrandr_monitor_layout(&String::from_utf8_lossy(&output.stdout))) + .flatten() +} + +#[derive(serde::Deserialize)] +struct HyprlandMonitorLayout { + x: i32, + y: i32, + width: i32, + height: i32, + scale: f64, + #[serde(default)] + transform: i32, +} + +fn parse_hyprland_monitor_layout(json: &[u8]) -> Option> { + let monitors: Vec = serde_json::from_slice(json).ok()?; + let layout = monitors + .into_iter() + .map(|monitor| { + if !monitor.scale.is_finite() + || monitor.scale <= 0.0 + || monitor.width <= 0 + || monitor.height <= 0 + { + return None; + } + let (width, height) = if monitor.transform.rem_euclid(2) == 1 { + (monitor.height, monitor.width) + } else { + (monitor.width, monitor.height) + }; + let width = (f64::from(width) / monitor.scale).round() as i32; + let height = (f64::from(height) / monitor.scale).round() as i32; + (width > 0 && height > 0).then_some(LogicalMonitor { + x: monitor.x, + y: monitor.y, + width, + height, + scale: monitor.scale, + }) + }) + .collect::>>()?; + (!layout.is_empty()).then_some(layout) +} + +#[derive(serde::Deserialize)] +struct KscreenConfig { + outputs: Vec, +} + +#[derive(serde::Deserialize)] +struct KscreenOutput { + pos: KscreenPoint, + size: KscreenSize, + scale: f64, + connected: bool, + enabled: bool, + #[serde(default, rename = "replicationSource")] + replication_source: Option, +} + +#[derive(serde::Deserialize)] +struct KscreenPoint { + x: i32, + y: i32, +} + +#[derive(serde::Deserialize)] +struct KscreenSize { + width: i32, + height: i32, +} + +fn parse_kscreen_monitor_layout(json: &[u8]) -> Option> { + let config: KscreenConfig = serde_json::from_slice(json).ok()?; + let layout = config + .outputs + .into_iter() + .filter(|output| { + output.connected && output.enabled && output.replication_source.unwrap_or(0) == 0 + }) + .map(|output| { + if !output.scale.is_finite() + || output.scale <= 0.0 + || output.size.width <= 0 + || output.size.height <= 0 + { + return None; + } + let width = (f64::from(output.size.width) / output.scale).round() as i32; + let height = (f64::from(output.size.height) / output.scale).round() as i32; + (width > 0 && height > 0).then_some(LogicalMonitor { + x: output.pos.x, + y: output.pos.y, + width, + height, + scale: output.scale, + }) + }) + .collect::>>()?; + (!layout.is_empty()).then_some(layout) +} + +#[derive(serde::Deserialize)] +struct SwayOutput { + active: bool, + rect: SwayRect, + scale: f64, +} + +#[derive(serde::Deserialize)] +struct SwayRect { + x: i32, + y: i32, + width: i32, + height: i32, +} + +fn parse_sway_monitor_layout(json: &[u8]) -> Option> { + let outputs: Vec = serde_json::from_slice(json).ok()?; + let layout = outputs + .into_iter() + .filter(|output| output.active) + .map(|output| { + (output.scale.is_finite() + && output.scale > 0.0 + && output.rect.width > 0 + && output.rect.height > 0) + .then_some(LogicalMonitor { + x: output.rect.x, + y: output.rect.y, + width: output.rect.width, + height: output.rect.height, + scale: output.scale, + }) + }) + .collect::>>()?; + (!layout.is_empty()).then_some(layout) +} + +#[derive(serde::Deserialize)] +struct NiriMonitorLayout { + logical: Option, +} + +#[derive(serde::Deserialize)] +struct NiriLogicalMonitor { + x: i32, + y: i32, + width: i32, + height: i32, + scale: f64, +} + +fn parse_niri_monitor_layout(json: &[u8]) -> Option> { + let outputs: HashMap = serde_json::from_slice(json).ok()?; + let layout = outputs + .into_values() + .filter_map(|output| output.logical) + .map(|monitor| { + (monitor.scale.is_finite() + && monitor.scale > 0.0 + && monitor.width > 0 + && monitor.height > 0) + .then_some(LogicalMonitor { + x: monitor.x, + y: monitor.y, + width: monitor.width, + height: monitor.height, + scale: monitor.scale, + }) + }) + .collect::>>()?; + (!layout.is_empty()).then_some(layout) +} + +fn parse_xrandr_monitor_layout(output: &str) -> Option> { + let mut lines = output.lines(); + let monitor_count = lines + .next()? + .strip_prefix("Monitors:")? + .trim() + .parse::() + .ok()?; + let layout = lines + .filter(|line| !line.trim().is_empty()) + .map(|line| { + line.split_whitespace() + .find_map(parse_xrandr_monitor_geometry) + }) + .collect::>>()?; + (monitor_count > 0 && layout.len() == monitor_count).then_some(layout) +} + +fn parse_xrandr_monitor_geometry(value: &str) -> Option { + let (width, rest) = value.split_once('/')?; + let (_, rest) = rest.split_once('x')?; + let (height, rest) = rest.split_once('/')?; + let offset_start = rest.find(['+', '-'])?; + let offsets = &rest[offset_start..]; + let second_sign = offsets[1..].find(['+', '-'])? + 1; + let (x, y) = offsets.split_at(second_sign); + let width = width.parse().ok()?; + let height = height.parse().ok()?; + let x = x.parse().ok()?; + let y = y.parse().ok()?; + (width > 0 && height > 0).then_some(LogicalMonitor { + x, + y, + width, + height, + scale: 0.0, }) } @@ -137,17 +552,22 @@ pub fn keysyms_for_text(text: &str) -> Result> { .collect() } -pub async fn click( +pub(crate) async fn click( session: &PortalPointerSession, x: i32, y: i32, button: PointerButton, click_count: u32, + operation_guard: InputOperationGuard, ) -> Result<()> { + let input_guard = Arc::clone(&session.input_lock).lock_owned().await; + session.ensure_current_layout().await?; let proxy = remote_desktop_proxy(&session.connection).await?; + let mut release_guard = PointerReleaseGuard::new(session, input_guard, operation_guard); let (stream_id, x, y) = session.map_absolute_point(x, y)?; notify_pointer_motion_absolute(&proxy, &session.session_handle, stream_id, x, y).await?; for _ in 0..click_count.max(1) { + release_guard.arm(button.evdev_code()); notify_pointer_button( &proxy, &session.session_handle, @@ -163,6 +583,7 @@ pub async fn click( POINTER_BUTTON_RELEASED, ) .await?; + release_guard.disarm(); } Ok(()) } @@ -173,31 +594,122 @@ pub async fn scroll( direction: ScrollDirection, steps: i32, ) -> Result<()> { + let _input_guard = Arc::clone(&session.input_lock).lock_owned().await; + if target_point.is_some() { + session.ensure_current_layout().await?; + } else { + session.ensure_valid()?; + } let proxy = remote_desktop_proxy(&session.connection).await?; if let Some((x, y)) = target_point { let (stream_id, x, y) = session.map_absolute_point(x, y)?; notify_pointer_motion_absolute(&proxy, &session.session_handle, stream_id, x, y).await?; } - let (axis, steps) = match direction { - ScrollDirection::Up => (AXIS_VERTICAL, steps.max(1)), - ScrollDirection::Down => (AXIS_VERTICAL, -steps.max(1)), - ScrollDirection::Left => (AXIS_HORIZONTAL, steps.max(1)), - ScrollDirection::Right => (AXIS_HORIZONTAL, -steps.max(1)), + let (axis, steps) = portal_scroll_axis_steps(direction, steps, portal_scroll_polarity()); + notify_pointer_axis_discrete(&proxy, &session.session_handle, axis, steps).await +} + +/// Native portal discrete-axis polarity for `NotifyPointerAxisDiscrete`. +/// +/// Positive vertical steps mean "scroll up" (same convention as ydotool +/// `mousemove --wheel` and Linux `REL_WHEEL`). xdg-desktop-portal-kde's +/// discrete path forwards the signed step without the vertical negation its +/// continuous path applies, so on Plasma the portal must invert vertical +/// steps to keep `direction: "up"|"down"` matching viewport motion. +/// Horizontal is left unchanged (KDE only special-cases continuous vertical). +/// +/// Override with `COMPUTER_USE_LINUX_PORTAL_SCROLL_INVERT=1|0|true|false` or +/// `CODEX_COMPUTER_USE_PORTAL_SCROLL_INVERT=1|0|true|false`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PortalScrollPolarity { + Standard, + InvertVertical, +} + +fn portal_scroll_polarity() -> PortalScrollPolarity { + for key in [ + "COMPUTER_USE_LINUX_PORTAL_SCROLL_INVERT", + "CODEX_COMPUTER_USE_PORTAL_SCROLL_INVERT", + ] { + let Ok(value) = std::env::var(key) else { + continue; + }; + let value = value.trim(); + if value.eq_ignore_ascii_case("1") + || value.eq_ignore_ascii_case("true") + || value.eq_ignore_ascii_case("yes") + || value.eq_ignore_ascii_case("on") + { + return PortalScrollPolarity::InvertVertical; + } + if value.eq_ignore_ascii_case("0") + || value.eq_ignore_ascii_case("false") + || value.eq_ignore_ascii_case("no") + || value.eq_ignore_ascii_case("off") + { + return PortalScrollPolarity::Standard; + } + } + + if desktop_env_is_kde_plasma() { + PortalScrollPolarity::InvertVertical + } else { + PortalScrollPolarity::Standard + } +} + +fn desktop_env_is_kde_plasma() -> bool { + env_token_contains("XDG_CURRENT_DESKTOP", "kde") + || env_token_contains("XDG_CURRENT_DESKTOP", "plasma") + || env_token_contains("DESKTOP_SESSION", "plasma") + || env_token_contains("DESKTOP_SESSION", "kde") +} + +fn env_token_contains(key: &str, needle: &str) -> bool { + std::env::var(key) + .map(|value| { + value + .split([':', ';', ',']) + .any(|part| part.trim().eq_ignore_ascii_case(needle)) + }) + .unwrap_or(false) +} + +pub(crate) fn portal_scroll_axis_steps( + direction: ScrollDirection, + steps: i32, + polarity: PortalScrollPolarity, +) -> (u32, i32) { + let magnitude = steps.max(1); + let (axis, standard_signed) = match direction { + ScrollDirection::Up => (AXIS_VERTICAL, magnitude), + ScrollDirection::Down => (AXIS_VERTICAL, -magnitude), + ScrollDirection::Left => (AXIS_HORIZONTAL, magnitude), + ScrollDirection::Right => (AXIS_HORIZONTAL, -magnitude), }; - notify_pointer_axis_discrete(&proxy, &session.session_handle, axis, steps).await + let signed = match (polarity, axis) { + (PortalScrollPolarity::InvertVertical, AXIS_VERTICAL) => -standard_signed, + _ => standard_signed, + }; + (axis, signed) } -pub async fn drag( +pub(crate) async fn drag( session: &PortalPointerSession, start_x: i32, start_y: i32, end_x: i32, end_y: i32, + operation_guard: InputOperationGuard, ) -> Result<()> { + let input_guard = Arc::clone(&session.input_lock).lock_owned().await; + session.ensure_current_layout().await?; let proxy = remote_desktop_proxy(&session.connection).await?; + let mut release_guard = PointerReleaseGuard::new(session, input_guard, operation_guard); let (start_stream, start_x, start_y) = session.map_absolute_point(start_x, start_y)?; + let (end_stream, end_x, end_y) = session.map_absolute_point(end_x, end_y)?; notify_pointer_motion_absolute( &proxy, &session.session_handle, @@ -206,6 +718,7 @@ pub async fn drag( start_y, ) .await?; + release_guard.arm(BTN_LEFT); notify_pointer_button( &proxy, &session.session_handle, @@ -214,7 +727,6 @@ pub async fn drag( ) .await?; tokio::time::sleep(Duration::from_millis(35)).await; - let (end_stream, end_x, end_y) = session.map_absolute_point(end_x, end_y)?; notify_pointer_motion_absolute(&proxy, &session.session_handle, end_stream, end_x, end_y) .await?; tokio::time::sleep(Duration::from_millis(35)).await; @@ -224,42 +736,289 @@ pub async fn drag( BTN_LEFT, POINTER_BUTTON_RELEASED, ) - .await + .await?; + release_guard.disarm(); + Ok(()) } -pub async fn type_text_with_keysyms( +pub(crate) async fn type_text_with_keysyms( session: &PortalKeyboardSession, keysyms: &[i32], + operation_guard: Option, ) -> Result<()> { + let input_guard = Arc::clone(&session.input_lock).lock_owned().await; + session.ensure_valid()?; let proxy = remote_desktop_proxy(&session.connection).await?; + let mut release_guard = KeyboardReleaseGuard::new(session, input_guard, operation_guard); for keysym in keysyms { + release_guard.push(PressedKey::Keysym(*keysym)); notify_keyboard_keysym(&proxy, &session.session_handle, *keysym, KEY_PRESSED).await?; tokio::time::sleep(Duration::from_millis(5)).await; notify_keyboard_keysym(&proxy, &session.session_handle, *keysym, KEY_RELEASED).await?; + release_guard.pop(); tokio::time::sleep(Duration::from_millis(5)).await; } Ok(()) } -pub async fn press_keycode_chord( +pub(crate) async fn press_keycode_chord( session: &PortalKeyboardSession, modifiers: &[i32], keycode: i32, + operation_guard: Option, ) -> Result<()> { + let input_guard = Arc::clone(&session.input_lock).lock_owned().await; + session.ensure_valid()?; let proxy = remote_desktop_proxy(&session.connection).await?; + let mut release_guard = KeyboardReleaseGuard::new(session, input_guard, operation_guard); for modifier in modifiers { + release_guard.push(PressedKey::Keycode(*modifier)); notify_keyboard_keycode(&proxy, &session.session_handle, *modifier, KEY_PRESSED).await?; } + release_guard.push(PressedKey::Keycode(keycode)); notify_keyboard_keycode(&proxy, &session.session_handle, keycode, KEY_PRESSED).await?; tokio::time::sleep(Duration::from_millis(35)).await; notify_keyboard_keycode(&proxy, &session.session_handle, keycode, KEY_RELEASED).await?; + release_guard.pop(); for modifier in modifiers.iter().rev() { notify_keyboard_keycode(&proxy, &session.session_handle, *modifier, KEY_RELEASED).await?; + release_guard.pop(); } Ok(()) } +impl PointerReleaseGuard { + fn new( + session: &PortalPointerSession, + input_guard: OwnedMutexGuard<()>, + operation_guard: InputOperationGuard, + ) -> Self { + Self { + connection: session.connection.clone(), + session_handle: session.session_handle.clone(), + button: None, + input_guard: Some(input_guard), + operation_guard: Some(operation_guard), + valid: Arc::clone(&session.valid), + } + } + + fn arm(&mut self, button: i32) { + self.button = Some(button); + } + + fn disarm(&mut self) { + self.button = None; + } +} + +impl Drop for PointerReleaseGuard { + fn drop(&mut self) { + let input_guard = self.input_guard.take(); + let operation_guard = self.operation_guard.take(); + let Some(button) = self.button else { + return; + }; + self.valid.store(false, Ordering::Release); + let connection = self.connection.clone(); + let session_handle = self.session_handle.clone(); + spawn_release_cleanup(input_guard, operation_guard, async move { + let _ = tokio::time::timeout(RELEASE_TIMEOUT, async { + if let Ok(proxy) = remote_desktop_proxy(&connection).await { + let _ = notify_pointer_button( + &proxy, + &session_handle, + button, + POINTER_BUTTON_RELEASED, + ) + .await; + } + }) + .await; + let _ = tokio::time::timeout( + RELEASE_TIMEOUT, + close_portal_session(&connection, &session_handle), + ) + .await; + }); + } +} + +impl KeyboardReleaseGuard { + fn new( + session: &PortalKeyboardSession, + input_guard: OwnedMutexGuard<()>, + operation_guard: Option, + ) -> Self { + Self { + connection: session.connection.clone(), + session_handle: session.session_handle.clone(), + pressed: Vec::new(), + input_guard: Some(input_guard), + operation_guard, + valid: Arc::clone(&session.valid), + } + } + + fn push(&mut self, key: PressedKey) { + self.pressed.push(key); + } + + fn pop(&mut self) { + self.pressed.pop(); + } +} + +impl PortalSessionCleanup { + fn new(connection: Connection, session_handle: OwnedObjectPath) -> Self { + Self { + connection, + session_handle, + armed: true, + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for PortalSessionCleanup { + fn drop(&mut self) { + if !self.armed { + return; + } + let connection = self.connection.clone(); + let session_handle = self.session_handle.clone(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + let _ = tokio::time::timeout( + RELEASE_TIMEOUT, + close_portal_session(&connection, &session_handle), + ) + .await; + }); + } + } +} + +impl Drop for KeyboardReleaseGuard { + fn drop(&mut self) { + let input_guard = self.input_guard.take(); + let operation_guard = self.operation_guard.take(); + if self.pressed.is_empty() { + return; + } + self.valid.store(false, Ordering::Release); + let connection = self.connection.clone(); + let session_handle = self.session_handle.clone(); + let pressed = std::mem::take(&mut self.pressed); + spawn_release_cleanup(input_guard, operation_guard, async move { + let _ = tokio::time::timeout(RELEASE_TIMEOUT, async { + if let Ok(proxy) = remote_desktop_proxy(&connection).await { + for key in pressed.into_iter().rev() { + match key { + PressedKey::Keysym(keysym) => { + let _ = notify_keyboard_keysym( + &proxy, + &session_handle, + keysym, + KEY_RELEASED, + ) + .await; + } + PressedKey::Keycode(keycode) => { + let _ = notify_keyboard_keycode( + &proxy, + &session_handle, + keycode, + KEY_RELEASED, + ) + .await; + } + } + } + } + }) + .await; + let _ = tokio::time::timeout( + RELEASE_TIMEOUT, + close_portal_session(&connection, &session_handle), + ) + .await; + }); + } +} + +fn spawn_release_cleanup( + input_guard: Option>, + operation_guard: Option, + cleanup: F, +) where + F: std::future::Future + Send + 'static, +{ + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + cleanup.await; + drop(input_guard); + drop(operation_guard); + }); + } +} + impl PortalPointerSession { + pub(crate) fn is_valid(&self) -> bool { + self.valid.load(Ordering::Acquire) + } + + pub(crate) fn invalidate_and_close(&self) { + invalidate_and_close(&self.valid, &self.connection, &self.session_handle); + } + + pub(crate) fn same_session(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.valid, &other.valid) + } + + fn ensure_valid(&self) -> Result<()> { + if !self.is_valid() { + bail!("remote desktop portal pointer session is no longer valid"); + } + Ok(()) + } + + async fn ensure_current_layout(&self) -> Result<()> { + self.ensure_valid()?; + let expected = self + .desktop_layout + .as_deref() + .context("remote desktop portal session has no authoritative monitor layout")?; + let current = logical_desktop_layout() + .await + .context("could not revalidate the current monitor layout")?; + if !same_monitor_layout(expected, ¤t) { + self.invalidate_and_close(); + bail!("desktop monitor layout changed after the remote desktop portal session started"); + } + Ok(()) + } + + pub(crate) fn logical_point_from_capture( + &self, + x: i32, + y: i32, + capture_size: Option<(u32, u32)>, + ) -> Option<(i32, i32)> { + let (width, height) = capture_size?; + map_capture_point_to_stream_layout( + &self.streams, + self.desktop_layout.as_deref()?, + x, + y, + width, + height, + ) + } + fn map_absolute_point(&self, x: i32, y: i32) -> Result<(u32, f64, f64)> { if let Some(stream) = self .streams @@ -269,13 +1028,151 @@ impl PortalPointerSession { return Ok(stream.relative_point(x, y)); } - self.streams - .first() - .map(|stream| stream.relative_point(x, y)) - .context("remote desktop portal session had no usable streams") + bail!("point ({x}, {y}) is outside the monitors shared with the remote desktop portal") } } +impl PortalKeyboardSession { + pub(crate) fn is_valid(&self) -> bool { + self.valid.load(Ordering::Acquire) + } + + pub(crate) fn invalidate_and_close(&self) { + invalidate_and_close(&self.valid, &self.connection, &self.session_handle); + } + + pub(crate) fn same_session(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.valid, &other.valid) + } + + fn ensure_valid(&self) -> Result<()> { + if !self.is_valid() { + bail!("remote desktop portal keyboard session is no longer valid"); + } + Ok(()) + } +} + +fn same_monitor_layout(expected: &[LogicalMonitor], current: &[LogicalMonitor]) -> bool { + if expected.len() != current.len() { + return false; + } + let mut expected = expected.to_vec(); + let mut current = current.to_vec(); + let sort_key = |monitor: &LogicalMonitor| (monitor.x, monitor.y, monitor.width, monitor.height); + expected.sort_by_key(sort_key); + current.sort_by_key(sort_key); + expected.iter().zip(current.iter()).all(|(left, right)| { + sort_key(left) == sort_key(right) && (left.scale - right.scale).abs() <= 0.01 + }) +} + +fn map_capture_point_to_stream_layout( + streams: &[PortalStream], + desktop_layout: &[LogicalMonitor], + x: i32, + y: i32, + capture_width: u32, + capture_height: u32, +) -> Option<(i32, i32)> { + if capture_width == 0 + || capture_height == 0 + || x < 0 + || y < 0 + || x >= i32::try_from(capture_width).unwrap_or(i32::MAX) + || y >= i32::try_from(capture_height).unwrap_or(i32::MAX) + { + return None; + } + + let mut stream_rects = streams + .iter() + .map(|stream| { + let (x, y) = stream.position?; + let (width, height) = stream.size?; + (width > 0 && height > 0).then_some((x, y, width, height)) + }) + .collect::>>()?; + if desktop_layout.is_empty() + || desktop_layout + .iter() + .any(|monitor| monitor.width <= 0 || monitor.height <= 0) + { + return None; + } + let mut monitor_rects = desktop_layout + .iter() + .map(|monitor| (monitor.x, monitor.y, monitor.width, monitor.height)) + .collect::>(); + stream_rects.sort_unstable(); + monitor_rects.sort_unstable(); + if stream_rects != monitor_rects { + return None; + } + + let unknown_multi_monitor_scale = desktop_layout.len() > 1 + && desktop_layout + .iter() + .any(|monitor| !monitor.scale.is_finite() || monitor.scale <= 0.0); + if desktop_layout.len() > 1 && !unknown_multi_monitor_scale { + let first_scale = desktop_layout.first()?.scale; + if desktop_layout + .iter() + .any(|monitor| (monitor.scale - first_scale).abs() > 0.01) + { + return None; + } + } + + let mut bounds = monitor_rects.iter().map(|(x, y, width, height)| { + ( + i64::from(*x), + i64::from(*y), + i64::from(*x) + i64::from(*width), + i64::from(*y) + i64::from(*height), + ) + }); + let (mut min_x, mut min_y, mut max_x, mut max_y) = bounds.next()?; + for (left, top, right, bottom) in bounds { + min_x = min_x.min(left); + min_y = min_y.min(top); + max_x = max_x.max(right); + max_y = max_y.max(bottom); + } + let logical_width = max_x - min_x; + let logical_height = max_y - min_y; + if logical_width <= 0 || logical_height <= 0 { + return None; + } + if unknown_multi_monitor_scale + && (i64::from(capture_width) != logical_width + || i64::from(capture_height) != logical_height) + { + return None; + } + let scale_x = f64::from(capture_width) / logical_width as f64; + let scale_y = f64::from(capture_height) / logical_height as f64; + if !scale_x.is_finite() || !scale_y.is_finite() || (scale_x - scale_y).abs() > 0.01 { + return None; + } + + let point = ( + map_capture_axis(x, capture_width, min_x, max_x - min_x), + map_capture_axis(y, capture_height, min_y, max_y - min_y), + ); + streams + .iter() + .any(|stream| stream.contains_global_point(point.0, point.1)) + .then_some(point) +} + +fn map_capture_axis(value: i32, capture_size: u32, target_origin: i64, target_size: i64) -> i32 { + let scaled = i64::from(value).saturating_mul(target_size) / i64::from(capture_size); + target_origin + .saturating_add(scaled) + .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32 +} + impl PortalStream { fn contains_global_point(&self, x: i32, y: i32) -> bool { let Some((stream_x, stream_y)) = self.position else { @@ -334,10 +1231,13 @@ async fn create_remote_desktop_session(connection: &Connection) -> Result Result<()> { let options: HashMap<&str, Value<'_>> = HashMap::new(); - let _: () = proxy - .call( + let _: () = tokio::time::timeout( + INPUT_TIMEOUT, + proxy.call( "NotifyPointerMotionAbsolute", &(session, options, stream_id, x, y), - ) - .await - .context("RemoteDesktop NotifyPointerMotionAbsolute failed")?; + ), + ) + .await + .context("RemoteDesktop NotifyPointerMotionAbsolute timed out")? + .context("RemoteDesktop NotifyPointerMotionAbsolute failed")?; Ok(()) } @@ -475,10 +1387,13 @@ async fn notify_pointer_button( state: u32, ) -> Result<()> { let options: HashMap<&str, Value<'_>> = HashMap::new(); - let _: () = proxy - .call("NotifyPointerButton", &(session, options, button, state)) - .await - .context("RemoteDesktop NotifyPointerButton failed")?; + let _: () = tokio::time::timeout( + INPUT_TIMEOUT, + proxy.call("NotifyPointerButton", &(session, options, button, state)), + ) + .await + .context("RemoteDesktop NotifyPointerButton timed out")? + .context("RemoteDesktop NotifyPointerButton failed")?; Ok(()) } @@ -489,13 +1404,16 @@ async fn notify_pointer_axis_discrete( steps: i32, ) -> Result<()> { let options: HashMap<&str, Value<'_>> = HashMap::new(); - let _: () = proxy - .call( + let _: () = tokio::time::timeout( + INPUT_TIMEOUT, + proxy.call( "NotifyPointerAxisDiscrete", &(session, options, axis, steps), - ) - .await - .context("RemoteDesktop NotifyPointerAxisDiscrete failed")?; + ), + ) + .await + .context("RemoteDesktop NotifyPointerAxisDiscrete timed out")? + .context("RemoteDesktop NotifyPointerAxisDiscrete failed")?; Ok(()) } @@ -506,10 +1424,13 @@ async fn notify_keyboard_keysym( state: u32, ) -> Result<()> { let options: HashMap<&str, Value<'_>> = HashMap::new(); - let _: () = proxy - .call("NotifyKeyboardKeysym", &(session, options, keysym, state)) - .await - .context("RemoteDesktop NotifyKeyboardKeysym failed")?; + let _: () = tokio::time::timeout( + INPUT_TIMEOUT, + proxy.call("NotifyKeyboardKeysym", &(session, options, keysym, state)), + ) + .await + .context("RemoteDesktop NotifyKeyboardKeysym timed out")? + .context("RemoteDesktop NotifyKeyboardKeysym failed")?; Ok(()) } @@ -520,13 +1441,44 @@ async fn notify_keyboard_keycode( state: u32, ) -> Result<()> { let options: HashMap<&str, Value<'_>> = HashMap::new(); - let _: () = proxy - .call("NotifyKeyboardKeycode", &(session, options, keycode, state)) - .await - .context("RemoteDesktop NotifyKeyboardKeycode failed")?; + let _: () = tokio::time::timeout( + INPUT_TIMEOUT, + proxy.call("NotifyKeyboardKeycode", &(session, options, keycode, state)), + ) + .await + .context("RemoteDesktop NotifyKeyboardKeycode timed out")? + .context("RemoteDesktop NotifyKeyboardKeycode failed")?; Ok(()) } +async fn close_portal_session(connection: &Connection, session: &OwnedObjectPath) { + if let Ok(proxy) = Proxy::new( + connection, + PORTAL_DESKTOP_SERVICE, + session.as_str(), + PORTAL_SESSION_INTERFACE, + ) + .await + { + let _: Result<(), _> = proxy.call("Close", &()).await; + } +} + +fn invalidate_and_close(valid: &AtomicBool, connection: &Connection, session: &OwnedObjectPath) { + if !valid.swap(false, Ordering::AcqRel) { + return; + } + let connection = connection.clone(); + let session = session.clone(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + let _ = + tokio::time::timeout(RELEASE_TIMEOUT, close_portal_session(&connection, &session)) + .await; + }); + } +} + async fn remote_desktop_proxy(connection: &Connection) -> Result> { Proxy::new( connection, @@ -713,4 +1665,515 @@ mod tests { assert!(error.contains("U+FDD0")); } + + #[test] + fn portal_scroll_standard_polarity_matches_ydotool_rel_wheel_signs() { + assert_eq!( + portal_scroll_axis_steps(ScrollDirection::Up, 1, PortalScrollPolarity::Standard), + (AXIS_VERTICAL, 1) + ); + assert_eq!( + portal_scroll_axis_steps(ScrollDirection::Down, 3, PortalScrollPolarity::Standard), + (AXIS_VERTICAL, -3) + ); + assert_eq!( + portal_scroll_axis_steps(ScrollDirection::Left, 2, PortalScrollPolarity::Standard), + (AXIS_HORIZONTAL, 2) + ); + assert_eq!( + portal_scroll_axis_steps(ScrollDirection::Right, 2, PortalScrollPolarity::Standard), + (AXIS_HORIZONTAL, -2) + ); + } + + #[test] + fn portal_scroll_kde_inverts_vertical_only() { + assert_eq!( + portal_scroll_axis_steps(ScrollDirection::Up, 1, PortalScrollPolarity::InvertVertical), + (AXIS_VERTICAL, -1) + ); + assert_eq!( + portal_scroll_axis_steps( + ScrollDirection::Down, + 3, + PortalScrollPolarity::InvertVertical + ), + (AXIS_VERTICAL, 3) + ); + assert_eq!( + portal_scroll_axis_steps( + ScrollDirection::Left, + 2, + PortalScrollPolarity::InvertVertical + ), + (AXIS_HORIZONTAL, 2) + ); + assert_eq!( + portal_scroll_axis_steps( + ScrollDirection::Right, + 2, + PortalScrollPolarity::InvertVertical + ), + (AXIS_HORIZONTAL, -2) + ); + } + + #[test] + fn portal_scroll_clamps_zero_or_negative_steps_to_one() { + assert_eq!( + portal_scroll_axis_steps(ScrollDirection::Down, 0, PortalScrollPolarity::Standard), + (AXIS_VERTICAL, -1) + ); + assert_eq!( + portal_scroll_axis_steps( + ScrollDirection::Down, + -5, + PortalScrollPolarity::InvertVertical + ), + (AXIS_VERTICAL, 1) + ); + } + + #[test] + fn parses_xrandr_monitor_layout_with_negative_origins() { + let layout = parse_xrandr_monitor_layout( + "Monitors: 2\n 0: +*eDP-1 1920/344x1080/194+0+0 eDP-1\n 1: +DP-2 2560/600x1440/340-2560+0 DP-2\n", + ) + .expect("XRandR layout should parse"); + + assert_eq!(layout.len(), 2); + assert_eq!( + (layout[0].x, layout[0].y, layout[0].width, layout[0].height), + (0, 0, 1920, 1080) + ); + assert_eq!( + (layout[1].x, layout[1].y, layout[1].width, layout[1].height), + (-2560, 0, 2560, 1440) + ); + } + + #[test] + fn parses_hyprland_monitor_layout_in_logical_coordinates() { + let layout = parse_hyprland_monitor_layout( + br#"[ + {"x":0,"y":0,"width":3840,"height":2160,"scale":2.0,"transform":0}, + {"x":1920,"y":0,"width":2560,"height":1440,"scale":1.0,"transform":1} + ]"#, + ) + .expect("Hyprland layout should parse"); + + assert_eq!( + (layout[0].x, layout[0].y, layout[0].width, layout[0].height), + (0, 0, 1920, 1080) + ); + assert_eq!( + (layout[1].x, layout[1].y, layout[1].width, layout[1].height), + (1920, 0, 1440, 2560) + ); + } + + #[test] + fn parses_kscreen_physical_sizes_in_logical_coordinates() { + let layout = parse_kscreen_monitor_layout( + br#"{ + "outputs":[ + {"pos":{"x":0,"y":0},"size":{"width":3840,"height":2160},"scale":2.0,"connected":true,"enabled":true}, + {"pos":{"x":1920,"y":0},"size":{"width":1440,"height":2560},"scale":1.0,"connected":true,"enabled":true}, + {"pos":{"x":0,"y":0},"size":{"width":0,"height":0},"scale":0.0,"connected":false,"enabled":false} + ] + }"#, + ) + .expect("KScreen layout should parse"); + + assert_eq!(layout.len(), 2); + assert_eq!( + (layout[0].x, layout[0].y, layout[0].width, layout[0].height), + (0, 0, 1920, 1080) + ); + assert_eq!( + (layout[1].x, layout[1].y, layout[1].width, layout[1].height), + (1920, 0, 1440, 2560) + ); + } + + #[test] + fn kscreen_layout_excludes_mirrored_outputs() { + let layout = parse_kscreen_monitor_layout( + br#"{"outputs":[ + {"pos":{"x":0,"y":0},"size":{"width":1920,"height":1080},"scale":1.0,"connected":true,"enabled":true,"replicationSource":0}, + {"pos":{"x":1920,"y":0},"size":{"width":3840,"height":2160},"scale":2.0,"connected":true,"enabled":true,"replicationSource":1} + ]}"#, + ) + .expect("KScreen workspace layout should parse"); + + assert_eq!(layout.len(), 1); + assert_eq!( + (layout[0].x, layout[0].y, layout[0].width, layout[0].height), + (0, 0, 1920, 1080) + ); + } + + #[test] + fn parses_sway_logical_output_rectangles() { + let layout = parse_sway_monitor_layout( + br#"[ + {"active":true,"rect":{"x":-1536,"y":0,"width":1536,"height":864},"scale":1.25}, + {"active":true,"rect":{"x":0,"y":0,"width":1920,"height":1080},"scale":1.0}, + {"active":false,"rect":{"x":0,"y":0,"width":0,"height":0},"scale":0.0} + ]"#, + ) + .expect("Sway layout should parse"); + + assert_eq!(layout.len(), 2); + assert_eq!( + ( + layout[0].x, + layout[0].y, + layout[0].width, + layout[0].height, + layout[0].scale, + ), + (-1536, 0, 1536, 864, 1.25) + ); + } + + #[test] + fn monitor_parsers_reject_partial_active_layouts() { + assert!(parse_hyprland_monitor_layout( + br#"[ + {"x":0,"y":0,"width":1920,"height":1080,"scale":1.0}, + {"x":1920,"y":0,"width":1920,"height":1080,"scale":0.0} + ]"#, + ) + .is_none()); + assert!(parse_kscreen_monitor_layout( + br#"{"outputs":[ + {"pos":{"x":0,"y":0},"size":{"width":1920,"height":1080},"scale":1.0,"connected":true,"enabled":true}, + {"pos":{"x":1920,"y":0},"size":{"width":0,"height":1080},"scale":1.0,"connected":true,"enabled":true} + ]}"#, + ) + .is_none()); + assert!(parse_sway_monitor_layout( + br#"[ + {"active":true,"rect":{"x":0,"y":0,"width":1920,"height":1080},"scale":1.0}, + {"active":true,"rect":{"x":1920,"y":0,"width":1920,"height":1080},"scale":0.0} + ]"#, + ) + .is_none()); + assert!(parse_sway_monitor_layout( + br#"[{"rect":{"x":0,"y":0,"width":1920,"height":1080},"scale":1.0}]"#, + ) + .is_none()); + assert!(parse_niri_monitor_layout( + br#"{ + "eDP-1":{"logical":{"x":0,"y":0,"width":1920,"height":1080,"scale":1.0}}, + "DP-1":{"logical":{"x":1920,"y":0,"width":1920,"height":1080,"scale":0.0}} + }"#, + ) + .is_none()); + assert!(parse_xrandr_monitor_layout( + "Monitors: 2\n 0: +*eDP-1 1920/344x1080/194+0+0 eDP-1\n" + ) + .is_none()); + } + + #[test] + fn monitor_layout_comparison_detects_reconfiguration() { + let original = [LogicalMonitor { + x: 0, + y: 0, + width: 1920, + height: 1080, + scale: 1.0, + }]; + let mut changed = original.clone(); + changed[0].scale = 1.25; + assert!(!same_monitor_layout(&original, &changed)); + changed[0].scale = 1.0; + changed[0].width = 1600; + assert!(!same_monitor_layout(&original, &changed)); + assert!(same_monitor_layout(&original, &original)); + } + + #[test] + fn parses_niri_logical_output_layout() { + let layout = parse_niri_monitor_layout( + br#"{ + "eDP-1":{"logical":{"x":0,"y":0,"width":1920,"height":1080,"scale":2.0}}, + "DP-1":{"logical":{"x":1920,"y":0,"width":2560,"height":1440,"scale":1.0}} + }"#, + ) + .expect("Niri layout should parse"); + + assert_eq!(layout.len(), 2); + assert!(layout.iter().any(|monitor| { + ( + monitor.x, + monitor.y, + monitor.width, + monitor.height, + monitor.scale, + ) == (0, 0, 1920, 1080, 2.0) + })); + assert!(layout.iter().any(|monitor| { + ( + monitor.x, + monitor.y, + monitor.width, + monitor.height, + monitor.scale, + ) == (1920, 0, 2560, 1440, 1.0) + })); + } + + #[test] + fn capture_points_scale_to_portal_logical_stream_space() { + let streams = [PortalStream { + node_id: 1, + position: Some((0, 0)), + size: Some((1920, 1200)), + }]; + let layout = [LogicalMonitor { + x: 0, + y: 0, + width: 1920, + height: 1200, + scale: 4.0 / 3.0, + }]; + + assert_eq!( + map_capture_point_to_stream_layout(&streams, &layout, 1280, 800, 2560, 1600), + Some((960, 600)) + ); + } + + #[test] + fn capture_points_preserve_negative_stream_layout_origins() { + let streams = [ + PortalStream { + node_id: 1, + position: Some((-1920, 0)), + size: Some((1920, 1080)), + }, + PortalStream { + node_id: 2, + position: Some((0, 0)), + size: Some((1920, 1080)), + }, + ]; + let layout = [ + LogicalMonitor { + x: -1920, + y: 0, + width: 1920, + height: 1080, + scale: 2.0, + }, + LogicalMonitor { + x: 0, + y: 0, + width: 1920, + height: 1080, + scale: 2.0, + }, + ]; + + assert_eq!( + map_capture_point_to_stream_layout(&streams, &layout, 0, 0, 7680, 2160), + Some((-1920, 0)) + ); + assert_eq!( + map_capture_point_to_stream_layout(&streams, &layout, 3840, 1080, 7680, 2160), + Some((0, 540)) + ); + } + + #[test] + fn capture_point_mapping_requires_usable_stream_metadata() { + let streams = [PortalStream { + node_id: 1, + position: None, + size: None, + }]; + let layout = [LogicalMonitor { + x: 0, + y: 0, + width: 1920, + height: 1200, + scale: 1.0, + }]; + + assert_eq!( + map_capture_point_to_stream_layout(&streams, &layout, 100, 200, 2560, 1600), + None + ); + } + + #[test] + fn capture_point_mapping_rejects_invalid_desktop_monitors() { + let streams = [PortalStream { + node_id: 1, + position: Some((0, 0)), + size: Some((1920, 1080)), + }]; + let layout = [ + LogicalMonitor { + x: 0, + y: 0, + width: 1920, + height: 1080, + scale: 1.0, + }, + LogicalMonitor { + x: 1920, + y: 0, + width: 0, + height: 1080, + scale: 1.0, + }, + ]; + + assert_eq!( + map_capture_point_to_stream_layout(&streams, &layout, 100, 200, 1920, 1080), + None + ); + } + + #[test] + fn capture_point_mapping_rejects_partially_shared_desktops() { + let streams = [PortalStream { + node_id: 1, + position: Some((0, 0)), + size: Some((1920, 1080)), + }]; + let layout = [ + LogicalMonitor { + x: 0, + y: 0, + width: 1920, + height: 1080, + scale: 1.0, + }, + LogicalMonitor { + x: 1920, + y: 0, + width: 1920, + height: 1080, + scale: 1.0, + }, + ]; + + assert_eq!( + map_capture_point_to_stream_layout(&streams, &layout, 3000, 500, 3840, 1080), + None + ); + } + + #[test] + fn capture_point_mapping_rejects_mixed_scale_desktops() { + let streams = [ + PortalStream { + node_id: 1, + position: Some((0, 0)), + size: Some((1920, 1080)), + }, + PortalStream { + node_id: 2, + position: Some((1920, 0)), + size: Some((1920, 1080)), + }, + ]; + let layout = [ + LogicalMonitor { + x: 0, + y: 0, + width: 1920, + height: 1080, + scale: 1.0, + }, + LogicalMonitor { + x: 1920, + y: 0, + width: 1920, + height: 1080, + scale: 2.0, + }, + ]; + + assert_eq!( + map_capture_point_to_stream_layout(&streams, &layout, 2000, 500, 3840, 1080), + None + ); + } + + #[test] + fn unknown_multi_monitor_scale_only_allows_identity_mapping() { + let streams = [ + PortalStream { + node_id: 1, + position: Some((0, 0)), + size: Some((1920, 1080)), + }, + PortalStream { + node_id: 2, + position: Some((1920, 0)), + size: Some((1920, 1080)), + }, + ]; + let layout = [ + LogicalMonitor { + x: 0, + y: 0, + width: 1920, + height: 1080, + scale: 0.0, + }, + LogicalMonitor { + x: 1920, + y: 0, + width: 1920, + height: 1080, + scale: 0.0, + }, + ]; + + assert_eq!( + map_capture_point_to_stream_layout(&streams, &layout, 2500, 500, 3840, 1080), + Some((2500, 500)) + ); + assert_eq!( + map_capture_point_to_stream_layout(&streams, &layout, 5000, 1000, 7680, 2160), + None + ); + } + + #[tokio::test] + async fn release_cleanup_retains_portal_and_cross_backend_guards() { + let portal_lock = Arc::new(AsyncMutex::new(())); + let operation_lock = Arc::new(AsyncMutex::new(())); + let portal_guard = Arc::clone(&portal_lock).lock_owned().await; + let operation_guard = + InputOperationGuard::new(Arc::clone(&operation_lock).lock_owned().await); + let caller_operation_guard = operation_guard.clone(); + let (finish_tx, finish_rx) = tokio::sync::oneshot::channel(); + let (finished_tx, finished_rx) = tokio::sync::oneshot::channel(); + + spawn_release_cleanup(Some(portal_guard), Some(operation_guard), async move { + let _ = finish_rx.await; + let _ = finished_tx.send(()); + }); + + assert!(portal_lock.try_lock().is_err()); + assert!(operation_lock.try_lock().is_err()); + finish_tx.send(()).expect("release cleanup stopped early"); + tokio::time::timeout(Duration::from_secs(1), finished_rx) + .await + .expect("release cleanup did not finish") + .expect("release cleanup dropped its completion marker"); + tokio::task::yield_now().await; + assert!(portal_lock.try_lock().is_ok()); + assert!(operation_lock.try_lock().is_err()); + drop(caller_operation_guard); + assert!(operation_lock.try_lock().is_ok()); + } } diff --git a/computer-use-linux/src/server.rs b/computer-use-linux/src/server.rs index f48b07464..f49bb6423 100644 --- a/computer-use-linux/src/server.rs +++ b/computer-use-linux/src/server.rs @@ -8,8 +8,8 @@ use crate::gnome_extension::{setup_window_targeting_report, WindowTargetingSetup use crate::remote_desktop::{ click as portal_click, drag as portal_drag, keysyms_for_text, press_keycode_chord, scroll as portal_scroll, start_portal_keyboard_session, start_portal_pointer_session, - type_text_with_keysyms, PointerButton, PortalKeyboardSession, PortalPointerSession, - ScrollDirection, + type_text_with_keysyms, InputOperationGuard, PointerButton, PortalKeyboardSession, + PortalPointerSession, ScrollDirection, }; use crate::screenshot::{ capture_screenshot_raw, prepare_screenshot_payload, RawScreenshotCapture, ScreenshotCapture, @@ -19,7 +19,7 @@ use crate::windowing::registry; use crate::windows::{ focus_window_target, focused_window, list_windows, resolve_window_target, window_permission_hint, WindowFocusResult, WindowInfo, WindowTarget, - GNOME_SHELL_INTROSPECT_BACKEND, + GNOME_SHELL_EXTENSION_BACKEND, GNOME_SHELL_INTROSPECT_BACKEND, KWIN_BACKEND, }; use crate::ydotool; use anyhow::Result; @@ -36,7 +36,7 @@ use std::{ os::unix::{ ffi::OsStrExt, fs::{FileTypeExt, MetadataExt}, - net::{UnixDatagram, UnixStream}, + net::UnixDatagram, }, path::{Path, PathBuf}, process::{Command, Output, Stdio}, @@ -44,14 +44,14 @@ use std::{ time::Duration, }; use tokio::{ - io::{AsyncRead, AsyncReadExt, AsyncWriteExt}, + io::AsyncWriteExt, net::UnixStream as TokioUnixStream, - process::{Child as TokioChild, Command as TokioCommand}, + process::Command as TokioCommand, time::{sleep, timeout}, }; use zbus::{Connection as ZbusConnection, Proxy as ZbusProxy}; -const YDOTOOL_TIMEOUT: Duration = Duration::from_secs(10); +const INPUT_COMMAND_TIMEOUT: Duration = Duration::from_secs(10); const YDOTOOL_TYPE_CHARS_PER_SECOND: u64 = 20; const KDE_CLIPBOARD_DBUS_TIMEOUT: Duration = Duration::from_secs(3); const KDE_KLIPPER_SERVICE: &str = "org.kde.klipper"; @@ -68,13 +68,58 @@ pub struct ComputerUseLinux { portal_keyboard_session: Arc>>, /// Lazily-created uinput absolute pointer (preferred coordinate backend). abs_pointer: Arc>>, - portal_keyboard_init_lock: Arc>, + portal_session_init_lock: Arc>, + input_operation_lock: Arc>, kde_clipboard_lock: Arc>, - /// Cached logical desktop size (union of monitors) from the most recent - /// full-frame capture; used for off-screen window/coordinate warnings. + /// Cached physical desktop size from the most recent full-frame capture; + /// used for off-screen warnings and portal logical-coordinate mapping. desktop_size: Arc>>, } +fn sanitize_unsigned_integer_formats(value: &mut serde_json::Value) { + let serde_json::Value::Object(object) = value else { + return; + }; + + let has_unsigned_format = matches!( + object.get("format").and_then(serde_json::Value::as_str), + Some("uint" | "uint8" | "uint16" | "uint32" | "uint64" | "usize") + ); + if has_unsigned_format { + object.remove("format"); + } + + for nested in object.values_mut() { + match nested { + serde_json::Value::Object(_) => sanitize_unsigned_integer_formats(nested), + serde_json::Value::Array(items) => { + for item in items { + sanitize_unsigned_integer_formats(item); + } + } + _ => {} + } + } +} + +impl ComputerUseLinux { + fn mcp_tool_router(&self) -> rmcp::handler::server::router::tool::ToolRouter { + let mut router = Self::tool_router(); + for route in router.map.values_mut() { + let input_schema = Arc::make_mut(&mut route.attr.input_schema); + for value in input_schema.values_mut() { + sanitize_unsigned_integer_formats(value); + } + if let Some(output_schema) = route.attr.output_schema.as_mut() { + for value in Arc::make_mut(output_schema).values_mut() { + sanitize_unsigned_integer_formats(value); + } + } + } + router + } +} + #[tool_router] impl ComputerUseLinux { #[tool( @@ -87,8 +132,12 @@ impl ComputerUseLinux { open_world_hint = false ) )] - fn doctor(&self) -> Json { - Json(doctor_report()) + async fn doctor(&self) -> Json { + Json( + tokio::task::spawn_blocking(doctor_report) + .await + .expect("diagnostics task panicked"), + ) } #[tool( @@ -101,8 +150,12 @@ impl ComputerUseLinux { open_world_hint = false ) )] - fn setup_accessibility(&self) -> Json { - Json(setup_accessibility_report()) + async fn setup_accessibility(&self) -> Json { + Json( + tokio::task::spawn_blocking(setup_accessibility_report) + .await + .expect("accessibility setup task panicked"), + ) } #[tool( @@ -253,21 +306,43 @@ impl ComputerUseLinux { Parameters(params): Parameters, ) -> Json { let verbose = params.verbose.unwrap_or(false); - let diagnostics = doctor_report(); + let diagnostics = tokio::task::spawn_blocking(doctor_report) + .await + .expect("diagnostics task panicked"); let (window_context, window_error, window_permissions_hint) = self.resolve_window_context(¶ms).await; let max_nodes = params.max_nodes.unwrap_or(120).clamp(1, 500); let max_depth = params.max_depth.unwrap_or(12).min(12); let include_screenshot = params.include_screenshot.unwrap_or(true); let screenshot_options = params.screenshot_options(); + let screenshot_target_requested = params.window_target().has_target(); let app_filter = self .resolve_accessibility_app_filter(¶ms, window_context.as_ref()) .await; let (screenshot, screenshot_error) = if include_screenshot { - match capture_screenshot_raw() - .await - .and_then(|raw| prepare_screenshot_payload(raw, screenshot_options)) - { + let result: Result = async { + let raw = capture_screenshot_raw().await?; + self.cache_desktop_size(raw.width, raw.height); + if let Some(window) = window_context.as_ref() { + ensure_readonly_screenshot_target_is_visible(window)?; + let crop = self.window_crop_rect_for_capture(window, &raw).await?; + prepare_app_state_screenshot( + raw, + Some(crop), + screenshot_target_requested, + screenshot_options, + ) + } else { + prepare_app_state_screenshot( + raw, + None, + screenshot_target_requested, + screenshot_options, + ) + } + } + .await; + match result { Ok(capture) => (Some(capture), None), Err(error) => (None, Some(format!("{error:#}"))), } @@ -374,25 +449,25 @@ impl ComputerUseLinux { Parameters(params): Parameters, ) -> Result { let target = params.window_target(); - - // When targeting a window, raise it first (so it isn't occluded) and - // resolve its bounds so we can crop to just that window. - let mut crop: Option = None; - let mut window_label: Option = None; - if let Some(target) = &target { - if params.raise_window.unwrap_or(true) { - let _ = focus_window_target(target).await; - tokio::time::sleep(Duration::from_millis(250)).await; - } - if !params.full_screen.unwrap_or(false) { - if let Ok(windows) = list_windows().await { - if let Ok(window) = resolve_window_target(&windows, target) { - crop = window.bounds.clone(); - window_label = window.title.clone(); - } - } - } - } + let target_window = match target.as_ref() { + Some(target) => Some( + self.resolve_screenshot_window(target, params.raise_window.unwrap_or(true)) + .await + .map_err(|error| { + ErrorData::internal_error( + format!("targeted screenshot failed: {error:#}"), + None, + ) + })?, + ), + None => None, + }; + let crop_window = (!params.full_screen.unwrap_or(false)) + .then_some(target_window.as_ref()) + .flatten(); + let window_label = target_window + .as_ref() + .and_then(|window| window.title.clone()); let raw_capture = capture_screenshot_raw() .await @@ -402,26 +477,40 @@ impl ComputerUseLinux { // Warn when the target window extends past the visible desktop: the // portal only captures on-screen pixels, so the crop silently loses the // off-screen region while coordinate metadata still claims full size. - let off_screen_note = match crop.as_ref() { + let off_screen_note = match crop_window.and_then(|window| window.bounds.as_ref()) { Some(bounds) => self.off_screen_note_for_bounds(bounds).await, None => None, }; - let (capture, cropped) = match crop.as_ref().and_then(window_crop_rect) { - Some((x, y, w, h)) => match crop_png(&raw_capture.bytes, x, y, w, h) { - Ok((bytes, cw, ch)) => ( + let (capture, cropped) = match crop_window { + Some(window) => { + let (x, y, width, height) = self + .window_crop_rect_for_capture(window, &raw_capture) + .await + .map_err(|error| { + ErrorData::internal_error( + format!("targeted screenshot crop failed: {error:#}"), + None, + ) + })?; + let (bytes, width, height) = crop_png(&raw_capture.bytes, x, y, width, height) + .map_err(|error| { + ErrorData::internal_error( + format!("targeted screenshot crop failed: {error}"), + None, + ) + })?; + ( RawScreenshotCapture { - mime_type: raw_capture.mime_type.clone(), + mime_type: raw_capture.mime_type, bytes, - source: raw_capture.source.clone(), - width: cw, - height: ch, + source: raw_capture.source, + width, + height, }, true, - ), - // If cropping fails, fall back to the full frame rather than erroring. - Err(_) => (raw_capture, false), - }, + ) + } None => (raw_capture, false), }; let capture = @@ -494,30 +583,6 @@ impl ComputerUseLinux { } } - /// Try a coordinate click through the absolute uinput pointer. `Some(ok)` if - /// the backend was used; `None` to fall through to portal / ydotool. - async fn try_abs_click( - &self, - x: i32, - y: i32, - button: Option<&str>, - count: u32, - ) -> Option { - if !self.ensure_abs_pointer().await { - return None; - } - let btn = crate::abs_pointer::PointerButton::from_name(button); - let abs_pointer = Arc::clone(&self.abs_pointer); - tokio::task::spawn_blocking(move || { - let mut guard = abs_pointer.lock().ok()?; - let pointer = guard.as_mut()?; - Some(pointer.click(x, y, btn, count).is_ok()) - }) - .await - .ok() - .flatten() - } - #[tool( name = "click", description = "Click an element by index, semantic selector, or desktop coordinate pixels from screenshot metadata.", @@ -530,6 +595,8 @@ impl ComputerUseLinux { )] async fn click(&self, Parameters(mut params): Parameters) -> Json { let received = Some(serde_json::json!(params.clone())); + let mut input_guard = Arc::clone(&self.input_operation_lock).lock_owned().await; + let mut portal_target_point = None; // Raise the target window first (if specified) so the click lands on the // intended app rather than whatever is stacked on top at that pixel. let window_target = params.window_target(); @@ -569,7 +636,22 @@ impl ComputerUseLinux { received, }); }; - if let Err(message) = apply_window_relative_click_coordinates(&mut params, focus) { + let coordinate_map = match self.focused_window_coordinate_map(focus).await { + Ok(mapping) => mapping, + Err(message) => { + return Json(ActionOutput { + ok: false, + implemented: true, + action: "click".to_string(), + message, + received, + }); + } + }; + if let Err(message) = apply_window_relative_click_coordinates( + &mut params, + coordinate_map.capture_rect, + ) { return Json(ActionOutput { ok: false, implemented: true, @@ -578,6 +660,10 @@ impl ComputerUseLinux { received, }); } + portal_target_point = params + .x + .zip(params.y) + .and_then(|(x, y)| coordinate_map.portal_point(x, y)); } } let target = match self.resolve_click_target(¶ms) { @@ -647,34 +733,81 @@ impl ComputerUseLinux { // Off-screen coordinates "succeed" at the uinput layer while landing on // no visible pixel — surface that instead of a silent no-op. let off_screen_note = self.off_screen_note_for_point(x, y).await; - if self - .try_abs_click( - x, - y, - params.button.as_deref(), - params.click_count.unwrap_or(1).clamp(1, 10), - ) - .await - == Some(true) - { - return Json(with_notes( - pointer_action_result(ActionOutput { - ok: true, - implemented: true, - action: "click".to_string(), - message: "Action sent through the uinput absolute pointer.".to_string(), - received, - }), - off_screen_note.clone(), - )); + if self.ensure_abs_pointer().await { + let btn = crate::abs_pointer::PointerButton::from_name(params.button.as_deref()); + let count = params.click_count.unwrap_or(1).clamp(1, 10); + let abs_pointer = Arc::clone(&self.abs_pointer); + let (returned_guard, clicked) = + run_cancellation_safe_guarded(input_guard, async move { + tokio::task::spawn_blocking(move || { + let mut guard = abs_pointer.lock().ok()?; + let result = guard + .as_mut()? + .click(x, y, btn, count) + .map_err(|error| format!("{error:#}")); + if result.is_err() { + guard.take(); + } + Some(result) + }) + .await + .ok() + .flatten() + }) + .await; + let Some(returned_guard) = returned_guard else { + return Json(with_notes( + action_result("click", Err(clicked.unwrap_err()), received), + off_screen_note, + )); + }; + input_guard = returned_guard; + match clicked { + Ok(Some(Ok(()))) => { + return Json(with_notes( + pointer_action_result(ActionOutput { + ok: true, + implemented: true, + action: "click".to_string(), + message: "Action sent through the uinput absolute pointer.".to_string(), + received, + }), + off_screen_note.clone(), + )); + } + Ok(Some(Err(error))) => { + return Json(with_notes( + action_result( + "click", + Err(format!( + "uinput click may have started before it failed; the device was invalidated and input was not replayed through another backend: {error}" + )), + received, + ), + off_screen_note, + )); + } + Ok(None) => {} + Err(_) => unreachable!("missing guard already handled the guarded task failure"), + } } if let Some(session) = self.cached_portal_pointer_session() { + let Some((portal_x, portal_y)) = + portal_target_point.or_else(|| self.logical_portal_point(&session, x, y)) + else { + self.clear_portal_pointer_session(&session); + return Json(with_notes( + pointer_action_result(portal_coordinate_error("click", received)), + off_screen_note.clone(), + )); + }; match portal_click( &session, - x, - y, + portal_x, + portal_y, PointerButton::from_name(params.button.as_deref()), params.click_count.unwrap_or(1).clamp(1, 10), + InputOperationGuard::new(input_guard), ) .await { @@ -690,39 +823,117 @@ impl ComputerUseLinux { off_screen_note.clone(), )); } - Err(_) => self.clear_portal_pointer_session(), + Err(error) => { + self.clear_portal_pointer_session(&session); + return Json(with_notes( + pointer_action_result(portal_action_error("click", error, received)), + off_screen_note.clone(), + )); + } } - } else if self.should_prefer_portal_pointer_backend() { + } else if self.should_prefer_portal_pointer_backend().await { match self.ensure_portal_pointer_session().await { - Ok(Some(session)) => match portal_click( - &session, - x, - y, - PointerButton::from_name(params.button.as_deref()), - params.click_count.unwrap_or(1).clamp(1, 10), - ) - .await - { - Ok(()) => { + Ok(Some(session)) => { + let Some((portal_x, portal_y)) = + portal_target_point.or_else(|| self.logical_portal_point(&session, x, y)) + else { + self.clear_portal_pointer_session(&session); return Json(with_notes( - pointer_action_result(ActionOutput { - ok: true, - implemented: true, - action: "click".to_string(), - message: "Action sent through the remote desktop portal." - .to_string(), - received, - }), + pointer_action_result(portal_coordinate_error("click", received)), off_screen_note.clone(), )); + }; + match portal_click( + &session, + portal_x, + portal_y, + PointerButton::from_name(params.button.as_deref()), + params.click_count.unwrap_or(1).clamp(1, 10), + InputOperationGuard::new(input_guard), + ) + .await + { + Ok(()) => { + return Json(with_notes( + pointer_action_result(ActionOutput { + ok: true, + implemented: true, + action: "click".to_string(), + message: "Action sent through the remote desktop portal." + .to_string(), + received, + }), + off_screen_note.clone(), + )); + } + Err(error) => { + self.clear_portal_pointer_session(&session); + return Json(with_notes( + pointer_action_result(portal_action_error( + "click", error, received, + )), + off_screen_note.clone(), + )); + } } - Err(_) => self.clear_portal_pointer_session(), - }, - Ok(None) => {} - Err(_) => {} + } + Ok(None) => { + return Json(with_notes( + pointer_action_result(portal_action_error( + "click", + anyhow::anyhow!("the selected portal pointer backend was unavailable"), + received, + )), + off_screen_note, + )); + } + Err(error) => { + return Json(with_notes( + pointer_action_result(portal_action_error("click", error, received)), + off_screen_note, + )); + } + } + } + if self.should_prefer_xdotool_pointer() { + if let Some(xdotool_args) = xdotool_pointer_click_args( + x, + y, + params.click_count.unwrap_or(1).clamp(1, 10), + params.button.as_deref(), + ) { + let ydotool_commands = vec![ + absolute_mousemove_args(x, y), + vec![ + "click".to_string(), + "--repeat".to_string(), + click_count.clone(), + button.clone(), + ], + ]; + let (input_guard, result) = run_cancellation_safe_input(input_guard, async move { + run_xdotool_pointer_or_fallback(Path::new("xdotool"), &xdotool_args, || async { + run_ydotool_sequence(&ydotool_commands).await + }) + .await + }) + .await; + let _input_guard = input_guard; + let used_xdotool = result + .as_ref() + .is_ok_and(|result| result.backend == KeyboardCommandBackend::Xdotool); + let mut output = pointer_action_result(action_result( + "click", + result.map(|result| result.outputs), + received, + )); + if output.ok && used_xdotool { + output.message = "Action sent through xdotool (X11 XTEST).".to_string(); + } + return Json(with_notes(output, off_screen_note)); } } - let result = run_ydotool_sequence(&[ + let commands = vec![ absolute_mousemove_args(x, y), vec![ "click".to_string(), @@ -730,8 +941,12 @@ impl ComputerUseLinux { click_count, button, ], - ]) + ]; + let (input_guard, result) = run_cancellation_safe_input(input_guard, async move { + run_ydotool_sequence(&commands).await + }) .await; + let _input_guard = input_guard; Json(with_notes( pointer_action_result(action_result("click", result, received)), off_screen_note, @@ -827,6 +1042,8 @@ impl ComputerUseLinux { )] async fn scroll(&self, Parameters(mut params): Parameters) -> Json { let received = Some(serde_json::json!(params.clone())); + let input_guard = Arc::clone(&self.input_operation_lock).lock_owned().await; + let mut portal_target_point = None; let units = ((params.pages.unwrap_or(1.0).abs().max(0.1) * 5.0).round() as i32).max(1); // Raise/focus the target window first (parity with click) so wheel // events land on the intended app. @@ -866,7 +1083,22 @@ impl ComputerUseLinux { received, }); }; - if let Err(message) = apply_window_relative_scroll_coordinates(&mut params, focus) { + let coordinate_map = match self.focused_window_coordinate_map(focus).await { + Ok(mapping) => mapping, + Err(message) => { + return Json(ActionOutput { + ok: false, + implemented: true, + action: "scroll".to_string(), + message, + received, + }); + } + }; + if let Err(message) = apply_window_relative_scroll_coordinates( + &mut params, + coordinate_map.capture_rect, + ) { return Json(ActionOutput { ok: false, implemented: true, @@ -875,6 +1107,10 @@ impl ComputerUseLinux { received, }); } + portal_target_point = params + .x + .zip(params.y) + .and_then(|(x, y)| coordinate_map.portal_point(x, y)); } else if params.x.is_none() && params.y.is_none() && params.element_index.is_none() { // A window target without a point would otherwise scroll // whatever happens to sit under the pointer: focusing does not @@ -890,7 +1126,21 @@ impl ComputerUseLinux { received, }); }; - if let Err(message) = apply_window_center_scroll_point(&mut params, focus) { + let coordinate_map = match self.focused_window_coordinate_map(focus).await { + Ok(mapping) => mapping, + Err(message) => { + return Json(ActionOutput { + ok: false, + implemented: true, + action: "scroll".to_string(), + message, + received, + }); + } + }; + if let Err(message) = + apply_window_center_scroll_point(&mut params, coordinate_map.capture_rect) + { return Json(ActionOutput { ok: false, implemented: true, @@ -899,6 +1149,10 @@ impl ComputerUseLinux { received, }); } + portal_target_point = params + .x + .zip(params.y) + .and_then(|(x, y)| coordinate_map.portal_point(x, y)); } } let target_point = @@ -934,9 +1188,28 @@ impl ComputerUseLinux { Some((x, y)) => self.off_screen_note_for_point(x, y).await, None => None, }; - if let Some(session) = self.cached_portal_pointer_session() { - match portal_scroll(&session, target_point, direction, units).await { + let mapped_target = match (portal_target_point, target_point) { + (Some(point), _) => Some(Some(point)), + (None, Some((x, y))) => self.logical_portal_point(&session, x, y).map(Some), + (None, None) => Some(None), + }; + let Some(portal_target_point) = mapped_target else { + self.clear_portal_pointer_session(&session); + return Json(with_notes( + pointer_action_result(portal_coordinate_error("scroll", received)), + off_screen_note.clone(), + )); + }; + let session_for_input = session.clone(); + let (input_guard, result) = run_cancellation_safe_input(input_guard, async move { + portal_scroll(&session_for_input, portal_target_point, direction, units) + .await + .map_err(|error| format!("{error:#}")) + }) + .await; + let _input_guard = input_guard; + match result { Ok(()) => { return Json(with_notes( pointer_action_result(ActionOutput { @@ -949,12 +1222,43 @@ impl ComputerUseLinux { off_screen_note.clone(), )); } - Err(_) => self.clear_portal_pointer_session(), + Err(error) => { + self.clear_portal_pointer_session(&session); + return Json(with_notes( + pointer_action_result(portal_action_error( + "scroll", + anyhow::anyhow!(error), + received, + )), + off_screen_note.clone(), + )); + } } - } else if self.should_prefer_portal_pointer_backend() { + } else if self.should_prefer_portal_pointer_backend().await { match self.ensure_portal_pointer_session().await { Ok(Some(session)) => { - match portal_scroll(&session, target_point, direction, units).await { + let mapped_target = match (portal_target_point, target_point) { + (Some(point), _) => Some(Some(point)), + (None, Some((x, y))) => self.logical_portal_point(&session, x, y).map(Some), + (None, None) => Some(None), + }; + let Some(portal_target_point) = mapped_target else { + self.clear_portal_pointer_session(&session); + return Json(with_notes( + pointer_action_result(portal_coordinate_error("scroll", received)), + off_screen_note.clone(), + )); + }; + let session_for_input = session.clone(); + let (input_guard, result) = + run_cancellation_safe_input(input_guard, async move { + portal_scroll(&session_for_input, portal_target_point, direction, units) + .await + .map_err(|error| format!("{error:#}")) + }) + .await; + let _input_guard = input_guard; + match result { Ok(()) => { return Json(with_notes( pointer_action_result(ActionOutput { @@ -968,11 +1272,35 @@ impl ComputerUseLinux { off_screen_note.clone(), )); } - Err(_) => self.clear_portal_pointer_session(), + Err(error) => { + self.clear_portal_pointer_session(&session); + return Json(with_notes( + pointer_action_result(portal_action_error( + "scroll", + anyhow::anyhow!(error), + received, + )), + off_screen_note.clone(), + )); + } } } - Ok(None) => {} - Err(_) => {} + Ok(None) => { + return Json(with_notes( + pointer_action_result(portal_action_error( + "scroll", + anyhow::anyhow!("the selected portal pointer backend was unavailable"), + received, + )), + off_screen_note, + )); + } + Err(error) => { + return Json(with_notes( + pointer_action_result(portal_action_error("scroll", error, received)), + off_screen_note, + )); + } } } let (dx, dy) = match params.direction.to_ascii_lowercase().as_str() { @@ -996,7 +1324,11 @@ impl ComputerUseLinux { sequence.push(absolute_mousemove_args(x, y)); } sequence.push(wheel_mousemove_args(dx, dy)); - let result = run_ydotool_sequence(&sequence).await; + let (input_guard, result) = run_cancellation_safe_input(input_guard, async move { + run_ydotool_sequence(&sequence).await + }) + .await; + let _input_guard = input_guard; Json(with_notes( pointer_action_result(action_result("scroll", result, received)), off_screen_note, @@ -1015,43 +1347,86 @@ impl ComputerUseLinux { )] async fn drag(&self, Parameters(params): Parameters) -> Json { let received = Some(serde_json::json!(params)); + let mut input_guard = Arc::clone(&self.input_operation_lock).lock_owned().await; // Preferred backend: the uinput absolute pointer (accurate landing). if self.ensure_abs_pointer().await { let abs_pointer = Arc::clone(&self.abs_pointer); - let dragged = tokio::task::spawn_blocking(move || { - if let Ok(mut guard) = abs_pointer.lock() { - guard.as_mut().map(|p| { - p.drag( - (params.start_x, params.start_y), - (params.end_x, params.end_y), - crate::abs_pointer::PointerButton::Left, - ) - .is_ok() + let start = (params.start_x, params.start_y); + let end = (params.end_x, params.end_y); + let (returned_guard, dragged) = + run_cancellation_safe_guarded(input_guard, async move { + tokio::task::spawn_blocking(move || { + if let Ok(mut guard) = abs_pointer.lock() { + let result = guard.as_mut().map(|pointer| { + pointer + .drag(start, end, crate::abs_pointer::PointerButton::Left) + .map_err(|error| format!("{error:#}")) + }); + if result.as_ref().is_some_and(Result::is_err) { + guard.take(); + } + result + } else { + None + } }) - } else { - None + .await + .ok() + .flatten() + }) + .await; + let Some(returned_guard) = returned_guard else { + return Json(action_result("drag", Err(dragged.unwrap_err()), received)); + }; + input_guard = returned_guard; + match dragged { + Ok(Some(Ok(()))) => { + return Json(pointer_action_result(ActionOutput { + ok: true, + implemented: true, + action: "drag".to_string(), + message: "Action sent through the uinput absolute pointer.".to_string(), + received, + })); } - }) - .await - .ok() - .flatten(); - if dragged == Some(true) { - return Json(pointer_action_result(ActionOutput { - ok: true, - implemented: true, - action: "drag".to_string(), - message: "Action sent through the uinput absolute pointer.".to_string(), - received, - })); + Ok(Some(Err(error))) => { + return Json(action_result( + "drag", + Err(format!( + "uinput drag may have started before it failed; the device was invalidated and input was not replayed through another backend: {error}" + )), + received, + )); + } + Ok(None) => {} + Err(_) => unreachable!("missing guard already handled the guarded task failure"), } } if let Some(session) = self.cached_portal_pointer_session() { + let _ = self.capture_space_rect().await; + let Some((start_x, start_y)) = + self.logical_portal_point(&session, params.start_x, params.start_y) + else { + self.clear_portal_pointer_session(&session); + return Json(pointer_action_result(portal_coordinate_error( + "drag", received, + ))); + }; + let Some((end_x, end_y)) = + self.logical_portal_point(&session, params.end_x, params.end_y) + else { + self.clear_portal_pointer_session(&session); + return Json(pointer_action_result(portal_coordinate_error( + "drag", received, + ))); + }; match portal_drag( &session, - params.start_x, - params.start_y, - params.end_x, - params.end_y, + start_x, + start_y, + end_x, + end_y, + InputOperationGuard::new(input_guard), ) .await { @@ -1064,41 +1439,80 @@ impl ComputerUseLinux { received, })); } - Err(_) => self.clear_portal_pointer_session(), + Err(error) => { + self.clear_portal_pointer_session(&session); + return Json(pointer_action_result(portal_action_error( + "drag", error, received, + ))); + } } - } else if self.should_prefer_portal_pointer_backend() { + } else if self.should_prefer_portal_pointer_backend().await { + let _ = self.capture_space_rect().await; match self.ensure_portal_pointer_session().await { - Ok(Some(session)) => match portal_drag( - &session, - params.start_x, - params.start_y, - params.end_x, - params.end_y, - ) - .await - { - Ok(()) => { - return Json(pointer_action_result(ActionOutput { - ok: true, - implemented: true, - action: "drag".to_string(), - message: "Action sent through the remote desktop portal.".to_string(), - received, - })); + Ok(Some(session)) => { + let Some((start_x, start_y)) = + self.logical_portal_point(&session, params.start_x, params.start_y) + else { + self.clear_portal_pointer_session(&session); + return Json(pointer_action_result(portal_coordinate_error( + "drag", received, + ))); + }; + let Some((end_x, end_y)) = + self.logical_portal_point(&session, params.end_x, params.end_y) + else { + self.clear_portal_pointer_session(&session); + return Json(pointer_action_result(portal_coordinate_error( + "drag", received, + ))); + }; + match portal_drag( + &session, + start_x, + start_y, + end_x, + end_y, + InputOperationGuard::new(input_guard), + ) + .await + { + Ok(()) => { + return Json(pointer_action_result(ActionOutput { + ok: true, + implemented: true, + action: "drag".to_string(), + message: "Action sent through the remote desktop portal." + .to_string(), + received, + })); + } + Err(error) => { + self.clear_portal_pointer_session(&session); + return Json(pointer_action_result(portal_action_error( + "drag", error, received, + ))); + } } - Err(_) => self.clear_portal_pointer_session(), - }, - Ok(None) => {} - Err(_) => {} + } + Ok(None) => { + return Json(pointer_action_result(portal_action_error( + "drag", + anyhow::anyhow!("the selected portal pointer backend was unavailable"), + received, + ))); + } + Err(error) => { + return Json(pointer_action_result(portal_action_error( + "drag", error, received, + ))); + } } } - let result = run_ydotool_sequence(&[ - absolute_mousemove_args(params.start_x, params.start_y), - vec!["click".to_string(), "0x40".to_string()], - absolute_mousemove_args(params.end_x, params.end_y), - vec!["click".to_string(), "0x80".to_string()], - ]) + let (input_guard, result) = run_cancellation_safe_input(input_guard, async move { + run_ydotool_drag(params.start_x, params.start_y, params.end_x, params.end_y).await + }) .await; + let _input_guard = input_guard; Json(pointer_action_result(action_result( "drag", result, received, ))) @@ -1106,7 +1520,7 @@ impl ComputerUseLinux { #[tool( name = "press_key", - description = "Press a key or key-combination on the keyboard, optionally after focusing a target window or terminal selector. Key grammar (case-insensitive; hyphens/spaces ignored): combos join with '+', e.g. Ctrl+L or Ctrl+Shift+T. Modifiers: ctrl/control, alt/option, shift, meta/super/cmd/command. Named keys: enter/return, escape/esc, tab, backspace, delete/del, space, home, end, pageup, pagedown, arrowleft/left, arrowright/right, arrowup/up, arrowdown/down, f1-f12. Plus single US letters a-z and digits 0-9. Anything else returns an error (never silently dropped). Note: compositor-level shortcuts (e.g. Super+Up) may be consumed by GNOME before reaching the app.", + description = "Press a key or key-combination on the keyboard, optionally after focusing a target window or terminal selector. Key grammar (case-insensitive; hyphens/spaces ignored): combos join with '+', e.g. Ctrl+L or Ctrl+Shift+T. Modifiers: ctrl/control, alt/option, shift, meta/super/cmd/command. Named keys: enter/return, escape/esc, tab, backspace, delete/del, space, home, end, pageup, pagedown, arrowleft/left, arrowright/right, arrowup/up, arrowdown/down, f1-f12. Plus single US letters a-z and digits 0-9. Anything else returns an error (never silently dropped). On Wayland, chords are sent through an active remote desktop portal keyboard session when one is available (or when ydotool is absent), falling back to ydotool otherwise. Note: compositor-level shortcuts (e.g. Super+Up) may be consumed by GNOME before reaching the app.", annotations( read_only_hint = false, destructive_hint = true, @@ -1119,6 +1533,8 @@ impl ComputerUseLinux { Parameters(params): Parameters, ) -> Json { let received = Some(serde_json::json!(params.clone())); + let input_guard = + InputOperationGuard::new(Arc::clone(&self.input_operation_lock).lock_owned().await); let focus = match self.focus_target_for_input(¶ms.window_target()).await { Ok(focus) => focus, Err(message) => { @@ -1131,7 +1547,7 @@ impl ComputerUseLinux { }); } }; - let Some(key_events) = key_sequence(¶ms.key) else { + let Some((chord_modifiers, chord_key)) = key_chord(¶ms.key) else { return Json(ActionOutput { ok: false, implemented: true, @@ -1140,15 +1556,115 @@ impl ComputerUseLinux { received, }); }; - let mut args = vec!["key".to_string()]; - args.extend(key_events); - let result = run_ydotool(&args).await.map(|output| vec![output]); - let mut output = action_result_with_focus("press_key", result, received, focus.clone()); - if output.ok && focus.is_some() { - let notes = self.input_landing_notes(focus.as_ref(), false).await; - output = with_notes(output, notes); - } - Json(output) + if self.should_prefer_portal_keyboard_for_chords().await { + match self.ensure_portal_keyboard_session().await { + Ok(Some(session)) => { + let modifiers = chord_modifiers + .iter() + .map(|modifier| i32::from(*modifier)) + .collect::>(); + match press_keycode_chord( + &session, + &modifiers, + i32::from(chord_key), + Some(input_guard), + ) + .await + { + Ok(()) => { + let notes = self.input_landing_notes(focus.as_ref(), false).await; + return Json(with_notes( + successful_action_with_focus( + "press_key", + "Action sent through the remote desktop portal.", + received, + focus, + ), + notes, + )); + } + Err(error) => { + self.clear_portal_keyboard_session(&session); + return Json(action_result_with_focus( + "press_key", + Err(format!("{error:#}")), + received, + focus, + )); + } + } + } + Ok(None) => { + return Json(action_result_with_focus( + "press_key", + Err("the selected portal keyboard backend was unavailable; input was not replayed through another backend".to_string()), + received, + focus, + )); + } + Err(error) => { + return Json(action_result_with_focus( + "press_key", + Err(format!("remote desktop portal keyboard initialization failed; input was not replayed through another backend: {error:#}")), + received, + focus, + )); + } + } + } + let Some(key_events) = key_sequence(¶ms.key) else { + return Json(ActionOutput { + ok: false, + implemented: true, + action: "press_key".to_string(), + message: "Unsupported key. Use names like Enter, Escape, Tab, ArrowLeft, Super, Ctrl+L, or a single US keyboard letter/digit.".to_string(), + received, + }); + }; + if self.should_prefer_xdotool_keyboard() { + if let Some(spec) = xdotool_key_spec(¶ms.key) { + let xdotool_args = vec!["key".to_string(), "--clearmodifiers".to_string(), spec]; + let ydotool_args = + ydotool_key_args(key_events.clone(), !chord_modifiers.is_empty()); + let (input_guard, result) = run_cancellation_safe_input(input_guard, async move { + run_xdotool_or_fallback(Path::new("xdotool"), &xdotool_args, || { + run_ydotool(&ydotool_args) + }) + .await + }) + .await; + let _input_guard = input_guard; + let used_xdotool = result + .as_ref() + .is_ok_and(|result| result.backend == KeyboardCommandBackend::Xdotool); + let mut output = action_result_with_focus( + "press_key", + result.map(|result| vec![result.output]), + received, + focus.clone(), + ); + if used_xdotool { + output.message = "Action sent through xdotool (X11 XTEST).".to_string(); + } + if output.ok && focus.is_some() { + let notes = self.input_landing_notes(focus.as_ref(), false).await; + output = with_notes(output, notes); + } + return Json(output); + } + } + let args = ydotool_key_args(key_events, !chord_modifiers.is_empty()); + let (input_guard, result) = run_cancellation_safe_input(input_guard, async move { + run_ydotool(&args).await.map(|output| vec![output]) + }) + .await; + let _input_guard = input_guard; + let mut output = action_result_with_focus("press_key", result, received, focus.clone()); + if output.ok && focus.is_some() { + let notes = self.input_landing_notes(focus.as_ref(), false).await; + output = with_notes(output, notes); + } + Json(output) } #[tool( @@ -1166,6 +1682,8 @@ impl ComputerUseLinux { Parameters(params): Parameters, ) -> Json { let received = Some(serde_json::json!(params.clone())); + let mut input_guard = + InputOperationGuard::new(Arc::clone(&self.input_operation_lock).lock_owned().await); let focus = match self.focus_target_for_input(¶ms.window_target()).await { Ok(focus) => focus, Err(message) => { @@ -1181,8 +1699,35 @@ impl ComputerUseLinux { if self.should_prefer_kde_clipboard_text_backend() { match self.ensure_portal_keyboard_session().await { Ok(Some(session)) => { - let _clipboard_guard = self.kde_clipboard_lock.lock().await; - match run_kde_clipboard_paste_text(&session, ¶ms.text).await { + let clipboard_guard = Arc::clone(&self.kde_clipboard_lock).lock_owned().await; + let session_for_input = session.clone(); + let text = params.text.clone(); + let portal_operation_guard = input_guard.clone(); + let (guards, guarded_result) = + run_cancellation_safe_guarded((input_guard, clipboard_guard), async move { + run_kde_clipboard_paste_text( + &session_for_input, + &text, + portal_operation_guard, + ) + .await + }) + .await; + let (returned_input_guard, clipboard_guard) = match guards { + Some(guards) => guards, + None => { + return Json(action_result_with_focus( + "type_text", + Err(guarded_result.unwrap_err()), + received, + focus, + )); + } + }; + input_guard = returned_input_guard; + drop(clipboard_guard); + let result = guarded_result.expect("guarded task returned its guards"); + match result { Ok(message) => { let notes = self.input_landing_notes(focus.as_ref(), true).await; return Json(with_notes( @@ -1197,7 +1742,7 @@ impl ComputerUseLinux { } Err(error) => { if error.clear_portal_keyboard_session { - self.clear_portal_keyboard_session(); + self.clear_portal_keyboard_session(&session); } if !error.can_fallback_to_ydotool { return Json(action_result_with_focus( @@ -1210,14 +1755,41 @@ impl ComputerUseLinux { } } } - Ok(None) => {} - Err(_) => {} + Ok(None) => { + return Json(action_result_with_focus( + "type_text", + Err("the selected KDE portal keyboard backend was unavailable; input was not replayed through another backend".to_string()), + received, + focus, + )); + } + Err(error) => { + return Json(action_result_with_focus( + "type_text", + Err(format!("KDE portal keyboard initialization failed; input was not replayed through another backend: {error:#}")), + received, + focus, + )); + } } } - if self.should_prefer_portal_keyboard_backend() { - if let Ok(keysyms) = keysyms_for_text(¶ms.text) { - match self.ensure_portal_keyboard_session().await { - Ok(Some(session)) => match type_text_with_keysyms(&session, &keysyms).await { + if self.should_prefer_portal_keyboard_backend().await { + let keysyms = match keysyms_for_text(¶ms.text) { + Ok(keysyms) => keysyms, + Err(error) => { + return Json(action_result_with_focus( + "type_text", + Err(format!( + "text cannot be represented by the selected portal keyboard backend; input was not replayed through another backend: {error:#}" + )), + received, + focus, + )); + } + }; + match self.ensure_portal_keyboard_session().await { + Ok(Some(session)) => { + match type_text_with_keysyms(&session, &keysyms, Some(input_guard)).await { Ok(()) => { let notes = self.input_landing_notes(focus.as_ref(), true).await; return Json(with_notes( @@ -1231,7 +1803,7 @@ impl ComputerUseLinux { )); } Err(error) => { - self.clear_portal_keyboard_session(); + self.clear_portal_keyboard_session(&session); return Json(action_result_with_focus( "type_text", Err(format!("{error:#}")), @@ -1239,15 +1811,63 @@ impl ComputerUseLinux { focus, )); } - }, - Ok(None) => {} - Err(_) => {} + } + } + Ok(None) => { + return Json(action_result_with_focus( + "type_text", + Err("the selected portal keyboard backend was unavailable; input was not replayed through another backend".to_string()), + received, + focus, + )); + } + Err(error) => { + return Json(action_result_with_focus( + "type_text", + Err(format!("remote desktop portal keyboard initialization failed; input was not replayed through another backend: {error:#}")), + received, + focus, + )); } } } - let result = run_ydotool_type_text(¶ms.text) - .await - .map(|output| vec![output]); + if self.should_prefer_xdotool_keyboard() { + let args = xdotool_type_args(¶ms.text); + let text = params.text.clone(); + let (input_guard, result) = run_cancellation_safe_input(input_guard, async move { + run_xdotool_or_fallback(Path::new("xdotool"), &args, || { + run_ydotool_type_text(&text) + }) + .await + }) + .await; + let _input_guard = input_guard; + let used_xdotool = result + .as_ref() + .is_ok_and(|result| result.backend == KeyboardCommandBackend::Xdotool); + let mut output = action_result_with_focus( + "type_text", + result.map(|result| vec![result.output]), + received, + focus.clone(), + ); + if used_xdotool { + output.message = "Action sent through xdotool (X11 XTEST).".to_string(); + } + if output.ok && focus.is_some() { + let notes = self.input_landing_notes(focus.as_ref(), true).await; + output = with_notes(output, notes); + } + return Json(output); + } + let text = params.text.clone(); + let (input_guard, result) = run_cancellation_safe_input(input_guard, async move { + run_ydotool_type_text(&text) + .await + .map(|output| vec![output]) + }) + .await; + let _input_guard = input_guard; let mut output = action_result_with_focus("type_text", result, received, focus.clone()); if output.ok && focus.is_some() { let notes = self.input_landing_notes(focus.as_ref(), true).await; @@ -1258,7 +1878,7 @@ impl ComputerUseLinux { #[tool( name = "move_window", - description = "Move a window to a new desktop position (frame top-left in desktop coordinates). Useful to recover windows that are partially off-screen. Requires the computer-use-linux GNOME Shell extension.", + description = "Move a window to a new desktop position (frame top-left in desktop coordinates). Useful to recover windows that are partially off-screen. Works through the Codex GNOME Shell extension or a generic X11/EWMH window manager (wmctrl).", annotations( read_only_hint = false, destructive_hint = false, @@ -1272,16 +1892,15 @@ impl ComputerUseLinux { ) -> Json { let received = Some(serde_json::json!(params.clone())); let target = params.target.clone().into_target(); - self.window_geometry_op(received, &target, |window_id| async move { - crate::windowing::backends::gnome::move_extension_window(window_id, params.x, params.y) - .await + self.window_geometry_op(received, &target, |window| async move { + registry::move_window(&window, params.x, params.y).await }) .await } #[tool( name = "resize_window", - description = "Resize a window to a new frame width/height in desktop pixels, unmaximizing it first if needed. Useful to fit a window fully on-screen. Requires the computer-use-linux GNOME Shell extension.", + description = "Resize a window to a new frame width/height in desktop pixels, unmaximizing it first if needed. Useful to fit a window fully on-screen. Works through the Codex GNOME Shell extension or a generic X11/EWMH window manager (wmctrl).", annotations( read_only_hint = false, destructive_hint = false, @@ -1295,25 +1914,21 @@ impl ComputerUseLinux { ) -> Json { let received = Some(serde_json::json!(params.clone())); let target = params.target.clone().into_target(); - self.window_geometry_op(received, &target, |window_id| async move { - crate::windowing::backends::gnome::resize_extension_window( - window_id, - params.width, - params.height, - ) - .await + self.window_geometry_op(received, &target, |window| async move { + registry::resize_window(&window, params.width, params.height).await }) .await } } #[tool_handler( + router = self.mcp_tool_router(), name = "codex-computer-use-linux", // NOTE: keep in lockstep with Cargo.toml + package.json on every release. // The rmcp tool_handler macro only accepts a string literal here, so this // can't be env!("CARGO_PKG_VERSION"); the MCP safety check (CI) fails the // build if it drifts from the Cargo version. - version = "0.3.1-linux-alpha1", + version = "0.4.6-linux-alpha1", instructions = "Begin every turn that uses Computer Use by calling get_app_state. If diagnostics report disabled GNOME accessibility, call setup_accessibility before asking the user to retry. Use list_windows/focused_window before targeted keyboard input. If diagnostics report windowing.can_list_windows=false on GNOME, call setup_window_targeting to install the optional GNOME Shell extension backend, then ask the user to log out and back in if the setup report says a shell reload is required. This Linux backend can capture size-bounded screenshots through GNOME Shell, the Codex GNOME Shell extension, or XDG Desktop Portal, read AT-SPI trees with action/value metadata, invoke native AT-SPI actions, set AT-SPI values or editable text, list/focus compositor windows through registered Linux window backends when the session permits it, attach best-effort terminal tty/process metadata to terminal windows, send coordinate or element-targeted click/scroll/drag input through the Wayland remote desktop portal when available, and send layout-safe literal type_text through KDE clipboard integration on Plasma Wayland or through portal keysyms on other Wayland sessions before falling back to ydotool. Screenshot results include width/height for the returned image plus coordinate_width/coordinate_height and scale for desktop coordinate conversion; request more detail with max_width, max_height, max_bytes, format=jpeg, quality, or a smaller target/crop instead of relying on unbounded screenshots. Tools with readOnlyHint=false may mutate local desktop or application state; hosts should require approval for actions that can submit, delete, send, purchase, or overwrite data. For element-targeted actions, prefer element_index from the latest get_app_state result; click, perform_action, and set_value can also use semantic role/name/text/states selectors when the target is unique. type_text and press_key accept optional window_id, pid, app_id, wm_class, title, tty, terminal_pid, terminal_command, or terminal_cwd selectors and refuse targeted input if focus cannot be verified. After targeted keyboard input, results append focused-element feedback from AT-SPI (role, name, editable) and warn when no editable element holds focus — treat that warning as the input not landing. Screenshot, click, and input results warn when the target window or coordinate is partially or fully off-screen; use move_window/resize_window (GNOME Shell extension backend) to bring a window fully on-screen before retrying. scroll accepts the same window targeting and relative coordinates as click. get_app_state returns a compact readiness block by default; pass verbose=true for the full diagnostics dump. Electron apps expose no AT-SPI tree unless launched with --force-renderer-accessibility." )] impl ServerHandler for ComputerUseLinux {} @@ -1910,9 +2525,9 @@ struct ActionOutput { impl ComputerUseLinux { fn is_wayland_session(&self) -> bool { crate::diagnostics::hydrate_session_bus_env(); - env::var("XDG_SESSION_TYPE") - .ok() - .is_some_and(|value| value.eq_ignore_ascii_case("wayland")) + let session_type = env::var("XDG_SESSION_TYPE").ok(); + let wayland_display = env::var("WAYLAND_DISPLAY").ok(); + session_is_wayland(session_type.as_deref(), wayland_display.as_deref()) } // The Wayland remote-desktop portal is now a *fallback* for input: when a @@ -1925,7 +2540,7 @@ impl ComputerUseLinux { // `COMPUTER_USE_LINUX_FORCE_PORTAL_*=1` always uses the portal. The // `CODEX_COMPUTER_USE_*` names are accepted for the embedded Codex app // bundle so downstream can share this source without local string patches. - fn should_prefer_portal_pointer_backend(&self) -> bool { + async fn should_prefer_portal_pointer_backend(&self) -> bool { if env_flag_enabled_any(&[ "COMPUTER_USE_LINUX_FORCE_YDOTOOL_POINTER", "CODEX_COMPUTER_USE_FORCE_YDOTOOL_POINTER", @@ -1940,17 +2555,20 @@ impl ComputerUseLinux { } should_prefer_portal_backend_by_default( self.is_wayland_session(), - ydotool_backend_available(), + ydotool_backend_available().await, ) } - fn should_prefer_portal_keyboard_backend(&self) -> bool { + async fn should_prefer_portal_keyboard_backend(&self) -> bool { if env_flag_enabled_any(&[ "COMPUTER_USE_LINUX_FORCE_YDOTOOL_KEYBOARD", "CODEX_COMPUTER_USE_FORCE_YDOTOOL_KEYBOARD", ]) { return false; } + if self.should_prefer_xdotool_keyboard() { + return false; + } if env_flag_enabled_any(&[ "COMPUTER_USE_LINUX_FORCE_PORTAL_KEYBOARD", "CODEX_COMPUTER_USE_FORCE_PORTAL_KEYBOARD", @@ -1960,15 +2578,70 @@ impl ComputerUseLinux { !self.is_kde_wayland_session() && should_prefer_portal_backend_by_default( self.is_wayland_session(), - ydotool_backend_available(), + ydotool_backend_available().await, ) } + async fn should_prefer_portal_keyboard_for_chords(&self) -> bool { + if env_flag_enabled_any(&[ + "COMPUTER_USE_LINUX_FORCE_YDOTOOL_KEYBOARD", + "CODEX_COMPUTER_USE_FORCE_YDOTOOL_KEYBOARD", + ]) { + return false; + } + if self.should_prefer_xdotool_keyboard() { + return false; + } + if !self.is_wayland_session() { + return false; + } + if self.cached_portal_keyboard_session().is_some() + || env_flag_enabled_any(&[ + "COMPUTER_USE_LINUX_FORCE_PORTAL_KEYBOARD", + "CODEX_COMPUTER_USE_FORCE_PORTAL_KEYBOARD", + ]) + { + return true; + } + !ydotool_backend_available().await + } + fn should_prefer_kde_clipboard_text_backend(&self) -> bool { !env_flag_enabled_any(&[ "COMPUTER_USE_LINUX_FORCE_YDOTOOL_KEYBOARD", "CODEX_COMPUTER_USE_FORCE_YDOTOOL_KEYBOARD", - ]) && self.is_kde_wayland_session() + ]) && !self.should_prefer_xdotool_keyboard() + && self.is_kde_wayland_session() + } + + fn should_prefer_xdotool_pointer(&self) -> bool { + crate::diagnostics::hydrate_session_bus_env(); + prefer_xdotool_pointer( + env_flag_enabled_any(&[ + "COMPUTER_USE_LINUX_FORCE_YDOTOOL_POINTER", + "CODEX_COMPUTER_USE_FORCE_YDOTOOL_POINTER", + ]), + env::var("XDG_SESSION_TYPE").ok().as_deref(), + env_var_non_empty("DISPLAY"), + env::var("WAYLAND_DISPLAY").ok().as_deref(), + xdotool_available(), + ) + } + + fn should_prefer_xdotool_keyboard(&self) -> bool { + prefer_xdotool_keyboard( + env_flag_enabled_any(&[ + "COMPUTER_USE_LINUX_FORCE_YDOTOOL_KEYBOARD", + "CODEX_COMPUTER_USE_FORCE_YDOTOOL_KEYBOARD", + ]), + env_flag_enabled_any(&[ + "COMPUTER_USE_LINUX_FORCE_XDOTOOL_KEYBOARD", + "CODEX_COMPUTER_USE_FORCE_XDOTOOL_KEYBOARD", + ]), + self.is_wayland_session(), + env_var_non_empty("DISPLAY"), + xdotool_available(), + ) } fn is_kde_wayland_session(&self) -> bool { @@ -1978,39 +2651,58 @@ impl ComputerUseLinux { } fn cached_portal_pointer_session(&self) -> Option { - self.portal_pointer_session - .lock() - .ok() - .and_then(|cached| cached.clone()) + let mut cached = self.portal_pointer_session.lock().ok()?; + if cached.as_ref().is_some_and(|session| !session.is_valid()) { + *cached = None; + } + cached.clone() } - fn clear_portal_pointer_session(&self) { + fn clear_portal_pointer_session(&self, failed: &PortalPointerSession) { + failed.invalidate_and_close(); if let Ok(mut cached) = self.portal_pointer_session.lock() { - *cached = None; + if cached + .as_ref() + .is_some_and(|session| session.same_session(failed)) + { + *cached = None; + } } } fn cached_portal_keyboard_session(&self) -> Option { - self.portal_keyboard_session - .lock() - .ok() - .and_then(|cached| cached.clone()) + let mut cached = self.portal_keyboard_session.lock().ok()?; + if cached.as_ref().is_some_and(|session| !session.is_valid()) { + *cached = None; + } + cached.clone() } - fn clear_portal_keyboard_session(&self) { + fn clear_portal_keyboard_session(&self, failed: &PortalKeyboardSession) { + failed.invalidate_and_close(); if let Ok(mut cached) = self.portal_keyboard_session.lock() { - *cached = None; + if cached + .as_ref() + .is_some_and(|session| session.same_session(failed)) + { + *cached = None; + } } } async fn ensure_portal_pointer_session(&self) -> Result> { - if !self.should_prefer_portal_pointer_backend() { + if !self.should_prefer_portal_pointer_backend().await { return Ok(None); } if let Some(session) = self.cached_portal_pointer_session() { return Ok(Some(session)); } + let _guard = self.portal_session_init_lock.lock().await; + if let Some(session) = self.cached_portal_pointer_session() { + return Ok(Some(session)); + } + let session = start_portal_pointer_session().await?; if let Ok(mut cached) = self.portal_pointer_session.lock() { *cached = Some(session.clone()); @@ -2030,7 +2722,7 @@ impl ComputerUseLinux { return Ok(Some(session)); } - let _guard = self.portal_keyboard_init_lock.lock().await; + let _guard = self.portal_session_init_lock.lock().await; if let Some(session) = self.cached_portal_keyboard_session() { return Ok(Some(session)); } @@ -2064,6 +2756,171 @@ impl ComputerUseLinux { } } + async fn resolve_screenshot_window( + &self, + target: &WindowTarget, + raise_window: bool, + ) -> Result { + let window = if raise_window { + let focus = focus_window_target(target).await?; + if !focus.exact_window_focused { + anyhow::bail!( + "the requested window could not be focused exactly; refusing to capture unrelated desktop pixels" + ); + } + sleep(Duration::from_millis(250)).await; + focus + .focused_window + .filter(|window| window.window_id == focus.requested_window.window_id) + .ok_or_else(|| anyhow::anyhow!("focused-window verification returned no window"))? + } else { + let windows = list_windows().await?; + let window = resolve_window_target(&windows, target)?.clone(); + if !window.focused { + anyhow::bail!( + "raise_window=false requires the requested window to already be focused" + ); + } + window + }; + if window.hidden { + anyhow::bail!("the requested window is hidden or minimized"); + } + Ok(window) + } + + async fn window_crop_rect_for_capture( + &self, + window: &WindowInfo, + raw: &RawScreenshotCapture, + ) -> Result<(i32, i32, u32, u32)> { + self.window_crop_rect_for_dimensions(window, raw.width, raw.height) + .await + } + + async fn window_crop_rect_for_dimensions( + &self, + window: &WindowInfo, + capture_width: u32, + capture_height: u32, + ) -> Result<(i32, i32, u32, u32)> { + Ok(self + .window_coordinate_map_for_dimensions(window, capture_width, capture_height) + .await? + .capture_rect) + } + + async fn window_coordinate_map_for_dimensions( + &self, + window: &WindowInfo, + capture_width: u32, + capture_height: u32, + ) -> Result { + let bounds = window.bounds.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "targeted screenshot requires window bounds; refusing to return the full desktop" + ) + })?; + let logical_rect = window_crop_rect(bounds).ok_or_else(|| { + anyhow::anyhow!( + "targeted screenshot has unusable window bounds; refusing to return the full desktop" + ) + })?; + let monitors = if window.backend == GNOME_SHELL_EXTENSION_BACKEND { + Some( + crate::windowing::backends::gnome::extension_monitor_layout() + .await + .map_err(|error| { + anyhow::anyhow!( + "GNOME targeted screenshot requires logical monitor geometry: {error:#}" + ) + })? + .into_iter() + .map(|monitor| (monitor.x, monitor.y, monitor.width, monitor.height)) + .collect(), + ) + } else if window.backend == GNOME_SHELL_INTROSPECT_BACKEND { + crate::windowing::backends::gnome::extension_monitor_layout() + .await + .ok() + .map(|monitors| { + monitors + .into_iter() + .map(|monitor| (monitor.x, monitor.y, monitor.width, monitor.height)) + .collect() + }) + } else if window.backend == KWIN_BACKEND { + Some(vec![ + crate::windowing::backends::kwin::logical_desktop_rect() + .await + .map_err(|error| { + anyhow::anyhow!( + "KWin targeted screenshot requires logical workspace geometry: {error:#}" + ) + })?, + ]) + } else { + None + }; + let (full_capture_rect, portal_rect) = match monitors { + Some(monitors) => ( + logical_window_crop_rect(bounds, &monitors, capture_width, capture_height)?, + Some(logical_rect), + ), + None => (logical_rect, None), + }; + Ok(WindowCoordinateMap { + capture_rect: clip_capture_rect(full_capture_rect, capture_width, capture_height)?, + full_capture_rect, + portal_rect, + }) + } + + async fn focused_window_coordinate_map( + &self, + focus: &WindowFocusResult, + ) -> std::result::Result { + let window = focus + .focused_window + .as_ref() + .unwrap_or(&focus.requested_window); + if !matches!( + window.backend.as_str(), + GNOME_SHELL_EXTENSION_BACKEND | GNOME_SHELL_INTROSPECT_BACKEND | KWIN_BACKEND + ) { + let full_capture_rect = window + .bounds + .as_ref() + .and_then(window_crop_rect) + .ok_or_else(|| { + "Window-relative coordinates require usable target-window bounds.".to_string() + })?; + let capture_rect = self + .desktop_size + .lock() + .ok() + .and_then(|guard| *guard) + .map(|(width, height)| { + clip_capture_rect(full_capture_rect, width, height).map_err(|error| { + format!("Could not map target-window coordinates: {error:#}") + }) + }) + .transpose()? + .unwrap_or(full_capture_rect); + return Ok(WindowCoordinateMap { + capture_rect, + full_capture_rect, + portal_rect: None, + }); + } + let (_, _, width, height) = self.capture_space_rect().await.ok_or_else(|| { + "Could not determine screenshot dimensions for window-relative coordinates.".to_string() + })?; + self.window_coordinate_map_for_dimensions(window, width as u32, height as u32) + .await + .map_err(|error| format!("Could not map target-window coordinates: {error:#}")) + } + async fn resolve_accessibility_app_filter( &self, params: &GetAppStateParams, @@ -2131,6 +2988,16 @@ impl ComputerUseLinux { } } + fn logical_portal_point( + &self, + session: &PortalPointerSession, + x: i32, + y: i32, + ) -> Option<(i32, i32)> { + let capture_size = self.desktop_size.lock().ok().and_then(|guard| *guard); + session.logical_point_from_capture(x, y, capture_size) + } + /// COORDINATE SPACES: window bounds (list_windows / extension frame rects) /// and the extension monitor layout are in LOGICAL pixels, while click/ /// scroll coordinates and screenshot captures are in PHYSICAL capture @@ -2260,7 +3127,7 @@ impl ComputerUseLinux { op: F, ) -> Json where - F: FnOnce(u64) -> Fut, + F: FnOnce(crate::windowing::WindowInfo) -> Fut, Fut: Future>, { let windows = match list_windows().await { @@ -2270,7 +3137,7 @@ impl ComputerUseLinux { return Json(WindowGeometryOutput { ok: false, implemented: true, - backend: crate::windowing::GNOME_SHELL_EXTENSION_BACKEND.to_string(), + backend: "unknown".to_string(), window: None, message: format!("Window listing failed: {error}"), permissions_hint: window_permission_hint(&error), @@ -2278,13 +3145,13 @@ impl ComputerUseLinux { }); } }; - let window_id = match resolve_window_target(&windows, target) { - Ok(window) => window.window_id, + let window = match resolve_window_target(&windows, target) { + Ok(window) => window.clone(), Err(error) => { return Json(WindowGeometryOutput { ok: false, implemented: true, - backend: crate::windowing::GNOME_SHELL_EXTENSION_BACKEND.to_string(), + backend: "unknown".to_string(), window: None, message: format!("{error:#}"), permissions_hint: None, @@ -2292,7 +3159,9 @@ impl ComputerUseLinux { }); } }; - match op(window_id).await { + let backend = window.backend.clone(); + let window_id = window.window_id; + match op(window).await { Ok(message) => { // Re-query so the caller sees the compositor-final geometry // (tiling constraints, minimum sizes, etc. may adjust it). @@ -2310,7 +3179,7 @@ impl ComputerUseLinux { Json(WindowGeometryOutput { ok: true, implemented: true, - backend: crate::windowing::GNOME_SHELL_EXTENSION_BACKEND.to_string(), + backend, window, message, permissions_hint: None, @@ -2322,7 +3191,7 @@ impl ComputerUseLinux { Json(WindowGeometryOutput { ok: false, implemented: true, - backend: crate::windowing::GNOME_SHELL_EXTENSION_BACKEND.to_string(), + backend, window: None, permissions_hint: window_permission_hint(&error), message: error, @@ -2970,6 +3839,12 @@ fn env_flag_enabled_any(keys: &[&str]) -> bool { keys.iter().any(|key| env_flag_enabled(key)) } +fn env_var_non_empty(key: &str) -> bool { + env::var(key) + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) +} + /// Return the base64 payload of a `data:` URL (or the original string if bare). fn data_url_payload(data_url: &str) -> String { data_url @@ -2979,6 +3854,205 @@ fn data_url_payload(data_url: &str) -> String { .to_string() } +fn session_is_wayland(session_type: Option<&str>, wayland_display: Option<&str>) -> bool { + match session_type + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(value) => value.eq_ignore_ascii_case("wayland"), + None => wayland_display.is_some_and(|value| !value.trim().is_empty()), + } +} + +fn native_x11_xdotool_pointer_session( + session_type: Option<&str>, + wayland_display: Option<&str>, +) -> bool { + session_type.is_some_and(|value| value.trim().eq_ignore_ascii_case("x11")) + && !wayland_display.is_some_and(|value| !value.trim().is_empty()) +} + +fn prefer_xdotool_pointer( + force_ydotool: bool, + session_type: Option<&str>, + display_available: bool, + wayland_display: Option<&str>, + xdotool_available: bool, +) -> bool { + !force_ydotool + && native_x11_xdotool_pointer_session(session_type, wayland_display) + && display_available + && xdotool_available +} + +fn prefer_xdotool_keyboard( + force_ydotool: bool, + force_xdotool: bool, + is_wayland: bool, + display_available: bool, + xdotool_available: bool, +) -> bool { + !force_ydotool && display_available && xdotool_available && (force_xdotool || !is_wayland) +} + +fn prepare_app_state_screenshot( + mut raw: RawScreenshotCapture, + crop: Option<(i32, i32, u32, u32)>, + target_requested: bool, + options: ScreenshotPayloadOptions, +) -> Result { + if target_requested && crop.is_none() { + anyhow::bail!( + "targeted screenshot requires a resolved window; refusing to return the full desktop" + ); + } + if let Some(rect) = crop { + let (x, y, width, height) = clip_capture_rect(rect, raw.width, raw.height)?; + let (bytes, width, height) = crop_png(&raw.bytes, x, y, width, height) + .map_err(|error| anyhow::anyhow!("targeted screenshot crop failed: {error}"))?; + raw = RawScreenshotCapture { + mime_type: raw.mime_type, + bytes, + source: raw.source, + width, + height, + }; + } + prepare_screenshot_payload(raw, options) +} + +fn ensure_readonly_screenshot_target_is_visible(window: &WindowInfo) -> Result<()> { + if window.hidden { + anyhow::bail!("targeted get_app_state screenshot requires a visible, unminimized window"); + } + if !window.focused { + anyhow::bail!( + "targeted get_app_state screenshot requires the window to already be focused; use the screenshot tool to raise it before capture" + ); + } + Ok(()) +} + +#[derive(Debug, Clone, Copy)] +struct WindowCoordinateMap { + capture_rect: (i32, i32, u32, u32), + full_capture_rect: (i32, i32, u32, u32), + portal_rect: Option<(i32, i32, u32, u32)>, +} + +impl WindowCoordinateMap { + fn portal_point(&self, capture_x: i32, capture_y: i32) -> Option<(i32, i32)> { + let (portal_x, portal_y, portal_width, portal_height) = self.portal_rect?; + let (full_x, full_y, full_width, full_height) = self.full_capture_rect; + Some(( + map_coordinate_between_rects(capture_x, full_x, full_width, portal_x, portal_width), + map_coordinate_between_rects(capture_y, full_y, full_height, portal_y, portal_height), + )) + } +} + +fn map_coordinate_between_rects( + value: i32, + source_origin: i32, + source_size: u32, + target_origin: i32, + target_size: u32, +) -> i32 { + let offset = i64::from(value) - i64::from(source_origin); + let scaled = offset.saturating_mul(i64::from(target_size)) / i64::from(source_size.max(1)); + (i64::from(target_origin) + scaled).clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32 +} + +fn logical_window_crop_rect( + bounds: &crate::windowing::WindowBounds, + monitors: &[(i32, i32, i32, i32)], + capture_width: u32, + capture_height: u32, +) -> Result<(i32, i32, u32, u32)> { + let mut monitors = monitors + .iter() + .filter(|(_, _, width, height)| *width > 0 && *height > 0); + let first = monitors + .next() + .ok_or_else(|| anyhow::anyhow!("desktop returned no usable monitor geometry"))?; + let (mut min_x, mut min_y) = (i64::from(first.0), i64::from(first.1)); + let (mut max_x, mut max_y) = ( + i64::from(first.0) + i64::from(first.2), + i64::from(first.1) + i64::from(first.3), + ); + for (x, y, width, height) in monitors { + min_x = min_x.min(i64::from(*x)); + min_y = min_y.min(i64::from(*y)); + max_x = max_x.max(i64::from(*x) + i64::from(*width)); + max_y = max_y.max(i64::from(*y) + i64::from(*height)); + } + let logical_width = max_x - min_x; + let logical_height = max_y - min_y; + if logical_width <= 0 || logical_height <= 0 || capture_width == 0 || capture_height == 0 { + anyhow::bail!("screenshot or monitor geometry is empty"); + } + let scale_x = f64::from(capture_width) / logical_width as f64; + let scale_y = f64::from(capture_height) / logical_height as f64; + if !scale_x.is_finite() || !scale_y.is_finite() || (scale_x - scale_y).abs() > 0.01 { + anyhow::bail!( + "captured desktop {}x{} does not have a uniform scale relative to the logical monitor layout {}x{}", + capture_width, + capture_height, + logical_width, + logical_height + ); + } + + let x = i64::from( + bounds + .x + .ok_or_else(|| anyhow::anyhow!("window x is unavailable"))?, + ); + let y = i64::from( + bounds + .y + .ok_or_else(|| anyhow::anyhow!("window y is unavailable"))?, + ); + if bounds.width == 0 || bounds.height == 0 { + anyhow::bail!("window bounds are empty"); + } + let left = (((x - min_x) as f64) * scale_x).floor() as i64; + let top = (((y - min_y) as f64) * scale_y).floor() as i64; + let right = (((x + i64::from(bounds.width) - min_x) as f64) * scale_x).ceil() as i64; + let bottom = (((y + i64::from(bounds.height) - min_y) as f64) * scale_y).ceil() as i64; + let width = u32::try_from(right - left) + .map_err(|_| anyhow::anyhow!("scaled window width is invalid"))?; + let height = u32::try_from(bottom - top) + .map_err(|_| anyhow::anyhow!("scaled window height is invalid"))?; + let left = i32::try_from(left).map_err(|_| anyhow::anyhow!("scaled window x is invalid"))?; + let top = i32::try_from(top).map_err(|_| anyhow::anyhow!("scaled window y is invalid"))?; + Ok((left, top, width, height)) +} + +fn clip_capture_rect( + (x, y, width, height): (i32, i32, u32, u32), + capture_width: u32, + capture_height: u32, +) -> Result<(i32, i32, u32, u32)> { + let left = i64::from(x).max(0); + let top = i64::from(y).max(0); + let right = (i64::from(x) + i64::from(width)).min(i64::from(capture_width)); + let bottom = (i64::from(y) + i64::from(height)).min(i64::from(capture_height)); + if right <= left || bottom <= top { + anyhow::bail!( + "targeted screenshot window is outside the captured desktop; refusing to return the full desktop" + ); + } + Ok(( + i32::try_from(left).map_err(|_| anyhow::anyhow!("capture crop x is invalid"))?, + i32::try_from(top).map_err(|_| anyhow::anyhow!("capture crop y is invalid"))?, + u32::try_from(right - left) + .map_err(|_| anyhow::anyhow!("capture crop width is invalid"))?, + u32::try_from(bottom - top) + .map_err(|_| anyhow::anyhow!("capture crop height is invalid"))?, + )) +} + /// Convert a window's bounds into a crop rectangle, if it has a usable origin /// and non-zero size. fn window_crop_rect(bounds: &crate::windowing::WindowBounds) -> Option<(i32, i32, u32, u32)> { @@ -2992,21 +4066,14 @@ fn window_crop_rect(bounds: &crate::windowing::WindowBounds) -> Option<(i32, i32 fn apply_window_relative_click_coordinates( params: &mut ClickParams, - focus: &WindowFocusResult, + capture_rect: (i32, i32, u32, u32), ) -> std::result::Result<(), String> { let (relative_x, relative_y) = params .x .zip(params.y) .ok_or_else(|| "Relative coordinate clicks require both x and y.".to_string())?; - let bounds = focus - .focused_window - .as_ref() - .and_then(|window| window.bounds.as_ref()) - .or(focus.requested_window.bounds.as_ref()) - .ok_or_else(|| { - "Relative coordinate clicks require resolved target-window bounds.".to_string() - })?; - if bounds.width == 0 || bounds.height == 0 { + let (origin_x, origin_y, width, height) = capture_rect; + if width == 0 || height == 0 { return Err( "Relative coordinate clicks require non-empty target-window bounds.".to_string(), ); @@ -3014,12 +4081,9 @@ fn apply_window_relative_click_coordinates( if relative_x < 0 || relative_y < 0 { return Err("Relative click coordinates must be inside target-window bounds.".to_string()); } - if relative_x as u32 >= bounds.width || relative_y as u32 >= bounds.height { + if relative_x as u32 >= width || relative_y as u32 >= height { return Err("Relative click coordinates must be inside target-window bounds.".to_string()); } - let (origin_x, origin_y) = bounds.x.zip(bounds.y).ok_or_else(|| { - "Relative coordinate clicks require target-window bounds with an origin.".to_string() - })?; let x = origin_x .checked_add(relative_x) .ok_or_else(|| "Relative click x coordinate overflowed.".to_string())?; @@ -3036,63 +4100,38 @@ fn apply_window_relative_click_coordinates( /// whatever is under the current pointer position. fn apply_window_center_scroll_point( params: &mut ScrollParams, - focus: &WindowFocusResult, + capture_rect: (i32, i32, u32, u32), ) -> std::result::Result<(), String> { - let bounds = focus - .focused_window - .as_ref() - .and_then(|window| window.bounds.as_ref()) - .or(focus.requested_window.bounds.as_ref()) - .ok_or_else(|| { - "Window-targeted scroll requires resolved target-window bounds; pass x/y explicitly." - .to_string() - })?; - if bounds.width == 0 || bounds.height == 0 { + let (origin_x, origin_y, width, height) = capture_rect; + if width == 0 || height == 0 { return Err( "Window-targeted scroll requires non-empty target-window bounds; pass x/y explicitly." .to_string(), ); } - let (origin_x, origin_y) = bounds.x.zip(bounds.y).ok_or_else(|| { - "Window-targeted scroll requires target-window bounds with an origin; pass x/y explicitly." - .to_string() - })?; - params.x = Some(origin_x.saturating_add((bounds.width / 2) as i32)); - params.y = Some(origin_y.saturating_add((bounds.height / 2) as i32)); + params.x = Some(origin_x.saturating_add((width / 2) as i32)); + params.y = Some(origin_y.saturating_add((height / 2) as i32)); Ok(()) } fn apply_window_relative_scroll_coordinates( params: &mut ScrollParams, - focus: &WindowFocusResult, + capture_rect: (i32, i32, u32, u32), ) -> std::result::Result<(), String> { let (relative_x, relative_y) = params .x .zip(params.y) .ok_or_else(|| "Relative scroll coordinates require both x and y.".to_string())?; - let bounds = focus - .focused_window - .as_ref() - .and_then(|window| window.bounds.as_ref()) - .or(focus.requested_window.bounds.as_ref()) - .ok_or_else(|| { - "Relative scroll coordinates require resolved target-window bounds.".to_string() - })?; - if bounds.width == 0 || bounds.height == 0 { + let (origin_x, origin_y, width, height) = capture_rect; + if width == 0 || height == 0 { return Err( "Relative scroll coordinates require non-empty target-window bounds.".to_string(), ); } - if relative_x < 0 - || relative_y < 0 - || relative_x as u32 >= bounds.width - || relative_y as u32 >= bounds.height + if relative_x < 0 || relative_y < 0 || relative_x as u32 >= width || relative_y as u32 >= height { return Err("Relative scroll coordinates must be inside target-window bounds.".to_string()); } - let (origin_x, origin_y) = bounds.x.zip(bounds.y).ok_or_else(|| { - "Relative scroll coordinates require target-window bounds with an origin.".to_string() - })?; params.x = Some(origin_x.saturating_add(relative_x)); params.y = Some(origin_y.saturating_add(relative_y)); Ok(()) @@ -3148,14 +4187,42 @@ fn action_result( } } -fn valid_runtime_component(value: &str) -> bool { - !value.is_empty() - && value != "." - && value != ".." - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) -} +fn portal_action_error( + action: &str, + error: anyhow::Error, + received: Option, +) -> ActionOutput { + ActionOutput { + ok: false, + implemented: true, + action: action.to_string(), + message: format!( + "Remote desktop portal {action} may have started before it failed; input was not replayed through another backend: {error:#}" + ), + received, + } +} + +fn portal_coordinate_error(action: &str, received: Option) -> ActionOutput { + ActionOutput { + ok: false, + implemented: true, + action: action.to_string(), + message: format!( + "Remote desktop portal {action} was not sent because the coordinate could not be mapped safely to the complete shared desktop; input was not replayed through another backend." + ), + received, + } +} + +fn valid_runtime_component(value: &str) -> bool { + !value.is_empty() + && value != "." + && value != ".." + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} fn avatar_cursor_identity_from_cmdline(cmdline: &[u8]) -> (Option, Option) { let executable = cmdline.split(|byte| *byte == 0).next().unwrap_or_default(); @@ -3436,6 +4503,61 @@ fn absolute_mousemove_args(x: i32, y: i32) -> Vec { ] } +fn xdotool_pointer_click_args( + x: i32, + y: i32, + count: u32, + button: Option<&str>, +) -> Option> { + let button = xdotool_pointer_button_code(button)?; + Some(vec![ + "mousemove".to_string(), + "--".to_string(), + x.to_string(), + y.to_string(), + "click".to_string(), + "--repeat".to_string(), + count.to_string(), + button.to_string(), + ]) +} + +fn xdotool_pointer_button_code(button: Option<&str>) -> Option<&'static str> { + match button.unwrap_or("left").to_ascii_lowercase().as_str() { + "left" => Some("1"), + "middle" => Some("2"), + "right" => Some("3"), + _ => None, + } +} + +#[derive(Debug)] +struct PointerCommandResult { + outputs: Vec, + backend: KeyboardCommandBackend, +} + +async fn run_xdotool_pointer_or_fallback( + program: &Path, + args: &[String], + fallback: F, +) -> std::result::Result +where + F: FnOnce() -> Fut, + Fut: Future, String>>, +{ + match run_xdotool(program, args).await { + XdotoolAttempt::Unavailable => fallback().await.map(|outputs| PointerCommandResult { + outputs, + backend: KeyboardCommandBackend::Ydotool, + }), + XdotoolAttempt::Finished(result) => result.map(|output| PointerCommandResult { + outputs: vec![output], + backend: KeyboardCommandBackend::Xdotool, + }), + } +} + fn wheel_mousemove_args(dx: i32, dy: i32) -> Vec { vec![ "mousemove".to_string(), @@ -3446,6 +4568,36 @@ fn wheel_mousemove_args(dx: i32, dy: i32) -> Vec { ] } +async fn run_cancellation_safe_guarded( + guard: G, + operation: F, +) -> (Option, std::result::Result) +where + G: Send + 'static, + T: Send + 'static, + F: Future + Send + 'static, +{ + // Dropping a JoinHandle detaches its task, retaining the guard until the + // stateful input operation has completed even if the caller is cancelled. + match tokio::spawn(async move { (guard, operation.await) }).await { + Ok((guard, result)) => (Some(guard), Ok(result)), + Err(error) => (None, Err(format!("stateful input task failed: {error}"))), + } +} + +async fn run_cancellation_safe_input( + input_guard: G, + operation: F, +) -> (Option, std::result::Result) +where + G: Send + 'static, + T: Send + 'static, + F: Future> + Send + 'static, +{ + let (input_guard, result) = run_cancellation_safe_guarded(input_guard, operation).await; + (input_guard, result.and_then(|result| result)) +} + async fn run_ydotool_sequence( commands: &[Vec], ) -> std::result::Result, String> { @@ -3459,115 +4611,97 @@ async fn run_ydotool_sequence( Ok(outputs) } +async fn run_ydotool_drag( + start_x: i32, + start_y: i32, + end_x: i32, + end_y: i32, +) -> std::result::Result, String> { + let mut outputs = vec![run_ydotool(&absolute_mousemove_args(start_x, start_y)).await?]; + sleep(Duration::from_millis(35)).await; + + let mut first_error = match run_ydotool(&["click".to_string(), "0x40".to_string()]).await { + Ok(output) => { + outputs.push(output); + None + } + Err(error) => Some(error), + }; + sleep(Duration::from_millis(35)).await; + + if first_error.is_none() { + match run_ydotool(&absolute_mousemove_args(end_x, end_y)).await { + Ok(output) => outputs.push(output), + Err(error) => first_error = Some(error), + } + sleep(Duration::from_millis(35)).await; + } + + let release = run_ydotool(&["click".to_string(), "0x80".to_string()]).await; + match (first_error, release) { + (None, Ok(output)) => { + outputs.push(output); + Ok(outputs) + } + (Some(error), Ok(_)) => Err(error), + (None, Err(error)) => Err(error), + (Some(error), Err(release_error)) => Err(format!( + "{error}; ydotool button release also failed: {release_error}" + )), + } +} + async fn run_ydotool(args: &[String]) -> std::result::Result { - ydotool::ensure_supported()?; - let mut command = TokioCommand::new("ydotool"); + let support = ydotool::ensure_supported_async().await?; + let mut command = TokioCommand::new(&support.executable); command.args(args); if let Some(socket) = ydotool_socket() { command.env("YDOTOOL_SOCKET", socket); } - command.stdout(Stdio::piped()); - command.stderr(Stdio::piped()); - - match command.spawn() { - Ok(child) => match wait_for_ydotool_output(child).await { - Ok(output) if output.status.success() => { - if let Some(error) = ydotool::cli_error(&output.stderr) { - Err(error) - } else { - Ok(output) - } - } - Ok(output) => Err(ydotool_output_error(output)), - Err(error) => Err(error), - }, - Err(error) => Err(format!("failed to run ydotool: {error}")), + let output = + crate::command_runner::output_with_timeout(command, "run ydotool", INPUT_COMMAND_TIMEOUT) + .await + .map_err(|error| format!("{error:#}"))?; + if output.status.success() { + if let Some(error) = ydotool::cli_error(&output.stderr) { + Err(error) + } else { + Ok(output) + } + } else { + Err(ydotool_output_error(output)) } } async fn run_ydotool_type_text(text: &str) -> std::result::Result { - ydotool::ensure_supported()?; - let mut command = TokioCommand::new("ydotool"); + let support = ydotool::ensure_supported_async().await?; + let mut command = TokioCommand::new(&support.executable); command.args(["type", "--file", "-"]); if let Some(socket) = ydotool_socket() { command.env("YDOTOOL_SOCKET", socket); } - command.stdin(Stdio::piped()); - command.stdout(Stdio::piped()); - command.stderr(Stdio::piped()); - - match command.spawn() { - Ok(mut child) => { - if let Some(mut stdin) = child.stdin.take() { - if let Err(error) = stdin.write_all(text.as_bytes()).await { - let _ = child.kill().await; - return Err(format!("failed to write text to ydotool stdin: {error}")); - } - } - let output = - wait_for_ydotool_output_with_timeout(child, ydotool_type_timeout(text)).await?; - if output.status.success() { - if let Some(error) = ydotool::cli_error(&output.stderr) { - Err(error) - } else { - Ok(output) - } - } else { - Err(ydotool_output_error(output)) - } + let output = crate::command_runner::output_with_stdin( + command, + "run ydotool type", + ydotool_type_timeout(text), + text.as_bytes().to_vec(), + ) + .await + .map_err(|error| format!("{error:#}"))?; + if output.status.success() { + if let Some(error) = ydotool::cli_error(&output.stderr) { + Err(error) + } else { + Ok(output) } - Err(error) => Err(format!("failed to run ydotool: {error}")), + } else { + Err(ydotool_output_error(output)) } } -async fn wait_for_ydotool_output(child: TokioChild) -> std::result::Result { - wait_for_ydotool_output_with_timeout(child, YDOTOOL_TIMEOUT).await -} - -async fn wait_for_ydotool_output_with_timeout( - mut child: TokioChild, - timeout_duration: Duration, -) -> std::result::Result { - let stdout_reader = read_child_pipe(child.stdout.take()); - let stderr_reader = read_child_pipe(child.stderr.take()); - let status = match timeout(timeout_duration, child.wait()).await { - Err(_) => { - let _ = child.kill().await; - let _ = child.wait().await; - stdout_reader.abort(); - stderr_reader.abort(); - return Err(format!( - "ydotool timed out after {}s", - timeout_duration.as_secs() - )); - } - Ok(result) => result.map_err(|error| format!("failed to wait for ydotool: {error}"))?, - }; - let stdout = stdout_reader.await.unwrap_or_default(); - let stderr = stderr_reader.await.unwrap_or_default(); - Ok(Output { - status, - stdout, - stderr, - }) -} - -fn read_child_pipe(pipe: Option) -> tokio::task::JoinHandle> -where - R: AsyncRead + Unpin + Send + 'static, -{ - tokio::spawn(async move { - let mut output = Vec::new(); - if let Some(mut pipe) = pipe { - let _ = pipe.read_to_end(&mut output).await; - } - output - }) -} - fn ydotool_type_timeout(text: &str) -> Duration { let text_seconds = (text.chars().count() as u64).div_ceil(YDOTOOL_TYPE_CHARS_PER_SECOND); - Duration::from_secs(YDOTOOL_TIMEOUT.as_secs().saturating_add(text_seconds)) + Duration::from_secs(INPUT_COMMAND_TIMEOUT.as_secs().saturating_add(text_seconds)) } const EVDEV_KEY_LEFTCTRL: i32 = 29; @@ -3609,22 +4743,45 @@ impl KdeClipboardPasteError { clear_portal_keyboard_session: true, } } + + fn ambiguous_clipboard_set(message: String) -> Self { + Self { + message, + can_fallback_to_ydotool: false, + clear_portal_keyboard_session: false, + } + } } async fn run_kde_clipboard_paste_text( session: &PortalKeyboardSession, text: &str, + operation_guard: InputOperationGuard, ) -> std::result::Result { let previous = kde_clipboard_contents() .await .map_err(KdeClipboardPasteError::before_text_input)?; - kde_set_clipboard_contents(text) - .await - .map_err(KdeClipboardPasteError::before_text_input)?; + if let Err(set_error) = kde_set_clipboard_contents(text).await { + let restore_result = kde_set_clipboard_contents(&previous).await; + let message = match restore_result { + Ok(()) => format!( + "KDE clipboard replacement failed ambiguously and the previous contents were restored; text was not replayed through another backend: {set_error}" + ), + Err(restore_error) => format!( + "KDE clipboard replacement failed ambiguously; restoring the previous contents also failed, and text was not replayed through another backend: {set_error}; restore failed: {restore_error}" + ), + }; + return Err(KdeClipboardPasteError::ambiguous_clipboard_set(message)); + } - let paste_result = press_keycode_chord(session, &[EVDEV_KEY_LEFTCTRL], EVDEV_KEY_V) - .await - .map_err(|error| format!("{error:#}")); + let paste_result = press_keycode_chord( + session, + &[EVDEV_KEY_LEFTCTRL], + EVDEV_KEY_V, + Some(operation_guard), + ) + .await + .map_err(|error| format!("{error:#}")); sleep(kde_clipboard_restore_delay(text)).await; let restore_result = kde_set_clipboard_contents(&previous).await; @@ -3712,6 +4869,166 @@ fn ydotool_output_error(output: Output) -> String { command_output_error("ydotool", output) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KeyboardCommandBackend { + Xdotool, + Ydotool, +} + +struct KeyboardCommandResult { + output: Output, + backend: KeyboardCommandBackend, +} + +enum XdotoolAttempt { + Unavailable, + Finished(std::result::Result), +} + +async fn run_xdotool(program: &Path, args: &[String]) -> XdotoolAttempt { + let mut command = TokioCommand::new(program); + command.args(args); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + command.kill_on_drop(true); + command.process_group(0); + + match command.spawn() { + Ok(child) => XdotoolAttempt::Finished( + match crate::command_runner::output_child(child, "run xdotool", INPUT_COMMAND_TIMEOUT) + .await + .map_err(|error| format!("{error:#}")) + { + Ok(output) if output.status.success() => Ok(output), + Ok(output) => Err(command_output_error("xdotool", output)), + Err(error) => Err(error), + }, + ), + Err(_) => XdotoolAttempt::Unavailable, + } +} + +async fn run_xdotool_or_fallback( + program: &Path, + args: &[String], + fallback: F, +) -> std::result::Result +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + match run_xdotool(program, args).await { + XdotoolAttempt::Unavailable => fallback().await.map(|output| KeyboardCommandResult { + output, + backend: KeyboardCommandBackend::Ydotool, + }), + XdotoolAttempt::Finished(result) => result.map(|output| KeyboardCommandResult { + output, + backend: KeyboardCommandBackend::Xdotool, + }), + } +} + +fn xdotool_available() -> bool { + which_in_path("xdotool") +} + +fn xdotool_type_args(text: &str) -> Vec { + vec![ + "type".to_string(), + "--clearmodifiers".to_string(), + "--delay".to_string(), + "0".to_string(), + "--".to_string(), + text.to_string(), + ] +} + +fn which_in_path(binary: &str) -> bool { + let Ok(path) = env::var("PATH") else { + return false; + }; + env::split_paths(&path).any(|directory| { + std::fs::metadata(directory.join(binary)) + .map(|metadata| metadata.is_file()) + .unwrap_or(false) + }) +} + +fn xdotool_key_spec(key: &str) -> Option { + let parts = key + .split('+') + .map(str::trim) + .filter(|part| !part.is_empty()) + .collect::>(); + let (key_part, modifier_parts) = parts.split_last()?; + + key_chord(key)?; + + let mut spec = Vec::new(); + for part in modifier_parts { + spec.push(xdotool_modifier_name(part)?.to_string()); + } + if modifier_parts.is_empty() { + if let Some(bare) = xdotool_modifier_keysym(key_part) { + return Some(bare.to_string()); + } + } + spec.push(xdotool_keysym_name(key_part)?); + Some(spec.join("+")) +} + +fn xdotool_modifier_name(key: &str) -> Option<&'static str> { + match normalize_key(key).as_str() { + "ctrl" | "control" => Some("ctrl"), + "alt" | "option" => Some("alt"), + "shift" => Some("shift"), + "meta" | "super" | "cmd" | "command" => Some("super"), + _ => None, + } +} + +fn xdotool_modifier_keysym(key: &str) -> Option<&'static str> { + xdotool_modifier_name(key) +} + +fn xdotool_keysym_name(key: &str) -> Option { + let normalized = normalize_key(key); + let named = match normalized.as_str() { + "enter" | "return" => "Return", + "escape" | "esc" => "Escape", + "tab" => "Tab", + "backspace" => "BackSpace", + "delete" | "del" => "Delete", + "space" => "space", + "home" => "Home", + "end" => "End", + "pageup" | "page_up" => "Page_Up", + "pagedown" | "page_down" => "Page_Down", + "arrowleft" | "left" => "Left", + "arrowright" | "right" => "Right", + "arrowup" | "up" => "Up", + "arrowdown" | "down" => "Down", + "f1" => "F1", + "f2" => "F2", + "f3" => "F3", + "f4" => "F4", + "f5" => "F5", + "f6" => "F6", + "f7" => "F7", + "f8" => "F8", + "f9" => "F9", + "f10" => "F10", + "f11" => "F11", + "f12" => "F12", + value if value.len() == 1 && value.as_bytes()[0].is_ascii_alphanumeric() => { + return Some(value.to_string()); + } + _ => return None, + }; + Some(named.to_string()) +} + fn command_output_error(command: &str, output: Output) -> String { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); @@ -3732,10 +5049,10 @@ fn ydotool_socket() -> Option { .map(|path| path.display().to_string()) } -fn ydotool_backend_available() -> bool { +async fn ydotool_backend_available() -> bool { ydotool_backend_available_from( ydotool_socket_connectable(), - ydotool::ensure_supported().is_ok(), + ydotool::ensure_supported_async().await.is_ok(), ) } @@ -3782,10 +5099,9 @@ fn connectable_ydotool_socket_from(candidates: Vec) -> Option } fn ydotool_socket_connects(path: &PathBuf) -> bool { - UnixStream::connect(path).is_ok() - || UnixDatagram::unbound() - .and_then(|socket| socket.connect(path)) - .is_ok() + UnixDatagram::unbound() + .and_then(|socket| socket.connect(path)) + .is_ok() } fn mouse_button_code(button: Option<&str>) -> String { @@ -3801,7 +5117,7 @@ fn mouse_button_code(button: Option<&str>) -> String { .to_string() } -fn key_sequence(key: &str) -> Option> { +fn key_chord(key: &str) -> Option<(Vec, u16)> { let parts = key .split('+') .map(str::trim) @@ -3810,7 +5126,7 @@ fn key_sequence(key: &str) -> Option> { let (key_part, modifier_parts) = parts.split_last()?; if modifier_parts.is_empty() { if let Some(modifier) = modifier_keycode(key_part) { - return Some(vec![format!("{modifier}:1"), format!("{modifier}:0")]); + return Some((Vec::new(), modifier)); } } let mut modifiers = Vec::new(); @@ -3818,7 +5134,11 @@ fn key_sequence(key: &str) -> Option> { modifiers.push(modifier_keycode(part)?); } let keycode = keycode(key_part)?; + Some((modifiers, keycode)) +} +fn key_sequence(key: &str) -> Option> { + let (modifiers, keycode) = key_chord(key)?; let mut events = Vec::new(); for modifier in &modifiers { events.push(format!("{modifier}:1")); @@ -3831,6 +5151,15 @@ fn key_sequence(key: &str) -> Option> { Some(events) } +fn ydotool_key_args(key_events: Vec, has_modifiers: bool) -> Vec { + let mut args = vec!["key".to_string()]; + if has_modifiers { + args.extend(["-d".to_string(), "100".to_string()]); + } + args.extend(key_events); + args +} + fn modifier_keycode(key: &str) -> Option { match normalize_key(key).as_str() { "ctrl" | "control" => Some(29), @@ -3985,6 +5314,8 @@ mod tests { use super::*; use crate::atspi_tree::{AccessibilityAction, Bounds}; use crate::windows::{WindowBounds, GNOME_SHELL_EXTENSION_BACKEND}; + use std::os::unix::fs::PermissionsExt; + use tokio::io::AsyncReadExt; struct EnvVarGuard { key: &'static str, @@ -4077,7 +5408,7 @@ mod tests { #[tokio::test] async fn avatar_cursor_signal_uses_the_private_unix_stream_protocol() { let root = std::env::temp_dir().join(format!( - "computer-use-avatar-cursor-{}-{}", + "cua-cursor-{}-{:x}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -4099,53 +5430,334 @@ mod tests { stream.read_exact(&mut payload).await.unwrap(); assert_eq!(&payload, b"pointer\n"); - drop(listener); - let _ = std::fs::remove_dir_all(root); + drop(listener); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn exported_tool_schemas_omit_unsigned_integer_formats() { + let tools = ComputerUseLinux::default().mcp_tool_router().list_all(); + let value = serde_json::to_value(tools).unwrap(); + let mut unsupported = Vec::new(); + collect_unsigned_integer_formats(&value, "$", &mut unsupported); + + assert!( + unsupported.is_empty(), + "unsupported unsigned integer formats: {unsupported:?}" + ); + } + + fn collect_unsigned_integer_formats( + value: &serde_json::Value, + path: &str, + unsupported: &mut Vec, + ) { + match value { + serde_json::Value::Object(object) => { + if matches!( + object.get("format").and_then(serde_json::Value::as_str), + Some("uint" | "uint8" | "uint16" | "uint32" | "uint64" | "usize") + ) { + unsupported.push(path.to_string()); + } + for (key, nested) in object { + collect_unsigned_integer_formats(nested, &format!("{path}/{key}"), unsupported); + } + } + serde_json::Value::Array(items) => { + for (index, nested) in items.iter().enumerate() { + collect_unsigned_integer_formats( + nested, + &format!("{path}/{index}"), + unsupported, + ); + } + } + _ => {} + } + } + + fn node(index: u32, bounds: Option) -> AccessibilityNode { + node_with_actions(index, bounds, Vec::new()) + } + + fn node_with_actions( + index: u32, + bounds: Option, + actions: Vec, + ) -> AccessibilityNode { + AccessibilityNode { + index, + parent_index: None, + depth: 0, + object_ref: format!(":1.{index}/org/a11y/atspi/accessible/{index}"), + role: "push button".to_string(), + name: Some(format!("Button {index}")), + description: None, + child_count: 0, + bounds, + states: Vec::new(), + actions, + value: None, + text: None, + supports_editable_text: false, + } + } + + fn click_action() -> AccessibilityAction { + AccessibilityAction { + index: 0, + name: "Click".to_string(), + description: "Clicks the element".to_string(), + keybinding: String::new(), + } + } + + fn solid_png(width: u32, height: u32) -> Vec { + let img = image::RgbaImage::from_pixel(width, height, image::Rgba([32, 128, 192, 255])); + let mut out = Vec::new(); + image::DynamicImage::ImageRgba8(img) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + out + } + + #[test] + fn targeted_app_state_crops_before_screenshot_payload_resize() { + let raw = RawScreenshotCapture { + mime_type: "image/png".to_string(), + bytes: solid_png(400, 200), + source: "test".to_string(), + width: 400, + height: 200, + }; + let capture = prepare_app_state_screenshot( + raw, + Some((50, 20, 200, 100)), + true, + ScreenshotPayloadOptions { + max_width: Some(100), + max_height: Some(100), + max_bytes: Some(1024 * 1024), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!( + (capture.coordinate_width, capture.coordinate_height), + (200, 100) + ); + assert_eq!((capture.width, capture.height), (100, 50)); + } + + #[test] + fn unresolved_app_state_target_refuses_full_desktop_screenshot() { + let raw = RawScreenshotCapture { + mime_type: "image/png".to_string(), + bytes: solid_png(400, 200), + source: "test".to_string(), + width: 400, + height: 200, + }; + + let error = + prepare_app_state_screenshot(raw, None, true, ScreenshotPayloadOptions::default()) + .unwrap_err(); + + assert!(error.to_string().contains("requires a resolved window")); + } + + #[test] + fn targeted_app_state_crops_only_visible_part_of_offscreen_window() { + let raw = RawScreenshotCapture { + mime_type: "image/png".to_string(), + bytes: solid_png(400, 200), + source: "test".to_string(), + width: 400, + height: 200, + }; + let capture = prepare_app_state_screenshot( + raw, + Some((-50, -40, 100, 100)), + true, + ScreenshotPayloadOptions::default(), + ) + .unwrap(); + + assert_eq!( + (capture.coordinate_width, capture.coordinate_height), + (50, 60) + ); + } + + #[test] + fn gnome_window_crop_scales_logical_bounds_to_capture_pixels() { + let bounds = WindowBounds { + x: Some(6), + y: Some(36), + width: 1357, + height: 1144, + }; + let monitors = [(0, 0, 1920, 1200)]; + + assert_eq!( + logical_window_crop_rect(&bounds, &monitors, 2560, 1600).unwrap(), + (8, 48, 1810, 1526) + ); + } + + #[test] + fn portal_points_map_capture_pixels_back_to_logical_window_space() { + let scaled = WindowCoordinateMap { + capture_rect: (8, 48, 1810, 1526), + full_capture_rect: (8, 48, 1810, 1526), + portal_rect: Some((6, 36, 1357, 1144)), + }; + assert_eq!(scaled.portal_point(913, 811), Some((684, 608))); + + let clipped = WindowCoordinateMap { + capture_rect: (0, 0, 50, 60), + full_capture_rect: (-50, -40, 100, 100), + portal_rect: Some((-50, -40, 100, 100)), + }; + assert_eq!(clipped.portal_point(0, 0), Some((0, 0))); + } + + #[test] + fn scaled_kwin_bounds_keep_capture_and_portal_spaces_distinct() { + let bounds = WindowBounds { + x: Some(1000), + y: Some(100), + width: 800, + height: 600, + }; + let logical_rect = window_crop_rect(&bounds).unwrap(); + let full_capture_rect = + logical_window_crop_rect(&bounds, &[(0, 0, 1920, 1080)], 3840, 2160).unwrap(); + let mapping = WindowCoordinateMap { + capture_rect: full_capture_rect, + full_capture_rect, + portal_rect: Some(logical_rect), + }; + + assert_eq!(mapping.capture_rect, (2000, 200, 1600, 1200)); + assert_eq!(mapping.portal_point(2400, 500), Some((1200, 250))); + } + + #[test] + fn kwin_mapping_uses_the_workspace_geometry_origin() { + let bounds = WindowBounds { + x: Some(1100), + y: Some(50), + width: 800, + height: 600, + }; + let logical_rect = window_crop_rect(&bounds).unwrap(); + let full_capture_rect = + logical_window_crop_rect(&bounds, &[(100, -50, 1920, 1080)], 3840, 2160).unwrap(); + let mapping = WindowCoordinateMap { + capture_rect: full_capture_rect, + full_capture_rect, + portal_rect: Some(logical_rect), + }; + + assert_eq!(mapping.capture_rect, (2000, 200, 1600, 1200)); + assert_eq!(mapping.portal_point(2400, 500), Some((1300, 200))); } - fn node(index: u32, bounds: Option) -> AccessibilityNode { - node_with_actions(index, bounds, Vec::new()) + #[test] + fn gnome_window_crop_accounts_for_negative_monitor_origins() { + let bounds = WindowBounds { + x: Some(-900), + y: Some(100), + width: 400, + height: 300, + }; + let monitors = [(-1000, 0, 1000, 800), (0, 0, 1200, 800)]; + + assert_eq!( + logical_window_crop_rect(&bounds, &monitors, 2200, 800).unwrap(), + (100, 100, 400, 300) + ); } - fn node_with_actions( - index: u32, - bounds: Option, - actions: Vec, - ) -> AccessibilityNode { - AccessibilityNode { - index, - parent_index: None, - depth: 0, - object_ref: format!(":1.{index}/org/a11y/atspi/accessible/{index}"), - role: "push button".to_string(), - name: Some(format!("Button {index}")), - description: None, - child_count: 0, - bounds, - states: Vec::new(), - actions, - value: None, - text: None, - supports_editable_text: false, - } + #[test] + fn readonly_targeted_screenshot_requires_focused_visible_window() { + let mut window = window_info(1, Some("Target"), None, None, None); + assert!(ensure_readonly_screenshot_target_is_visible(&window).is_err()); + window.focused = true; + assert!(ensure_readonly_screenshot_target_is_visible(&window).is_ok()); + window.hidden = true; + assert!(ensure_readonly_screenshot_target_is_visible(&window).is_err()); } - fn click_action() -> AccessibilityAction { - AccessibilityAction { - index: 0, - name: "Click".to_string(), - description: "Clicks the element".to_string(), - keybinding: String::new(), - } + #[test] + fn wayland_display_is_enough_to_select_portal_fallback() { + assert!(session_is_wayland(None, Some("wayland-1"))); + assert!(session_is_wayland(Some(" "), Some("wayland-1"))); + assert!(session_is_wayland(Some("wayland"), None)); + assert!(!session_is_wayland(Some("x11"), None)); + assert!(!session_is_wayland(None, Some(" "))); } - fn solid_png(width: u32, height: u32) -> Vec { - let img = image::RgbaImage::from_pixel(width, height, image::Rgba([32, 128, 192, 255])); - let mut out = Vec::new(); - image::DynamicImage::ImageRgba8(img) - .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) - .unwrap(); - out + #[test] + fn xdotool_keyboard_override_policy_matches_documented_precedence() { + assert!(prefer_xdotool_keyboard(false, true, true, true, true)); + assert!(!prefer_xdotool_keyboard(true, true, true, true, true)); + assert!(!prefer_xdotool_keyboard(false, true, true, false, true)); + assert!(!prefer_xdotool_keyboard(false, false, true, true, true)); + assert!(prefer_xdotool_keyboard(false, false, false, true, true)); + } + + #[test] + fn native_x11_pointer_policy_requires_explicit_x11_without_wayland_display() { + assert!(native_x11_xdotool_pointer_session(Some("x11"), None)); + assert!(!native_x11_xdotool_pointer_session( + Some("wayland"), + Some("wayland-0") + )); + assert!(!native_x11_xdotool_pointer_session( + Some("x11"), + Some("wayland-0") + )); + } + + #[test] + fn xdotool_pointer_policy_requires_all_pure_gating_conditions() { + let eligible = (false, Some("x11"), true, None, true); + assert!(prefer_xdotool_pointer( + eligible.0, eligible.1, eligible.2, eligible.3, eligible.4 + )); + assert!(!prefer_xdotool_pointer(true, Some("x11"), true, None, true)); + assert!(!prefer_xdotool_pointer( + false, + Some("wayland"), + true, + None, + true + )); + assert!(!prefer_xdotool_pointer( + false, + None, + true, + Some("wayland-0"), + true + )); + assert!(!prefer_xdotool_pointer( + false, + Some("x11"), + false, + None, + true + )); + assert!(!prefer_xdotool_pointer( + false, + Some("x11"), + true, + None, + false + )); } #[test] @@ -4204,75 +5816,8 @@ mod tests { } } - fn focus_result_with_bounds(bounds: Option) -> WindowFocusResult { - let mut requested_window = window_info( - 42, - Some("Target"), - Some("target-app"), - Some("target-app"), - Some(4242), - ); - requested_window.bounds = bounds; - let mut focused_window = requested_window.clone(); - focused_window.focused = true; - WindowFocusResult { - requested_window, - focused_window: Some(focused_window), - exact_window_focused: true, - app_focused: true, - backend: GNOME_SHELL_EXTENSION_BACKEND.to_string(), - note: "test focus".to_string(), - } - } - - fn window_bounds(x: Option, y: Option, width: u32, height: u32) -> WindowBounds { - WindowBounds { - x, - y, - width, - height, - } - } - - #[test] - fn relative_click_coordinates_use_verified_window_bounds() { - let focus = focus_result_with_bounds(Some(window_bounds(Some(100), Some(200), 800, 600))); - let mut params = ClickParams { - x: Some(7), - y: Some(9), - relative: Some(true), - ..Default::default() - }; - - apply_window_relative_click_coordinates(&mut params, &focus).unwrap(); - - assert_eq!((params.x, params.y), (Some(107), Some(209))); - } - - #[test] - fn relative_click_coordinates_prefer_focused_window_bounds() { - let mut focus = - focus_result_with_bounds(Some(window_bounds(Some(100), Some(200), 800, 600))); - let focused_window = focus - .focused_window - .as_mut() - .expect("test focus should include focused window"); - focused_window.bounds = Some(window_bounds(Some(300), Some(400), 800, 600)); - let mut params = ClickParams { - x: Some(7), - y: Some(9), - relative: Some(true), - ..Default::default() - }; - - apply_window_relative_click_coordinates(&mut params, &focus).unwrap(); - - assert_eq!((params.x, params.y), (Some(307), Some(409))); - } - #[test] - fn relative_click_coordinates_require_window_bounds_origin() { - let focus = focus_result_with_bounds(Some(window_bounds(None, Some(200), 800, 600))); + fn relative_click_coordinates_use_capture_space_rect() { let mut params = ClickParams { x: Some(7), y: Some(9), @@ -4280,22 +5825,21 @@ mod tests { ..Default::default() }; - let error = apply_window_relative_click_coordinates(&mut params, &focus).unwrap_err(); + apply_window_relative_click_coordinates(&mut params, (133, 267, 1067, 800)).unwrap(); - assert!(error.contains("bounds with an origin")); - assert_eq!((params.x, params.y), (Some(7), Some(9))); + assert_eq!((params.x, params.y), (Some(140), Some(276))); } #[test] fn relative_click_coordinates_require_xy() { - let focus = focus_result_with_bounds(Some(window_bounds(Some(100), Some(200), 800, 600))); let mut params = ClickParams { x: Some(7), relative: Some(true), ..Default::default() }; - let error = apply_window_relative_click_coordinates(&mut params, &focus).unwrap_err(); + let error = + apply_window_relative_click_coordinates(&mut params, (100, 200, 800, 600)).unwrap_err(); assert!(error.contains("both x and y")); assert_eq!((params.x, params.y), (Some(7), None)); @@ -4303,8 +5847,6 @@ mod tests { #[test] fn relative_click_coordinates_must_stay_inside_bounds() { - let focus = focus_result_with_bounds(Some(window_bounds(Some(100), Some(200), 800, 600))); - for (x, y) in [(-1, 9), (7, -1), (800, 9), (7, 600)] { let mut params = ClickParams { x: Some(x), @@ -4313,7 +5855,8 @@ mod tests { ..Default::default() }; - let error = apply_window_relative_click_coordinates(&mut params, &focus).unwrap_err(); + let error = apply_window_relative_click_coordinates(&mut params, (100, 200, 800, 600)) + .unwrap_err(); assert!(error.contains("inside target-window bounds")); assert_eq!((params.x, params.y), (Some(x), Some(y))); @@ -4563,6 +6106,14 @@ mod tests { ); } + #[test] + fn ambiguous_kde_clipboard_set_never_allows_input_replay() { + let error = KdeClipboardPasteError::ambiguous_clipboard_set("timed out".to_string()); + + assert!(!error.can_fallback_to_ydotool); + assert!(!error.clear_portal_keyboard_session); + } + #[tokio::test] async fn kde_clipboard_dbus_operation_times_out_when_pending() { let error = kde_clipboard_dbus_operation_with_timeout( @@ -4797,6 +6348,37 @@ mod tests { ); } + #[test] + fn xdotool_pointer_command_is_single_no_sync_move_and_click() { + assert_eq!( + xdotool_pointer_click_args(1550, 930, 3, Some("right")), + Some(vec![ + "mousemove".to_string(), + "--".to_string(), + "1550".to_string(), + "930".to_string(), + "click".to_string(), + "--repeat".to_string(), + "3".to_string(), + "3".to_string(), + ]) + ); + } + + #[test] + fn xdotool_pointer_supports_only_standard_buttons() { + assert!(xdotool_pointer_click_args(10, 20, 1, None).is_some()); + assert!(xdotool_pointer_click_args(10, 20, 1, Some("middle")).is_some()); + assert!(xdotool_pointer_click_args(10, 20, 1, Some("right")).is_some()); + } + + #[test] + fn extended_pointer_buttons_do_not_construct_xdotool_commands() { + for button in ["side", "extra", "forward", "back"] { + assert_eq!(xdotool_pointer_click_args(10, 20, 1, Some(button)), None); + } + } + #[test] fn wheel_mousemove_uses_coordinate_separator_for_negative_values() { assert_eq!( @@ -4844,6 +6426,15 @@ mod tests { )); } + #[test] + fn key_chord_splits_modifiers_and_key() { + assert_eq!(key_chord("Ctrl+Shift+P"), Some((vec![29, 42], 25))); + assert_eq!(key_chord("Ctrl+S"), Some((vec![29], 31))); + assert_eq!(key_chord("Enter"), Some((vec![], 28))); + assert_eq!(key_chord("Super"), Some((vec![], 125))); + assert_eq!(key_chord("NotAKey"), None); + } + #[test] fn key_sequence_presses_modifiers_around_key() { assert_eq!( @@ -4859,6 +6450,13 @@ mod tests { ); } + #[test] + fn ydotool_modifier_chords_include_an_inter_event_delay() { + let args = ydotool_key_args(key_sequence("Ctrl+T").unwrap(), true); + + assert_eq!(args, ["key", "-d", "100", "29:1", "20:1", "20:0", "29:0"]); + } + #[test] fn key_sequence_presses_bare_modifier() { assert_eq!( @@ -4867,6 +6465,276 @@ mod tests { ); } + #[test] + fn xdotool_key_spec_maps_named_keys_to_x11_keysyms() { + assert_eq!(xdotool_key_spec("Return"), Some("Return".to_string())); + assert_eq!(xdotool_key_spec("enter"), Some("Return".to_string())); + assert_eq!(xdotool_key_spec("Escape"), Some("Escape".to_string())); + assert_eq!(xdotool_key_spec("backspace"), Some("BackSpace".to_string())); + assert_eq!(xdotool_key_spec("PageUp"), Some("Page_Up".to_string())); + assert_eq!(xdotool_key_spec("ArrowLeft"), Some("Left".to_string())); + assert_eq!(xdotool_key_spec("f5"), Some("F5".to_string())); + assert_eq!(xdotool_key_spec("space"), Some("space".to_string())); + } + + #[test] + fn xdotool_key_spec_maps_chords_with_modifier_prefixes() { + assert_eq!(xdotool_key_spec("ctrl+a"), Some("ctrl+a".to_string())); + assert_eq!(xdotool_key_spec("Ctrl+S"), Some("ctrl+s".to_string())); + assert_eq!( + xdotool_key_spec("Ctrl+Shift+P"), + Some("ctrl+shift+p".to_string()) + ); + assert_eq!( + xdotool_key_spec("Meta+Return"), + Some("super+Return".to_string()) + ); + assert_eq!(xdotool_key_spec("Alt+F4"), Some("alt+F4".to_string())); + } + + #[test] + fn xdotool_key_spec_maps_bare_modifier_to_single_keysym() { + assert_eq!(xdotool_key_spec("Super"), Some("super".to_string())); + assert_eq!(xdotool_key_spec("ctrl"), Some("ctrl".to_string())); + } + + #[test] + fn xdotool_type_disables_per_character_delay_for_long_input() { + let text = "x".repeat(10_000); + let args = xdotool_type_args(&text); + + assert_eq!( + &args[..5], + ["type", "--clearmodifiers", "--delay", "0", "--"] + ); + assert_eq!(args[5], text); + } + + #[tokio::test] + async fn launched_xdotool_failure_does_not_replay_through_ydotool() { + let dir = std::env::temp_dir().join(format!( + "codex-xdotool-fallback-{}-{:?}", + std::process::id(), + std::time::SystemTime::now() + )); + std::fs::create_dir_all(&dir).expect("create command test directory"); + let ydotool = dir.join("ydotool"); + let xdotool_marker = dir.join("xdotool-ran"); + let ydotool_marker = dir.join("ydotool-ran"); + std::fs::write( + &ydotool, + format!("#!/bin/sh\ntouch '{}'\n", ydotool_marker.display()), + ) + .expect("write fake ydotool"); + std::fs::set_permissions(&ydotool, std::fs::Permissions::from_mode(0o700)) + .expect("make fake ydotool executable"); + let xdotool_args = vec![ + "-c".to_string(), + format!("touch '{}'; exit 9", xdotool_marker.display()), + ]; + + let result = run_xdotool_or_fallback(Path::new("/bin/sh"), &xdotool_args, || async { + TokioCommand::new(&ydotool) + .output() + .await + .map_err(|error| error.to_string()) + }) + .await; + + assert!(result.is_err()); + assert!(xdotool_marker.exists(), "fake xdotool did not execute"); + assert!( + !ydotool_marker.exists(), + "ydotool replayed input after xdotool started" + ); + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn unavailable_xdotool_uses_ydotool_fallback() { + let result = run_xdotool_or_fallback( + Path::new("/definitely/missing/xdotool"), + &xdotool_type_args("text"), + || async { + TokioCommand::new("sh") + .args(["-c", "exit 0"]) + .output() + .await + .map_err(|error| error.to_string()) + }, + ) + .await + .expect("spawn failure should use fallback"); + + assert_eq!(result.backend, KeyboardCommandBackend::Ydotool); + assert!(result.output.status.success()); + } + + #[tokio::test] + async fn pointer_xdotool_spawn_failure_uses_ydotool_fallback() { + let result = run_xdotool_pointer_or_fallback( + Path::new("/definitely/missing/xdotool"), + &[], + || async { Ok::<_, String>(Vec::new()) }, + ) + .await + .expect("spawn failure should use fallback"); + + assert_eq!(result.backend, KeyboardCommandBackend::Ydotool); + } + + #[tokio::test] + async fn pointer_xdotool_nonzero_exit_does_not_use_ydotool_fallback() { + let result = run_xdotool_pointer_or_fallback( + Path::new("/bin/sh"), + &["-c".to_string(), "exit 9".to_string()], + || async { Err::, _>("fallback called".to_string()) }, + ) + .await; + + let error = result.expect_err("launched nonzero xdotool must be terminal"); + assert!(!error.contains("fallback called")); + } + + #[tokio::test] + async fn cancelling_xdotool_wait_kills_the_child() { + let dir = std::env::temp_dir().join(format!( + "computer-use-linux-xdotool-cancel-{}-{:?}", + std::process::id(), + std::time::SystemTime::now() + )); + std::fs::create_dir_all(&dir).expect("create command test directory"); + let xdotool = dir.join("xdotool"); + let pid_path = dir.join("pid"); + std::fs::write( + &xdotool, + format!( + "#!/bin/sh\nprintf '%s' $$ > '{}'\nexec sleep 60\n", + pid_path.display() + ), + ) + .expect("write fake xdotool"); + std::fs::set_permissions(&xdotool, std::fs::Permissions::from_mode(0o700)) + .expect("make fake xdotool executable"); + + let task = tokio::spawn(async move { run_xdotool(&xdotool, &[]).await }); + let mut pid = None; + for _ in 0..50 { + if let Ok(value) = std::fs::read_to_string(&pid_path) { + pid = value.parse::().ok(); + if pid.is_some() { + break; + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let pid = pid.expect("fake xdotool did not record its pid"); + task.abort(); + let _ = task.await; + + for _ in 0..50 { + if !Path::new(&format!("/proc/{pid}")).exists() { + let _ = std::fs::remove_dir_all(dir); + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + unsafe { + libc::kill(pid as i32, libc::SIGKILL); + } + let _ = std::fs::remove_dir_all(dir); + panic!("cancelled xdotool child {pid} was not killed"); + } + + #[tokio::test] + async fn cancelling_between_press_and_release_keeps_input_locked() { + let lock = std::sync::Arc::new(tokio::sync::Mutex::new(())); + let guard = std::sync::Arc::clone(&lock).lock_owned().await; + let (pressed_tx, pressed_rx) = tokio::sync::oneshot::channel(); + let (allow_release_tx, allow_release_rx) = tokio::sync::oneshot::channel(); + let (released_tx, released_rx) = tokio::sync::oneshot::channel(); + + let waiter = tokio::spawn(async move { + run_cancellation_safe_input(guard, async move { + let _ = pressed_tx.send(()); + let _ = allow_release_rx.await; + let _ = released_tx.send(()); + Ok::<(), String>(()) + }) + .await + }); + + pressed_rx.await.expect("press command did not finish"); + waiter.abort(); + let _ = waiter.await; + assert!( + lock.try_lock().is_err(), + "input lock was released while the paired operation was incomplete" + ); + + allow_release_tx + .send(()) + .expect("release command stopped on caller cancellation"); + timeout(Duration::from_secs(1), released_rx) + .await + .expect("release command did not finish") + .expect("release command dropped its completion marker"); + timeout( + Duration::from_secs(1), + std::sync::Arc::clone(&lock).lock_owned(), + ) + .await + .expect("input lock remained held after the operation finished"); + } + + #[tokio::test] + async fn cancelled_clipboard_restore_keeps_both_operation_locks() { + let input_lock = std::sync::Arc::new(tokio::sync::Mutex::new(())); + let clipboard_lock = std::sync::Arc::new(tokio::sync::Mutex::new(())); + let input_guard = std::sync::Arc::clone(&input_lock).lock_owned().await; + let clipboard_guard = std::sync::Arc::clone(&clipboard_lock).lock_owned().await; + let (pasted_tx, pasted_rx) = tokio::sync::oneshot::channel(); + let (allow_restore_tx, allow_restore_rx) = tokio::sync::oneshot::channel(); + let (restored_tx, restored_rx) = tokio::sync::oneshot::channel(); + + let waiter = tokio::spawn(async move { + run_cancellation_safe_guarded((input_guard, clipboard_guard), async move { + let _ = pasted_tx.send(()); + let _ = allow_restore_rx.await; + let _ = restored_tx.send(()); + }) + .await + }); + + pasted_rx.await.expect("paste did not finish"); + waiter.abort(); + let _ = waiter.await; + assert!(input_lock.try_lock().is_err()); + assert!(clipboard_lock.try_lock().is_err()); + + allow_restore_tx + .send(()) + .expect("clipboard restore stopped on caller cancellation"); + timeout(Duration::from_secs(1), restored_rx) + .await + .expect("clipboard restore did not finish") + .expect("clipboard restore dropped its completion marker"); + tokio::task::yield_now().await; + assert!(input_lock.try_lock().is_ok()); + assert!(clipboard_lock.try_lock().is_ok()); + } + + #[test] + fn xdotool_key_spec_rejects_everything_key_chord_rejects() { + for key in ["NotAKey", "", "ctrl+", "ctrl+NotAKey", "f13", "hyper+a"] { + assert_eq!( + xdotool_key_spec(key).is_some(), + key_chord(key).is_some(), + "backend grammars diverged for {key:?}" + ); + } + } + #[test] fn key_sequence_keeps_shortcuts_and_navigation_on_raw_events() { assert_eq!( @@ -4901,14 +6769,15 @@ mod tests { } #[tokio::test] - async fn ydotool_wait_drains_output_before_exit() { + async fn command_wait_drains_output_before_exit() { let mut command = tokio::process::Command::new("sh"); command.args(["-c", "yes noisy | head -c 200000 >&2; exit 7"]); command.stdout(Stdio::piped()); command.stderr(Stdio::piped()); - let output = wait_for_ydotool_output_with_timeout( - command.spawn().expect("spawn noisy child"), + let output = crate::command_runner::output_with_timeout( + command, + "run test command", Duration::from_secs(5), ) .await @@ -4919,7 +6788,7 @@ mod tests { } #[test] - fn ydotool_socket_selection_skips_unconnectable_candidates() { + fn ydotool_socket_selection_rejects_legacy_stream_socket() { let dir = std::env::temp_dir().join(format!("codex-computer-use-server-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); @@ -4930,10 +6799,9 @@ mod tests { let listener = std::os::unix::net::UnixListener::bind(&usable_socket).expect("bind usable socket"); - let selected = connectable_ydotool_socket_from(vec![stale_socket, usable_socket.clone()]) - .expect("usable socket should be selected"); + let selected = connectable_ydotool_socket_from(vec![stale_socket, usable_socket.clone()]); - assert_eq!(selected, usable_socket); + assert!(selected.is_none()); drop(listener); let _ = std::fs::remove_dir_all(&dir); } @@ -5197,15 +7065,7 @@ mod tests { window_title: None, relative: Some(true), }; - let focus = WindowFocusResult { - requested_window: window_with_bounds(1, 100, 200, 800, 600), - focused_window: None, - app_focused: true, - exact_window_focused: true, - backend: "test".to_string(), - note: String::new(), - }; - apply_window_relative_scroll_coordinates(&mut params, &focus).unwrap(); + apply_window_relative_scroll_coordinates(&mut params, (100, 200, 800, 600)).unwrap(); assert_eq!(params.x, Some(110)); assert_eq!(params.y, Some(220)); } @@ -5225,21 +7085,13 @@ mod tests { window_title: None, relative: None, }; - let focus = WindowFocusResult { - requested_window: window_with_bounds(1, 100, 200, 800, 600), - focused_window: None, - app_focused: true, - exact_window_focused: true, - backend: "test".to_string(), - note: String::new(), - }; - apply_window_center_scroll_point(&mut params, &focus).unwrap(); + apply_window_center_scroll_point(&mut params, (100, 200, 800, 600)).unwrap(); assert_eq!(params.x, Some(500)); assert_eq!(params.y, Some(500)); } #[test] - fn window_targeted_scroll_without_bounds_errors() { + fn window_targeted_scroll_with_empty_capture_rect_errors() { let mut params = ScrollParams { element_index: None, x: None, @@ -5253,17 +7105,7 @@ mod tests { window_title: None, relative: None, }; - let mut window = window_with_bounds(1, 0, 0, 1, 1); - window.bounds = None; - let focus = WindowFocusResult { - requested_window: window, - focused_window: None, - app_focused: true, - exact_window_focused: true, - backend: "test".to_string(), - note: String::new(), - }; - let error = apply_window_center_scroll_point(&mut params, &focus).unwrap_err(); + let error = apply_window_center_scroll_point(&mut params, (0, 0, 0, 0)).unwrap_err(); assert!(error.contains("pass x/y explicitly")); assert_eq!(params.x, None); assert_eq!(params.y, None); @@ -5284,36 +7126,8 @@ mod tests { window_title: None, relative: Some(true), }; - let focus = WindowFocusResult { - requested_window: window_with_bounds(1, 100, 200, 800, 600), - focused_window: None, - app_focused: true, - exact_window_focused: true, - backend: "test".to_string(), - note: String::new(), - }; - assert!(apply_window_relative_scroll_coordinates(&mut params, &focus).is_err()); - } - - fn window_with_bounds(id: u64, x: i32, y: i32, width: u32, height: u32) -> WindowInfo { - WindowInfo { - window_id: id, - title: None, - app_id: None, - wm_class: None, - pid: None, - bounds: Some(crate::windowing::WindowBounds { - x: Some(x), - y: Some(y), - width, - height, - }), - workspace: None, - focused: true, - hidden: false, - client_type: None, - backend: "test".to_string(), - terminal: None, - } + assert!( + apply_window_relative_scroll_coordinates(&mut params, (100, 200, 800, 600)).is_err() + ); } } diff --git a/computer-use-linux/src/windowing/backends/cosmic.rs b/computer-use-linux/src/windowing/backends/cosmic.rs index f1e295821..3b82318b1 100644 --- a/computer-use-linux/src/windowing/backends/cosmic.rs +++ b/computer-use-linux/src/windowing/backends/cosmic.rs @@ -27,8 +27,8 @@ pub fn probe() -> BackendProbe { } } -pub fn list_windows() -> Result> { - let json = cosmic_helper::list_windows_json()?; +pub async fn list_windows() -> Result> { + let json = cosmic_helper::list_windows_json().await?; let mut windows: Vec = serde_json::from_str(&json).context("COSMIC helper returned invalid list-windows JSON")?; for window in &mut windows { @@ -39,8 +39,8 @@ pub fn list_windows() -> Result> { Ok(windows) } -pub fn focused_window() -> Result> { - let json = cosmic_helper::focused_window_json()?; +pub async fn focused_window() -> Result> { + let json = cosmic_helper::focused_window_json().await?; let mut window: Option = serde_json::from_str(&json) .context("COSMIC helper returned invalid focused-window JSON")?; if let Some(window) = window.as_mut() { @@ -49,8 +49,8 @@ pub fn focused_window() -> Result> { Ok(window) } -pub fn activate_window(window_id: u64) -> Result<()> { - let activation = cosmic_helper::activate_window(window_id)?; +pub async fn activate_window(window_id: u64) -> Result<()> { + let activation = cosmic_helper::activate_window(window_id).await?; if activation.ok { Ok(()) } else { diff --git a/computer-use-linux/src/windowing/backends/hyprland.rs b/computer-use-linux/src/windowing/backends/hyprland.rs index c8a9d6eb8..5baa68640 100644 --- a/computer-use-linux/src/windowing/backends/hyprland.rs +++ b/computer-use-linux/src/windowing/backends/hyprland.rs @@ -1,3 +1,4 @@ +use crate::command_runner; use crate::terminal::enrich_terminal_windows; use crate::windowing::registry::BackendProbe; use crate::windowing::types::{WindowBounds, WindowInfo}; @@ -6,8 +7,9 @@ use serde::Deserialize; use std::fs; use std::os::unix::fs::{FileTypeExt, MetadataExt}; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::Command as StdCommand; use std::time::SystemTime; +use tokio::process::Command; pub const HYPRLAND_BACKEND: &str = "hyprland"; @@ -55,8 +57,10 @@ pub fn probe() -> BackendProbe { } } -pub fn list_windows() -> Result> { - let output = hyprctl_output(&["clients", "-j"]).context("failed to run hyprctl clients -j")?; +pub async fn list_windows() -> Result> { + let output = hyprctl_output_async(&["clients", "-j"]) + .await + .context("failed to run hyprctl clients -j")?; if !output.status.success() { bail!( "hyprctl clients -j failed: {}", @@ -64,13 +68,146 @@ pub fn list_windows() -> Result> { ); } - parse_hyprland_clients(&String::from_utf8_lossy(&output.stdout)) + let clients_json = String::from_utf8_lossy(&output.stdout); + let monitors_output = hyprctl_output_async(&["monitors", "-j"]).await.ok(); + match monitors_output.filter(|output| output.status.success()) { + Some(monitors) => parse_hyprland_clients_with_monitors(&clients_json, &monitors.stdout), + None => parse_hyprland_clients_without_bounds(&clients_json), + } +} + +fn parse_hyprland_clients_with_monitors( + clients_json: &str, + monitors_json: &[u8], +) -> Result> { + let mut clients: Vec = + serde_json::from_str(clients_json).context("failed to parse hyprctl clients -j output")?; + let Ok(monitors) = serde_json::from_slice::>(monitors_json) else { + clear_hyprland_client_bounds(&mut clients); + return windows_from_hyprland_clients(clients); + }; + let Some(layout) = HyprlandCaptureLayout::from_monitors(&monitors) else { + clear_hyprland_client_bounds(&mut clients); + return windows_from_hyprland_clients(clients); + }; + let monitor_ids = monitors + .iter() + .map(|monitor| monitor.id) + .collect::>(); + for client in &mut clients { + if !client + .monitor + .is_some_and(|monitor_id| monitor_ids.contains(&monitor_id)) + { + client.at = None; + client.size = None; + continue; + } + let Some((at, size)) = client + .at + .zip(client.size) + .and_then(|(at, size)| layout.map_bounds(at, size)) + else { + client.at = None; + client.size = None; + continue; + }; + client.at = Some(at); + client.size = Some(size); + } + windows_from_hyprland_clients(clients) } +#[cfg(test)] pub(crate) fn parse_hyprland_clients(json: &str) -> Result> { let clients: Vec = serde_json::from_str(json).context("failed to parse hyprctl clients -j output")?; + windows_from_hyprland_clients(clients) +} + +fn parse_hyprland_clients_without_bounds(json: &str) -> Result> { + let mut clients: Vec = + serde_json::from_str(json).context("failed to parse hyprctl clients -j output")?; + clear_hyprland_client_bounds(&mut clients); + windows_from_hyprland_clients(clients) +} + +fn clear_hyprland_client_bounds(clients: &mut [HyprlandClient]) { + for client in clients { + client.at = None; + client.size = None; + } +} + +#[derive(Debug, Clone, Copy)] +struct HyprlandCaptureLayout { + origin_x: i32, + origin_y: i32, + scale: f64, +} + +impl HyprlandCaptureLayout { + fn from_monitors(monitors: &[HyprlandMonitor]) -> Option { + let first = monitors.first()?; + if monitors + .iter() + .any(|monitor| !monitor.scale.is_finite() || monitor.scale <= 0.0) + { + return None; + } + Some(Self { + origin_x: monitors + .iter() + .map(|monitor| monitor.x) + .min() + .unwrap_or(first.x), + origin_y: monitors + .iter() + .map(|monitor| monitor.y) + .min() + .unwrap_or(first.y), + scale: monitors + .iter() + .map(|monitor| monitor.scale) + .fold(first.scale, f64::max), + }) + } + + fn map_bounds(&self, at: [i32; 2], size: [u32; 2]) -> Option<([i32; 2], [u32; 2])> { + if size[0] == 0 || size[1] == 0 { + return None; + } + let left = ((i64::from(at[0]) - i64::from(self.origin_x)) as f64 * self.scale).floor(); + let top = ((i64::from(at[1]) - i64::from(self.origin_y)) as f64 * self.scale).floor(); + let right = ((i64::from(at[0]) + i64::from(size[0]) - i64::from(self.origin_x)) as f64 + * self.scale) + .ceil(); + let bottom = ((i64::from(at[1]) + i64::from(size[1]) - i64::from(self.origin_y)) as f64 + * self.scale) + .ceil(); + if !left.is_finite() + || !top.is_finite() + || !right.is_finite() + || !bottom.is_finite() + || left < f64::from(i32::MIN) + || left > f64::from(i32::MAX) + || top < f64::from(i32::MIN) + || top > f64::from(i32::MAX) + || right <= left + || bottom <= top + || right - left > f64::from(u32::MAX) + || bottom - top > f64::from(u32::MAX) + { + return None; + } + Some(( + [left as i32, top as i32], + [(right - left) as u32, (bottom - top) as u32], + )) + } +} +fn windows_from_hyprland_clients(clients: Vec) -> Result> { let mut windows = clients .into_iter() .filter(|client| client.mapped.unwrap_or(true)) @@ -81,22 +218,55 @@ pub(crate) fn parse_hyprland_clients(json: &str) -> Result> { Ok(windows) } -pub fn activate_window(window_id: u64) -> Result<()> { +pub async fn activate_window(window_id: u64) -> Result<()> { let address = format!("address:0x{window_id:x}"); - let output = hyprctl_output(&["dispatch", "focuswindow", &address]) + let lua_dispatch = lua_focus_dispatch(&address); + let lua_output = hyprctl_output_async(&["dispatch", &lua_dispatch]) + .await + .with_context(|| format!("failed to run Hyprland Lua focus dispatcher for {address}"))?; + if dispatch_succeeded(&lua_output) { + return Ok(()); + } + + let legacy_output = hyprctl_output_async(&["dispatch", "focuswindow", &address]) + .await .with_context(|| format!("failed to run hyprctl dispatch focuswindow {address}"))?; - if output.status.success() { + if dispatch_succeeded(&legacy_output) { Ok(()) } else { bail!( - "hyprctl dispatch focuswindow {address} failed: {}", - String::from_utf8_lossy(&output.stderr).trim() + "Hyprland window focus failed for {address}; Lua dispatcher: {}; legacy dispatcher: {}", + command_detail(&lua_output), + command_detail(&legacy_output) ); } } +fn dispatch_succeeded(output: &std::process::Output) -> bool { + output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "ok" +} + +fn command_detail(output: &std::process::Output) -> String { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + if detail.is_empty() { + format!("exit status {}", output.status) + } else { + detail.to_string() + } +} + +fn lua_focus_dispatch(address: &str) -> String { + format!("hl.dsp.focus({{ window = \"{address}\" }})") +} + fn hyprctl_output(args: &[&str]) -> std::io::Result { - let mut command = Command::new("hyprctl"); + let mut command = StdCommand::new("hyprctl"); let has_signature = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") .ok() .is_some_and(|value| !value.trim().is_empty()); @@ -108,6 +278,20 @@ fn hyprctl_output(args: &[&str]) -> std::io::Result { command.args(args).output() } +async fn hyprctl_output_async(args: &[&str]) -> Result { + let mut command = Command::new("hyprctl"); + let has_signature = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") + .ok() + .is_some_and(|value| !value.trim().is_empty()); + if !has_signature { + if let Some(signature) = infer_hyprland_instance_signature() { + command.args(["-i", &signature]); + } + } + command.args(args); + command_runner::output(command, "run hyprctl").await +} + fn infer_hyprland_instance_signature() -> Option { let runtime = xdg_runtime_dir()?; let hypr_dir = runtime.join("hypr"); @@ -190,8 +374,124 @@ struct HyprlandInstanceCandidate { #[cfg(test)] mod tests { use super::*; + use std::os::unix::process::ExitStatusExt; use std::time::Duration; + #[test] + fn rebases_global_window_coordinates_to_screenshot_space() { + let clients = r#"[{ + "address":"0x1234", + "mapped":true, + "at":[4714,1494], + "size":[931,1124], + "monitor":0, + "class":"com.mitchellh.ghostty", + "title":"Ghostty" + }]"#; + let monitors = br#"[ + {"id":0,"x":3747,"y":1440,"scale":1.8}, + {"id":1,"x":5667,"y":0,"scale":2.0} + ]"#; + let windows = parse_hyprland_clients_with_monitors(clients, monitors).unwrap(); + + let bounds = windows[0].bounds.as_ref().unwrap(); + assert_eq!((bounds.x, bounds.y), (Some(1934), Some(2988))); + assert_eq!((bounds.width, bounds.height), (1862, 2248)); + } + + #[test] + fn global_union_accounts_for_negative_monitor_origins() { + let clients = r#"[{ + "address":"0x1234", + "at":[-1800,100], + "size":[600,400], + "monitor":0, + "class":"foot", + "title":"Shell" + }]"#; + let monitors = br#"[ + {"id":0,"x":-1920,"y":0,"scale":1.0}, + {"id":1,"x":0,"y":0,"scale":2.0} + ]"#; + let windows = parse_hyprland_clients_with_monitors(clients, monitors).unwrap(); + + let bounds = windows[0].bounds.as_ref().unwrap(); + assert_eq!((bounds.x, bounds.y), (Some(240), Some(200))); + assert_eq!((bounds.width, bounds.height), (1200, 800)); + } + + #[test] + fn fractional_scale_rounds_crop_edges_outward() { + let clients = r#"[{ + "address":"0x1234", + "at":[1,1], + "size":[1,1], + "monitor":0, + "class":"foot", + "title":"Shell" + }]"#; + let monitors = br#"[{"id":0,"x":0,"y":0,"scale":1.25}]"#; + let windows = parse_hyprland_clients_with_monitors(clients, monitors).unwrap(); + + let bounds = windows[0].bounds.as_ref().unwrap(); + assert_eq!((bounds.x, bounds.y), (Some(1), Some(1))); + assert_eq!((bounds.width, bounds.height), (2, 2)); + } + + #[test] + fn bounds_are_omitted_without_valid_monitor_metadata() { + let clients = r#"[{ + "address":"0x1234", + "at":[100,100], + "size":[600,400], + "monitor":7, + "class":"foot", + "title":"Shell" + }]"#; + + for monitors in [ + br#"[]"#.as_slice(), + br#"[{"id":7,"x":0,"y":0,"scale":0.0}]"#.as_slice(), + b"not json".as_slice(), + ] { + let windows = parse_hyprland_clients_with_monitors(clients, monitors).unwrap(); + assert!(windows[0].bounds.is_none()); + } + let windows = parse_hyprland_clients_without_bounds(clients).unwrap(); + assert!(windows[0].bounds.is_none()); + } + + #[test] + fn builds_hyprland_055_lua_focus_dispatch() { + assert_eq!( + lua_focus_dispatch("address:0x1234abcd"), + "hl.dsp.focus({ window = \"address:0x1234abcd\" })" + ); + } + + #[test] + fn dispatch_rejects_exit_zero_error_output() { + let output = std::process::Output { + status: std::process::ExitStatus::from_raw(0), + stdout: b"Invalid dispatcher\n".to_vec(), + stderr: Vec::new(), + }; + + assert!(!dispatch_succeeded(&output)); + assert_eq!(command_detail(&output), "Invalid dispatcher"); + } + + #[test] + fn dispatch_accepts_ok_output() { + let output = std::process::Output { + status: std::process::ExitStatus::from_raw(0), + stdout: b"ok\n".to_vec(), + stderr: Vec::new(), + }; + + assert!(dispatch_succeeded(&output)); + } + #[test] fn selects_wayland_matching_hyprland_instance_before_newer_nonmatch() { let older_match = HyprlandInstanceCandidate { @@ -229,6 +529,14 @@ mod tests { } } +#[derive(Debug, Deserialize)] +struct HyprlandMonitor { + id: i32, + x: i32, + y: i32, + scale: f64, +} + #[derive(Debug, Deserialize)] struct HyprlandClient { address: String, @@ -236,6 +544,7 @@ struct HyprlandClient { hidden: Option, at: Option<[i32; 2]>, size: Option<[u32; 2]>, + monitor: Option, workspace: Option, #[serde(rename = "class")] class_name: Option, diff --git a/computer-use-linux/src/windowing/backends/i3.rs b/computer-use-linux/src/windowing/backends/i3.rs index 902d156db..5b40336c6 100644 --- a/computer-use-linux/src/windowing/backends/i3.rs +++ b/computer-use-linux/src/windowing/backends/i3.rs @@ -1,9 +1,11 @@ +use crate::command_runner; use crate::terminal::enrich_terminal_windows; use crate::windowing::registry::BackendProbe; use crate::windowing::types::{WindowBounds, WindowInfo}; use anyhow::{bail, Context, Result}; use serde::Deserialize; use std::{env, fs, os::unix::fs::FileTypeExt, path::PathBuf, process::Command}; +use tokio::process::Command as TokioCommand; pub const I3_BACKEND: &str = "i3"; @@ -51,11 +53,10 @@ pub fn probe() -> BackendProbe { } } -pub fn list_windows() -> Result> { - let output = i3_msg_command() - .args(["-t", "get_tree"]) - .output() - .context("failed to run i3-msg -t get_tree")?; +pub async fn list_windows() -> Result> { + let mut command = i3_msg_command_async(); + command.args(["-t", "get_tree"]); + let output = command_runner::output(command, "run i3-msg -t get_tree").await?; if !output.status.success() { bail!( "i3-msg -t get_tree failed: {}", @@ -64,7 +65,7 @@ pub fn list_windows() -> Result> { } let mut windows = parse_i3_tree(&String::from_utf8_lossy(&output.stdout))?; - hydrate_i3_window_pids(&mut windows); + hydrate_i3_window_pids(&mut windows).await; enrich_terminal_windows(&mut windows); Ok(windows) } @@ -78,12 +79,11 @@ pub(crate) fn parse_i3_tree(json: &str) -> Result> { Ok(windows) } -pub fn activate_window(window_id: u64) -> Result<()> { +pub async fn activate_window(window_id: u64) -> Result<()> { let selector = format!(r#"[id="0x{window_id:x}"] focus"#); - let output = i3_msg_command() - .arg(&selector) - .output() - .with_context(|| format!("failed to run i3-msg {selector}"))?; + let mut command = i3_msg_command_async(); + command.arg(&selector); + let output = command_runner::output(command, &format!("run i3-msg {selector}")).await?; if !output.status.success() { bail!( "i3-msg {selector} failed: {}", @@ -138,18 +138,19 @@ fn collect_i3_windows( } } -fn hydrate_i3_window_pids(windows: &mut [WindowInfo]) { +async fn hydrate_i3_window_pids(windows: &mut [WindowInfo]) { for window in windows { if window.pid.is_none() { - window.pid = i3_window_pid(window.window_id); + window.pid = i3_window_pid(window.window_id).await; } } } -fn i3_window_pid(window_id: u64) -> Option { - let output = Command::new("xprop") - .args(["-id", &window_id.to_string(), "_NET_WM_PID"]) - .output() +async fn i3_window_pid(window_id: u64) -> Option { + let mut command = TokioCommand::new("xprop"); + command.args(["-id", &window_id.to_string(), "_NET_WM_PID"]); + let output = command_runner::output(command, "query X11 window pid") + .await .ok()?; if !output.status.success() { return None; @@ -169,6 +170,14 @@ fn i3_msg_command() -> Command { command } +fn i3_msg_command_async() -> TokioCommand { + let mut command = TokioCommand::new("i3-msg"); + if let Some(socket_path) = i3_socket_path() { + command.arg("-s").arg(socket_path); + } + command +} + fn i3_socket_path() -> Option { if let Some(value) = env_var("I3SOCK") { return Some(PathBuf::from(value)); diff --git a/computer-use-linux/src/windowing/backends/kwin.rs b/computer-use-linux/src/windowing/backends/kwin.rs index 92ed08f19..8d4b15bae 100644 --- a/computer-use-linux/src/windowing/backends/kwin.rs +++ b/computer-use-linux/src/windowing/backends/kwin.rs @@ -49,6 +49,11 @@ pub async fn list_windows() -> Result> { Ok(windows) } +pub(crate) async fn logical_desktop_rect() -> Result<(i32, i32, i32, i32)> { + let json = call_kwin_window_script().await?; + parse_kwin_logical_desktop_rect(&json) +} + pub async fn activate_window(window_id: u64) -> Result<()> { let uuid = kwin_uuid_for_window_id(window_id).await?.with_context(|| { format!("No KWin window matched window_id {window_id} during activation") @@ -74,7 +79,7 @@ struct KwinScriptResult { async fn call_kwin_activate_script(uuid: &str) -> Result<()> { let uuid = uuid.to_string(); - let json = call_kwin_script(|service_name, callback_object_path, plugin_name| { + let json = call_kwin_script(move |service_name, callback_object_path, plugin_name| { write_kwin_activate_script(service_name, callback_object_path, plugin_name, &uuid) }) .await?; @@ -111,17 +116,20 @@ where let plugin_name = temporary_kwin_plugin_name(); let callback_object_path = format!("{KWIN_CALLBACK_OBJECT_PATH_PREFIX}/{plugin_name}"); let (sender, receiver) = mpsc::channel(); + let mut cleanup = KwinScriptCleanup::new( + connection.clone(), + plugin_name.clone(), + callback_object_path.clone(), + ); connection .object_server() .at(callback_object_path.as_str(), KwinWindowCallback { sender }) .await .context("failed to register temporary KWin callback object")?; - let mut script_path = None; - let mut loaded_script = false; let result = async { let path = write_script(&unique_name, &callback_object_path, &plugin_name)?; - script_path = Some(path.clone()); + cleanup.script_path = Some(path.clone()); let scripting_proxy = Proxy::new( &connection, KWIN_SCRIPTING_SERVICE, @@ -140,7 +148,6 @@ where ) .await .context("KWin loadScript failed")?; - loaded_script = true; let _: () = scripting_proxy .call("start", &()) @@ -162,8 +169,73 @@ where .context("KWin temporary script did not return data before timeout")? } .await; + cleanup.run().await; + result +} - if loaded_script { +struct KwinScriptCleanup { + connection: zbus::Connection, + plugin_name: String, + callback_object_path: String, + script_path: Option, + armed: bool, +} + +impl KwinScriptCleanup { + fn new( + connection: zbus::Connection, + plugin_name: String, + callback_object_path: String, + ) -> Self { + Self { + connection, + plugin_name, + callback_object_path, + script_path: None, + armed: true, + } + } + + async fn run(&mut self) { + cleanup_kwin_script( + self.connection.clone(), + self.plugin_name.clone(), + self.callback_object_path.clone(), + self.script_path.clone(), + ) + .await; + self.script_path = None; + self.armed = false; + } +} + +impl Drop for KwinScriptCleanup { + fn drop(&mut self) { + if !self.armed { + return; + } + let connection = self.connection.clone(); + let plugin_name = self.plugin_name.clone(); + let callback_object_path = self.callback_object_path.clone(); + let script_path = self.script_path.take(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(cleanup_kwin_script( + connection, + plugin_name, + callback_object_path, + script_path, + )); + } + } +} + +async fn cleanup_kwin_script( + connection: zbus::Connection, + plugin_name: String, + callback_object_path: String, + script_path: Option, +) { + let _ = timeout(Duration::from_secs(1), async { if let Ok(scripting_proxy) = Proxy::new( &connection, KWIN_SCRIPTING_SERVICE, @@ -176,7 +248,8 @@ where .call("unloadScript", &(plugin_name.as_str())) .await; } - } + }) + .await; let _: Result = connection .object_server() .remove::(callback_object_path.as_str()) @@ -184,8 +257,6 @@ where if let Some(script_path) = script_path { let _ = fs::remove_file(script_path); } - - result } struct KwinWindowCallback { @@ -291,6 +362,22 @@ pub(crate) fn kwin_window_script_source( }}; }} + function workspaceGeometry() {{ + var rect = null; + try {{ + rect = workspace.virtualScreenGeometry; + }} catch (error) {{}} + if (rect === null || rect === undefined) {{ + return null; + }} + return {{ + x: read(rect, "x"), + y: read(rect, "y"), + width: read(rect, "width"), + height: read(rect, "height") + }}; + }} + function firstDesktop(window) {{ var desktops = read(window, "desktops"); if (!Array.isArray(desktops) || desktops.length === 0) {{ @@ -363,6 +450,7 @@ pub(crate) fn kwin_window_script_source( callDBus(serviceName, objectPath, iface, "ReceiveWindows", JSON.stringify({{ backend: "kwin", pluginName: pluginName, + desktopGeometry: workspaceGeometry(), windows: windows }})); }})(); @@ -601,15 +689,49 @@ pub(crate) fn parse_kwin_windows(json: &str) -> Result> { Ok(windows) } +pub(crate) fn parse_kwin_logical_desktop_rect(json: &str) -> Result<(i32, i32, i32, i32)> { + parse_kwin_snapshot(json)?.logical_desktop_rect() +} + fn parse_kwin_snapshot(json: &str) -> Result { serde_json::from_str(json).context("failed to parse KWin temporary script output") } #[derive(Debug, Deserialize)] struct KwinWindowSnapshot { + #[serde(default, rename = "desktopGeometry")] + desktop_geometry: Option, windows: Vec, } +impl KwinWindowSnapshot { + fn logical_desktop_rect(&self) -> Result<(i32, i32, i32, i32)> { + let geometry = self + .desktop_geometry + .as_ref() + .context("KWin did not expose virtualScreenGeometry")?; + let x = json_value_as_i32(geometry.x.as_ref()) + .context("KWin virtualScreenGeometry x is unavailable")?; + let y = json_value_as_i32(geometry.y.as_ref()) + .context("KWin virtualScreenGeometry y is unavailable")?; + let width = json_value_as_i32(geometry.width.as_ref()) + .filter(|width| *width > 0) + .context("KWin virtualScreenGeometry width is unavailable")?; + let height = json_value_as_i32(geometry.height.as_ref()) + .filter(|height| *height > 0) + .context("KWin virtualScreenGeometry height is unavailable")?; + Ok((x, y, width, height)) + } +} + +#[derive(Debug, Deserialize)] +struct KwinRawGeometry { + x: Option, + y: Option, + width: Option, + height: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct KwinRawWindow { diff --git a/computer-use-linux/src/windowing/backends/mod.rs b/computer-use-linux/src/windowing/backends/mod.rs index c7865c362..7640dd134 100644 --- a/computer-use-linux/src/windowing/backends/mod.rs +++ b/computer-use-linux/src/windowing/backends/mod.rs @@ -4,3 +4,4 @@ pub mod hyprland; pub mod i3; pub mod kwin; pub mod niri; +pub mod x11; diff --git a/computer-use-linux/src/windowing/backends/niri.rs b/computer-use-linux/src/windowing/backends/niri.rs index 05594815f..0a4779b41 100644 --- a/computer-use-linux/src/windowing/backends/niri.rs +++ b/computer-use-linux/src/windowing/backends/niri.rs @@ -1,9 +1,11 @@ +use crate::command_runner; use crate::terminal::enrich_terminal_windows; use crate::windowing::registry::BackendProbe; use crate::windowing::types::{WindowBounds, WindowInfo}; use anyhow::{bail, Context, Result}; use serde::Deserialize; -use std::process::Command; +use std::process::Command as StdCommand; +use tokio::process::Command; pub const NIRI_BACKEND: &str = "niri"; @@ -47,8 +49,9 @@ pub fn probe() -> BackendProbe { } } -pub fn list_windows() -> Result> { - let output = niri_output(&["msg", "--json", "windows"]) +pub async fn list_windows() -> Result> { + let output = niri_output_async(&["msg", "--json", "windows"]) + .await .context("failed to run niri msg --json windows")?; if !output.status.success() { bail!( @@ -72,9 +75,10 @@ pub(crate) fn parse_niri_windows(json: &str) -> Result> { Ok(windows) } -pub fn activate_window(window_id: u64) -> Result<()> { +pub async fn activate_window(window_id: u64) -> Result<()> { let args = niri_focus_args(window_id); - let output = niri_output(&args.iter().map(String::as_str).collect::>()) + let output = niri_output_async(&args.iter().map(String::as_str).collect::>()) + .await .with_context(|| format!("failed to focus Niri window {window_id}"))?; if output.status.success() { Ok(()) @@ -97,7 +101,13 @@ pub(crate) fn niri_focus_args(window_id: u64) -> [String; 5] { } fn niri_output(args: &[&str]) -> std::io::Result { - Command::new("niri").args(args).output() + StdCommand::new("niri").args(args).output() +} + +async fn niri_output_async(args: &[&str]) -> Result { + let mut command = Command::new("niri"); + command.args(args); + command_runner::output(command, "run niri IPC command").await } fn command_failure_detail(output: &std::process::Output) -> String { diff --git a/computer-use-linux/src/windowing/backends/x11.rs b/computer-use-linux/src/windowing/backends/x11.rs new file mode 100644 index 000000000..63f394f5a --- /dev/null +++ b/computer-use-linux/src/windowing/backends/x11.rs @@ -0,0 +1,566 @@ +//! Generic X11 / EWMH window backend. +//! +//! Unlike the compositor-specific backends (GNOME Shell, KWin, Hyprland, i3), +//! this one talks plain [EWMH]/[ICCCM] through `wmctrl` + `xprop`, so it works +//! on any reasonably standards-compliant X11 window manager that does not have +//! a dedicated backend — Cinnamon/Muffin, MATE/Marco, Xfce/xfwm4, Openbox, etc. +//! It is intentionally registered last so a session-native backend always wins +//! when one is present. +//! +//! [EWMH]: https://specifications.freedesktop.org/wm-spec/latest/ +//! [ICCCM]: https://tronche.com/gui/x/icccm/ + +use crate::command_runner; +use crate::terminal::enrich_terminal_windows; +use crate::windowing::registry::BackendProbe; +use crate::windowing::types::{WindowBounds, WindowInfo}; +use anyhow::{bail, Result}; +use std::env; +use std::os::unix::fs::PermissionsExt; +use std::process::Command; +use tokio::process::Command as TokioCommand; +use tokio::time::{sleep, Duration}; + +pub const X11_BACKEND: &str = "x11"; +const GEOMETRY_VERIFY_ATTEMPTS: usize = 11; +const GEOMETRY_VERIFY_DELAY: Duration = Duration::from_millis(50); +const PROBE_TIMEOUT: Duration = Duration::from_secs(2); + +/// True when this looks like a plain X11 session we can drive over EWMH. +/// +/// Requires an X `DISPLAY` and either an explicit `x11` session type or the +/// absence of a Wayland display, so we never hijack XWayland under a Wayland +/// compositor (where a native backend should answer instead). +fn is_x11_session() -> bool { + if env_nonempty("DISPLAY").is_none() { + return false; + } + match env_nonempty("XDG_SESSION_TYPE").as_deref() { + Some("x11") => true, + Some("wayland") => false, + _ => env_nonempty("WAYLAND_DISPLAY").is_none(), + } +} + +pub(crate) fn can_exact_focus() -> bool { + is_x11_session() && command_on_path("wmctrl") && command_on_path("xprop") +} + +pub fn probe() -> BackendProbe { + if !is_x11_session() { + return probe_fail("no X11 session (needs DISPLAY on an X11, not Wayland, session)"); + } + let mut command = wmctrl(); + command.args(["-l", "-p", "-G", "-x"]); + match command_runner::output_blocking_with_timeout( + &mut command, + "probe X11 windows", + PROBE_TIMEOUT, + ) { + Ok(output) if output.status.success() => { + // Listing only needs wmctrl, but the `focused` flag (and therefore + // focused_window() and activate_window's focus verification) comes + // from `_NET_ACTIVE_WINDOW` read via xprop. Without xprop we can list + // but cannot verify focus, so don't advertise focus capabilities. + let can_focus = can_exact_focus() && active_window_query().is_some(); + BackendProbe { + id: X11_BACKEND, + ok: true, + can_list_windows: true, + can_focus_apps: can_focus, + can_focus_windows: can_focus, + detail: if can_focus { + "wmctrl listed X11/EWMH windows".to_string() + } else { + "wmctrl listed X11/EWMH windows; xprop missing, so focused-window verification is unavailable".to_string() + }, + } + } + Ok(output) => probe_fail(&format!( + "wmctrl -l failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )), + Err(error) => probe_fail(&format!("wmctrl unavailable: {error}")), + } +} + +fn probe_fail(detail: &str) -> BackendProbe { + BackendProbe { + id: X11_BACKEND, + ok: false, + can_list_windows: false, + can_focus_apps: false, + can_focus_windows: false, + detail: detail.to_string(), + } +} + +pub async fn list_windows() -> Result> { + list_windows_with_focus_query(false).await +} + +pub(crate) async fn list_windows_for_exact_focus() -> Result> { + list_windows_with_focus_query(true).await +} + +async fn list_windows_with_focus_query(require_focus_query: bool) -> Result> { + // Guard the session too, not just probe(): registry::list_windows() tries + // each backend directly, so without this a Wayland session with no native + // backend would fall through here and return XWayland-only windows. + if !is_x11_session() { + bail!("not an X11 session (needs DISPLAY on an X11, not Wayland, session)"); + } + let mut command = wmctrl_async(); + command.args(["-l", "-p", "-G", "-x"]); + let output = command_runner::output(command, "run wmctrl -l -p -G -x").await?; + if !output.status.success() { + bail!( + "wmctrl -l -p -G -x failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let active_id = + resolve_active_window_query(active_window_query_async().await, require_focus_query)?; + let mut windows = parse_wmctrl_windows(&String::from_utf8_lossy(&output.stdout), active_id); + enrich_terminal_windows(&mut windows); + Ok(windows) +} + +pub async fn activate_window(window_id: u64) -> Result<()> { + let id = window_id_arg(window_id); + run_wmctrl(&["-i", "-a", id.as_str()], "activate window", window_id).await +} + +pub async fn move_window(window_id: u64, x: i32, y: i32) -> Result { + unmaximize(window_id).await?; + let id = window_id_arg(window_id); + // wmctrl -e is `gravity,x,y,width,height`; the trailing -1,-1 keep the size. + // wmctrl also reads -1 in the x/y fields as "preserve current position", so a + // literal -1 target would be dropped — nudge it to -2 so the move still lands. + let geometry = format!("0,{},{},-1,-1", wmctrl_move_coord(x), wmctrl_move_coord(y)); + run_wmctrl( + &["-i", "-r", id.as_str(), "-e", geometry.as_str()], + "move window", + window_id, + ) + .await?; + let expected_x = wmctrl_move_coord(x); + let expected_y = wmctrl_move_coord(y); + let (bounds, exact) = wait_for_window_geometry(window_id, |bounds| { + bounds_at_position(bounds, expected_x, expected_y) + }) + .await?; + if exact { + Ok(format!("Moved window to ({x}, {y}) via X11/EWMH (wmctrl).")) + } else { + Ok(format!( + "Requested move to ({x}, {y}) via X11/EWMH; the window manager reported position ({}, {}).", + bounds.x.map_or_else(|| "unknown".to_string(), |value| value.to_string()), + bounds.y.map_or_else(|| "unknown".to_string(), |value| value.to_string()) + )) + } +} + +/// `wmctrl -e` treats -1 in any field as "keep current value", so a literal -1 +/// coordinate is silently ignored. Map it to -2 so the window actually moves +/// (a 1px difference at the screen edge is harmless). +fn wmctrl_move_coord(value: i32) -> i32 { + if value == -1 { + -2 + } else { + value + } +} + +pub async fn resize_window(window_id: u64, width: i32, height: i32) -> Result { + // wmctrl -e reads -1 (and rejects <= 0) as "preserve current value" per + // field, so a non-positive size would silently leave a dimension unchanged + // while reporting success. Reject it up front. + if width <= 0 || height <= 0 { + bail!("resize requires positive width and height (got {width}x{height})"); + } + unmaximize(window_id).await?; + let id = window_id_arg(window_id); + let geometry = format!("0,-1,-1,{width},{height}"); + run_wmctrl( + &["-i", "-r", id.as_str(), "-e", geometry.as_str()], + "resize window", + window_id, + ) + .await?; + let (bounds, exact) = wait_for_window_geometry(window_id, |bounds| { + bounds_at_size(bounds, width as u32, height as u32) + }) + .await?; + if exact { + Ok(format!( + "Resized window to {width}x{height} via X11/EWMH (wmctrl)." + )) + } else { + Ok(format!( + "Requested resize to {width}x{height} via X11/EWMH; the window manager reported {}x{}.", + bounds.width, bounds.height + )) + } +} + +async fn wait_for_window_geometry( + window_id: u64, + matches: impl Fn(&WindowBounds) -> bool, +) -> Result<(WindowBounds, bool)> { + let mut last_bounds = None; + for attempt in 0..GEOMETRY_VERIFY_ATTEMPTS { + if let Some(bounds) = query_window_bounds(window_id).await { + if matches(&bounds) { + return Ok((bounds, true)); + } + last_bounds = Some(bounds); + } + if attempt + 1 < GEOMETRY_VERIFY_ATTEMPTS { + sleep(GEOMETRY_VERIFY_DELAY).await; + } + } + match last_bounds { + Some(bounds) => Ok((bounds, false)), + None => { + bail!("X11/EWMH window 0x{window_id:x} could not be queried after the geometry request") + } + } +} + +async fn query_window_bounds(window_id: u64) -> Option { + let mut command = wmctrl_async(); + command.args(["-l", "-p", "-G", "-x"]); + let output = command_runner::output(command, "query X11 window geometry") + .await + .ok()?; + if !output.status.success() { + return None; + } + parse_wmctrl_windows(&String::from_utf8_lossy(&output.stdout), None) + .into_iter() + .find(|window| window.window_id == window_id) + .and_then(|window| window.bounds) +} + +fn bounds_at_position(bounds: &WindowBounds, x: i32, y: i32) -> bool { + bounds.x == Some(x) && bounds.y == Some(y) +} + +fn bounds_at_size(bounds: &WindowBounds, width: u32, height: u32) -> bool { + bounds.width == width && bounds.height == height +} + +/// EWMH move/resize only take effect on unmaximized windows, so drop the +/// maximized state first (mirrors the GNOME extension backend behaviour). +async fn unmaximize(window_id: u64) -> Result<()> { + let id = window_id_arg(window_id); + run_wmctrl( + &[ + "-i", + "-r", + id.as_str(), + "-b", + "remove,maximized_vert,maximized_horz", + ], + "unmaximize window", + window_id, + ) + .await +} + +async fn run_wmctrl(args: &[&str], action: &str, window_id: u64) -> Result<()> { + let mut command = wmctrl_async(); + command.args(args); + let output = + command_runner::output(command, &format!("run wmctrl to {action} 0x{window_id:x}")).await?; + if !output.status.success() { + bail!( + "wmctrl failed to {action} 0x{window_id:x}: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +fn window_id_arg(window_id: u64) -> String { + format!("0x{window_id:08x}") +} + +fn wmctrl() -> Command { + Command::new("wmctrl") +} + +fn wmctrl_async() -> TokioCommand { + TokioCommand::new("wmctrl") +} + +async fn active_window_query_async() -> Option> { + let mut command = TokioCommand::new("xprop"); + command.args(["-root", "-notype", "_NET_ACTIVE_WINDOW"]); + let output = command_runner::output(command, "query the active X11 window") + .await + .ok()?; + if !output.status.success() { + return None; + } + parse_active_window_query(&String::from_utf8_lossy(&output.stdout)) +} + +fn resolve_active_window_query( + query: Option>, + require_focus_query: bool, +) -> Result> { + if require_focus_query && query.is_none() { + bail!("xprop could not query _NET_ACTIVE_WINDOW"); + } + Ok(query.flatten()) +} + +fn active_window_query() -> Option> { + let mut command = Command::new("xprop"); + command.args(["-root", "-notype", "_NET_ACTIVE_WINDOW"]); + let output = command_runner::output_blocking_with_timeout( + &mut command, + "probe the active X11 window", + PROBE_TIMEOUT, + ) + .ok()?; + if !output.status.success() { + return None; + } + parse_active_window_query(&String::from_utf8_lossy(&output.stdout)) +} + +fn parse_active_window_query(xprop_output: &str) -> Option> { + let after = xprop_output.split("0x").nth(1)?; + let hex: String = after + .chars() + .take_while(|character| character.is_ascii_hexdigit()) + .collect(); + let id = u64::from_str_radix(&hex, 16).ok()?; + Some((id != 0).then_some(id)) +} + +/// Parse `wmctrl -l -p -G -x` output into window records. +/// +/// Each line is: `id desktop pid x y w h wm_class client_machine title...`, +/// where `title` is the free-form remainder (may contain spaces). +pub(crate) fn parse_wmctrl_windows(list_output: &str, active_id: Option) -> Vec { + let mut windows: Vec = list_output + .lines() + .filter_map(|line| parse_wmctrl_line(line, active_id)) + .collect(); + windows.sort_by_key(|window| window.window_id); + windows +} + +fn parse_wmctrl_line(line: &str, active_id: Option) -> Option { + let mut rest = line; + let id_field = next_field(&mut rest)?; + let desktop_field = next_field(&mut rest)?; + let pid_field = next_field(&mut rest)?; + let x_field = next_field(&mut rest)?; + let y_field = next_field(&mut rest)?; + let width_field = next_field(&mut rest)?; + let height_field = next_field(&mut rest)?; + let class_field = next_field(&mut rest)?; + let _client_machine = next_field(&mut rest); + let title = rest.trim(); + + let window_id = u64::from_str_radix(id_field.trim_start_matches("0x"), 16).ok()?; + let desktop = desktop_field.parse::().ok()?; + let pid = pid_field.parse::().ok().filter(|pid| *pid != 0); + let x = x_field.parse::().ok()?; + let y = y_field.parse::().ok()?; + let width = width_field.parse::().ok()?; + let height = height_field.parse::().ok()?; + + let (app_id, wm_class) = split_wm_class(class_field); + + Some(WindowInfo { + window_id, + title: clean(title), + app_id, + wm_class, + pid, + bounds: Some(WindowBounds { + x: Some(x), + y: Some(y), + width, + height, + }), + workspace: (desktop >= 0).then_some(desktop), + focused: active_id == Some(window_id), + hidden: false, + client_type: Some("x11".to_string()), + backend: X11_BACKEND.to_string(), + terminal: None, + }) +} + +/// `wmctrl -x` prints `WM_CLASS` as `instance.Class`. Map `instance` to +/// `app_id` and `Class` to `wm_class`, mirroring the i3 backend. +fn split_wm_class(value: &str) -> (Option, Option) { + match value.split_once('.') { + Some((instance, class)) => (clean(instance), clean(class)), + None => (clean(value), clean(value)), + } +} + +/// Consume the next whitespace-delimited field, advancing `rest` past it. +fn next_field<'a>(rest: &mut &'a str) -> Option<&'a str> { + *rest = rest.trim_start(); + if rest.is_empty() { + return None; + } + let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + let (field, tail) = rest.split_at(end); + *rest = tail; + Some(field) +} + +fn clean(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty() && !value.eq_ignore_ascii_case("N/A")).then(|| value.to_string()) +} + +fn env_nonempty(name: &str) -> Option { + env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +/// True if `cmd` is an executable found on `$PATH`. Used to gate focus +/// capabilities on `xprop` without spawning it (xprop with no args would block +/// reading a window interactively). +fn command_on_path(cmd: &str) -> bool { + env::var_os("PATH").is_some_and(|paths| { + env::split_paths(&paths).any(|dir| { + dir.join(cmd).metadata().is_ok_and(|metadata| { + metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 + }) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_active_window_id_from_xprop() { + assert_eq!( + parse_active_window_query("_NET_ACTIVE_WINDOW: window id # 0x7000004\n"), + Some(Some(0x7000004)) + ); + assert_eq!( + parse_active_window_query("_NET_ACTIVE_WINDOW(WINDOW): window id # 0x03a00017\n"), + Some(Some(0x03a00017)) + ); + assert_eq!( + parse_active_window_query("_NET_ACTIVE_WINDOW: window id # 0x0\n"), + Some(None) + ); + assert_eq!( + parse_active_window_query("_NET_ACTIVE_WINDOW: not found.\n"), + None + ); + } + + #[test] + fn exact_focus_listing_requires_a_live_active_window_query() { + assert!(resolve_active_window_query(None, true).is_err()); + assert_eq!(resolve_active_window_query(Some(None), true).unwrap(), None); + assert_eq!( + resolve_active_window_query(Some(Some(0x7000004)), true).unwrap(), + Some(0x7000004) + ); + assert_eq!(resolve_active_window_query(None, false).unwrap(), None); + } + + #[test] + fn parses_wmctrl_windows_as_window_info() { + // Real `wmctrl -l -p -G -x` lines: id desktop pid x y w h class host title. + let output = "\ +0x03000003 0 3843 0 0 5120 1400 nemo-desktop.Nemo-desktop rog nemo-desktop +0x03a00017 2 4564 -40 -40 2600 1440 Navigator.firefox rog Nouvel onglet — Mozilla Firefox +0x05c00007 0 26606 0 72 2560 1368 terminator.Terminator rog jo@rog: ~ +0x07000004 -1 0 10 10 400 300 claude-desktop.claude-desktop rog Claude +"; + let windows = parse_wmctrl_windows(output, Some(0x07000004)); + + assert_eq!(windows.len(), 4); + + let firefox = windows + .iter() + .find(|window| window.window_id == 0x03a00017) + .unwrap(); + assert_eq!(firefox.app_id.as_deref(), Some("Navigator")); + assert_eq!(firefox.wm_class.as_deref(), Some("firefox")); + assert_eq!(firefox.pid, Some(4564)); + assert_eq!( + firefox.title.as_deref(), + Some("Nouvel onglet — Mozilla Firefox") + ); + assert_eq!(firefox.workspace, Some(2)); + let bounds = firefox.bounds.as_ref().unwrap(); + assert_eq!( + (bounds.x, bounds.y, bounds.width, bounds.height), + (Some(-40), Some(-40), 2600, 1440) + ); + assert_eq!(firefox.client_type.as_deref(), Some("x11")); + assert_eq!(firefox.backend, X11_BACKEND); + assert!(!firefox.focused); + + // Active window is flagged; sticky desktop (-1) has no workspace; pid 0 -> None. + let claude = windows + .iter() + .find(|window| window.window_id == 0x07000004) + .unwrap(); + assert!(claude.focused); + assert_eq!(claude.workspace, None); + assert_eq!(claude.pid, None); + } + + #[test] + fn move_coord_avoids_wmctrl_preserve_sentinel() { + assert_eq!(wmctrl_move_coord(-1), -2); + assert_eq!(wmctrl_move_coord(0), 0); + assert_eq!(wmctrl_move_coord(-40), -40); + assert_eq!(wmctrl_move_coord(1920), 1920); + } + + #[test] + fn geometry_matchers_require_the_requested_values() { + let bounds = WindowBounds { + x: Some(10), + y: Some(20), + width: 800, + height: 600, + }; + assert!(bounds_at_position(&bounds, 10, 20)); + assert!(!bounds_at_position(&bounds, 11, 20)); + assert!(bounds_at_size(&bounds, 800, 600)); + assert!(!bounds_at_size(&bounds, 801, 600)); + } + + #[test] + fn clean_drops_na_case_insensitively_and_blanks() { + assert_eq!(clean("N/A"), None); + assert_eq!(clean("n/a"), None); + assert_eq!(clean(" "), None); + assert_eq!(clean(" Firefox "), Some("Firefox".to_string())); + } + + #[test] + fn parses_title_with_multiple_spaces_and_empty_title() { + let output = "0x00000001 0 100 0 0 800 600 term.Term rog\n"; + let windows = parse_wmctrl_windows(output, None); + assert_eq!(windows.len(), 1); + assert_eq!(windows[0].title, None); + assert_eq!(windows[0].wm_class.as_deref(), Some("Term")); + } +} diff --git a/computer-use-linux/src/windowing/mod.rs b/computer-use-linux/src/windowing/mod.rs index 7e5916c34..1acce11db 100644 --- a/computer-use-linux/src/windowing/mod.rs +++ b/computer-use-linux/src/windowing/mod.rs @@ -6,7 +6,7 @@ pub mod types; #[allow(unused_imports)] pub use registry::{ COSMIC_WAYLAND_BACKEND, GNOME_SHELL_EXTENSION_BACKEND, GNOME_SHELL_INTROSPECT_BACKEND, - HYPRLAND_BACKEND, I3_BACKEND, KWIN_BACKEND, NIRI_BACKEND, WINDOW_PERMISSION_HINT, + HYPRLAND_BACKEND, I3_BACKEND, KWIN_BACKEND, NIRI_BACKEND, WINDOW_PERMISSION_HINT, X11_BACKEND, }; #[allow(unused_imports)] pub use target::{ @@ -23,7 +23,7 @@ mod tests { use super::backends::i3::{parse_i3_tree, parse_xprop_pid, I3_BACKEND}; use super::backends::kwin::{ kwin_activate_script_source, kwin_window_id_from_uuid, kwin_window_script_source, - parse_kwin_windows, KWIN_BACKEND, + parse_kwin_logical_desktop_rect, parse_kwin_windows, KWIN_BACKEND, }; use super::backends::niri::{niri_focus_args, parse_niri_windows, NIRI_BACKEND}; use super::registry::{ @@ -58,6 +58,7 @@ mod tests { HYPRLAND_BACKEND, NIRI_BACKEND, I3_BACKEND, + X11_BACKEND, ] ); } @@ -748,6 +749,7 @@ mod tests { let uuid = "b4dfacf8-a559-43c9-8b1f-ecd5cfd78359"; let windows_json = r#"{ "backend": "kwin", + "desktopGeometry": {"x": 100, "y": -50, "width": 3840, "height": "2160"}, "windows": [ { "uuid": "{b4dfacf8-a559-43c9-8b1f-ecd5cfd78359}", @@ -791,6 +793,10 @@ mod tests { assert!(!windows[0].hidden); assert_eq!(windows[0].client_type.as_deref(), Some("wayland")); assert_eq!(windows[0].backend, KWIN_BACKEND); + assert_eq!( + parse_kwin_logical_desktop_rect(windows_json).unwrap(), + (100, -50, 3840, 2160) + ); } #[test] @@ -819,6 +825,8 @@ mod tests { assert!(script.contains("workspace.clientList()")); assert!(script .contains(r#"activeWindow = "activeWindow" in workspace ? workspace.activeWindow : workspace.activeClient;"#)); + assert!(script.contains("workspace.virtualScreenGeometry")); + assert!(script.contains("desktopGeometry: workspaceGeometry()")); } #[test] diff --git a/computer-use-linux/src/windowing/registry.rs b/computer-use-linux/src/windowing/registry.rs index ad8893bfe..bd057a909 100644 --- a/computer-use-linux/src/windowing/registry.rs +++ b/computer-use-linux/src/windowing/registry.rs @@ -1,4 +1,4 @@ -use crate::windowing::backends::{cosmic, gnome, hyprland, i3, kwin, niri}; +use crate::windowing::backends::{cosmic, gnome, hyprland, i3, kwin, niri, x11}; use crate::windowing::types::WindowInfo; use anyhow::{anyhow, Result}; @@ -8,6 +8,7 @@ pub use hyprland::HYPRLAND_BACKEND; pub use i3::I3_BACKEND; pub use kwin::KWIN_BACKEND; pub use niri::NIRI_BACKEND; +pub use x11::X11_BACKEND; pub const WINDOW_PERMISSION_HINT: &str = "Computer Use could not access a supported window list backend. Targeted window input requires session-bus access plus GNOME Shell Introspect, the Codex GNOME Shell extension, the COSMIC Wayland helper, KWin/Plasma DBus scripting, Hyprland hyprctl, Niri IPC, or i3-msg. On GNOME, run setup_window_targeting to install the extension backend."; @@ -39,6 +40,7 @@ enum BackendKind { Hyprland, Niri, I3, + X11, } const BACKEND_ORDER: &[BackendKind] = &[ @@ -49,6 +51,8 @@ const BACKEND_ORDER: &[BackendKind] = &[ BackendKind::Hyprland, BackendKind::Niri, BackendKind::I3, + // Generic X11/EWMH: last, so a session-native backend always wins first. + BackendKind::X11, ]; const DESCRIPTORS: &[BackendDescriptor] = &[ @@ -101,6 +105,13 @@ const DESCRIPTORS: &[BackendDescriptor] = &[ missing_hint: "On i3, ensure i3-msg can reach the active i3 IPC socket.", can_exact_focus: true, }, + BackendDescriptor { + id: X11_BACKEND, + failure_label: "X11/EWMH", + list_note: "Window list came from X11/EWMH (wmctrl). Terminal windows may include best-effort PTY and active-process context when the process tree is readable.", + missing_hint: "On other X11 window managers (Cinnamon, MATE, Xfce, Openbox, etc.), ensure wmctrl and xprop are installed.", + can_exact_focus: true, + }, ]; pub fn descriptors() -> &'static [BackendDescriptor] { @@ -122,7 +133,11 @@ pub fn list_note(id: &str) -> &'static str { } pub fn backend_can_exact_focus(id: &str) -> bool { - descriptor(id).is_some_and(|descriptor| descriptor.can_exact_focus) + if id == X11_BACKEND { + x11::can_exact_focus() + } else { + descriptor(id).is_some_and(|descriptor| descriptor.can_exact_focus) + } } pub async fn list_windows() -> Result> { @@ -131,12 +146,30 @@ pub async fn list_windows() -> Result> { if let Some(windows) = usable_backend_windows(*backend, list_windows_for(*backend).await, &mut errors) { + if matches!(backend, BackendKind::GnomeIntrospect) { + let x11_result = if x11::can_exact_focus() { + Some(x11::list_windows_for_exact_focus().await) + } else { + None + }; + return Ok(prefer_exact_x11_windows(windows, x11_result, &mut errors)); + } return Ok(windows); } } Err(anyhow!(errors.join("; "))) } +fn prefer_exact_x11_windows( + introspect_windows: Vec, + x11_result: Option>>, + errors: &mut Vec, +) -> Vec { + x11_result + .and_then(|result| usable_backend_windows(BackendKind::X11, result, errors)) + .unwrap_or(introspect_windows) +} + fn usable_backend_windows( backend: BackendKind, result: Result>, @@ -159,11 +192,12 @@ async fn list_windows_for(backend: BackendKind) -> Result> { match backend { BackendKind::GnomeExtension => gnome::list_extension_windows().await, BackendKind::GnomeIntrospect => gnome::list_introspect_windows().await, - BackendKind::Cosmic => cosmic::list_windows(), + BackendKind::Cosmic => cosmic::list_windows().await, BackendKind::Kwin => kwin::list_windows().await, - BackendKind::Hyprland => hyprland::list_windows(), - BackendKind::Niri => niri::list_windows(), - BackendKind::I3 => i3::list_windows(), + BackendKind::Hyprland => hyprland::list_windows().await, + BackendKind::Niri => niri::list_windows().await, + BackendKind::I3 => i3::list_windows().await, + BackendKind::X11 => x11::list_windows().await, } } @@ -183,19 +217,63 @@ pub async fn activate_window(window: &WindowInfo) -> Result<()> { })?; gnome::focus_app(app_id).await } - COSMIC_WAYLAND_BACKEND => cosmic::activate_window(window.window_id), + COSMIC_WAYLAND_BACKEND => cosmic::activate_window(window.window_id).await, KWIN_BACKEND => kwin::activate_window(window.window_id).await, - HYPRLAND_BACKEND => hyprland::activate_window(window.window_id), - NIRI_BACKEND => niri::activate_window(window.window_id), - I3_BACKEND => i3::activate_window(window.window_id), + HYPRLAND_BACKEND => hyprland::activate_window(window.window_id).await, + NIRI_BACKEND => niri::activate_window(window.window_id).await, + I3_BACKEND => i3::activate_window(window.window_id).await, + X11_BACKEND => x11::activate_window(window.window_id).await, backend => Err(anyhow!( "Unsupported window backend for activation: {backend}" )), } } -pub fn focused_window_override() -> Option { - cosmic::focused_window().ok().flatten() +pub async fn focused_window_for_backend(backend: &str) -> Result> { + let windows = match backend { + GNOME_SHELL_EXTENSION_BACKEND => gnome::list_extension_windows().await?, + GNOME_SHELL_INTROSPECT_BACKEND => gnome::list_introspect_windows().await?, + COSMIC_WAYLAND_BACKEND => return cosmic::focused_window().await, + KWIN_BACKEND => kwin::list_windows().await?, + HYPRLAND_BACKEND => hyprland::list_windows().await?, + NIRI_BACKEND => niri::list_windows().await?, + I3_BACKEND => i3::list_windows().await?, + X11_BACKEND => x11::list_windows_for_exact_focus().await?, + backend => { + return Err(anyhow!( + "Unsupported window backend for focus query: {backend}" + )) + } + }; + Ok(windows.into_iter().find(|window| window.focused)) +} + +pub async fn move_window(window: &WindowInfo, x: i32, y: i32) -> Result { + match window.backend.as_str() { + GNOME_SHELL_EXTENSION_BACKEND => { + gnome::move_extension_window(window.window_id, x, y).await + } + X11_BACKEND => x11::move_window(window.window_id, x, y).await, + backend => Err(anyhow!( + "Window backend {backend} cannot move windows; move_window needs the Codex GNOME Shell extension or a generic X11/EWMH session." + )), + } +} + +pub async fn resize_window(window: &WindowInfo, width: i32, height: i32) -> Result { + match window.backend.as_str() { + GNOME_SHELL_EXTENSION_BACKEND => { + gnome::resize_extension_window(window.window_id, width, height).await + } + X11_BACKEND => x11::resize_window(window.window_id, width, height).await, + backend => Err(anyhow!( + "Window backend {backend} cannot resize windows; resize_window needs the Codex GNOME Shell extension or a generic X11/EWMH session." + )), + } +} + +pub async fn focused_window_override() -> Option { + cosmic::focused_window().await.ok().flatten() } pub fn probe_backends() -> Vec { @@ -207,6 +285,7 @@ pub fn probe_backends() -> Vec { hyprland::probe(), niri::probe(), i3::probe(), + x11::probe(), ] } @@ -220,6 +299,7 @@ impl BackendKind { BackendKind::Hyprland => HYPRLAND_BACKEND, BackendKind::Niri => NIRI_BACKEND, BackendKind::I3 => I3_BACKEND, + BackendKind::X11 => X11_BACKEND, } } @@ -277,6 +357,31 @@ mod tests { assert_eq!(errors, vec!["GNOME Shell Introspect returned no windows"]); } + #[test] + fn exact_x11_result_wins_over_gnome_list_only_result() { + let mut errors = Vec::new(); + let selected = prefer_exact_x11_windows( + vec![window(GNOME_SHELL_INTROSPECT_BACKEND)], + Some(Ok(vec![window(X11_BACKEND)])), + &mut errors, + ); + assert_eq!(selected[0].backend, X11_BACKEND); + assert!(errors.is_empty()); + } + + #[test] + fn gnome_list_only_result_survives_failed_x11_listing() { + let mut errors = Vec::new(); + let selected = prefer_exact_x11_windows( + vec![window(GNOME_SHELL_INTROSPECT_BACKEND)], + Some(Err(anyhow!("wmctrl failed"))), + &mut errors, + ); + + assert_eq!(selected[0].backend, GNOME_SHELL_INTROSPECT_BACKEND); + assert_eq!(errors, ["X11/EWMH failed: wmctrl failed"]); + } + #[test] fn records_backend_failures_with_registry_labels() { let mut errors = Vec::new(); diff --git a/computer-use-linux/src/windowing/target.rs b/computer-use-linux/src/windowing/target.rs index f8ed237be..fc0509e51 100644 --- a/computer-use-linux/src/windowing/target.rs +++ b/computer-use-linux/src/windowing/target.rs @@ -1,9 +1,11 @@ use crate::windowing::registry::{self, WINDOW_PERMISSION_HINT}; use crate::windowing::types::{WindowFocusResult, WindowInfo, WindowTarget}; use anyhow::{bail, Result}; -use tokio::time::{sleep, Duration}; +use std::future::Future; +use tokio::time::{sleep_until, timeout_at, Duration, Instant}; -const FOCUS_VERIFY_ATTEMPTS: usize = 6; +const FOCUS_VERIFY_TRANSITION_TIMEOUT: Duration = Duration::from_secs(1); +const FOCUS_VERIFY_QUERY_TIMEOUT: Duration = Duration::from_secs(5); const FOCUS_VERIFY_DELAY: Duration = Duration::from_millis(50); pub async fn list_windows() -> Result> { @@ -58,7 +60,7 @@ pub(crate) fn ensure_backend_can_focus_target( } async fn current_focused_window() -> Result> { - if let Some(window) = registry::focused_window_override() { + if let Some(window) = registry::focused_window_override().await { return Ok(Some(window)); } @@ -69,23 +71,53 @@ async fn current_focused_window() -> Result> { } async fn wait_for_focused_window(requested_window: &WindowInfo) -> Option { + wait_for_focused_window_with( + requested_window, + FOCUS_VERIFY_TRANSITION_TIMEOUT, + FOCUS_VERIFY_QUERY_TIMEOUT, + || registry::focused_window_for_backend(&requested_window.backend), + ) + .await +} + +async fn wait_for_focused_window_with( + requested_window: &WindowInfo, + transition_timeout: Duration, + query_timeout: Duration, + mut query: F, +) -> Option +where + F: FnMut() -> Fut, + Fut: Future>>, +{ + // A stale first query and a fresh second query may each consume the full + // backend budget (for example Hyprland runs two bounded commands). Keep the + // workspace-transition allowance separate from those query costs. + let deadline = Instant::now() + transition_timeout + query_timeout.saturating_mul(2); let mut last_focused_window = None; - for attempt in 0..FOCUS_VERIFY_ATTEMPTS { - if let Ok(focused_window) = current_focused_window().await { - if focused_window - .as_ref() - .is_some_and(|window| window.window_id == requested_window.window_id) - { - return focused_window; - } - if focused_window.is_some() { - last_focused_window = focused_window; + loop { + let query_deadline = (Instant::now() + query_timeout).min(deadline); + match timeout_at(query_deadline, query()).await { + Ok(Ok(focused_window)) => { + if focused_window + .as_ref() + .is_some_and(|window| window.window_id == requested_window.window_id) + { + return focused_window; + } + if focused_window.is_some() { + last_focused_window = focused_window; + } } + Ok(Err(_)) => {} + Err(_) => break, } - if attempt + 1 < FOCUS_VERIFY_ATTEMPTS { - sleep(FOCUS_VERIFY_DELAY).await; + let now = Instant::now(); + if now >= deadline { + break; } + sleep_until((now + FOCUS_VERIFY_DELAY).min(deadline)).await; } last_focused_window } @@ -371,3 +403,101 @@ fn same_optional_string(left: &Option, right: &Option) -> bool { _ => false, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn focus_verification_allows_workspace_transition_latency() { + assert!(FOCUS_VERIFY_TRANSITION_TIMEOUT >= Duration::from_secs(1)); + assert!(FOCUS_VERIFY_QUERY_TIMEOUT >= Duration::from_secs(4)); + } + + #[tokio::test] + async fn slow_focus_query_cannot_exceed_verification_deadline() { + let requested_window = WindowInfo { + window_id: 1, + title: None, + app_id: None, + wm_class: None, + pid: None, + bounds: None, + workspace: None, + focused: false, + hidden: false, + client_type: None, + backend: "test".to_string(), + terminal: None, + }; + let started = Instant::now(); + + let focused = wait_for_focused_window_with( + &requested_window, + Duration::from_millis(20), + Duration::from_millis(20), + || async { + tokio::time::sleep(Duration::from_secs(1)).await; + Ok::<_, anyhow::Error>(None) + }, + ) + .await; + + assert!(focused.is_none()); + assert!(started.elapsed() < Duration::from_millis(500)); + } + + #[tokio::test] + async fn delayed_fresh_query_can_verify_after_a_stale_result() { + let requested_window = WindowInfo { + window_id: 1, + title: None, + app_id: None, + wm_class: None, + pid: None, + bounds: None, + workspace: None, + focused: false, + hidden: false, + client_type: None, + backend: "test".to_string(), + terminal: None, + }; + let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + + let focused = wait_for_focused_window_with( + &requested_window, + Duration::from_millis(100), + Duration::from_millis(20), + || { + let attempts = std::sync::Arc::clone(&attempts); + async move { + tokio::time::sleep(Duration::from_millis(15)).await; + let window_id = + if attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 { + 2 + } else { + 1 + }; + Ok::<_, anyhow::Error>(Some(WindowInfo { + window_id, + title: None, + app_id: None, + wm_class: None, + pid: None, + bounds: None, + workspace: None, + focused: true, + hidden: false, + client_type: None, + backend: "test".to_string(), + terminal: None, + })) + } + }, + ) + .await; + + assert_eq!(focused.map(|window| window.window_id), Some(1)); + } +} diff --git a/computer-use-linux/src/ydotool.rs b/computer-use-linux/src/ydotool.rs index bb784159d..465f10bca 100644 --- a/computer-use-linux/src/ydotool.rs +++ b/computer-use-linux/src/ydotool.rs @@ -1,4 +1,18 @@ -use std::{process::Command, sync::OnceLock}; +use crate::command_runner; +use std::{ + env, + ffi::{CString, OsStr, OsString}, + fs, io, + os::unix::{ + ffi::{OsStrExt, OsStringExt}, + fs::{DirBuilderExt, MetadataExt, PermissionsExt}, + net::UnixDatagram, + }, + path::{Path, PathBuf}, + process::{self, Command, Stdio}, + sync::{Mutex, OnceLock}, + time::{Duration, Instant}, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum CliGeneration { @@ -6,30 +20,433 @@ pub(crate) enum CliGeneration { LegacyNamed, } -const UNSUPPORTED_MESSAGE: &str = "unsupported legacy ydotool CLI; Computer Use requires ydotool 1.0 or newer with raw key events and absolute mouse movement"; +const MAX_UNIX_SOCKET_PATH_BYTES: usize = 107; +const PROBE_DIRECTORY_ATTEMPTS: usize = 8; +const PROBE_COMMAND_TIMEOUT: Duration = Duration::from_secs(2); +const FAILED_PROBE_CACHE_TTL: Duration = Duration::from_secs(5); +const UNSUPPORTED_MESSAGE: &str = "unsupported ydotool CLI; Computer Use requires ydotool 1.0.3 or newer with raw key events, wheel movement, stdin typing, and absolute mouse movement"; -pub(crate) fn ensure_supported() -> Result { - static RESULT: OnceLock> = OnceLock::new(); - RESULT.get_or_init(probe).clone() +struct ProbeSocket { + _socket: UnixDatagram, + directory: PathBuf, + path: PathBuf, } -fn probe() -> Result { +#[derive(Debug, Clone, PartialEq, Eq)] +struct ExecutableFingerprint { + path: PathBuf, + device: u64, + inode: u64, + length: u64, + modified_seconds: i64, + modified_nanoseconds: i64, + changed_seconds: i64, + changed_nanoseconds: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SupportedYdotool { + pub(crate) executable: PathBuf, + pub(crate) detail: String, +} + +#[derive(Debug, PartialEq, Eq)] +enum ProbeCacheKey { + Executable(ExecutableFingerprint), + SearchPath { + path: OsString, + current_dir: Option, + }, +} + +struct CachedProbe { + key: K, + result: Result, + checked_at: Instant, +} + +impl ProbeSocket { + fn bind_with(runtime_dir: Option<&OsStr>, temp_dir: &Path) -> Result { + let uid = unsafe { libc::geteuid() }; + let mut failures = Vec::new(); + for base in probe_socket_bases(runtime_dir, temp_dir) { + if let Err(error) = validate_probe_base(&base, uid) { + failures.push(format!("{}: {error}", base.display())); + continue; + } + match Self::bind_in(&base) { + Ok(socket) => return Ok(socket), + Err(error) => failures.push(format!("{}: {error}", base.display())), + } + } + + let detail = if failures.is_empty() { + "no candidate runtime directory was available".to_string() + } else { + failures.join("; ") + }; + Err(format!( + "failed to bind isolated ydotool probe socket: {detail}" + )) + } + + fn bind_in(base: &Path) -> io::Result { + let sample_path = base.join(format!( + ".codex-ydotool-probe-{}-0000000000000000/s", + process::id() + )); + if !unix_socket_path_fits(&sample_path) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "probe socket path is too long", + )); + } + + for _ in 0..PROBE_DIRECTORY_ATTEMPTS { + let nonce = random_hex(8)?; + let directory = base.join(format!(".codex-ydotool-probe-{}-{nonce}", process::id())); + let path = directory.join("s"); + let mut builder = fs::DirBuilder::new(); + builder.mode(0o700); + match builder.create(&directory) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + if let Err(error) = fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)) { + let _ = fs::remove_dir(&directory); + return Err(error); + } + match UnixDatagram::bind(&path) { + Ok(socket) => { + return Ok(Self { + _socket: socket, + directory, + path, + }); + } + Err(error) => { + let _ = fs::remove_file(&path); + let _ = fs::remove_dir(&directory); + return Err(error); + } + } + } + + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "could not allocate a unique probe directory", + )) + } +} + +impl Drop for ProbeSocket { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + let _ = fs::remove_dir(&self.directory); + } +} + +pub(crate) fn ensure_supported() -> Result { + static SUPPORTED: OnceLock>>> = + OnceLock::new(); + let search_path = executable_search_path(); + let current_dir = env::current_dir().ok(); + let executable = resolve_executable_with("ydotool", &search_path, current_dir.as_deref()); + let key = executable + .as_deref() + .and_then(executable_fingerprint) + .map(ProbeCacheKey::Executable) + .unwrap_or_else(|| ProbeCacheKey::SearchPath { + path: search_path, + current_dir, + }); + cached_result_or_probe( + SUPPORTED.get_or_init(|| Mutex::new(None)), + key, + FAILED_PROBE_CACHE_TTL, + move || probe_executable(executable), + ) +} + +pub(crate) async fn ensure_supported_async() -> Result { + tokio::task::spawn_blocking(ensure_supported) + .await + .map_err(|error| format!("ydotool capability probe task failed: {error}"))? +} + +fn cached_result_or_probe( + cache: &Mutex>>, + key: K, + failure_ttl: Duration, + probe: impl FnOnce() -> Result, +) -> Result { + let Ok(mut cached) = cache.lock() else { + return probe(); + }; + if let Some(cached) = cached.as_ref() { + if cached.key == key && (cached.result.is_ok() || cached.checked_at.elapsed() < failure_ttl) + { + return cached.result.clone(); + } + } + let result = probe(); + *cached = Some(CachedProbe { + key, + result: result.clone(), + checked_at: Instant::now(), + }); + result +} + +fn executable_search_path() -> OsString { + env::var_os("PATH").unwrap_or_else(default_search_path) +} + +fn default_search_path() -> OsString { + let length = unsafe { libc::confstr(libc::_CS_PATH, std::ptr::null_mut(), 0) }; + if length == 0 { + return OsString::from("/bin:/usr/bin"); + } + let mut buffer = vec![0_u8; length]; + let written = unsafe { + libc::confstr( + libc::_CS_PATH, + buffer.as_mut_ptr().cast::(), + buffer.len(), + ) + }; + if written == 0 { + return OsString::from("/bin:/usr/bin"); + } + if buffer.last() == Some(&0) { + buffer.pop(); + } + OsString::from_vec(buffer) +} + +fn resolve_executable_with( + command: &str, + search_path: &OsStr, + current_dir: Option<&Path>, +) -> Option { + env::split_paths(search_path) + .filter_map(|directory| { + let directory = if directory.as_os_str().is_empty() { + current_dir?.to_path_buf() + } else if directory.is_absolute() { + directory + } else { + current_dir?.join(directory) + }; + Some(directory.join(command)) + }) + .find(|path| executable_by_effective_user(path)) +} + +fn executable_by_effective_user(path: &Path) -> bool { + if !path.metadata().is_ok_and(|metadata| metadata.is_file()) { + return false; + } + let Ok(path) = CString::new(path.as_os_str().as_bytes()) else { + return false; + }; + unsafe { libc::faccessat(libc::AT_FDCWD, path.as_ptr(), libc::X_OK, libc::AT_EACCESS) == 0 } +} + +fn executable_fingerprint(path: &Path) -> Option { + let metadata = path.metadata().ok()?; + Some(ExecutableFingerprint { + path: path.to_path_buf(), + device: metadata.dev(), + inode: metadata.ino(), + length: metadata.len(), + modified_seconds: metadata.mtime(), + modified_nanoseconds: metadata.mtime_nsec(), + changed_seconds: metadata.ctime(), + changed_nanoseconds: metadata.ctime_nsec(), + }) +} + +fn probe_executable(executable: Option) -> Result { + let executable = + executable.ok_or_else(|| "ydotool executable was not found in PATH".to_string())?; + let runtime_dir = env::var_os("XDG_RUNTIME_DIR"); + let detail = probe_with(&executable, runtime_dir.as_deref(), &env::temp_dir())?; + Ok(SupportedYdotool { executable, detail }) +} + +fn probe_with( + ydotool_path: &Path, + runtime_dir: Option<&OsStr>, + temp_dir: &Path, +) -> Result { let mut output_text = String::new(); for argument in ["help", "--help"] { - let output = Command::new("ydotool") - .arg(argument) - .output() - .map_err(|error| format!("failed to run ydotool: {error}"))?; + let mut command = Command::new(ydotool_path); + command.arg(argument); + let output = command_output_with_timeout(&mut command, "ydotool", PROBE_COMMAND_TIMEOUT)?; output_text.push_str(&String::from_utf8_lossy(&output.stdout)); output_text.push_str(&String::from_utf8_lossy(&output.stderr)); if let Some(generation) = classify_help(&output_text) { return match generation { - CliGeneration::RawEvents => Ok("compatible raw-event CLI detected".to_string()), + CliGeneration::RawEvents => { + probe_raw_semantics(ydotool_path, runtime_dir, temp_dir) + .map(|()| "compatible raw-event CLI detected".to_string()) + } CliGeneration::LegacyNamed => Err(UNSUPPORTED_MESSAGE.to_string()), }; } } - Err("unrecognized ydotool CLI; Computer Use requires ydotool 1.0 or newer".to_string()) + Err("unrecognized ydotool CLI; Computer Use requires ydotool 1.0.3 or newer".to_string()) +} + +fn probe_raw_semantics( + ydotool_path: &Path, + runtime_dir: Option<&OsStr>, + temp_dir: &Path, +) -> Result<(), String> { + let absolute = run_probe_command( + ydotool_path, + runtime_dir, + temp_dir, + &["mousemove", "--absolute", "--", "0", "0"], + None, + )?; + require_semantic_probe(&absolute)?; + let wheel = run_probe_command( + ydotool_path, + runtime_dir, + temp_dir, + &["mousemove", "--wheel", "--", "0", "0"], + None, + )?; + require_semantic_probe(&wheel)?; + let click = run_probe_command( + ydotool_path, + runtime_dir, + temp_dir, + &["click", "0xC0"], + None, + )?; + require_semantic_probe(&click)?; + let key = run_probe_command( + ydotool_path, + runtime_dir, + temp_dir, + &["key", "-d", "100", "1:1", "1:0"], + None, + )?; + require_semantic_probe(&key)?; + let type_from_stdin = run_probe_command( + ydotool_path, + runtime_dir, + temp_dir, + &["type", "--file", "-"], + Some(Path::new("/proc/self/fd")), + )?; + require_semantic_probe(&type_from_stdin) +} + +fn run_probe_command( + ydotool_path: &Path, + runtime_dir: Option<&OsStr>, + temp_dir: &Path, + args: &[&str], + current_dir: Option<&Path>, +) -> Result { + let socket = ProbeSocket::bind_with(runtime_dir, temp_dir)?; + let mut command = Command::new(ydotool_path); + command + .args(args) + .env("YDOTOOL_SOCKET", &socket.path) + // ydotool 1.0.2 swapped the YDOTOOL_SOCKET/XDG_RUNTIME_DIR branches. + // Removing XDG_RUNTIME_DIR makes that version fail closed instead of + // sending capability-probe events to the user's live daemon. + .env_remove("XDG_RUNTIME_DIR") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(current_dir) = current_dir { + command.current_dir(current_dir); + } + command_output_with_timeout( + &mut command, + "ydotool capability probe", + PROBE_COMMAND_TIMEOUT, + ) +} + +fn command_output_with_timeout( + command: &mut Command, + label: &str, + timeout_duration: Duration, +) -> Result { + command_runner::output_blocking_with_timeout(command, label, timeout_duration) + .map_err(|error| format!("{error:#}")) +} + +fn probe_socket_bases(runtime_dir: Option<&OsStr>, temp_dir: &Path) -> Vec { + let mut bases = Vec::new(); + if let Some(runtime_dir) = runtime_dir { + push_unique_path(&mut bases, PathBuf::from(runtime_dir)); + } + push_unique_path(&mut bases, temp_dir.to_path_buf()); + push_unique_path(&mut bases, PathBuf::from("/tmp")); + bases +} + +fn push_unique_path(paths: &mut Vec, path: PathBuf) { + if !paths.iter().any(|candidate| candidate == &path) { + paths.push(path); + } +} + +fn validate_probe_base(path: &Path, uid: libc::uid_t) -> Result<(), String> { + if !path.is_absolute() { + return Err("path is not absolute".to_string()); + } + let metadata = fs::symlink_metadata(path).map_err(|error| error.to_string())?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err("path is not a real directory".to_string()); + } + let mode = metadata.permissions().mode(); + let user_owned_safe_directory = metadata.uid() == uid && mode & 0o022 == 0; + let root_owned_sticky_directory = metadata.uid() == 0 && mode & libc::S_ISVTX != 0; + if !user_owned_safe_directory && !root_owned_sticky_directory { + return Err("directory is not private or root-owned sticky".to_string()); + } + Ok(()) +} + +fn random_hex(byte_count: usize) -> io::Result { + let mut bytes = vec![0_u8; byte_count]; + getrandom::fill(&mut bytes).map_err(io::Error::other)?; + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(byte_count * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + Ok(output) +} + +fn unix_socket_path_fits(path: &Path) -> bool { + path.as_os_str().as_bytes().len() <= MAX_UNIX_SOCKET_PATH_BYTES +} + +fn raw_semantic_probes_succeeded(results: &[(bool, &[u8])]) -> bool { + results + .iter() + .all(|(success, stderr)| *success && cli_error(stderr).is_none()) +} + +fn require_semantic_probe(output: &std::process::Output) -> Result<(), String> { + if raw_semantic_probes_succeeded(&[(output.status.success(), &output.stderr)]) { + Ok(()) + } else { + Err(UNSUPPORTED_MESSAGE.to_string()) + } } pub(crate) fn classify_help(help: &str) -> Option { @@ -38,7 +455,11 @@ pub(crate) fn classify_help(help: &str) -> Option { .map(str::trim) .filter(|line| !line.is_empty()) .collect::>(); - if commands.contains(&"debug") && commands.contains(&"stdin") { + let required_raw_commands = ["click", "mousemove", "type", "key", "debug"]; + if required_raw_commands + .iter() + .all(|command| commands.contains(command)) + { Some(CliGeneration::RawEvents) } else if commands.contains(&"recorder") { Some(CliGeneration::LegacyNamed) @@ -65,6 +486,87 @@ pub(crate) fn cli_error(stderr: &[u8]) -> Option { #[cfg(test)] mod tests { use super::*; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Barrier, + }; + use std::thread; + + struct TestDirectory(PathBuf); + + impl TestDirectory { + fn new(label: &str) -> Self { + let path = env::temp_dir().join(format!( + "codex-ydotool-{label}-{}-{}", + process::id(), + random_hex(8).expect("test nonce") + )); + let mut builder = fs::DirBuilder::new(); + builder.mode(0o700); + builder.create(&path).expect("create test directory"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)) + .expect("secure test directory"); + Self(path) + } + } + + impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn fake_supported_ydotool(root: &Path) -> PathBuf { + let path = root.join("ydotool"); + fs::write( + &path, + r#"#!/bin/sh +case "$1" in + help|--help) + printf '%s\n' \ + 'Usage: ydotool ' \ + 'Available commands:' \ + ' click' \ + ' mousemove' \ + ' type' \ + ' key' \ + ' debug' + exit 0 + ;; + *) + test -z "${XDG_RUNTIME_DIR+x}" || exit 65 + printf '%s\n' "$YDOTOOL_SOCKET" >> "${0%/*}/socket-paths" + ;; +esac +case "$1" in + mousemove) + test -S "$YDOTOOL_SOCKET" && + { test "$2" = '--wheel' || test "$2" = '--absolute'; } && + test "$3" = '--' && test "$4" = '0' && test "$5" = '0' + ;; + click) + test -S "$YDOTOOL_SOCKET" && test "$2" = '0xC0' + ;; + key) + test -S "$YDOTOOL_SOCKET" && test "$2" = '-d' && + test "$3" = '100' && test "$4" = '1:1' && test "$5" = '1:0' + ;; + type) + test -S "$YDOTOOL_SOCKET" && + test "$2" = '--file' && + test "$3" = '-' + ;; + *) + exit 64 + ;; +esac +"#, + ) + .expect("write fake ydotool"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)) + .expect("make fake ydotool executable"); + path + } #[test] fn classifies_legacy_named_cli_from_ubuntu_ydotool() { @@ -80,6 +582,243 @@ mod tests { assert_eq!(classify_help(help), Some(CliGeneration::RawEvents)); } + #[test] + fn classifies_arch_1_0_4_cli_as_raw_events() { + let help = "Usage: ydotool \nAvailable commands:\n click\n mousemove\n type\n key\n debug\n bakers\n"; + + assert_eq!(classify_help(help), Some(CliGeneration::RawEvents)); + } + + #[test] + fn accepts_supported_raw_cli_without_xdg_runtime_dir() { + let root = TestDirectory::new("no-xdg-cli"); + let ydotool = fake_supported_ydotool(&root.0); + + assert_eq!( + probe_with(&ydotool, None, &root.0), + Ok("compatible raw-event CLI detected".to_string()) + ); + assert_eq!( + fs::read_dir(&root.0) + .expect("read test directory") + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".codex-ydotool-probe-") + }) + .count(), + 0 + ); + let socket_paths = fs::read_to_string(root.0.join("socket-paths")) + .expect("read recorded probe socket paths") + .lines() + .map(str::to_string) + .collect::>(); + let mut unique_paths = socket_paths.clone(); + unique_paths.sort(); + unique_paths.dedup(); + assert_eq!(socket_paths.len(), 5); + assert_eq!(unique_paths.len(), 5); + } + + #[test] + fn capability_probe_commands_are_bounded() { + let mut command = Command::new("sh"); + command.args(["-c", "exec sleep 1"]); + + let started = Instant::now(); + let error = + command_output_with_timeout(&mut command, "test probe", Duration::from_millis(20)) + .unwrap_err(); + + assert!(error.contains("timed out")); + assert!(started.elapsed() < Duration::from_secs(1)); + } + + #[test] + fn capability_probe_kills_descendants_that_hold_output_pipes() { + let root = TestDirectory::new("probe-process-group"); + let pid_path = root.0.join("descendant-pid"); + let mut command = Command::new("sh"); + command.args([ + "-c", + &format!("sleep 60 & printf %s $! > '{}'; exit 0", pid_path.display()), + ]); + + let error = + command_output_with_timeout(&mut command, "test probe", Duration::from_millis(100)) + .unwrap_err(); + + assert!(error.contains("timed out")); + let pid = fs::read_to_string(&pid_path) + .expect("read descendant pid") + .parse::() + .expect("parse descendant pid"); + wait_for_process_exit(pid); + } + + #[test] + fn capability_probe_drains_large_output() { + let mut command = Command::new("sh"); + command.args([ + "-c", + "yes stdout | head -c 200000; yes stderr | head -c 200000 >&2", + ]); + + let output = + command_output_with_timeout(&mut command, "noisy probe", Duration::from_secs(5)) + .expect("noisy probe should complete"); + + assert!(output.status.success()); + assert!(output.stdout.len() >= 200_000); + assert!(output.stderr.len() >= 200_000); + } + + #[test] + fn capability_probe_continuous_output_remains_bounded() { + let mut command = Command::new("sh"); + command.args(["-c", "exec yes output"]); + let started = Instant::now(); + + let error = + command_output_with_timeout(&mut command, "continuous probe", Duration::from_secs(5)) + .unwrap_err(); + + assert!(error.contains("output exceeded")); + assert!(started.elapsed() < Duration::from_secs(1)); + } + + #[test] + fn capability_probe_forces_stdin_closed() { + let mut command = Command::new("sh"); + command.args(["-c", "read value && exit 7 || exit 0"]); + command.stdin(Stdio::piped()); + + let output = + command_output_with_timeout(&mut command, "stdin probe", Duration::from_millis(100)) + .expect("probe stdin should be closed"); + + assert!(output.status.success()); + } + + #[test] + fn executable_resolution_uses_one_absolute_path() { + let root = TestDirectory::new("resolve-executable"); + let first = root.0.join("first"); + let second = root.0.join("second"); + fs::create_dir_all(&first).expect("create first PATH directory"); + fs::create_dir_all(&second).expect("create second PATH directory"); + fs::write(first.join("ydotool"), "not executable").expect("write first candidate"); + fs::write(second.join("ydotool"), "#!/bin/sh\nexit 0\n").expect("write second candidate"); + fs::set_permissions(second.join("ydotool"), fs::Permissions::from_mode(0o700)) + .expect("make second candidate executable"); + let search_path = + env::join_paths([Path::new("first"), Path::new("second")]).expect("join relative PATH"); + + let resolved = resolve_executable_with("ydotool", &search_path, Some(&root.0)); + + assert_eq!(resolved, Some(second.join("ydotool"))); + } + + #[test] + fn failed_probe_is_temporarily_cached_and_success_is_persistent() { + let cache = Mutex::new(None); + let attempts = AtomicUsize::new(0); + let probe = || { + let attempt = attempts.fetch_add(1, Ordering::SeqCst); + if attempt == 0 { + Err("not installed".to_string()) + } else { + Ok("compatible".to_string()) + } + }; + + assert_eq!( + cached_result_or_probe(&cache, "first", Duration::from_secs(60), probe), + Err("not installed".to_string()) + ); + assert_eq!( + cached_result_or_probe(&cache, "first", Duration::from_secs(60), probe), + Err("not installed".to_string()) + ); + assert_eq!(attempts.load(Ordering::SeqCst), 1); + assert_eq!( + cached_result_or_probe(&cache, "first", Duration::ZERO, probe), + Ok("compatible".to_string()) + ); + assert_eq!( + cached_result_or_probe(&cache, "first", Duration::ZERO, probe), + Ok("compatible".to_string()) + ); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + + assert_eq!( + cached_result_or_probe(&cache, "replacement", Duration::ZERO, probe), + Ok("compatible".to_string()) + ); + assert_eq!(attempts.load(Ordering::SeqCst), 3); + } + + #[test] + fn concurrent_capability_probes_are_coalesced() { + let cache = Arc::new(Mutex::new(None)); + let attempts = Arc::new(AtomicUsize::new(0)); + let barrier = Arc::new(Barrier::new(3)); + let handles = (0..2) + .map(|_| { + let cache = Arc::clone(&cache); + let attempts = Arc::clone(&attempts); + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + barrier.wait(); + cached_result_or_probe(&cache, "same", Duration::from_secs(60), || { + attempts.fetch_add(1, Ordering::SeqCst); + thread::sleep(Duration::from_millis(30)); + Ok("compatible".to_string()) + }) + }) + }) + .collect::>(); + barrier.wait(); + + for handle in handles { + assert_eq!(handle.join().unwrap(), Ok("compatible".to_string())); + } + assert_eq!(attempts.load(Ordering::SeqCst), 1); + } + + #[test] + fn rejects_raw_cli_without_wheel_semantics() { + assert!(!raw_semantic_probes_succeeded(&[ + (true, b""), + (true, b"mousemove: unrecognized option '--wheel'\n"), + ])); + } + + #[test] + fn rejects_raw_cli_without_stdin_file_semantics() { + assert!(!raw_semantic_probes_succeeded(&[ + (true, b""), + ( + false, + b"ydotool: type: error: failed to open -: No such file or directory\n", + ), + ])); + } + + #[test] + fn accepts_raw_cli_with_required_semantics() { + assert!(raw_semantic_probes_succeeded(&[ + (true, b""), + (true, b""), + (true, b""), + (true, b""), + (true, b""), + ])); + } + #[test] fn rejects_unknown_cli_shape() { assert_eq!(classify_help("Usage: ydotool "), None); @@ -97,4 +836,17 @@ mod tests { fn ignores_non_error_stderr() { assert_eq!(cli_error(b"ydotoold socket ready\n"), None); } + + fn wait_for_process_exit(pid: u32) { + for _ in 0..100 { + if !Path::new(&format!("/proc/{pid}")).exists() { + return; + } + thread::sleep(Duration::from_millis(10)); + } + unsafe { + libc::kill(pid as i32, libc::SIGKILL); + } + panic!("probe descendant {pid} was not killed") + } } diff --git a/docs/architecture.md b/docs/architecture.md index 1ca5d6d99..c82516e8c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,8 +48,8 @@ implementation code lives under `scripts/patches/impl/` by domain; generic helpers live under `scripts/patches/lib/`. The deleted compatibility barrels are intentionally not part of the architecture. -`ciPolicy` is the single criticality axis, enforced by the patch engine — -patches themselves never abort the build: +`ciPolicy` is the ordinary descriptor criticality axis, enforced by the patch +engine — patches themselves never abort the build: - `required-upstream` (critical): the app does not launch or is core-unusable without it. If one fails (no match or a throw), the patcher exits non-zero @@ -60,6 +60,12 @@ patches themselves never abort the build: `optional patches not fully applied` summary so they can be fixed later. - `opt-in`: disabled unless explicitly enabled; recorded as `skipped-disabled`. +Integrity failures are the deliberate global exception to `ciPolicy`. A +descriptor that throws `PatchIntegrityError` is recorded as +`failed-integrity` and always fails candidate acceptance, even when the +descriptor is optional. This status means a transactional mutation failed and +could not prove that rollback restored the original bytes. + Every build writes a patch report (`/.codex-linux/patch-report.json`, next to `build-info.json`). `scripts/lib/patch-report.js` owns report statuses and failure predicates. CI validates the same report with diff --git a/docs/linux-computer-use.md b/docs/linux-computer-use.md index c424bcda9..abd8b8064 100644 --- a/docs/linux-computer-use.md +++ b/docs/linux-computer-use.md @@ -8,19 +8,20 @@ It supports: - app listing and accessibility trees through AT-SPI - screenshots through GNOME Shell DBus, the Codex GNOME Shell extension, or XDG Desktop Portal -- window listing and focusing on GNOME, KWin/Plasma, Hyprland, Niri, COSMIC, - and i3 +- window listing and focusing on GNOME, KWin/Plasma 5 and 6, Hyprland, Niri, + COSMIC, i3, and generic X11/EWMH window managers; GNOME extension and X11 + windows can also be moved and resized - keyboard, text, click, scroll, and drag input through `/dev/uinput`, XDG - RemoteDesktop portal, or `ydotool` + RemoteDesktop portal, `xdotool` on X11, or `ydotool` - pointer-direction feedback for the built-in V2 pet after successful click, scroll, and drag actions ## Runtime Dependencies -Install `ydotool` 1.0 or newer when you need the fallback input path. Some -Debian and Ubuntu releases still package the incompatible pre-1.0 CLI; the -Computer Use readiness report detects and rejects it instead of sending unsafe -input commands. +Install `ydotool` 1.0.3 or newer when you need the fallback input path. The +backend probes the exact absolute move, wheel move, click, delayed key, and +stdin typing command shapes it emits. Earlier or incompatible CLIs are rejected +even if `ydotoold` and its socket are present. ```bash # Debian / Ubuntu @@ -49,6 +50,17 @@ sudo usermod -a -G input "$USER" Then log out and back in. +On X11, install `xdotool` for layout-correct XTEST keyboard/text input and +coordinate clicks, and `wmctrl` plus `xprop` for generic EWMH window listing, +focus, move, and resize. `xdotool` is preferred only with a nonempty `DISPLAY`; +ydotool is used when it cannot be launched. Once xdotool starts, a failure or +timeout is returned and input is never replayed through ydotool. Override +keyboard selection with `COMPUTER_USE_LINUX_FORCE_YDOTOOL_KEYBOARD=1` or +`CODEX_COMPUTER_USE_FORCE_YDOTOOL_KEYBOARD=1`; the corresponding +`*_FORCE_XDOTOOL_KEYBOARD=1` names force XTEST when available. Set +`COMPUTER_USE_LINUX_FORCE_YDOTOOL_POINTER=1` or +`CODEX_COMPUTER_USE_FORCE_YDOTOOL_POINTER=1` to skip native-X11 xdotool clicks. + Some distros name the unit `ydotool.service` instead of `ydotoold.service`, and some install `/usr/bin/ydotoold` without a service unit. If the system unit path is awkward, a user-session service that binds `%t/.ydotool_socket` is also @@ -67,6 +79,10 @@ session's `NIRI_SOCKET`. The Computer Use backend hydrates `NIRI_SOCKET` for GUI starts, but the socket must still belong to the active Niri session and be reachable by the desktop user. +The optional `x11-ewmh-computer-use` Linux feature remains available as a +separate, alternative namespaced tool surface. It is not required for the core +backend's generic X11/EWMH support. + ## Verify Readiness Once Computer Use is visible in the Codex UI, ask Codex: diff --git a/docs/linux-features-architecture.md b/docs/linux-features-architecture.md index e891a0bf3..aecb36f00 100644 --- a/docs/linux-features-architecture.md +++ b/docs/linux-features-architecture.md @@ -110,6 +110,16 @@ Supported patch phases are `main-bundle`, `extracted-app:pre-webview`, `webview-asset`, and `extracted-app:post-webview`; `order` is sorted only inside each phase. +When a feature extends a completed core transform in the same asset, its +descriptor may declare `composesPatches: ["linux-core-patch-id"]`. The runner +uses this metadata to authorize one active feature descriptor to replace the +core completion marker with a delegated marker. The owner must be an existing +core descriptor in the same phase. Descriptors excluded by `appliesTo` or +`enabled` do not participate, and multiple active delegates for one owner are +rejected. The feature descriptor must run after an active owner. Core still +owns its generic completion seam; the feature owns and validates the complete +composed result. + Use `requires` and `conflicts` to declare feature relationships: ```json diff --git a/docs/nix.md b/docs/nix.md index bc165d385..bb688067f 100644 --- a/docs/nix.md +++ b/docs/nix.md @@ -205,6 +205,7 @@ The Home Manager and NixOS modules accept these feature IDs through | Feature ID | Purpose | | --- | --- | | `appshots` | Linux AppShots capture integration | +| `codex-micro` | Work Louder Codex Micro USB and Bluetooth HID integration | | `directory-only-working-tree-watch` | Bounded directory-only working-tree watches | | `frameless-titlebar` | Hide app-provided titlebar controls for compositor-managed decorations | | `mcp-helper-reaper` | Cleanup for stale configured MCP helper processes | @@ -218,6 +219,11 @@ equivalent configurations produce the same derivation. Features that are not in this Nix allowlist remain available through the regular opt-in feature flow but cannot be selected from a pure flake configuration. +When `codex-micro` is selected, the NixOS module installs the feature's udev +rule automatically. Home Manager and direct flake installs cannot change +system-wide udev policy; follow the manual rule-installation steps in the +[Codex Micro feature documentation](../linux-features/codex-micro/README.md). + ## Home Manager / NixOS Module For a declarative install with the mobile remote-control app-server managed by diff --git a/docs/raspberry-pi-5.md b/docs/raspberry-pi-5.md new file mode 100644 index 000000000..34429f4c8 --- /dev/null +++ b/docs/raspberry-pi-5.md @@ -0,0 +1,168 @@ +# Raspberry Pi 5 + +The core ChatGPT Desktop for Linux build has been validated on a 16 GB +Raspberry Pi 5. The existing upstream ARM64 support built and ran without a +Pi-specific source patch. + +This page records a field test, not a separate Raspberry Pi port. ARM64 support +comes from the work already maintained in this repository by +[@ilysenko](https://github.com/ilysenko) and its contributors. + +## Validated environment + +The successful test used: + +- Raspberry Pi 5 with 16 GB RAM +- 64-bit Debian 13 (trixie), `aarch64` +- NVMe storage +- LightDM with the Raspberry Pi Labwc Wayland desktop +- 1920x1080 display output +- repository version `0.10.4` at commit `ab314923b5bf` +- upstream ChatGPT app version `26.727.51351` +- Electron `42.3.0` + +The native build acceptance verdict was `accepted`, with no blockers or +warnings. The generated Debian package reported `Architecture: arm64`, and the +Electron executable, native Node modules, Linux helpers, and Codex CLI platform +binary were verified as AArch64 executables. + +## Build and install + +Use a 64-bit operating system. Active cooling and SSD or NVMe storage are +recommended for the native build. + +The repository's normal Debian-family setup path should be used: + +```bash +git clone https://github.com/ilysenko/codex-desktop-linux.git +cd codex-desktop-linux +PACKAGE_WITH_UPDATER=0 MAX_BUILD_THREADS=4 make bootstrap-native +``` + +`PACKAGE_WITH_UPDATER=0` keeps the first Pi installation simple by omitting the +automatic rebuild service. After the baseline is stable, it can be evaluated +separately. Limiting build parallelism to four jobs is a conservative starting +point for Pi thermals and responsiveness. + +The tested run performed the same stages separately: + +```bash +bash scripts/install-deps.sh +PACKAGE_WITH_UPDATER=0 MAX_BUILD_THREADS=4 make build-app-fresh +PACKAGE_WITH_UPDATER=0 MAX_BUILD_THREADS=4 make deb +``` + +Install the generated package from `dist/` with the normal Debian package +manager. Do not download or redistribute someone else's generated package: +this project intentionally performs the conversion locally from the official +upstream application. + +## Desktop setup + +The application needs a graphical desktop session. A Pi configured for +console-only boot must have its existing display manager enabled before the +desktop launcher can be tested. The validated system used LightDM automatic +login with the Raspberry Pi Labwc session. + +After installation, start **ChatGPT** from the desktop menu. The first launch +may install or update the Codex CLI. If manual setup is needed, include the +optional platform dependency: + +```bash +npm install -g --include=optional --prefix ~/.local @openai/codex +``` + +## Validation results + +The following checks passed on the test Pi: + +- clean ARM64 app build and native module rebuild +- native `arm64` Debian package creation and installation +- graphical reboot into the Labwc Wayland desktop +- application launch from the live desktop session +- correctly rendered ChatGPT sign-in window +- account sign-in +- Codex app-server startup using the ARM64 Codex CLI +- workspace file creation and editing +- integrated command execution +- Python, SQLite, automated test, and local Git workflows +- a Chromium publishing workflow using Linux Computer Use for screen capture, + accessible element discovery, and global pointer and keyboard input, with + external manual `wlrctl` shell commands for Labwc window listing and focus + +## Optional capability results + +A combined Chromium workflow was validated on the Labwc Wayland session after +the desktop-control dependencies were completed. Initially, screenshots worked, +but accessibility discovery, pointer input, and keyboard input were incomplete. +Labwc is not currently a supported window-control backend, so window listing +and focus were supplied separately through manual `wlrctl` shell commands. + +The successful Pi configuration added: + +- `at-spi2-core` and toolkit accessibility for AT-SPI element discovery +- external manual use of `wlrctl` for window listing and focus through Labwc's + wlroots foreign-toplevel interface; the Computer Use backend did not invoke + these commands +- an ARM64 build of `ydotool` 1.0.3 or newer and an enabled per-user + `ydotoold.service` +- membership of the desktop user in the `input` group + +A scoped udev rule granted the `input` group read/write access to +`/dev/uinput`: + +```udev +KERNEL=="uinput", GROUP="input", MODE="0660" +``` + +Debian 13 did not offer a `ydotool` package on the validated image, so +`ydotool` and `ydotoold` were built for ARM64 and installed under +`/usr/local/bin`. The daemon exposed its socket at +`$XDG_RUNTIME_DIR/.ydotool_socket`. See [Linux Computer Use](linux-computer-use.md) +for the general dependency, daemon, UI opt-in, and supported-backend readiness +instructions. + +The final test combined Linux Computer Use screen capture, AT-SPI inspection, +and global pointer and keyboard input with external `wlrctl` shell focus to +open an external user-owned web application, complete a content form, publish +a persistent test item, and read back its public URL. One initial keyboard +attempt reached the wrong window; the manual focus step was added before the +successful retry. + +The public item verifies that the combined workflow published and persisted the +result. It does not establish that the backend's built-in `list_windows`, +`focused_window`, or targeted-input verification supported Labwc. Treat those +window-control capabilities as unavailable on Labwc until a dedicated backend +is implemented. + +Granting access to `/dev/uinput` and running `ydotoold` allows synthetic input. +Limit access to trusted local users, keep the device rule group-scoped, and do +not use a world-writable device mode. + +One known architecture-specific gap remains: the repository's Browser Use +`node_repl` fallback resource is currently x86-64-only when no compatible +upstream or user-supplied ARM64 binary is available. The Browser and Chrome +plugins were enabled and discoverable during this test, but the demonstrated +workflow used Chromium through Linux Computer Use. Treat Browser Use as a +separate capability until its execution path is independently validated on +ARM64. + +## Remaining validation + +Long-running thermal behavior, peak memory use, and the automatic update +manager were not measured during this first validation. + +## Reporting Pi issues + +Include the following when reporting a Raspberry Pi problem: + +- Pi model and RAM size +- operating system and architecture from `uname -m` +- desktop environment and X11 or Wayland session type +- repository commit and upstream app version +- exact build command +- package format +- relevant output from `~/.cache/codex-desktop/launcher.log` + +Keep generated applications and packages out of pull requests. Documentation, +diagnostics, tests, and fixes should target the repository sources. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ad7954bfe..564a19980 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -29,8 +29,10 @@ | `Atomic directory exchange is unsupported` | Keep the candidate and final app as sibling directories on a local Linux filesystem that supports `renameat2(RENAME_EXCHANGE)`; promotion deliberately does not use a non-atomic fallback | | Interrupted install left a promotion journal | Run the installer again. It recovers the previous app into the recorded backup before reusing the candidate path; the canonical app remains available throughout | | Computer Use plugin invisible in UI | Enable the Computer Use UI opt-in; upstream server/account rollout can still hide some controls | -| Computer Use `doctor` reports no input backend | Grant `/dev/uinput`, enable XDG RemoteDesktop portal, or start `ydotoold` / `ydotool.service` | +| Computer Use `doctor` reports no input backend | On X11, install `xdotool` and confirm `DISPLAY` is nonempty. Otherwise grant `/dev/uinput`, enable the XDG RemoteDesktop portal, or install ydotool 1.0.3+ and start `ydotoold` / `ydotool.service` with a connectable socket | | Computer Use `doctor` reports `ydotool_socket: Permission denied` | Adjust the daemon socket so users in the `input` group can use it | +| Computer Use reports an unsupported ydotool CLI even though `ydotoold` is running | Upgrade to ydotool 1.0.3+ with absolute/wheel mousemove, click, `key -d 100`, and `type --file -` semantics; socket presence alone is intentionally insufficient | +| Computer Use cannot list or focus windows on a generic X11 desktop | Install `wmctrl` and `xprop`, confirm `XDG_SESSION_TYPE=x11` (or no Wayland display) and a nonempty `DISPLAY`, then rerun `codex-computer-use-linux doctor` | | `ConnectTimeoutError` for Electron headers | Re-run `make build-app`; the installer uses `https://artifacts.electronjs.org/headers/dist` by default | | Computer Use AT-SPI tree empty | Run `codex-computer-use-linux setup`, then restart the target app | | `ERR_NO_SUPPORTED_PROXIES` with an authenticated proxy | Do not pass credentials inside Chromium's `--proxy-server` URL; enable the optional `authenticated-proxy` Linux feature | diff --git a/docs/updater.md b/docs/updater.md index dad86a9fa..396b547e2 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -8,6 +8,8 @@ It: - checks upstream `Codex.dmg` on daemon startup, every 6 hours, and in the background on app launch when stale - rebuilds a local native package with `/opt/codex-desktop/update-builder` + after detection; users who enable the opt-in deferred-build feature can + instead wait for an explicit update check - waits for Electron to exit before installing a ready update - runs unprivileged; the final package install uses `pkexec` when a graphical polkit authentication agent is available, or keeps the package ready and @@ -22,6 +24,59 @@ installer instead of being replaced through npm. Homebrew/Linuxbrew installs are reused and reported, but the updater does not replace them with an npm-managed install. +If an interrupted npm upgrade leaves a stale Arborist retirement directory, +automatic daemon, status, and launcher paths record the exact condition but do +not remove it or retry npm. A functional existing Codex CLI remains selected, +and updater status directs the user to the read-only diagnostic command: + +```bash +codex-update-manager diagnose +``` + +The diagnostic output explains the stale npm condition and prints the explicit +repair command: + +```bash +codex-update-manager repair-cli +``` + +`repair-cli` acquires the shared CLI install lock, reloads the dedicated repair +journal, derives and revalidates the managed npm paths, and records each planned +quarantine before moving the stale directory. It then retries npm once with a +bounded subprocess. Quarantines are preserved and reported after both +successful and failed repairs, including when npm recreates the same retirement +directory during a later explicit retry. A failed or interrupted repair remains +visible in later `diagnose` output and can be retried explicitly. + +Mutating npm commands run under an internal bounded supervisor that retains the +CLI install lock while preventing npm and its descendants from inheriting the +lock descriptor. The supervisor and npm share one dedicated process group; the +supervisor terminates remaining npm members before it exits, and the updater +keeps the supervisor unreaped while applying the same cleanup if the supervisor +itself fails. If the updater parent exits abruptly, the supervisor cleans the +group before releasing the lock. Its own timeout remains active independently +of the updater parent. When an entrypoint first encounters contention, its PID +is recorded in the updater log. + +CLI maintenance and the updater lifecycle merge their separately owned fields +under a shared state lock. Concurrent daemon, status, and launcher processes +therefore cannot overwrite a newer CLI result with an older full-state +snapshot. Before routine CLI state writes, the process acquires the CLI install +lock and reloads the repair journal so a late registry result cannot hide a +newer actionable repair condition. The final state write also compares the CLI +fields with the caller's original snapshot; if another CLI writer completed +while the caller was waiting, the caller reloads that result instead of +overwriting it. A pending journal overrides only CLI status and error text on +top of the latest persisted CLI identity. Operations that need both locks +acquire the CLI install lock first and hold the state lock only for the final +reload, comparison, merge, and atomic write. + +Missing-CLI launcher preflight acquires the install lock before changing state +or consulting the npm registry. After contention, it reloads the latest +CLI-owned state and re-resolves both the requested and persisted CLI paths. If +another entrypoint completed installation or repair while it waited, preflight +uses that CLI without a second registry lookup or install attempt. + The updater scopes permission hardening to the official standalone installer process. New managed releases use the caller's existing umask plus the group/world write restrictions from `0022`; stricter policies such as `0027` @@ -113,12 +168,45 @@ Runtime files: ```text ~/.config/codex-update-manager/config.toml ~/.local/state/codex-update-manager/state.json +~/.local/state/codex-update-manager/state.lock +~/.local/state/codex-update-manager/cli-install.lock +~/.local/state/codex-update-manager/cli-repair.json ~/.local/state/codex-update-manager/service.log ~/.cache/codex-update-manager/ ~/.cache/codex-desktop/launcher.log ~/.local/state/codex-desktop/app.pid ``` +## Update Preferences + +Core Linux updater behavior builds detected updates automatically. The Linux +desktop settings page always exposes **Install updates when you close +ChatGPT**, which controls only installation after a package has been built. +When off, a ready package waits for the user to choose **Update**. + +The disabled-by-default `deferred-update-build` Linux feature adds a separate +**Build updates automatically** toggle. When off, background checks detect, +download, and notify about the newest upstream DMG without starting the local +package build. Choosing **Check for updates** revalidates upstream and builds +the current DMG. If upstream replaced the candidate or the cached file was +removed, that same check downloads the current DMG before building it. +Fresh app-launch checks keep a deferred candidate without an upstream DMG +request. Once the normal check interval expires, the updater uses HEAD to confirm +its identity and reuses the cached DMG without downloading it again; an offline +background check leaves the deferred candidate pending. + +Detection still downloads the DMG because its content hash is the updater's +authoritative release identity. Disabling automatic builds avoids Electron, +native-module, and package rebuild work; it does not turn update checks into a +metadata-only request. Disabling the feature itself immediately restores core +automatic-build behavior, including for a previously deferred candidate. + +Deferred candidates keep the existing serialized `update_detected` status and +add an optional `deferred_build` marker. Updater 0.10.x ignores the marker and +continues its earlier automatic-build behavior if it reads state written by +0.11.x. Prerelease state that used `update_available` is accepted and rewritten +as `update_detected`. + ## Generated Artifact Cleanup The updater always prunes unreferenced updater workspaces under diff --git a/docs/upstream-dmg-intelligence.md b/docs/upstream-dmg-intelligence.md index 2bdd04cb2..9a95223c5 100644 --- a/docs/upstream-dmg-intelligence.md +++ b/docs/upstream-dmg-intelligence.md @@ -65,8 +65,9 @@ make inspect-upstream-intel-devcontainer DMG=/path/to/new/Codex.dmg When `dist-next/rebuild/patch-report.json` exists, `make inspect-upstream-intel` folds it into the drift report. Required patch failures are classified as -blocking `PATCH_BROKEN`; optional skipped or warning statuses are classified as -review-only `PATCH_REVIEW`. +blocking `PATCH_BROKEN`. A `failed-integrity` status is classified separately +as blocking `PATCH_INTEGRITY_BROKEN`, regardless of descriptor policy. Optional +skipped or warning statuses are classified as review-only `PATCH_REVIEW`. ## Outputs @@ -158,6 +159,9 @@ review item before accepting the upstream DMG. baseline. - `PATCH_BROKEN`: a required patch-report failure matched this protected surface. +- `PATCH_INTEGRITY_BROKEN`: a transactional patch failure could not prove that + rollback restored the original bytes. Reject the candidate and rebuild from + the fresh current DMG after diagnosing the mutation or rollback failure. - `PATCH_REVIEW`: an optional patch-report warning or skip matched this protected surface. - `LINUX_SUBSTRATE_GAP`: upstream evidence exists, but the registry's required @@ -189,7 +193,7 @@ navigation layer: - `PAYLOAD_CHANGED` means a stable protected file changed hash or size. Review the listed file samples and run the owning Linux feature or backend tests. - `REMOVED`, `PROTECTED_SURFACE_MISSING`, `PROTECTED_SURFACE_PARTIAL`, - `PATCH_BROKEN`, and `LINUX_SUBSTRATE_GAP` are acceptance blockers until the - registry, patch, or Linux substrate action is resolved. `PATCH_REVIEW` remains - review-only unless the protected surface is also missing, partial, removed, or - has a required patch failure. + `PATCH_BROKEN`, `PATCH_INTEGRITY_BROKEN`, and `LINUX_SUBSTRATE_GAP` are + acceptance blockers until the registry, patch, integrity, or Linux substrate + action is resolved. `PATCH_REVIEW` remains review-only unless the protected + surface is also missing, partial, removed, or has a required patch failure. diff --git a/docs/windowless-warm-start-fix-report.md b/docs/windowless-warm-start-fix-report.md new file mode 100644 index 000000000..edb85bc94 --- /dev/null +++ b/docs/windowless-warm-start-fix-report.md @@ -0,0 +1,128 @@ +# Windowless Warm-Start Fix + +## Status + +The Linux explicit-quit path now guarantees bounded cleanup followed by process +exit. A later desktop launch no longer attaches to a surviving Electron process +that has no window or launch-action socket. + +The change is confined to the existing main-process lifecycle patch and its +regression coverage. Launcher handoff behavior is unchanged. + +## Failure + +Choosing **Quit** from the application menu could remove the last window and +partially dispose the main-process application context without terminating the +Electron process. A later launch then lost the Electron single-instance lock to +that process. The surviving process could not present a window because the +services needed by its launch handler had already been disposed. + +The warm-start handoff exposed the failure but did not cause it. Reopening a +window or extending the launch-action socket lifetime after teardown begins +would route work into a partially destroyed application context. + +## Root Cause + +The current upstream main bundle has one targeted lifecycle `will-quit` handler +with two cleanup branches: + +- a reduced branch flushes global state, stops Codex Micro, and flushes tracing; +- a full branch additionally flushes settings. + +Both branches call `preventDefault()`, run lifecycle disposers, wait with +`Promise.allSettled()`, dispose the application context and shared disposable +collection, and then call `app.quit()` again. + +That sequence was not total: + +- a synchronous lifecycle-disposer or drain-setup exception could occur before + the promise continuation was installed; +- a stalled drain had no deadline; +- a context or shared-disposable failure could skip the terminal action; +- rejected `Promise.allSettled()` members were not observable; +- the final `app.quit()` could re-enter Electron before the first quit attempt + had finished unwinding. + +Electron 42.3.0 sets `is_quitting_` before notifying `will-quit`. When the event +is prevented, Electron clears the flag only after the observer returns. Its +event bridge performs a microtask checkpoint while returning from JavaScript, +so a synchronously settled cleanup continuation can call `app.quit()` while the +flag is still set; `Browser::Quit()` then returns immediately. This is an +ordering race, not a claim that preventing `will-quit` leaves the flag set +permanently. + +Relevant upstream behavior: + +- [`Browser::Quit()` and `Browser::NotifyAndShutdown()`](https://github.com/electron/electron/blob/v42.3.0/shell/browser/browser.cc) +- [event-emission microtask scope](https://github.com/electron/electron/blob/v42.3.0/shell/common/gin_helper/event_emitter_caller.cc) +- [`app.exit()` lifecycle contract](https://www.electronjs.org/docs/latest/api/app#appexitexitcode) + +## Fix + +`applyLinuxWillQuitDrainTimeoutPatch()` semantically locates the single current +upstream handler. It verifies that both drain branches share the expected +event, lifecycle managers, drain functions, and finalizer before changing the +bundle. + +On Linux, both branches now pass a cleanup factory to one bounded helper: + +1. `Promise.resolve().then(factory)` contains synchronous disposer and drain + setup exceptions. +2. `Promise.race()` limits the complete drain and context-disposal sequence to + three seconds. +3. Rejected `Promise.allSettled()` results and deadline expiry are logged. +4. Context disposal runs inside the same three-second deadline, so the upstream + timeout cannot extend the lifetime of an already windowless process. +5. Shared-disposable failure is logged and cannot suppress `app.exit(0)`. + +`app.exit(0)` is deliberate. The patched path has already run the available +bounded cleanup, and Electron documents `app.exit()` as bypassing +`before-quit` and `will-quit`. It therefore terminates without re-entering the +graceful-quit sequence that produced the windowless survivor. + +Non-Linux behavior remains on the upstream cleanup and `app.quit()` path. + +The helper has no separate once-only state. A single `Promise.race()` +continuation invokes the Linux finalizer, and a promise settles only once. + +## Drift And Idempotence + +This repository supports only the latest upstream DMG. The patch does not retain +the obsolete lifecycle matcher. + +An unchanged source is considered already patched only when the generated +markers and the scoped Linux `app.exit(0)` postcondition are present. Otherwise +the current handler must resolve to exactly one semantic target. Zero or +multiple targets emit a warning; because this descriptor is +`required-upstream`, the patch report records `failed-required` and candidate +promotion is rejected. + +This prevents an unrelated `app.exit(0)`, a previous non-exiting finalizer, or +an upstream anchor rename from being mistaken for a successful application. + +## Validation + +Automated regression coverage exercises: + +- both current upstream drain branches; +- synchronous lifecycle-disposer and drain-setup failures; +- asynchronous drain rejection and deadline expiry; +- context-disposal stalls and failures, plus shared-disposable failures; +- Linux forced exit and unchanged non-Linux graceful quit; +- late drain settlement after the deadline; +- exact idempotence and scoped postcondition detection; +- missing, renamed, malformed, and ambiguous current-upstream targets. + +The complete patcher suite, script smoke suite, syntax checks, and +`git diff --check` must pass on the final source. + +The manual exit-path gate used a packaged Arch Linux build under +Hyprland/Wayland. Ten consecutive top-bar **Quit** and relaunch cycles each +reached zero primary processes, zero packaged helper processes, no +launch-action socket, and no compositor window before the next launch. An +eleventh launch opened a healthy window after the tenth exit. + +The manual A/B witness also distinguished the terminal operation: the +cleanup-factory build ending in `app.quit()` left the primary process alive and +windowless, while the otherwise equivalent `app.exit(0)` build completed all +ten cycles. diff --git a/flake.nix b/flake.nix index 9efcd7e56..674df3393 100644 --- a/flake.nix +++ b/flake.nix @@ -94,10 +94,10 @@ codexDmg = pkgs.fetchurl { url = "https://persistent.oaistatic.com/codex-app-prod/ChatGPT.dmg"; - hash = "sha256-roZOLe99tW0Lt3qHaly+TkwvVUzMZUzskhuUaJJYPAo="; + hash = "sha256-+KWnSss4qrSlmsCtf+87tLtPKAp+0l88t+9hRd9eh0c="; }; - codexVersion = "26.721.41059"; + codexVersion = "26.803.41515"; electronVersion = "42.3.0"; electronPlatform = { @@ -121,6 +121,12 @@ hash = "sha256-ghAJ+cGDAFDYlK755hkGywpTeyAAstm77ZmF//HV4NA="; }; + codexMicroNodeHidArchive = pkgs.fetchurl { + name = "node-hid-3.3.0.tgz"; + url = "https://registry.npmjs.org/node-hid/-/node-hid-3.3.0.tgz"; + hash = "sha512-j+dFgJLRAE0nufQKXk3IfS6T6YuHhCgMvz4TrG0sgtb6DSCdYpfJ1etcdmeCmPQjUgO+yo32ktVrRliNs/+fmg=="; + }; + browserUseNodeReplRuntime = pkgs.fetchurl { url = "https://persistent.oaistatic.com/codex-primary-runtime/26.426.12240/codex-primary-runtime-linux-x64-26.426.12240.tar.xz"; hash = "sha256-21Yk6276NrZuxvbdBIjO+5ZuSWNoYqq2IJpDNsHKkMQ="; @@ -321,6 +327,7 @@ mesa libgbm alsa-lib + pipewire libX11 libXcomposite libXdamage @@ -345,6 +352,12 @@ stdenv.cc.cc.lib zlib ]); + codexMicroRuntimeLibPath = pkgs.lib.makeLibraryPath (with pkgs; [ + systemd + libusb1 + stdenv.cc.cc.lib + glibc + ]); gsettingsSchemaPackages = with pkgs; [ gsettings-desktop-schemas gtk3 @@ -511,6 +524,7 @@ PY else normalizeLinuxFeaturesConfig linuxFeaturesConfigOverride; effectiveLinuxFeatureIds = effectiveLinuxFeaturesConfig.enabled; + codexMicroEnabled = builtins.elem "codex-micro" effectiveLinuxFeatureIds; in pkgs.stdenv.mkDerivation { pname = "codex-desktop${packageSuffix { inherit enableComputerUseUi; linuxFeatureIds = effectiveLinuxFeatureIds; }}-payload"; @@ -563,6 +577,9 @@ PY export CODEX_LINUX_FEATURES_CONFIG="${linuxFeaturesConfigFile effectiveLinuxFeaturesConfig}" export CODEX_ELECTRON_ZIP_SOURCE="${electronZip}" export CODEX_NATIVE_MODULES_SOURCE="${codexNativeModules}" + ${pkgs.lib.optionalString codexMicroEnabled '' + export CODEX_MICRO_NODE_HID_ARCHIVE="${codexMicroNodeHidArchive}" + ''} ${pkgs.lib.optionalString (browserUseNodeRepl != null) '' export CODEX_LINUX_NODE_REPL_SOURCE="${browserUseNodeRepl}/bin/node_repl" ''} @@ -611,6 +628,7 @@ PY else normalizeLinuxFeaturesConfig linuxFeaturesConfigOverride; normalizedLinuxFeatureIds = effectiveLinuxFeaturesConfig.enabled; + codexMicroEnabled = builtins.elem "codex-micro" normalizedLinuxFeatureIds; featureArgs = { inherit enableComputerUseUi; linuxFeatureIds = normalizedLinuxFeatureIds; @@ -658,6 +676,31 @@ PY --unpack "{*.node,*.so,*.dylib}" rm -rf "$resources_dir/app-extracted" + ${pkgs.lib.optionalString codexMicroEnabled '' + codex_micro_node_count=0 + while IFS= read -r codex_micro_node; do + codex_micro_node_count=$((codex_micro_node_count + 1)) + patchelf --set-rpath "${codexMicroRuntimeLibPath}" "$codex_micro_node" + actual_rpath="$(patchelf --print-rpath "$codex_micro_node")" + if [ "$actual_rpath" != "${codexMicroRuntimeLibPath}" ]; then + echo "codex-micro node-hid RPATH verification failed: $actual_rpath" >&2 + exit 1 + fi + done < <( + find "$resources_dir/app.asar.unpacked" -type f \ + -path '*/node-hid/prebuilds/HID_hidraw-linux-*/node-napi-v4.node' \ + -print + ) + if [ "$codex_micro_node_count" -ne 1 ]; then + echo "expected exactly one codex-micro node-hid Linux binding, found $codex_micro_node_count" >&2 + exit 1 + fi + + install -Dm0644 \ + "$out/opt/codex-desktop/.codex-linux/features/codex-micro/70-codex-micro.rules" \ + "$out/lib/udev/rules.d/70-codex-micro.rules" + ''} + for node_repl_binary in \ "$resources_dir/node_repl" \ "$resources_dir/node_repl.codex-linux-original"; do @@ -697,6 +740,7 @@ PY makeWrapper "$out/opt/codex-desktop/start.sh" "$out/bin/codex-desktop" \ --prefix PATH : "${payloadLauncherPath}" \ + --set-default ALSA_PLUGIN_DIR "${pkgs.pipewire}/lib/alsa-lib" \ --run 'export XDG_DATA_DIRS="''${XDG_DATA_DIRS:-${xdgDefaultDataDirs}}"' \ --prefix XDG_DATA_DIRS : "${gsettingsSchemaDataDirs}" \ --prefix PATH : "/run/current-system/sw/bin" \ @@ -798,6 +842,36 @@ PY grep -F 'CODEX_NOTIFICATION_ACTIONS_SOURCE=' ${installer}/bin/codex-desktop-installer >/dev/null touch "$out" ''; + nix-pipewire-alsa-wrapper = pkgs.runCommand "codex-desktop-nix-pipewire-alsa-wrapper-check" { } '' + plugin="${pkgs.pipewire}/lib/alsa-lib/libasound_module_pcm_pipewire.so" + expected_plugin_dir="${pkgs.pipewire}/lib/alsa-lib" + test -f "$plugin" + + run_wrapper() { + case "$1" in + unset) unset ALSA_PLUGIN_DIR ;; + custom) export ALSA_PLUGIN_DIR=/custom/lib/alsa-lib ;; + *) echo "unknown test case: $1" >&2; return 1 ;; + esac + + actual_plugin_dir="$({ + exec() { + printf '%s\n' "$ALSA_PLUGIN_DIR" + } + + source ${codexDesktop}/bin/codex-desktop + })" + if [ "$actual_plugin_dir" != "$2" ]; then + printf 'expected ALSA_PLUGIN_DIR <%s>, got <%s>\n' \\ + "$2" "$actual_plugin_dir" >&2 + return 1 + fi + } + + run_wrapper unset "$expected_plugin_dir" + run_wrapper custom /custom/lib/alsa-lib + touch "$out" + ''; nix-gsettings-schema-wrapper = pkgs.runCommand "codex-desktop-nix-gsettings-schema-wrapper-check" { } '' schema_data_dirs=${pkgs.lib.escapeShellArg gsettingsSchemaDataDirs} default_data_dirs=${pkgs.lib.escapeShellArg xdgDefaultDataDirs} diff --git a/install.sh b/install.sh index 7e702eb3c..747bc1e96 100755 --- a/install.sh +++ b/install.sh @@ -71,7 +71,11 @@ write_transaction_dmg_metadata() { "${CODEX_ACCEPTANCE_NODE:-node}" - "$output_path" "$dmg_path" "$cached_metadata" "$DMG_URL" <<'NODE' const fs = require("node:fs"); const [outputPath, dmgPath, metadataPath, url] = process.argv.slice(2); -const metadata = { url, path: dmgPath }; +let metadata = {}; +if (fs.existsSync(outputPath)) { + metadata = JSON.parse(fs.readFileSync(outputPath, "utf8")); +} +Object.assign(metadata, { url, path: dmgPath }); if (metadataPath && fs.existsSync(metadataPath)) { for (const line of fs.readFileSync(metadataPath, "utf8").split(/\r?\n/)) { const separator = line.indexOf("="); @@ -135,6 +139,7 @@ transactional_install() { CODEX_INSTALL_DIR="$candidate_dir" \ CODEX_PATCH_REPORT_JSON="$core_report" \ CODEX_REBUILD_REPORT_JSON="$rebuild_report" \ + CODEX_UPSTREAM_DMG_METADATA_JSON="$metadata_path" \ "$BASH" "$SCRIPT_DIR/install.sh" "${original_args[@]}"; then build_status="success" fi @@ -332,6 +337,7 @@ main() { local app_dir app_dir=$(extract_dmg "$dmg_path") + record_upstream_app_version "$app_dir" detect_electron_version "$app_dir" if [ "$INSPECT_ONLY" -eq 1 ]; then diff --git a/launcher/start.sh.template b/launcher/start.sh.template index 1a4f7804d..b8247330a 100644 --- a/launcher/start.sh.template +++ b/launcher/start.sh.template @@ -58,7 +58,34 @@ resolve_script_dir() { cd -P "$(dirname "$source")" && pwd } +capture_launched_desktop_entry() { + unset CODEX_LINUX_LAUNCHED_DESKTOP_ENTRY + + local desktop_file="${GIO_LAUNCHED_DESKTOP_FILE:-}" + local launched_pid="${GIO_LAUNCHED_DESKTOP_FILE_PID:-}" + local current_pid="${BASHPID:-$$}" + local desktop_file_name + local desktop_entry + + [ "$launched_pid" = "$current_pid" ] || return 0 + case "$desktop_file" in + /*) ;; + *) return 0 ;; + esac + + desktop_file_name="${desktop_file##*/}" + case "$desktop_file_name" in + *.desktop) desktop_entry="${desktop_file_name%.desktop}" ;; + *) return 0 ;; + esac + [ -n "$desktop_entry" ] || return 0 + + CODEX_LINUX_LAUNCHED_DESKTOP_ENTRY="$desktop_entry" + export CODEX_LINUX_LAUNCHED_DESKTOP_ENTRY +} + SCRIPT_DIR="$(resolve_script_dir)" +capture_launched_desktop_entry if [ -z "${CODEX_HOME:-}" ]; then if [ -n "${HOME:-}" ]; then CODEX_HOME="$HOME/.codex" @@ -352,6 +379,7 @@ Options: -h, --help Show this help message and exit --new-instance Start a separate app instance on the first free port in the multi-launch range -p, --profile NAME Start using the named Codex profile + --show Show or focus the main window --new-chat Open the main window on a new chat --quick-chat Open a projectless quick chat --prompt-chat Show the compact prompt for a new chat @@ -1176,23 +1204,16 @@ scripts_dir = plugin_dir / "scripts" extension_id = None host_name = None -extension_id_json = scripts_dir / "extension-id.json" +extension_ids_json = scripts_dir / "extension-ids.json" try: - data = json.loads(extension_id_json.read_text(encoding="utf-8")) - extension_id = data.get("extensionId") + data = json.loads(extension_ids_json.read_text(encoding="utf-8")) + extension_ids = data.get("extensionIds") + if isinstance(extension_ids, list) and extension_ids: + extension_id = extension_ids[0] host_name = data.get("extensionHostName") -except OSError: +except (OSError, json.JSONDecodeError): pass -if extension_id is None or host_name is None: - install_manifest = (scripts_dir / "installManifest.mjs").read_text(encoding="utf-8") - extension_id_match = re.search(r'extensionId\s*:\s*"([a-p]{32})"', install_manifest) - host_name_match = re.search(r'extensionHostName\s*:\s*"([A-Za-z0-9_.]+)"', install_manifest) - if extension_id is None and extension_id_match is not None: - extension_id = extension_id_match.group(1) - if host_name is None and host_name_match is not None: - host_name = host_name_match.group(1) - if not isinstance(extension_id, str) or re.fullmatch(r"[a-p]{32}", extension_id) is None: raise SystemExit("Invalid Chrome extension id in bundled plugin metadata") if not isinstance(host_name, str) or re.fullmatch(r"[A-Za-z0-9_.]+", host_name) is None: @@ -1259,6 +1280,11 @@ sync_chrome_bundled_plugin_cache() { local cache_install_manifest local cache_parent local tmp_plugin + local managed_cache_root + local managed_cache_plugin + local managed_cache_client + local managed_tmp_plugin + local managed_backup_plugin local marketplace_root local marketplace_plugins_dir local marketplace_plugin_link @@ -1294,7 +1320,12 @@ sync_chrome_bundled_plugin_cache() { version="$(bundled_plugin_version "$plugin_json" 2>/dev/null || true)" [ -n "$version" ] || return 0 - cache_root="$codex_home/plugins/cache/openai-bundled/chrome" + # Keep the Linux native-host/runtime copy outside app-server's managed + # plugin cache. plugin/install replaces + # $CODEX_HOME/plugins/cache/openai-bundled/chrome and would otherwise + # remove both the marketplace source link and the native-host launcher + # while the renderer is still finishing the install. + cache_root="$codex_home/plugins/linux-runtime-cache/openai-bundled/chrome" cache_plugin="$cache_root/$version" cache_host="$cache_plugin/extension-host/linux/$extension_arch/extension-host" cache_client="$cache_plugin/scripts/browser-client.mjs" @@ -1303,8 +1334,8 @@ sync_chrome_bundled_plugin_cache() { if path_has_unsafe_write \ "$codex_home" \ "$codex_home/plugins" \ - "$codex_home/plugins/cache" \ - "$codex_home/plugins/cache/openai-bundled" \ + "$codex_home/plugins/linux-runtime-cache" \ + "$codex_home/plugins/linux-runtime-cache/openai-bundled" \ "$cache_root" || tree_has_unsafe_write "$cache_plugin"; then cache_was_untrusted=1 fi @@ -1312,8 +1343,8 @@ sync_chrome_bundled_plugin_cache() { if ! make_path_owner_trusted \ "$codex_home" \ "$codex_home/plugins" \ - "$codex_home/plugins/cache" \ - "$codex_home/plugins/cache/openai-bundled" \ + "$codex_home/plugins/linux-runtime-cache" \ + "$codex_home/plugins/linux-runtime-cache/openai-bundled" \ "$cache_root"; then echo "Chrome plugin cache ancestors could not be hardened; skipping cache and native-host manifest sync." return 0 @@ -1337,7 +1368,7 @@ sync_chrome_bundled_plugin_cache() { scripts/check-extension-installed.js \ scripts/check-native-host-manifest.js \ scripts/chrome-is-running.js \ - scripts/extension-id.json \ + scripts/extension-ids.json \ scripts/installed-browsers.js \ scripts/open-chrome-window.js; do [ -f "$source_plugin/$relative" ] || continue @@ -1356,8 +1387,8 @@ sync_chrome_bundled_plugin_cache() { if ! make_path_owner_trusted \ "$codex_home" \ "$codex_home/plugins" \ - "$codex_home/plugins/cache" \ - "$codex_home/plugins/cache/openai-bundled" \ + "$codex_home/plugins/linux-runtime-cache" \ + "$codex_home/plugins/linux-runtime-cache/openai-bundled" \ "$cache_root" \ "$cache_parent"; then echo "Chrome plugin cache parent could not be hardened; skipping cache and native-host manifest sync." @@ -1387,6 +1418,50 @@ sync_chrome_bundled_plugin_cache() { fi replace_symlink "$version" "$cache_root/latest" + # app-server keys its managed plugin cache by the upstream version. Linux + # compatibility patches do not change that version, so an already-installed + # Chrome plugin can otherwise keep stale JavaScript after an RPM upgrade. + # Refresh an existing same-version install from the bundled source while + # leaving this app-server-owned tree writable and outside the trusted native + # host/runtime cache above. + managed_cache_root="$codex_home/plugins/cache/openai-bundled/chrome" + managed_cache_plugin="$managed_cache_root/$version" + managed_cache_client="$managed_cache_plugin/scripts/browser-client.mjs" + if [ -d "$managed_cache_root" ] && [ ! -L "$managed_cache_root" ] && \ + [ -d "$managed_cache_plugin" ] && [ ! -L "$managed_cache_plugin" ] && \ + { [ ! -f "$managed_cache_client" ] || ! cmp -s "$source_client" "$managed_cache_client"; }; then + managed_tmp_plugin="$managed_cache_root/.chrome-$version.linux-refresh.$$" + managed_backup_plugin="$managed_cache_root/.chrome-$version.linux-backup.$$" + if ! remove_tree_if_exists "$managed_tmp_plugin" || \ + ! remove_tree_if_exists "$managed_backup_plugin"; then + echo "Chrome app-server cache refresh preparation failed; continuing with the existing install." + elif cp -R "$source_plugin" "$managed_tmp_plugin"; then + make_tree_owner_writable "$managed_tmp_plugin" + chmod --reference="$managed_cache_plugin" "$managed_tmp_plugin" 2>/dev/null || true + if ! find "$managed_tmp_plugin" -type f -name '*:com.apple.*' -delete; then + remove_tree_if_exists "$managed_tmp_plugin" || true + echo "Chrome app-server cache refresh cleanup failed; continuing with the existing install." + elif ! mv -T -- "$managed_cache_plugin" "$managed_backup_plugin"; then + remove_tree_if_exists "$managed_tmp_plugin" || true + echo "Chrome app-server cache refresh failed; existing cache was preserved." + elif mv -T -- "$managed_tmp_plugin" "$managed_cache_plugin"; then + remove_tree_if_exists "$managed_backup_plugin" || \ + echo "Chrome app-server cache backup cleanup failed: $managed_backup_plugin" + echo "Chrome app-server cache refreshed from bundled resources: $managed_cache_plugin" + else + remove_tree_if_exists "$managed_tmp_plugin" || true + if mv -T -- "$managed_backup_plugin" "$managed_cache_plugin"; then + echo "Chrome app-server cache refresh failed; previous cache was restored." + else + echo "Chrome app-server cache refresh failed and the previous cache could not be restored: $managed_backup_plugin" + fi + fi + else + remove_tree_if_exists "$managed_tmp_plugin" || true + echo "Chrome app-server cache refresh failed; continuing with the existing install." + fi + fi + marketplace_root="$codex_home/.tmp/bundled-marketplaces/openai-bundled" marketplace_plugins_dir="$marketplace_root/.agents/plugins" marketplace_plugin_link="$marketplace_root/plugins/chrome" @@ -2565,7 +2640,7 @@ find_running_app_pid() { if [ -f "$APP_PID_FILE" ]; then pid="$(cat "$APP_PID_FILE" 2>/dev/null || true)" - if pid_matches_executable "$pid" "$SCRIPT_DIR/electron"; then + if pid_matches_running_app "$pid"; then echo "$pid" return 0 fi @@ -2610,15 +2685,52 @@ pid_matches_app_identity() { [[ "$cmdline" == *"--app-id=$CODEX_LINUX_APP_ID"* ]] } +pid_matches_appimage_install() { + local pid="$1" + local actual + local resident_appimage + + [ -n "${APPIMAGE:-}" ] || return 1 + [[ "$pid" =~ ^[0-9]+$ ]] || return 1 + [ -d "/proc/$pid" ] || return 1 + actual="$(pid_cmdline_arg0_path "$pid")" + case "${actual##*/}" in + electron|electron\ *) ;; + *) return 1 ;; + esac + pid_is_current_user "$pid" || return 1 + ! pid_is_electron_helper "$pid" || return 1 + resident_appimage="$(pid_environ_value "$pid" APPIMAGE 2>/dev/null || true)" + [ -n "$resident_appimage" ] && [ "$resident_appimage" = "$APPIMAGE" ] +} + +pid_matches_app_install() { + local pid="$1" + + pid_matches_executable "$pid" "$SCRIPT_DIR/electron" || pid_matches_appimage_install "$pid" +} + +pid_matches_running_app() { + local pid="$1" + + if pid_matches_executable "$pid" "$SCRIPT_DIR/electron"; then + return 0 + fi + + pid_matches_appimage_install "$pid" || return 1 + pid_matches_app_identity "$pid" || return 1 + pid_in_same_launch_instance "$pid" +} + pid_is_foreign_codex_electron() { local pid="$1" local actual [[ "$pid" =~ ^[0-9]+$ ]] || return 1 [ -d "/proc/$pid" ] || return 1 + ! pid_matches_app_install "$pid" || return 1 actual="$(pid_cmdline_arg0_path "$pid")" [ -n "$actual" ] || return 1 - ! pid_arg0_matches_path "$actual" "$SCRIPT_DIR/electron" || return 1 case "${actual##*/}" in electron|electron\ *) ;; *) return 1 ;; @@ -2636,7 +2748,7 @@ discover_running_app_pid() { [ -r "$proc_cmdline" ] || continue pid="${proc_cmdline#/proc/}" pid="${pid%/cmdline}" - if pid_matches_executable "$pid" "$SCRIPT_DIR/electron" && pid_in_same_launch_instance "$pid"; then + if pid_matches_running_app "$pid" && pid_in_same_launch_instance "$pid"; then echo "$pid" return 0 fi @@ -2721,7 +2833,7 @@ set_detected_running_app() { } running_app_is_active() { - [ -n "${RUNNING_APP_PID:-}" ] && pid_matches_executable "$RUNNING_APP_PID" "$SCRIPT_DIR/electron" + [ -n "${RUNNING_APP_PID:-}" ] && pid_matches_running_app "$RUNNING_APP_PID" } using_second_instance_handoff() { @@ -2740,9 +2852,12 @@ detect_warm_start() { return 0 fi - if runtime_recovery_scan_needed && pid="$(discover_running_app_pid)"; then - set_detected_running_app "$pid" - return 0 + if runtime_recovery_scan_needed; then + if pid="$(discover_running_app_pid)"; then + set_detected_running_app "$pid" + return 0 + fi + detect_cross_install_conflict fi if ! linux_setting_enabled "codex-linux-warm-start-enabled" 1; then @@ -2768,13 +2883,14 @@ terminate_stale_electron_with_pidfd() { local pid="$1" local expected_start_time - pid_matches_executable "$pid" "$SCRIPT_DIR/electron" || return 1 + pid_matches_running_app "$pid" || return 1 expected_start_time="$(pid_start_time "$pid")" || return 1 python3 - \ "$pid" \ "$expected_start_time" \ "$SCRIPT_DIR/electron" \ + "${APPIMAGE:-}" \ "$CODEX_LINUX_APP_ID" \ "$CODEX_LINUX_INSTANCE_ID" \ "$MULTI_LAUNCH_ACTIVE" <<'PY' @@ -2786,9 +2902,10 @@ import sys pid = int(sys.argv[1]) expected_start_time = sys.argv[2] expected_executable = sys.argv[3] -expected_app_id = sys.argv[4] -expected_instance_id = sys.argv[5] -expected_multi_launch = sys.argv[6] == "1" +expected_appimage = sys.argv[4] +expected_app_id = sys.argv[5] +expected_instance_id = sys.argv[6] +expected_multi_launch = sys.argv[7] == "1" if not hasattr(os, "pidfd_open") or not hasattr(signal, "pidfd_send_signal"): print("pidfd APIs are unavailable; refusing to terminate the stale Electron process", file=sys.stderr) @@ -2822,11 +2939,19 @@ for entry in environ_entries: key, value = entry.split(b"=", 1) environ[os.fsdecode(key)] = os.fsdecode(value) +actual_executable = os.fsdecode(cmdline[0]) if cmdline else "" +install_matches = actual_executable == expected_executable +if not install_matches and expected_appimage: + install_matches = ( + os.path.basename(actual_executable) == "electron" + and environ.get("APPIMAGE") == expected_appimage + ) + identity_matches = ( actual_start_time == expected_start_time and actual_uid == os.geteuid() and bool(cmdline) - and os.fsdecode(cmdline[0]) == expected_executable + and install_matches and not any(part.startswith(b"--type=") for part in cmdline[1:]) and expected_app_id in { environ.get("CODEX_LINUX_APP_ID"), @@ -2931,6 +3056,7 @@ recover_unhealthy_running_app() { send_warm_start_launch_action() { [ "$WARM_START" -eq 1 ] || return 1 + running_app_is_active || return 1 [ -S "$LAUNCH_ACTION_SOCKET" ] || return 1 python3 - "$LAUNCH_ACTION_SOCKET" "$@" <<'PY' @@ -3354,7 +3480,7 @@ clear_stale_pid_file() { local pid="" pid="$(cat "$APP_PID_FILE" 2>/dev/null || true)" - if [ -z "$pid" ] || ! pid_matches_executable "$pid" "$SCRIPT_DIR/electron"; then + if [ -z "$pid" ] || ! pid_matches_running_app "$pid"; then rm -f "$APP_PID_FILE" fi } @@ -4627,7 +4753,7 @@ launch_electron() { exec "$SCRIPT_DIR/electron" "${ELECTRON_LAUNCH_ARGS[@]}" "${ELECTRON_ARGS[@]}" ) & ELECTRON_PID=$! - if [ -n "${RUNNING_APP_PID:-}" ] && pid_matches_executable "$RUNNING_APP_PID" "$SCRIPT_DIR/electron"; then + if [ -n "${RUNNING_APP_PID:-}" ] && pid_matches_running_app "$RUNNING_APP_PID"; then echo "Preserving ChatGPT Desktop pid=$RUNNING_APP_PID liveness marker for second-instance handoff" else echo "$ELECTRON_PID" > "$APP_PID_FILE" diff --git a/linux-features/agent-workspace/patch.js b/linux-features/agent-workspace/patch.js index 7aab88c54..adc0fbbdb 100644 --- a/linux-features/agent-workspace/patch.js +++ b/linux-features/agent-workspace/patch.js @@ -1839,7 +1839,7 @@ function inferRuntimeDependenciesFromSettingsSource(source) { function inferRuntimeDependenciesFromSettingsAssets(assetsDir) { const candidates = fs .readdirSync(assetsDir) - .filter((name) => /^settings-page-.*\.js$/.test(name) || /(?:^|~)settings-page(?:[-~].*)?\.js$/.test(name)) + .filter((name) => /^settings-page-[^.]+\.js$/.test(name)) .sort(); for (const candidate of candidates) { const dependencies = inferRuntimeDependenciesFromSettingsSource( @@ -1909,19 +1909,41 @@ function isAgentWorkspaceSettingsRouteBundleSource(currentSource) { ); } +const CURRENT_SETTINGS_CATALOG_SLUGS = "local-environments.worktrees.environments"; +const PATCHED_SETTINGS_CATALOG_SLUGS = "local-environments.agent-workspaces.worktrees.environments"; +const CURRENT_SETTINGS_CATALOG_ITEMS = "{slug:`local-environments`},{slug:`worktrees`}"; +const PATCHED_SETTINGS_CATALOG_ITEMS = "{slug:`local-environments`},{slug:`agent-workspaces`},{slug:`worktrees`}"; +const CURRENT_SETTINGS_NAVIGATION_SLUGS = "local-environments.worktrees.browser-use"; +const PATCHED_SETTINGS_NAVIGATION_SLUGS = "local-environments.agent-workspaces.worktrees.browser-use"; +const CURRENT_SETTINGS_NAVIGATION_GROUP = "`local-environments`,`environments`,`worktrees`"; +const PATCHED_SETTINGS_NAVIGATION_GROUP = + "`local-environments`,`agent-workspaces`,`environments`,`worktrees`"; +const CURRENT_SETTINGS_VISIBILITY_CASES = + "case`worktrees`:case`local-environments`:case`environments`:return"; +const PATCHED_SETTINGS_VISIBILITY_CASES = + "case`worktrees`:case`local-environments`:case`agent-workspaces`:case`environments`:return"; +const CURRENT_SETTINGS_ICON_PATTERN = + /"local-environments":([A-Za-z_$][\w$]*),worktrees:([A-Za-z_$][\w$]*)/; +const PATCHED_SETTINGS_ICON_PATTERN = + /"local-environments":([A-Za-z_$][\w$]*),"agent-workspaces":([A-Za-z_$][\w$]*),worktrees:([A-Za-z_$][\w$]*)/; + function isAgentWorkspaceSettingsNavigationBundleSource(currentSource) { return ( - /[A-Za-z_$][\w$]*=\{[^;]*"local-environments":[A-Za-z_$][\w$]*,[^;]*worktrees:/.test(currentSource) && - currentSource.includes("slugs:[`") && - currentSource.includes("`local-environments`") && - currentSource.includes("`worktrees`") + (currentSource.includes(CURRENT_SETTINGS_NAVIGATION_SLUGS) || + currentSource.includes(PATCHED_SETTINGS_NAVIGATION_SLUGS)) && + (currentSource.includes(CURRENT_SETTINGS_NAVIGATION_GROUP) || + currentSource.includes(PATCHED_SETTINGS_NAVIGATION_GROUP)) ); } -const CURRENT_SETTINGS_CATALOG_SLUGS = "local-environments.worktrees.environments"; -const PATCHED_SETTINGS_CATALOG_SLUGS = "local-environments.agent-workspaces.worktrees.environments"; -const CURRENT_SETTINGS_CATALOG_ITEMS = "{slug:`local-environments`},{slug:`worktrees`}"; -const PATCHED_SETTINGS_CATALOG_ITEMS = "{slug:`local-environments`},{slug:`agent-workspaces`},{slug:`worktrees`}"; +function isAgentWorkspaceSettingsVisibilityBundleSource(currentSource) { + return ( + (CURRENT_SETTINGS_ICON_PATTERN.test(currentSource) || + PATCHED_SETTINGS_ICON_PATTERN.test(currentSource)) && + (currentSource.includes(CURRENT_SETTINGS_VISIBILITY_CASES) || + currentSource.includes(PATCHED_SETTINGS_VISIBILITY_CASES)) + ); +} function isAgentWorkspaceSettingsCatalogBundleSource(currentSource) { return ( @@ -1952,50 +1974,6 @@ function applyAgentWorkspaceSettingsCatalogPatch(currentSource) { .replace(CURRENT_SETTINGS_CATALOG_ITEMS, PATCHED_SETTINGS_CATALOG_ITEMS); } -function addAgentWorkspaceToSettingsSlugLists(currentSource) { - return currentSource - .replaceAll( - "`local-environments`,`worktrees`", - "`local-environments`,`agent-workspaces`,`worktrees`", - ) - .replaceAll( - "`local-environments`,`environments`,`worktrees`", - "`local-environments`,`agent-workspaces`,`environments`,`worktrees`", - ); -} - -function addAgentWorkspaceVisibilityCases(currentSource) { - let patchedSource = currentSource; - const replacements = [[ - "case`worktrees`:case`local-environments`:case`environments`:return", - "case`worktrees`:case`local-environments`:case`agent-workspaces`:case`environments`:return", - ]]; - - for (const [needle, replacement] of replacements) { - if (!patchedSource.includes(replacement) && patchedSource.includes(needle)) { - patchedSource = patchedSource.replace(needle, replacement); - } - } - - return patchedSource; -} - -function addAgentWorkspaceLoadingCases(currentSource) { - let patchedSource = currentSource; - const replacements = [[ - "case`local-environments`:case`worktrees`:case`environments`:", - "case`local-environments`:case`agent-workspaces`:case`worktrees`:case`environments`:", - ]]; - - for (const [needle, replacement] of replacements) { - if (!patchedSource.includes(replacement) && patchedSource.includes(needle)) { - patchedSource = patchedSource.replace(needle, replacement); - } - } - - return patchedSource; -} - function applyAgentWorkspaceSettingsSharedPatch(currentSource) { let patchedSource = currentSource; if (!patchedSource.includes(`settings.nav.${SETTINGS_SLUG}`)) { @@ -2047,34 +2025,45 @@ function applyAgentWorkspaceSettingsIndexPatch(currentSource) { function applyAgentWorkspaceSettingsPagePatch(currentSource) { let patchedSource = currentSource; - - // Reuse an existing icon alias instead of injecting a new minified-scope - // symbol. Upstream can wrap the icon map in initializer closures, and a - // dangling injected symbol breaks the whole Settings route. - const agentWorkspaceIcon = patchedSource.match(/"local-environments":([A-Za-z_$][\w$]*)/)?.[1] ?? null; - - if (agentWorkspaceIcon != null) { - patchedSource = patchedSource.replace( - new RegExp(`"${SETTINGS_SLUG}":[A-Za-z_$][\\w$]*`), - `"${SETTINGS_SLUG}":${agentWorkspaceIcon}`, - ); + let matched = false; + + if (isAgentWorkspaceSettingsNavigationBundleSource(patchedSource)) { + matched = true; + const slugsPatched = patchedSource.includes(PATCHED_SETTINGS_NAVIGATION_SLUGS); + const groupPatched = patchedSource.includes(PATCHED_SETTINGS_NAVIGATION_GROUP); + if (slugsPatched !== groupPatched) { + throw new Error("agent workspace settings navigation is partially patched"); + } + if (!slugsPatched) { + patchedSource = patchedSource + .replace(CURRENT_SETTINGS_NAVIGATION_SLUGS, PATCHED_SETTINGS_NAVIGATION_SLUGS) + .replace(CURRENT_SETTINGS_NAVIGATION_GROUP, PATCHED_SETTINGS_NAVIGATION_GROUP); + } } - if ( - !new RegExp(`[,{]"${SETTINGS_SLUG}":[A-Za-z_$][\\w$]*,worktrees`).test(patchedSource) && - /"local-environments":([A-Za-z_$][\w$]*),worktrees:/.test(patchedSource) - ) { - patchedSource = patchedSource.replace( - /"local-environments":([A-Za-z_$][\w$]*),worktrees:/, - `"local-environments":$1,"${SETTINGS_SLUG}":${agentWorkspaceIcon ?? "$1"},worktrees:`, - ); + if (isAgentWorkspaceSettingsVisibilityBundleSource(patchedSource)) { + matched = true; + const iconMatch = patchedSource.match(PATCHED_SETTINGS_ICON_PATTERN); + if (iconMatch != null && iconMatch[1] !== iconMatch[2]) { + throw new Error("agent workspace settings visibility has an unexpected icon"); + } + const iconPatched = iconMatch != null && iconMatch[1] === iconMatch[2]; + const casesPatched = patchedSource.includes(PATCHED_SETTINGS_VISIBILITY_CASES); + if (iconPatched !== casesPatched) { + throw new Error("agent workspace settings visibility is partially patched"); + } + if (!iconPatched) { + patchedSource = patchedSource + .replace( + CURRENT_SETTINGS_ICON_PATTERN, + (_match, localEnvironmentsIcon, worktreesIcon) => + `"local-environments":${localEnvironmentsIcon},"${SETTINGS_SLUG}":${localEnvironmentsIcon},worktrees:${worktreesIcon}`, + ) + .replace(CURRENT_SETTINGS_VISIBILITY_CASES, PATCHED_SETTINGS_VISIBILITY_CASES); + } } - patchedSource = addAgentWorkspaceToSettingsSlugLists(patchedSource); - patchedSource = addAgentWorkspaceVisibilityCases(patchedSource); - patchedSource = addAgentWorkspaceLoadingCases(patchedSource); - - if (!patchedSource.includes(`\`${SETTINGS_SLUG}\``)) { + if (!matched) { throw new Error("could not add agent workspace settings navigation"); } @@ -2090,13 +2079,15 @@ function collectAgentWorkspaceRouteAndNavigationPatches(extractedDir) { const candidates = fs .readdirSync(assetsDir) .filter((name) => - /^app-initial~app-main~.*\.js$/.test(name) || - /(?:^|~)settings-page(?:[-~].*)?\.js$/.test(name) + /^app-initial-[^.]+\.js$/.test(name) || + /^settings-page-[^.]+\.js$/.test(name) || + /^use-visible-settings-sections-[^.]+\.js$/.test(name) ) .sort(); let metadataMatched = false; let routeMatched = false; let navigationMatched = false; + let visibilityMatched = false; let catalogMatched = false; const patches = []; @@ -2116,6 +2107,10 @@ function collectAgentWorkspaceRouteAndNavigationPatches(extractedDir) { navigationMatched = true; patchedSource = applyAgentWorkspaceSettingsPagePatch(patchedSource); } + if (isAgentWorkspaceSettingsVisibilityBundleSource(currentSource)) { + visibilityMatched = true; + patchedSource = applyAgentWorkspaceSettingsPagePatch(patchedSource); + } if (isAgentWorkspaceSettingsCatalogBundleSource(currentSource)) { catalogMatched = true; patchedSource = applyAgentWorkspaceSettingsCatalogPatch(patchedSource); @@ -2134,6 +2129,9 @@ function collectAgentWorkspaceRouteAndNavigationPatches(extractedDir) { if (!navigationMatched) { throw new Error("could not find webview settings navigation bundle"); } + if (!visibilityMatched) { + throw new Error("could not find webview settings visibility bundle"); + } if (!catalogMatched) { throw new Error("could not find current webview settings catalog bundle"); } diff --git a/linux-features/agent-workspace/test.js b/linux-features/agent-workspace/test.js index c457fb900..16f506a36 100644 --- a/linux-features/agent-workspace/test.js +++ b/linux-features/agent-workspace/test.js @@ -27,6 +27,7 @@ const { SETTINGS_PERMISSIONS_KEY, SETTINGS_SLUG, applyAgentWorkspaceMainBridgePatch, + applyAgentWorkspaceSettingsCatalogPatch, applyAgentWorkspaceSettingsIndexPatch, applyAgentWorkspaceSettingsPagePatch, applyAgentWorkspaceSettingsSharedPatch, @@ -138,26 +139,47 @@ function buildBridgeHarness({ env = {}, globalState = new Map(), execFile, spawn return { handlers: host.handlers(), execCalls, spawnCalls }; } -function syntheticSettingsShared() { +function syntheticCurrentSettingsMetadata() { return [ "var c=r({", '"general-settings":{id:`settings.nav.general-settings`,defaultMessage:`General`,description:`Title for general settings section`},', '"local-environments":{id:`settings.nav.local-environments`,defaultMessage:`Environments`,description:`Title for environments settings section`},', "worktrees:{id:`settings.nav.worktrees`,defaultMessage:`Worktrees`,description:`Title for worktrees settings section`}", "});", - "function m(e){let t=(0,u.c)(3),{slug:r}=e;switch(r){", - "case`general-settings`:{return (0,d.jsx)(n,{id:`settings.section.general-settings`,defaultMessage:`General`})}", - "case`local-environments`:{return (0,d.jsx)(n,{id:`settings.section.local-environments`,defaultMessage:`Environments`})}", - "case`worktrees`:{return (0,d.jsx)(n,{id:`settings.section.worktrees`,defaultMessage:`Worktrees`})}", + "function m(e){let t=(0,u.c)(30),{slug:r}=e;switch(r){", + "case`general-settings`:{let e;return t[0]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,d.jsx)(n,{id:`settings.section.general-settings`,defaultMessage:`General`}),t[0]=e):e=t[0],e}", + "case`local-environments`:{let e;return t[21]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,d.jsx)(n,{id:`settings.section.local-environments`,defaultMessage:`Environments`}),t[21]=e):e=t[21],e}", + "case`worktrees`:{let e;return t[22]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,d.jsx)(n,{id:`settings.section.worktrees`,defaultMessage:`Worktrees`}),t[22]=e):e=t[22],e}", "}}", ].join(""); } -function syntheticCurrentAppMainRouteRegistry() { +function syntheticCurrentAppInitialBundle() { return [ "function render(e){return currentRouteMap[e.slug]}", 'var currentRouteMap={"general-settings":BN(async()=>(await Y(async()=>{let{GeneralSettings:e}=await import(`./general-settings-TbWU8D8b.js`);return{GeneralSettings:e}},__vite__mapDeps([1,2]),import.meta.url)).GeneralSettings),', 'import:BN(async()=>(await Y(async()=>{let{ImportSettings:e}=await import(`./import-settings-DmsueF_s.js`);return{ImportSettings:e}},__vite__mapDeps([3]),import.meta.url)).ImportSettings)};', + syntheticCurrentSettingsMetadata(), + syntheticCurrentSettingsCatalog(), + ].join(""); +} + +function syntheticCurrentSettingsNavigation() { + return [ + 'import{s as __toESM}from"./chunk-test.js";', + 'import{r as ReactFactory,j as jsxFactory}from"./runtime-test.js";', + 'var React=__toESM(ReactFactory(),1),$=jsxFactory();', + 'function RuntimeProbe(){let [value]=(0,React.useState)(0);return (0,$.jsx)("span",{children:value})}', + "var nn=`general-settings.linux-desktop.import.profile.appearance.voice.chronicle.appshots.agent.personalization.pets.usage.debug.keyboard-shortcuts.codex-micro.mcp-settings.hooks-settings.connections.cloud-settings.cloud-environments.code-review.git-settings.local-environments.worktrees.browser-use.computer-use.data-controls`.split(`.`);", + "var rn=[{key:`personal`,slugs:[`general-settings`,`linux-desktop`]},{key:`coding`,slugs:[`hooks-settings`,`connections`,`cloud-settings`,`cloud-environments`,`code-review`,`git-settings`,`local-environments`,`environments`,`worktrees`]}];", + ].join(""); +} + +function syntheticCurrentSettingsVisibility() { + return [ + "var H=e=>e,F=e=>e;", + 'var it={"linux-desktop":H,"general-settings":H,"local-environments":H,worktrees:F,environments:H,"mcp-settings":H,connections:H};', + "function visible(S){switch(S.slug){case`computer-use`:return!0;case`browser-use`:return!0;case`appearance`:return!0;case`pets`:case`git-settings`:case`worktrees`:case`local-environments`:case`environments`:return!0;case`data-controls`:return!0;case`linux-desktop`:case`general-settings`:case`agent`:case`personalization`:return!0;}}", ].join(""); } @@ -216,30 +238,15 @@ function rewriteSettingsAssetsWithConsolidatedCurrentLayout(assetsDir) { ); fs.writeFileSync( path.join(assetsDir, "settings-page-test.js"), - [ - 'import{s as __toESM}from"./chunk-test.js";', - 'import{r as ReactFactory,j as jsxFactory}from"./runtime-test.js";', - 'var React=__toESM(ReactFactory(),1),$=jsxFactory();', - 'function RuntimeProbe(){let [value]=(0,React.useState)(0);return (0,$.jsx)("span",{children:value})}', - "var Z=$,S=e=>(0,Z.jsxs)(`svg`,{children:[]}),ln=S,F=S;", - 'var Hn={"linux-desktop":S,"general-settings":S,"local-environments":ln,worktrees:F,environments:ln,"mcp-settings":S,connections:S};', - "var Wn=[`general-settings`,`linux-desktop`,`local-environments`,`worktrees`,`data-controls`],Gn=[{key:`personal`,slugs:[`general-settings`,`linux-desktop`]},{key:`coding`,slugs:[`local-environments`,`environments`,`worktrees`]}];", - "function visible(S){switch(S.slug){case`computer-use`:return!0;case`browser-use`:return!0;case`appearance`:return!0;case`pets`:case`git-settings`:case`worktrees`:case`local-environments`:case`environments`:return!0;case`data-controls`:return!0;case`linux-desktop`:case`general-settings`:case`agent`:case`personalization`:return!0;}}", - "function load(S){let T=!1;switch(S.slug){case`local-environments`:case`worktrees`:case`environments`:case`mcp-settings`:case`connections`:T=!1;break}return T}", - "var lr=[`profile`,`agent`,`personalization`,`mcp-settings`,`hooks-settings`,`local-environments`,`worktrees`,`data-controls`];", - ].join(""), + syntheticCurrentSettingsNavigation(), ); fs.writeFileSync( - path.join(assetsDir, "app-initial~app-main~messages-test.js"), - syntheticSettingsShared(), + path.join(assetsDir, "app-initial-test.js"), + syntheticCurrentAppInitialBundle(), ); fs.writeFileSync( - path.join(assetsDir, "app-initial~app-main~automations-page-test.js"), - syntheticCurrentAppMainRouteRegistry(), - ); - fs.writeFileSync( - path.join(assetsDir, "app-initial~app-main~hotkey-window-thread-page~keyboard-shortcuts-settings~thread-app-shell~current-test.js"), - syntheticCurrentSettingsCatalog(), + path.join(assetsDir, "use-visible-settings-sections-test.js"), + syntheticCurrentSettingsVisibility(), ); } @@ -1589,31 +1596,45 @@ test("generated settings UI auto-opens the GPUI viewer after approved workspace }); test("settings asset patches add navigation, route, visibility, and title", () => { - const shared = applyAgentWorkspaceSettingsSharedPatch(syntheticSettingsShared()); + const shared = applyAgentWorkspaceSettingsSharedPatch(syntheticCurrentAppInitialBundle()); assert.match(shared, new RegExp(`settings\\.nav\\.${SETTINGS_SLUG}`)); assert.match(shared, new RegExp(`settings\\.section\\.${SETTINGS_SLUG}`)); assert.equal(applyAgentWorkspaceSettingsSharedPatch(shared), shared); - const currentAppMain = applyAgentWorkspaceSettingsIndexPatch(syntheticCurrentAppMainRouteRegistry()); + const currentAppMain = applyAgentWorkspaceSettingsIndexPatch(shared); assert.match( currentAppMain, /"agent-workspaces":BN\(async\(\)=>\(await Y\(async\(\)=>\{let\{default:e\}=await import\(`\.\/agent-workspaces-linux\.js`\);return\{default:e\}\},\[\],import\.meta\.url\)\)\.default\),"general-settings":/, ); assert.equal(applyAgentWorkspaceSettingsIndexPatch(currentAppMain), currentAppMain); - const settingsPage = applyAgentWorkspaceSettingsPagePatch( - [ - 'var Hn={"linux-desktop":S,"general-settings":S,"local-environments":ln,worktrees:F,environments:ln,"mcp-settings":S,connections:S};', - "var Wn=[`general-settings`,`linux-desktop`,`local-environments`,`worktrees`,`data-controls`],Gn=[{key:`coding`,slugs:[`local-environments`,`environments`,`worktrees`]}];", - "function visible(S){switch(S.slug){case`pets`:case`git-settings`:case`worktrees`:case`local-environments`:case`environments`:return!0;case`data-controls`:return!0;}}", - "function load(S){switch(S.slug){case`local-environments`:case`worktrees`:case`environments`:case`mcp-settings`:return!1}}", - ].join(""), + const catalog = applyAgentWorkspaceSettingsCatalogPatch(currentAppMain); + assert.match(catalog, /local-environments\.agent-workspaces\.worktrees/); + assert.match(catalog, /\{slug:`local-environments`\},\{slug:`agent-workspaces`\},\{slug:`worktrees`\}/); + assert.equal(applyAgentWorkspaceSettingsCatalogPatch(catalog), catalog); + + const settingsNavigation = applyAgentWorkspaceSettingsPagePatch( + syntheticCurrentSettingsNavigation(), + ); + assert.match(settingsNavigation, /local-environments\.agent-workspaces\.worktrees\.browser-use/); + assert.match( + settingsNavigation, + /`local-environments`,`agent-workspaces`,`environments`,`worktrees`/, + ); + assert.equal(applyAgentWorkspaceSettingsPagePatch(settingsNavigation), settingsNavigation); + + const settingsVisibility = applyAgentWorkspaceSettingsPagePatch( + syntheticCurrentSettingsVisibility(), + ); + assert.match( + settingsVisibility, + new RegExp(`"local-environments":H,"${SETTINGS_SLUG}":H,worktrees:F`), ); - assert.match(settingsPage, new RegExp(`"local-environments":ln,"${SETTINGS_SLUG}":ln,worktrees`)); - assert.match(settingsPage, /`local-environments`,`agent-workspaces`,`worktrees`/); - assert.match(settingsPage, /case`worktrees`:case`local-environments`:case`agent-workspaces`:case`environments`:return!0/); - assert.match(settingsPage, /case`local-environments`:case`agent-workspaces`:case`worktrees`:case`environments`/); - assert.equal(applyAgentWorkspaceSettingsPagePatch(settingsPage), settingsPage); + assert.match( + settingsVisibility, + /case`worktrees`:case`local-environments`:case`agent-workspaces`:case`environments`:return!0/, + ); + assert.equal(applyAgentWorkspaceSettingsPagePatch(settingsVisibility), settingsVisibility); }); test("agent-workspace feature participates in ASAR patching and reports", () => { @@ -1633,8 +1654,9 @@ test("agent-workspace feature participates in ASAR patching and reports", () => assert.ok(fs.existsSync(path.join(assetsDir, SETTINGS_ASSET))); assert.match(fs.readFileSync(path.join(assetsDir, SETTINGS_ASSET), "utf8"), /AgentWorkspacesSettings/); assert.match(fs.readFileSync(path.join(assetsDir, "settings-page-test.js"), "utf8"), /agent-workspaces/); - assert.match(fs.readFileSync(path.join(assetsDir, "app-initial~app-main~messages-test.js"), "utf8"), /Agent Workspaces/); - assert.match(fs.readFileSync(path.join(assetsDir, "app-initial~app-main~automations-page-test.js"), "utf8"), new RegExp(SETTINGS_ASSET)); + assert.match(fs.readFileSync(path.join(assetsDir, "use-visible-settings-sections-test.js"), "utf8"), /agent-workspaces/); + assert.match(fs.readFileSync(path.join(assetsDir, "app-initial-test.js"), "utf8"), /Agent Workspaces/); + assert.match(fs.readFileSync(path.join(assetsDir, "app-initial-test.js"), "utf8"), new RegExp(SETTINGS_ASSET)); assert.equal( fs.readFileSync(path.join(assetsDir, "local-conversation-thread-test.js"), "utf8"), staleConversationMonitorBundle(), @@ -1673,7 +1695,7 @@ test("agent-workspace settings resolve latest upstream request API asset", () => const settingsSource = fs.readFileSync(path.join(assetsDir, SETTINGS_ASSET), "utf8"); assert.match(settingsSource, /import\{l as __post\}from"\.\/setting-storage-test\.js"/); assert.match(settingsSource, /AgentWorkspacesSettings/); - assert.match(fs.readFileSync(path.join(assetsDir, "app-initial~app-main~automations-page-test.js"), "utf8"), new RegExp(SETTINGS_ASSET)); + assert.match(fs.readFileSync(path.join(assetsDir, "app-initial-test.js"), "utf8"), new RegExp(SETTINGS_ASSET)); } finally { fs.rmSync(tempApp, { recursive: true, force: true }); } @@ -1699,7 +1721,7 @@ test("agent-workspace settings infer runtime dependencies from bundled settings assert.match(settingsSource, /function SettingsPage/); assert.match(settingsSource, /AgentWorkspacesSettings/); assert.match(fs.readFileSync(path.join(assetsDir, "settings-page-test.js"), "utf8"), /agent-workspaces/); - assert.match(fs.readFileSync(path.join(assetsDir, "app-initial~app-main~automations-page-test.js"), "utf8"), new RegExp(SETTINGS_ASSET)); + assert.match(fs.readFileSync(path.join(assetsDir, "app-initial-test.js"), "utf8"), new RegExp(SETTINGS_ASSET)); } finally { fs.rmSync(tempApp, { recursive: true, force: true }); } @@ -1722,27 +1744,29 @@ test("agent-workspace settings patch supports consolidated current settings bund assert.match(settingsSource, /function SettingsPage/); const settingsPageSource = fs.readFileSync(path.join(assetsDir, "settings-page-test.js"), "utf8"); - assert.match(settingsPageSource, /"local-environments":ln,"agent-workspaces":ln,worktrees:F/); - assert.match(settingsPageSource, /`local-environments`,`agent-workspaces`,`worktrees`/); - assert.match(settingsPageSource, /slugs:\[`local-environments`,`agent-workspaces`,`environments`,`worktrees`\]/); - assert.match(settingsPageSource, /case`worktrees`:case`local-environments`:case`agent-workspaces`:case`environments`:return!0/); - assert.match(settingsPageSource, /case`local-environments`:case`agent-workspaces`:case`worktrees`:case`environments`/); - assert.match(settingsPageSource, /lr=\[`profile`,`agent`,`personalization`,`mcp-settings`,`hooks-settings`,`local-environments`,`agent-workspaces`,`worktrees`,`data-controls`\]/); - - const sharedSource = fs.readFileSync(path.join(assetsDir, "app-initial~app-main~messages-test.js"), "utf8"); - assert.match(sharedSource, /settings\.nav\.agent-workspaces/); - assert.match(sharedSource, /settings\.section\.agent-workspaces/); - - const routeSource = fs.readFileSync(path.join(assetsDir, "app-initial~app-main~automations-page-test.js"), "utf8"); - assert.match(routeSource, new RegExp(SETTINGS_ASSET)); - assert.match(routeSource, /"agent-workspaces":BN\(async\(\)=>\(await Y\(/); - - const catalogSource = fs.readFileSync( - path.join(assetsDir, "app-initial~app-main~hotkey-window-thread-page~keyboard-shortcuts-settings~thread-app-shell~current-test.js"), + assert.match(settingsPageSource, /local-environments\.agent-workspaces\.worktrees\.browser-use/); + assert.match( + settingsPageSource, + /`local-environments`,`agent-workspaces`,`environments`,`worktrees`/, + ); + + const visibilitySource = fs.readFileSync( + path.join(assetsDir, "use-visible-settings-sections-test.js"), "utf8", ); - assert.match(catalogSource, /local-environments\.agent-workspaces\.worktrees/); - assert.match(catalogSource, /\{slug:`local-environments`\},\{slug:`agent-workspaces`\},\{slug:`worktrees`\}/); + assert.match(visibilitySource, /"local-environments":H,"agent-workspaces":H,worktrees:F/); + assert.match( + visibilitySource, + /case`worktrees`:case`local-environments`:case`agent-workspaces`:case`environments`:return!0/, + ); + + const appInitialSource = fs.readFileSync(path.join(assetsDir, "app-initial-test.js"), "utf8"); + assert.match(appInitialSource, /settings\.nav\.agent-workspaces/); + assert.match(appInitialSource, /settings\.section\.agent-workspaces/); + assert.match(appInitialSource, new RegExp(SETTINGS_ASSET)); + assert.match(appInitialSource, /"agent-workspaces":BN\(async\(\)=>\(await Y\(/); + assert.match(appInitialSource, /local-environments\.agent-workspaces\.worktrees/); + assert.match(appInitialSource, /\{slug:`local-environments`\},\{slug:`agent-workspaces`\},\{slug:`worktrees`\}/); assert.equal(patchAgentWorkspaceSettingsAssets(tempApp).changed, 0); } finally { fs.rmSync(tempApp, { recursive: true, force: true }); @@ -1755,7 +1779,7 @@ test("agent-workspace settings patch rejects a partially patched current catalog const { assetsDir } = writeSyntheticExtractedApp(tempApp); const catalogPath = path.join( assetsDir, - "app-initial~app-main~hotkey-window-thread-page~keyboard-shortcuts-settings~thread-app-shell~current-test.js", + "app-initial-test.js", ); fs.writeFileSync( catalogPath, diff --git a/linux-features/api-key-model-visibility/patch.js b/linux-features/api-key-model-visibility/patch.js index 012408699..0d6a37292 100644 --- a/linux-features/api-key-model-visibility/patch.js +++ b/linux-features/api-key-model-visibility/patch.js @@ -8,37 +8,43 @@ function warn(message, patchName) { } function applyApiKeyModelVisibilityPatch(source) { - const modelVisibilityPattern = new RegExp( - `(function ${JS_IDENT}\\(\\{authMethod:(${JS_IDENT}),availableModels:${JS_IDENT},` + - `defaultModel:${JS_IDENT},enabledReasoningEfforts:${JS_IDENT},` + - `includeUltraReasoningEffort:${JS_IDENT},models:${JS_IDENT},` + - `useHiddenModels:(${JS_IDENT})\\}\\)\\{let[\\s\\S]{0,600}?[,;]${JS_IDENT}=)` + - `\\3&&\\2!==\\\`amazonBedrock\\\`(?=[,;])`, + if (source.includes(PATCH_MARKER)) { + return source; + } + + // Current upstream shape (refactored): the allowlist gate lives in a + // per-model visibility helper, e.g. + // function q$r({additionalAvailableModels:e,authMethod:t,availableModels:n,model:r,useHiddenModels:i}){return e?.has(r.model)===!0||(i&&t!==`amazonBedrock`?n.has(r.model):!r.hidden)} + // Bypass the allowlist for API-key authenticated hosts the same way it is + // already bypassed for non-ChatGPT hosts: add `&&authMethod!==`apikey``. + const helperPattern = new RegExp( + `(function ${JS_IDENT}\\(\\{additionalAvailableModels:(${JS_IDENT}),` + + `authMethod:(${JS_IDENT}),availableModels:(${JS_IDENT}),model:(${JS_IDENT}),` + + `useHiddenModels:(${JS_IDENT})\\}\\)\\{return ` + + `\\2\\?\\.has\\(\\5\\.model\\)===!0\\|\\|\\()` + + `\\6&&\\3!==\\\`amazonBedrock\\\`` + + `(\\?\\4\\.has\\(\\5\\.model\\):!\\5\\.hidden\\)\\})`, "g", ); - const patchedVisibilityPattern = new RegExp( - `function ${JS_IDENT}\\(\\{authMethod:(${JS_IDENT}),availableModels:${JS_IDENT},` + - `defaultModel:${JS_IDENT},enabledReasoningEfforts:${JS_IDENT},` + - `includeUltraReasoningEffort:${JS_IDENT},models:${JS_IDENT},` + - `useHiddenModels:(${JS_IDENT})\\}\\)\\{let[\\s\\S]{0,600}?[,;]${JS_IDENT}=` + - `\\2&&\\1!==\\\`amazonBedrock\\\`&&\\1!==\\\`apikey\\\`/\\*${PATCH_MARKER}\\*/(?=[,;])`, - ); - const patched = source.replace( - modelVisibilityPattern, - (_match, prefix, authMethodVar, useHiddenModelsVar) => + helperPattern, + ( + _match, + prefix, + _additionalAvailableModelsVar, + authMethodVar, + _availableModelsVar, + _modelVar, + useHiddenModelsVar, + suffix, + ) => `${prefix}${useHiddenModelsVar}&&${authMethodVar}!==\`amazonBedrock\`&&` + - `${authMethodVar}!==\`apikey\`/*${PATCH_MARKER}*/`, + `${authMethodVar}!==\`apikey\`/*${PATCH_MARKER}*/${suffix}`, ); - if (patched !== source) { return patched; } - if (patchedVisibilityPattern.test(source)) { - return source; - } - if ( source.includes("list-models-for-host") && source.includes("useHiddenModels") && @@ -55,7 +61,7 @@ const descriptors = [ phase: "webview-asset", order: 20550, ciPolicy: "optional", - pattern: /^app-initial~app-main~.*\.js$/, + pattern: /^app-initial-[^.]+\.js$/, missingDescription: "app main webview bundle", skipDescription: "API key model visibility patch", apply: applyApiKeyModelVisibilityPatch, diff --git a/linux-features/api-key-model-visibility/test.js b/linux-features/api-key-model-visibility/test.js index 30828109b..6cb8c0c12 100644 --- a/linux-features/api-key-model-visibility/test.js +++ b/linux-features/api-key-model-visibility/test.js @@ -14,9 +14,6 @@ const { const { loadLinuxFeaturePatchDescriptors, } = require("../../scripts/lib/linux-features.js"); -const { - applyApiKeyServiceTierPatch, -} = require("../api-key-service-tier/patch.js"); const { applyApiKeyModelVisibilityPatch, descriptors, @@ -29,12 +26,14 @@ function applyPatchTwice(patchFn, source) { return once; } -function modelCatalogFixture() { - return "function vbe({authMethod:e,availableModels:t,defaultModel:n,enabledReasoningEfforts:r,includeUltraReasoningEffort:i,models:a,useHiddenModels:o}){let s=[],c=null,l=o&&e!==`amazonBedrock`;return a.forEach(n=>{if(l?t.has(n.model):!n.hidden){s.push(n),n.isDefault&&(c=n)}}),c??=s.find(e=>e.model===n)??null,{models:s,defaultModel:c}}"; +function modelVisibilityHelperFixture() { + return "function q$r({additionalAvailableModels:e,authMethod:t,availableModels:n,model:r,useHiddenModels:i}){return e?.has(r.model)===!0||(i&&t!==`amazonBedrock`?n.has(r.model):!r.hidden)}"; } -function serviceTierCompatibleFixture() { - return "function vbe({authMethod:e,availableModels:t,defaultModel:n,enabledReasoningEfforts:r,includeUltraReasoningEffort:i,models:a,useHiddenModels:o}){let s=[],c=null,l=o&&e!==`amazonBedrock`,u=a.some(e=>e.supportedReasoningEfforts.some(({reasoningEffort:e})=>e===`max`)),d=i&&a.some(e=>e.supportedReasoningEfforts.some(({reasoningEffort:e})=>e===`ultra`));return a.forEach(n=>{if(l?t.has(n.model):!n.hidden){let t=i?n.supportedReasoningEfforts:n.supportedReasoningEfforts.filter(({reasoningEffort:e})=>e!==`ultra`),a=(e===`copilot`?[t.find(e=>e.reasoningEffort===`medium`)??{reasoningEffort:`medium`,description:`medium effort`}]:t).filter(({reasoningEffort:e})=>Gx(e)&&r.has(e)),o={...n,supportedReasoningEfforts:a};s.push(o),n.isDefault&&(c=o)}}),c??=s.find(e=>e.model===n)??null,{models:s,defaultModel:c}}"; +function modelCatalogFixture() { + // Current upstream shape (refactored): catalog filter delegates per-model + // visibility to a q$r-style helper that owns the allowlist gate. + return "function vbe({additionalAvailableModels:e,authMethod:t,availableModels:n,defaultModel:r,enabledReasoningEfforts:i,includeUltraReasoningEffort:a,models:o,useHiddenModels:s}){let c=[],l=null;return o.forEach(r=>{if(q$r({additionalAvailableModels:e,authMethod:t,availableModels:n,model:r,useHiddenModels:s})){c.push(r),r.isDefault&&(l=r)}}),l??=c.find(e=>e.model===r)??null,{models:c,defaultModel:l}}" + modelVisibilityHelperFixture(); } function evaluateCatalog(source, authMethod, useHiddenModels = true) { @@ -106,7 +105,8 @@ test("descriptor is optional and targets app main webview chunks", () => { descriptors.map((descriptor) => [descriptor.id, descriptor.phase, descriptor.ciPolicy]), [["api-key-model-visibility-ui", "webview-asset", "optional"]], ); - assert.equal(descriptors[0].pattern.test("app-initial~app-main~onboarding-page-abc.js"), true); + assert.equal(descriptors[0].pattern.test("app-initial~app-main~onboarding-page-abc.js"), false); + assert.equal(descriptors[0].pattern.test("app-initial-CKNQDTeE.js"), true); assert.equal(descriptors[0].pattern.test("settings-page-abc.js"), false); }); @@ -114,7 +114,7 @@ test("API-key hosts use visible CLI models instead of the desktop allowlist", () const patched = applyPatchTwice(applyApiKeyModelVisibilityPatch, modelCatalogFixture()); const catalog = evaluateCatalog(patched, "apikey"); - assert.match(patched, /e!==`apikey`\/\*codexLinuxApiKeyModelVisibility\*\//); + assert.match(patched, /!==`apikey`\/\*codexLinuxApiKeyModelVisibility\*\//); assert.deepEqual(modelNames(catalog), [ "gpt-5.6-sol", "gpt-5.6-terra", @@ -134,6 +134,7 @@ test("ChatGPT and existing no-allowlist paths keep their upstream behavior", () const patched = applyApiKeyModelVisibilityPatch(modelCatalogFixture()); assert.deepEqual(modelNames(evaluateCatalog(patched, "chatgpt")), ["gpt-5.5"]); + assert.deepEqual(modelNames(evaluateCatalog(patched, "copilot")), ["gpt-5.5"]); assert.deepEqual(modelNames(evaluateCatalog(patched, "chatgpt", false)), [ "gpt-5.6-sol", "gpt-5.6-terra", @@ -148,36 +149,31 @@ test("ChatGPT and existing no-allowlist paths keep their upstream behavior", () ]); }); -test("model visibility and API key service tier patches compose in either order", () => { - const source = serviceTierCompatibleFixture(); - const visibilityFirst = applyApiKeyServiceTierPatch( - applyApiKeyModelVisibilityPatch(source), - ); - const serviceTierFirst = applyApiKeyModelVisibilityPatch( - applyApiKeyServiceTierPatch(source), - ); - - assert.equal(visibilityFirst, serviceTierFirst); - for (const patched of [visibilityFirst, serviceTierFirst]) { - assert.match(patched, /codexLinuxApiKeyModelVisibility/); - assert.match(patched, /codexLinuxApiKeyServiceTierModel:e===`apikey`/); +test("drifted model visibility helpers fail soft and stay byte-identical", () => { + const helper = modelVisibilityHelperFixture(); + const driftedHelpers = [ + "function q$r({additionalAvailableModels:e,authMethod:t,availableModels:n,model:r,useHiddenModels:i}){return i&&t!==`amazonBedrock`;}", + "function q$r({additionalAvailableModels:e,authMethod:t,availableModels:n,model:r,useHiddenModels:i}){return i&&t!==`amazonBedrock`,n.has(r.model)}", + helper.replace( + "?n.has(r.model):!r.hidden", + "?featureGate&&n.has(r.model):!r.hidden", + ), + helper.replace( + "?n.has(r.model):!r.hidden", + "?n.has(r.model):featureGate&&!r.hidden", + ), + ]; + + for (const source of driftedHelpers) { + assert.equal(applyApiKeyModelVisibilityPatch(source), source); } }); -test("extended upstream model gates fail soft instead of patching mid-expression", () => { - const source = modelCatalogFixture().replace( - "l=o&&e!==`amazonBedrock`;", - "l=o&&e!==`amazonBedrock`&&featureGate;", - ); - - assert.equal(applyApiKeyModelVisibilityPatch(source), source); -}); - test("enabled descriptor patches a matching extracted webview asset", () => { withFeatureConfig(["api-key-model-visibility"], (featuresRoot) => { withTempDir((extractedDir) => { const assetsDir = path.join(extractedDir, "webview", "assets"); - const assetPath = path.join(assetsDir, "app-initial~app-main~fixture.js"); + const assetPath = path.join(assetsDir, "app-initial-CKNQDTeE.js"); fs.mkdirSync(assetsDir, { recursive: true }); fs.writeFileSync(assetPath, modelCatalogFixture()); diff --git a/linux-features/codex-micro/README.md b/linux-features/codex-micro/README.md new file mode 100644 index 000000000..a870cd311 --- /dev/null +++ b/linux-features/codex-micro/README.md @@ -0,0 +1,168 @@ +# Codex Micro + +This opt-in Linux feature enables the Work Louder Codex Micro integration that +already ships in the upstream Codex desktop app. It does three narrowly scoped +things: + +1. enables the upstream Codex Micro feature gate locally; and +2. adds the verified `node-hid@3.3.0` Linux prebuild for the current app's + nested Work Louder dependency; and +3. watches Linux `hidraw` topology so a Micro connected after ChatGPT starts is + discovered without restarting the app. + +The feature is disabled by default. + +## Hot-plug discovery + +The upstream service uses a native HID topology watcher that is not available +in the Linux build. Without a replacement, discovery falls back to a 30-second +scan and a newly connected device can appear to require an app restart. + +On Linux, this feature watches `/dev` for `hidraw` additions and removals, +debounces duplicate events, and asks the existing service to reconcile device +topology. If the filesystem watcher cannot start or reports an error, a +two-second unreferenced polling fallback takes over. The service's existing +settle retries still handle the short delay between device-node creation and +udev ACL application. Disconnecting or disabling the feature disposes the +watcher and any fallback timer. + +## Enable + +Copy `linux-features/features.example.json` to the gitignored +`linux-features/features.json`, then add `codex-micro` to `enabled` and rebuild: + +```json +{ + "enabled": [ + "codex-micro" + ] +} +``` + +```bash +./install.sh +``` + +The feature rejects the build if the current DMG no longer contains the +expected Codex Micro gate/route or the exact nested `node-hid` loader contract. +Only the pinned x64 and arm64 prebuilds are supported; there is no source-build +fallback. + +## Runtime libraries + +Native packages declare the required `libudev.so.1` and `libusb-1.0.so.0` +dependencies. For AppImage, source, or user-local installs, install them +yourself: + +```bash +# Debian / Ubuntu +sudo apt install libudev1 libusb-1.0-0 + +# Fedora +sudo dnf install systemd-libs libusb1 + +# Arch Linux +sudo pacman -S systemd-libs libusb +``` + +## Device access + +Feature-enabled Debian, RPM, and pacman packages install +`/usr/lib/udev/rules.d/70-codex-micro.rules`. Native package scripts do not +reload the system udev daemon. Reload after the first install and after a +package rebuild that enables or disables the feature, then reconnect USB or +Bluetooth: + +```bash +sudo udevadm control --reload-rules +``` + +AppImage, source, user-local, Home Manager, and direct flake installs cannot +change host udev policy. Install the copy staged for your install mode once. + +For a source build: + +```bash +sudo install -Dm0644 \ + codex-app/.codex-linux/features/codex-micro/70-codex-micro.rules \ + /etc/udev/rules.d/70-codex-micro.rules +sudo udevadm control --reload-rules +``` + +For a user-local install, use the installed app copy: + +```bash +sudo install -Dm0644 \ + "$HOME/.local/opt/codex-desktop-linux/codex-app/.codex-linux/features/codex-micro/70-codex-micro.rules" \ + /etc/udev/rules.d/70-codex-micro.rules +sudo udevadm control --reload-rules +``` + +For an AppImage, extract its staged copy first: + +```bash +codex_appimage="$(readlink -f ./codex-desktop-*.AppImage)" +codex_extract_dir="$(mktemp -d)" +trap 'rm -rf "$codex_extract_dir"' EXIT +( + cd "$codex_extract_dir" + "$codex_appimage" --appimage-extract >/dev/null +) +sudo install -Dm0644 \ + "$codex_extract_dir/squashfs-root/opt/codex-desktop/.codex-linux/features/codex-micro/70-codex-micro.rules" \ + /etc/udev/rules.d/70-codex-micro.rules +sudo udevadm control --reload-rules +``` + +For Home Manager or a direct flake package, resolve the selected Nix store +output from the installed launcher: + +```bash +codex_package_root="$( + dirname "$(dirname "$(readlink -f "$(command -v codex-desktop)")")" +)" +sudo install -Dm0644 \ + "$codex_package_root/lib/udev/rules.d/70-codex-micro.rules" \ + /etc/udev/rules.d/70-codex-micro.rules +sudo udevadm control --reload-rules +``` + +The USB rule imports `usb_id` before matching the observed Work Louder +VID/PID/interface (`303a:8360`, interface `00`). The Bluetooth rule matches the +same vendor HID channel on the Bluetooth HID bus. Both rules use `uaccess` and +`0660`; they do not make hidraw devices world-writable. + +NixOS installs the rule automatically when the feature is selected through the +module. Other install modes require the matching manual procedure above. +When disabling a manually installed copy, remove +`/etc/udev/rules.d/70-codex-micro.rules`, reload the rules, and reconnect the +device. Removing or disabling a native package build removes its packaged copy, +but still requires the same reload and reconnect. + +## Bluetooth + +Pair the Micro through the desktop Bluetooth settings before opening Codex. +Channel selection and pairing mode are device operations; see the Work Louder +Micro setup guide. + +## Verify + +With the Micro connected, identify its hidraw node and exercise the actual rule: + +```bash +udevadm info --attribute-walk --name=/dev/hidrawN +sudo udevadm test "$(udevadm info --query=path --name=/dev/hidrawN)" +getfacl /dev/hidrawN +``` + +The test output must show the Codex Micro rule, imported USB properties for a +USB connection, the `uaccess` tag, and mode `0660`. Then open +`Settings -> Codex Micro` and verify connection state, buttons, dial, joystick, +battery/status reporting, and lighting controls. + +Run focused checks with: + +```bash +node --test linux-features/codex-micro/test.js +node --test scripts/lib/linux-features.test.js +``` diff --git a/linux-features/codex-micro/feature.json b/linux-features/codex-micro/feature.json new file mode 100644 index 000000000..8a38ea807 --- /dev/null +++ b/linux-features/codex-micro/feature.json @@ -0,0 +1,42 @@ +{ + "id": "codex-micro", + "title": "Codex Micro hardware support", + "description": "Adds the verified Linux node-hid binding and narrow USB-C/Bluetooth hidraw policy required by the upstream Work Louder Codex Micro service.", + "defaultEnabled": false, + "entrypoints": { + "patchDescriptors": "./patch.js" + }, + "resources": [ + { + "source": "resources/70-codex-micro.rules", + "target": ".codex-linux/features/codex-micro/70-codex-micro.rules", + "mode": "0644" + } + ], + "packageResources": [ + { + "source": "resources/70-codex-micro.rules", + "target": "usr/lib/udev/rules.d/70-codex-micro.rules", + "mode": "0644", + "formats": [ + "deb", + "rpm", + "pacman" + ] + } + ], + "packageDependencies": { + "deb": [ + "libudev1", + "libusb-1.0-0" + ], + "rpm": [ + "libudev.so.1%{codex_elf_suffix}", + "libusb-1.0.so.0%{codex_elf_suffix}" + ], + "pacman": [ + "libusb", + "systemd-libs" + ] + } +} diff --git a/linux-features/codex-micro/native-artifacts.json b/linux-features/codex-micro/native-artifacts.json new file mode 100644 index 000000000..246483978 --- /dev/null +++ b/linux-features/codex-micro/native-artifacts.json @@ -0,0 +1,27 @@ +{ + "name": "node-hid", + "version": "3.3.0", + "license": "(MIT OR X11)", + "integrity": "sha512-j+dFgJLRAE0nufQKXk3IfS6T6YuHhCgMvz4TrG0sgtb6DSCdYpfJ1etcdmeCmPQjUgO+yo32ktVrRliNs/+fmg==", + "shasum": "2b00639e8bb9fc96592e8366fda7ae380826a7ee", + "loaderContract": { + "main": "./nodehid.js", + "napiVersions": [ + 4 + ], + "files": { + "nodehid.js": "84053a6ea19b238e61368f5220a9a8af96b27e752569f14d63dba2127a37988b", + "binding-options.js": "e7c820107f3b6571ca1505a5ffbe17511088336e4c410ec718ea9ec200c6b1e6" + } + }, + "prebuilds": { + "x64": { + "path": "prebuilds/HID_hidraw-linux-x64/node-napi-v4.node", + "sha256": "6c7f3b3fcc238a74e7e3237b50b2ff05181e94862b1963e8074ff8fc75885021" + }, + "arm64": { + "path": "prebuilds/HID_hidraw-linux-arm64/node-napi-v4.node", + "sha256": "06ea97f377e2246a1e9bf3770186727e72ff3c166579d9c259c6d32a07aeaa60" + } + } +} diff --git a/linux-features/codex-micro/native-binding.js b/linux-features/codex-micro/native-binding.js new file mode 100644 index 000000000..edf4ad4f3 --- /dev/null +++ b/linux-features/codex-micro/native-binding.js @@ -0,0 +1,531 @@ +#!/usr/bin/env node +"use strict"; + +const childProcess = require("node:child_process"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const SUPPORTED_ARCHITECTURES = new Set(["x64", "arm64"]); + +function digest(contents, algorithm, encoding) { + return crypto.createHash(algorithm).update(contents).digest(encoding); +} + +function integrityFor(contents) { + return `sha512-${digest(contents, "sha512", "base64")}`; +} + +function readPackageMetadata(packageDir, label) { + const packagePath = path.join(packageDir, "package.json"); + const stat = fs.lstatSync(packagePath, { throwIfNoEntry: false }); + if (!stat?.isFile() || stat.isSymbolicLink()) { + throw new Error(`${label} package.json is missing or unsafe: ${packagePath}`); + } + try { + return JSON.parse(fs.readFileSync(packagePath, "utf8")); + } catch (error) { + throw new Error(`${label} package.json is unreadable: ${error.message}`); + } +} + +function requirePackageDirectory(packageDir, expectedName, label) { + const stat = fs.lstatSync(packageDir, { throwIfNoEntry: false }); + if (!stat?.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`${label} package is missing or unsafe: ${packageDir}`); + } + const metadata = readPackageMetadata(packageDir, label); + if (metadata.name !== expectedName) { + throw new Error( + `${label} package identity mismatch: expected ${expectedName}, got ${metadata.name ?? "unknown"}`, + ); + } + return metadata; +} + +function discoverBundledNodeHid(extractedDir) { + const extractedRoot = path.resolve(extractedDir); + const deviceKitDir = path.join( + extractedRoot, + "node_modules", + "@worklouder", + "device-kit-oai", + ); + assertNoSymbolicLinkAncestors(extractedRoot, deviceKitDir); + requirePackageDirectory(deviceKitDir, "@worklouder/device-kit-oai", "Work Louder device-kit-oai"); + + const workLouderKitDir = path.join( + deviceKitDir, + "node_modules", + "@worklouder", + "wl-device-kit", + ); + assertNoSymbolicLinkAncestors(extractedRoot, workLouderKitDir); + requirePackageDirectory( + workLouderKitDir, + "@worklouder/wl-device-kit", + "Work Louder wl-device-kit", + ); + + const nodeHidDir = path.join(workLouderKitDir, "node_modules", "node-hid"); + assertNoSymbolicLinkAncestors(extractedRoot, nodeHidDir); + const nodeHid = requirePackageDirectory( + nodeHidDir, + "node-hid", + "Work Louder nested node-hid", + ); + return { + deviceKitDir, + workLouderKitDir, + nodeHidDir, + packageMetadata: nodeHid, + name: nodeHid.name, + version: nodeHid.version, + license: nodeHid.license, + }; +} + +function normalizeArchitecture(arch) { + if (!SUPPORTED_ARCHITECTURES.has(arch)) { + throw new Error(`Unsupported Codex Micro native binding architecture: ${String(arch)}`); + } + return arch; +} + +function selectPrebuild(artifactManifest, arch) { + normalizeArchitecture(arch); + return artifactManifest?.prebuilds?.[arch] ?? null; +} + +function inspectElf(contents) { + if ( + !Buffer.isBuffer(contents) + || contents.length < 20 + || !contents.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) + ) { + throw new Error("Native binding is not an ELF binary"); + } + if (contents[4] !== 2) { + throw new Error(`Unsupported ELF class ${contents[4]}; expected a 64-bit ELF binary`); + } + if (contents[5] !== 1) { + throw new Error(`Unsupported ELF encoding ${contents[5]}; expected little-endian`); + } + const machine = contents.readUInt16LE(18); + const arch = machine === 62 ? "x64" : machine === 183 ? "arm64" : null; + if (arch == null) { + throw new Error(`Unsupported ELF machine ${machine}`); + } + return { arch, machine }; +} + +function safeRelativeFilePath(value, label) { + if ( + typeof value !== "string" + || value.length === 0 + || path.isAbsolute(value) + || value.split(/[\\/]+/).some((part) => part === "" || part === "." || part === "..") + ) { + throw new Error(`${label} must be a safe relative file path`); + } + return value; +} + +function validateArtifactManifest(artifactManifest) { + if ( + artifactManifest == null + || typeof artifactManifest !== "object" + || Array.isArray(artifactManifest) + ) { + throw new Error("Codex Micro node-hid artifact manifest is invalid"); + } + for (const key of ["name", "version", "license", "integrity", "shasum"]) { + if (typeof artifactManifest[key] !== "string" || artifactManifest[key].length === 0) { + throw new Error(`Codex Micro node-hid artifact manifest is missing ${key}`); + } + } + if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(artifactManifest.integrity)) { + throw new Error("Codex Micro node-hid artifact integrity is invalid"); + } + if (!/^[0-9a-f]{40}$/.test(artifactManifest.shasum)) { + throw new Error("Codex Micro node-hid artifact shasum is invalid"); + } + + const loaderContract = artifactManifest.loaderContract; + if ( + loaderContract == null + || typeof loaderContract !== "object" + || Array.isArray(loaderContract) + || typeof loaderContract.main !== "string" + || !Array.isArray(loaderContract.napiVersions) + || loaderContract.napiVersions.length === 0 + || !loaderContract.napiVersions.every(Number.isSafeInteger) + || loaderContract.files == null + || typeof loaderContract.files !== "object" + || Array.isArray(loaderContract.files) + || Object.keys(loaderContract.files).length === 0 + ) { + throw new Error("Codex Micro node-hid loader contract is invalid"); + } + for (const [relativePath, sha256] of Object.entries(loaderContract.files)) { + safeRelativeFilePath(relativePath, "Codex Micro node-hid loader contract file"); + if (typeof sha256 !== "string" || !/^[0-9a-f]{64}$/.test(sha256)) { + throw new Error(`Codex Micro node-hid loader contract hash is invalid: ${relativePath}`); + } + } + + for (const arch of SUPPORTED_ARCHITECTURES) { + const prebuild = artifactManifest.prebuilds?.[arch]; + if (prebuild == null || typeof prebuild !== "object" || Array.isArray(prebuild)) { + throw new Error(`Codex Micro node-hid artifact manifest is missing the ${arch} prebuild`); + } + const expectedPath = `prebuilds/HID_hidraw-linux-${arch}/node-napi-v4.node`; + if (safeRelativeFilePath(prebuild.path, `Codex Micro ${arch} prebuild path`) !== expectedPath) { + throw new Error(`Codex Micro ${arch} prebuild path must be ${expectedPath}`); + } + if (typeof prebuild.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(prebuild.sha256)) { + throw new Error(`Codex Micro ${arch} prebuild SHA-256 is invalid`); + } + } +} + +function validateBundledPackage(discovered, artifactManifest) { + if (discovered.name !== artifactManifest.name) { + throw new Error( + `Bundled node-hid identity mismatch: expected ${artifactManifest.name}, got ${discovered.name}`, + ); + } + if (discovered.version !== artifactManifest.version) { + throw new Error( + `Bundled node-hid version mismatch: expected ${artifactManifest.version}, got ${discovered.version}`, + ); + } + if (discovered.license !== artifactManifest.license) { + throw new Error( + `Bundled node-hid license mismatch: expected ${artifactManifest.license}, got ${discovered.license}`, + ); + } + + const loaderContract = artifactManifest.loaderContract; + if (discovered.packageMetadata.main !== loaderContract.main) { + throw new Error( + `Bundled node-hid loader entrypoint mismatch: expected ${loaderContract.main}, ` + + `got ${discovered.packageMetadata.main ?? "unknown"}`, + ); + } + if ( + JSON.stringify(discovered.packageMetadata.binary?.napi_versions) + !== JSON.stringify(loaderContract.napiVersions) + ) { + throw new Error( + `Bundled node-hid N-API contract mismatch: expected ` + + `${JSON.stringify(loaderContract.napiVersions)}, got ` + + `${JSON.stringify(discovered.packageMetadata.binary?.napi_versions ?? null)}`, + ); + } + for (const [relativePath, expectedSha256] of Object.entries(loaderContract.files)) { + const filePath = path.join(discovered.nodeHidDir, relativePath); + assertNoSymbolicLinkAncestors(discovered.nodeHidDir, filePath); + const stat = fs.lstatSync(filePath, { throwIfNoEntry: false }); + if (!stat?.isFile() || stat.isSymbolicLink()) { + throw new Error(`Bundled node-hid loader contract file is missing or unsafe: ${relativePath}`); + } + const actualSha256 = digest(fs.readFileSync(filePath), "sha256", "hex"); + if (actualSha256 !== expectedSha256) { + throw new Error( + `Bundled node-hid loader contract hash mismatch for ${relativePath}: ` + + `expected ${expectedSha256}, got ${actualSha256}`, + ); + } + } +} + +function validateMaterializedPackage(materialized, artifactManifest) { + if (materialized == null || typeof materialized !== "object") { + throw new Error("node-hid materializer returned no package"); + } + if (materialized.integrity !== artifactManifest.integrity) { + throw new Error( + `node-hid artifact integrity mismatch: expected ${artifactManifest.integrity}, ` + + `got ${materialized.integrity ?? "unknown"}`, + ); + } + if (materialized.shasum !== artifactManifest.shasum) { + throw new Error( + `node-hid artifact shasum mismatch: expected ${artifactManifest.shasum}, ` + + `got ${materialized.shasum ?? "unknown"}`, + ); + } + const metadata = readPackageMetadata(materialized.packageDir, "Materialized node-hid"); + for (const key of ["name", "version", "license"]) { + if (metadata[key] !== artifactManifest[key]) { + throw new Error( + `node-hid artifact ${key} mismatch: expected ${artifactManifest[key]}, ` + + `got ${metadata[key] ?? "unknown"}`, + ); + } + } +} + +function validateBinding(contents, expectedArch, expectedSha256) { + const actualSha256 = digest(contents, "sha256", "hex"); + if (actualSha256 !== expectedSha256) { + throw new Error( + `node-hid native binding SHA-256 mismatch: expected ${expectedSha256}, got ${actualSha256}`, + ); + } + const elf = inspectElf(contents); + if (elf.arch !== expectedArch) { + throw new Error( + `node-hid native binding ELF architecture ${elf.arch} does not match ${expectedArch}`, + ); + } + return elf; +} + +function assertNoSymbolicLinkAncestors(root, target) { + const resolvedRoot = path.resolve(root); + const resolvedTarget = path.resolve(target); + const rootStat = fs.lstatSync(resolvedRoot, { throwIfNoEntry: false }); + if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) { + throw new Error(`Codex Micro native binding root must be a safe directory: ${resolvedRoot}`); + } + const relative = path.relative(resolvedRoot, resolvedTarget); + if ( + relative === "" + || relative === ".." + || relative.startsWith(`..${path.sep}`) + || path.isAbsolute(relative) + ) { + throw new Error(`Codex Micro native binding target must stay inside ${resolvedRoot}`); + } + + let current = resolvedRoot; + for (const part of relative.split(path.sep)) { + current = path.join(current, part); + const stat = fs.lstatSync(current, { throwIfNoEntry: false }); + if (stat?.isSymbolicLink()) { + throw new Error(`Codex Micro native binding path must not contain symlinks: ${current}`); + } + } +} + +function atomicWriteBinding(nodeHidDir, targetPath, contents) { + assertNoSymbolicLinkAncestors(nodeHidDir, targetPath); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + assertNoSymbolicLinkAncestors(nodeHidDir, targetPath); + const temporaryPath = path.join( + path.dirname(targetPath), + `.${path.basename(targetPath)}.codex-micro-${process.pid}-` + + crypto.randomBytes(8).toString("hex"), + ); + let temporaryFd; + try { + const noFollow = fs.constants.O_NOFOLLOW ?? 0; + temporaryFd = fs.openSync( + temporaryPath, + fs.constants.O_WRONLY + | fs.constants.O_CREAT + | fs.constants.O_EXCL + | noFollow, + 0o755, + ); + fs.writeFileSync(temporaryFd, contents); + fs.fchmodSync(temporaryFd, 0o755); + fs.closeSync(temporaryFd); + temporaryFd = undefined; + assertNoSymbolicLinkAncestors(nodeHidDir, targetPath); + fs.renameSync(temporaryPath, targetPath); + } finally { + if (temporaryFd != null) { + fs.closeSync(temporaryFd); + } + fs.rmSync(temporaryPath, { force: true }); + } +} + +function run(command, args, options = {}) { + try { + return childProcess.execFileSync(command, args, { + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + ...options, + }); + } catch (error) { + const stderr = typeof error?.stderr === "string" ? error.stderr.trim() : ""; + const failure = stderr || error?.code || error?.message || "unknown error"; + throw new Error(`${command} ${args.join(" ")} failed: ${failure}`); + } +} + +async function defaultMaterializePackage(request) { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-micro-node-hid-")); + try { + const archiveOverride = process.env.CODEX_MICRO_NODE_HID_ARCHIVE?.trim(); + let archivePath; + if (archiveOverride) { + archivePath = path.resolve(archiveOverride); + const stat = fs.lstatSync(archivePath, { throwIfNoEntry: false }); + if (!stat?.isFile() || stat.isSymbolicLink()) { + throw new Error(`CODEX_MICRO_NODE_HID_ARCHIVE is not a safe file: ${archivePath}`); + } + } else { + const packOutput = run( + "npm", + [ + "pack", + `${request.name}@${request.version}`, + "--ignore-scripts", + "--json", + "--pack-destination", + temporaryRoot, + ], + { env: { ...process.env, npm_config_ignore_scripts: "true" } }, + ); + let packResult; + try { + [packResult] = JSON.parse(packOutput); + } catch (error) { + throw new Error(`Could not parse npm pack output: ${error.message}`); + } + archivePath = path.join(temporaryRoot, packResult.filename); + } + + const archive = fs.readFileSync(archivePath); + const integrity = integrityFor(archive); + const shasum = digest(archive, "sha1", "hex"); + if (integrity !== request.integrity || shasum !== request.shasum) { + throw new Error( + `node-hid archive verification failed: expected ${request.integrity} / ` + + `${request.shasum}, got ${integrity} / ${shasum}`, + ); + } + + const extractRoot = path.join(temporaryRoot, "extracted"); + fs.mkdirSync(extractRoot); + run("tar", ["-xzf", archivePath, "-C", extractRoot]); + const packageDir = path.join(extractRoot, "package"); + const stat = fs.lstatSync(packageDir, { throwIfNoEntry: false }); + if (!stat?.isDirectory() || stat.isSymbolicLink()) { + throw new Error("node-hid archive did not contain a safe package/ directory"); + } + return { + packageDir, + integrity, + shasum, + cleanup: () => fs.rmSync(temporaryRoot, { recursive: true, force: true }), + }; + } catch (error) { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + throw error; + } +} + +async function stageCodexMicroNativeBinding(options) { + const arch = normalizeArchitecture(options.arch); + const artifactManifest = options.artifactManifest; + const materializePackage = options.materializePackage ?? defaultMaterializePackage; + validateArtifactManifest(artifactManifest); + + const discovered = discoverBundledNodeHid(options.extractedDir); + validateBundledPackage(discovered, artifactManifest); + + const prebuild = selectPrebuild(artifactManifest, arch); + if (prebuild == null) { + throw new Error(`A verified node-hid prebuild is required for ${arch}`); + } + const targetPath = path.join(discovered.nodeHidDir, prebuild.path); + assertNoSymbolicLinkAncestors(path.resolve(options.extractedDir), targetPath); + const existingStat = fs.lstatSync(targetPath, { throwIfNoEntry: false }); + if (existingStat != null) { + if (!existingStat.isFile() || existingStat.isSymbolicLink()) { + throw new Error(`Existing node-hid native binding is unsafe: ${targetPath}`); + } + const existing = fs.readFileSync(targetPath); + const existingSha256 = digest(existing, "sha256", "hex"); + if (existingSha256 !== prebuild.sha256) { + throw new Error( + `Existing node-hid native binding hash mismatch: expected ${prebuild.sha256}, ` + + `got ${existingSha256}`, + ); + } + validateBinding(existing, arch, prebuild.sha256); + return { + changed: false, + alreadyApplied: true, + version: artifactManifest.version, + targetPath, + source: "existing-prebuild", + integrity: artifactManifest.integrity, + }; + } + + let materialized; + try { + materialized = await materializePackage({ + name: artifactManifest.name, + version: artifactManifest.version, + integrity: artifactManifest.integrity, + shasum: artifactManifest.shasum, + }); + validateMaterializedPackage(materialized, artifactManifest); + + const sourcePath = path.join(materialized.packageDir, prebuild.path); + assertNoSymbolicLinkAncestors(materialized.packageDir, sourcePath); + const sourceStat = fs.lstatSync(sourcePath, { throwIfNoEntry: false }); + if (!sourceStat?.isFile() || sourceStat.isSymbolicLink()) { + throw new Error(`Verified node-hid prebuild is missing or unsafe: ${prebuild.path}`); + } + const contents = fs.readFileSync(sourcePath); + validateBinding(contents, arch, prebuild.sha256); + atomicWriteBinding(path.resolve(options.extractedDir), targetPath, contents); + + return { + changed: true, + alreadyApplied: false, + version: artifactManifest.version, + targetPath, + source: "verified-prebuild", + integrity: artifactManifest.integrity, + }; + } finally { + materialized?.cleanup?.(); + } +} + +function currentArtifactManifest() { + return JSON.parse( + fs.readFileSync(path.join(__dirname, "native-artifacts.json"), "utf8"), + ); +} + +async function main() { + if (process.argv[2] !== "--stage" || !process.argv[3]) { + console.error("Usage: native-binding.js --stage "); + process.exitCode = 1; + return; + } + const result = await stageCodexMicroNativeBinding({ + extractedDir: process.argv[3], + arch: process.arch, + artifactManifest: currentArtifactManifest(), + }); + process.stdout.write(`${JSON.stringify(result)}\n`); +} + +if (require.main === module) { + main().catch((error) => { + console.error(`ERROR: ${error.message}`); + process.exitCode = 1; + }); +} + +module.exports = { + defaultMaterializePackage, + discoverBundledNodeHid, + inspectElf, + selectPrebuild, + stageCodexMicroNativeBinding, + validateArtifactManifest, +}; diff --git a/linux-features/codex-micro/patch.js b/linux-features/codex-micro/patch.js new file mode 100644 index 000000000..1c7143cca --- /dev/null +++ b/linux-features/codex-micro/patch.js @@ -0,0 +1,340 @@ +"use strict"; + +const childProcess = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const { + extractedAppPatch, + webviewAssetPatch, +} = require("../../scripts/patches/descriptor.js"); + +const CODEX_MICRO_GATE_ID = "3207467860"; +const CODEX_MICRO_ROUTE = "/settings/codex-micro"; +const CODEX_MICRO_GATE_MARKER = "codexLinuxCodexMicroGateOverride"; +const CODEX_MICRO_HOTPLUG_MARKER = "codexLinuxCodexMicroHotplug"; +const FEATURE_GATE_WARNING = "useFeatureGate hook failed to find a valid StatsigClient"; +const JS_IDENT = "[A-Za-z_$][\\w$]*"; +const CODEX_MICRO_SERVICE_PATTERN = + /^service-[A-Za-z0-9_-]+\.js$/; +const WATCH_TOPOLOGY_FUNCTION = new RegExp( + `function (${JS_IDENT})\\((${JS_IDENT})\\)\\{return ` + + `(${JS_IDENT})\\(\\)\\.watch\\(\\2\\)\\}`, + "g", +); + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function exportedFeatureGateHook(source) { + const exportStart = source.lastIndexOf("export{"); + const exportEnd = exportStart < 0 ? -1 : source.indexOf("}", exportStart); + if (exportStart < 0 || exportEnd < 0) { + return null; + } + + const exportBlock = source.slice(exportStart, exportEnd + 1); + const candidates = new RegExp( + `function (${JS_IDENT})\\((${JS_IDENT})\\)\\{return ` + + `(${JS_IDENT})\\(\\),(${JS_IDENT})\\((${JS_IDENT}),\\2\\)\\}`, + "g", + ); + const exportedCandidates = []; + for (const match of source.matchAll(candidates)) { + const hookName = match[1]; + const exportedAsGateHook = new RegExp( + `(?:\\{|,)${escapeRegExp(hookName)} as ${JS_IDENT}(?:,|\\})`, + ); + if (exportedAsGateHook.test(exportBlock)) { + exportedCandidates.push({ + source: match[0], + hookName, + argumentName: match[2], + contextHookName: match[3], + atomReadName: match[4], + gateAtomName: match[5], + }); + } + } + return exportedCandidates.length === 1 ? exportedCandidates[0] : null; +} + +function hasCodexMicroCallsite(source, hookName) { + if (typeof source !== "string" || typeof hookName !== "string") { + return false; + } + const gateCall = new RegExp( + `(?:^|[^A-Za-z0-9_$.])${escapeRegExp(hookName)}\\(\`${CODEX_MICRO_GATE_ID}\`\\)`, + ); + return gateCall.test(source) + && source.includes(`\`${CODEX_MICRO_ROUTE}\``); +} + +function matchesCodexMicroFeatureGateContract(source) { + if (typeof source !== "string") { + return false; + } + if (source.includes(CODEX_MICRO_GATE_MARKER)) { + return true; + } + const hook = exportedFeatureGateHook(source); + return source.includes(FEATURE_GATE_WARNING) + && hook != null + && hasCodexMicroCallsite(source, hook.hookName); +} + +function applyCodexMicroFeatureGatePatch(source) { + if (typeof source !== "string" || source.includes(CODEX_MICRO_GATE_MARKER)) { + return source; + } + + const hook = exportedFeatureGateHook(source); + if (hook == null) { + if (source.includes(FEATURE_GATE_WARNING)) { + console.warn( + "WARN: Could not find the current exported feature-gate hook - " + + "skipping Codex Micro gate override", + ); + } + return source; + } + if (!hasCodexMicroCallsite(source, hook.hookName)) { + return source; + } + + const replacement = + `function ${hook.hookName}(${hook.argumentName}){return ` + + `${hook.contextHookName}(),${hook.atomReadName}(${hook.gateAtomName},${hook.argumentName})||` + + `${hook.argumentName}===\`${CODEX_MICRO_GATE_ID}\`/*${CODEX_MICRO_GATE_MARKER}*/}`; + return source.replace(hook.source, replacement); +} + +function hasCodexMicroServiceContract(source) { + return typeof source === "string" + && source.includes("hid-topology-watcher.node") + && source.includes("hid_topology_watcher.node") + && source.includes(".findCodexMicroInterfaces()") + && source.includes("scheduleTopologyFallbackScan()"); +} + +function codexMicroTopologyWatcher(source) { + if (!hasCodexMicroServiceContract(source)) { + return null; + } + WATCH_TOPOLOGY_FUNCTION.lastIndex = 0; + const matches = [...source.matchAll(WATCH_TOPOLOGY_FUNCTION)] + .filter((match) => + source.includes(`${match[3]}().findCodexMicroInterfaces()`), + ); + if (matches.length !== 1) { + return null; + } + const [match] = matches; + return { + source: match[0], + functionName: match[1], + callbackName: match[2], + loaderName: match[3], + }; +} + +function patchCodexMicroHotplugSource(source) { + const markerCount = source.split(CODEX_MICRO_HOTPLUG_MARKER).length - 1; + if (markerCount === 1) { + return { source, matched: 1, changed: 0, reason: null }; + } + if (markerCount !== 0) { + return { + source, + matched: 0, + changed: 0, + reason: `Found ${markerCount} Codex Micro hot-plug markers`, + }; + } + + const watcher = codexMicroTopologyWatcher(source); + if (watcher == null) { + return { + source, + matched: 0, + changed: 0, + reason: "Current Codex Micro topology watcher contract not found", + }; + } + + const replacement = + `function ${watcher.functionName}(${watcher.callbackName}){` + + `if(process.platform===\`linux\`){` + + `let codexLinuxHotplugTimer=null,codexLinuxDevWatcher=null,` + + `codexLinuxPollTimer=null,codexLinuxDisposed=!1,` + + `codexLinuxNotify=()=>{if(codexLinuxDisposed)return;` + + `codexLinuxHotplugTimer!=null&&` + + `clearTimeout(codexLinuxHotplugTimer),codexLinuxHotplugTimer=setTimeout(()=>{` + + `codexLinuxHotplugTimer=null,codexLinuxDisposed||` + + `${watcher.callbackName}()},100)},codexLinuxStartPolling=()=>{` + + `codexLinuxPollTimer==null&&(codexLinuxPollTimer=` + + `setInterval(codexLinuxNotify,2e3),codexLinuxPollTimer.unref?.())};` + + `try{codexLinuxDevWatcher=require(\`node:fs\`).watch(` + + `\`/dev\`,{persistent:!1},(eventType,filename)=>{` + + `(filename==null||/^hidraw[0-9]+$/.test(String(filename)))&&` + + `codexLinuxNotify()}),codexLinuxDevWatcher.on(\`error\`,()=>{` + + `if(codexLinuxDisposed)return;` + + `codexLinuxDevWatcher?.close(),codexLinuxDevWatcher=null,` + + `codexLinuxStartPolling(),codexLinuxNotify()})}catch{` + + `codexLinuxDisposed||(` + + `codexLinuxStartPolling(),codexLinuxNotify())}` + + `return{dispose(){codexLinuxDisposed=!0,` + + `codexLinuxHotplugTimer!=null&&clearTimeout(codexLinuxHotplugTimer),` + + `codexLinuxPollTimer!=null&&clearInterval(codexLinuxPollTimer),` + + `codexLinuxDevWatcher?.close(),` + + `codexLinuxDevWatcher=null}}}` + + `return ${watcher.loaderName}().watch(${watcher.callbackName})}` + + `/*${CODEX_MICRO_HOTPLUG_MARKER}*/`; + return { + source: source.replace(watcher.source, replacement), + matched: 1, + changed: 1, + reason: null, + }; +} + +function applyCodexMicroHotplugPatch(source) { + if (typeof source !== "string") { + return source; + } + return patchCodexMicroHotplugSource(source).source; +} + +function findCodexMicroServiceBundle(extractedDir) { + const buildDir = path.join(extractedDir, ".vite", "build"); + if (!fs.existsSync(buildDir)) { + return { + target: null, + result: null, + reason: ".vite/build directory not found", + }; + } + + const candidates = fs.readdirSync(buildDir, { withFileTypes: true }) + .filter((entry) => + entry.isFile() && CODEX_MICRO_SERVICE_PATTERN.test(entry.name), + ) + .map((entry) => path.join(buildDir, entry.name)) + .sort() + .map((bundlePath) => { + const source = fs.readFileSync(bundlePath, "utf8"); + return { + bundlePath, + result: patchCodexMicroHotplugSource(source), + }; + }) + .filter(({ result }) => result.matched === 1); + + if (candidates.length !== 1) { + return { + target: null, + result: null, + reason: + `Found ${candidates.length} current Codex Micro service bundles`, + }; + } + return { + target: candidates[0].bundlePath, + result: candidates[0].result, + reason: candidates[0].result.reason, + }; +} + +function patchCodexMicroService(extractedDir) { + const discovery = findCodexMicroServiceBundle(extractedDir); + if (discovery.target == null || discovery.result?.matched !== 1) { + const reason = + discovery.reason ?? "Current Codex Micro service bundle not found"; + console.warn(`WARN: ${reason} - skipping Linux Codex Micro hot-plug patch`); + return { matched: 0, changed: 0, reason }; + } + if (discovery.result.changed === 1) { + fs.writeFileSync(discovery.target, discovery.result.source, "utf8"); + } + return { + matched: discovery.result.matched, + changed: discovery.result.changed, + reason: discovery.result.reason, + target: path.relative(extractedDir, discovery.target), + }; +} + +function stageNativeBinding(extractedDir) { + const helper = path.join(__dirname, "native-binding.js"); + const output = childProcess.execFileSync(process.execPath, [helper, "--stage", extractedDir], { + encoding: "utf8", + env: process.env, + maxBuffer: 16 * 1024 * 1024, + }); + return JSON.parse(output); +} + +module.exports = { + CODEX_MICRO_GATE_ID, + CODEX_MICRO_GATE_MARKER, + CODEX_MICRO_HOTPLUG_MARKER, + CODEX_MICRO_ROUTE, + applyCodexMicroFeatureGatePatch, + applyCodexMicroHotplugPatch, + codexMicroTopologyWatcher, + exportedFeatureGateHook, + findCodexMicroServiceBundle, + hasCodexMicroCallsite, + hasCodexMicroServiceContract, + matchesCodexMicroFeatureGateContract, + patchCodexMicroHotplugSource, + patchCodexMicroService, + descriptors: [ + extractedAppPatch({ + id: "linux-hid-hotplug", + phase: "extracted-app:pre-webview", + order: 28_980, + ciPolicy: "opt-in", + targetSummary: "current Codex Micro main-process service bundle", + apply: patchCodexMicroService, + status: (result, warnings) => { + if (result?.matched !== 1) { + return { + status: "skipped-optional", + reason: result?.reason ?? warnings[0] ?? null, + }; + } + return result.changed === 1 ? "applied" : "already-applied"; + }, + }), + webviewAssetPatch({ + id: "webview-feature-gate", + order: 28_990, + ciPolicy: "opt-in", + pattern: /^app-initial-[A-Za-z0-9_-]+\.js$/, + assetMatch: matchesCodexMicroFeatureGateContract, + missingDescription: "current Codex Micro feature-gate webview bundle", + skipDescription: "Codex Micro feature-gate override", + apply: applyCodexMicroFeatureGatePatch, + }), + extractedAppPatch({ + id: "linux-node-hid-binding", + phase: "extracted-app:post-webview", + order: 29_000, + ciPolicy: "opt-in", + targetSummary: "current Work Louder nested node-hid 3.3.0 dependency", + apply: (extractedDir) => stageNativeBinding(extractedDir), + status: (result) => ({ + status: result?.changed + ? "applied" + : result?.alreadyApplied + ? "already-applied" + : "skipped-optional", + reason: result == null + ? "node-hid binding staging returned no result" + : `${result.source} node-hid ${result.version}`, + }), + }), + ], +}; diff --git a/linux-features/codex-micro/resources/70-codex-micro.rules b/linux-features/codex-micro/resources/70-codex-micro.rules new file mode 100644 index 000000000..2f2784c18 --- /dev/null +++ b/linux-features/codex-micro/resources/70-codex-micro.rules @@ -0,0 +1,5 @@ +# OpenAI x Work Louder Codex Micro vendor HID channel (USB interface 00 only). +# usb_id must run before its imported properties are matched on a hidraw event. +SUBSYSTEM=="hidraw", KERNEL=="hidraw*", IMPORT{builtin}="usb_id", ENV{ID_VENDOR_ID}=="303a", ENV{ID_MODEL_ID}=="8360", ENV{ID_USB_INTERFACE_NUM}=="00", TAG+="uaccess", MODE="0660" +# BlueZ exposes the same vendor HID channel through UHID on Bluetooth bus 0005. +SUBSYSTEM=="hidraw", KERNEL=="hidraw*", KERNELS=="0005:303A:8360.*", TAG+="uaccess", MODE="0660" diff --git a/linux-features/codex-micro/test.js b/linux-features/codex-micro/test.js new file mode 100644 index 000000000..706fe2497 --- /dev/null +++ b/linux-features/codex-micro/test.js @@ -0,0 +1,916 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const { + discoverBundledNodeHid, + inspectElf, + selectPrebuild, + stageCodexMicroNativeBinding, + validateArtifactManifest, +} = require("./native-binding.js"); +const { + CODEX_MICRO_GATE_ID, + CODEX_MICRO_GATE_MARKER, + CODEX_MICRO_HOTPLUG_MARKER, + CODEX_MICRO_ROUTE, + applyCodexMicroFeatureGatePatch, + applyCodexMicroHotplugPatch, + descriptors, + exportedFeatureGateHook, + findCodexMicroServiceBundle, + matchesCodexMicroFeatureGateContract, + patchCodexMicroService, +} = require("./patch.js"); +const { + enabledLinuxFeaturePackageDependencies, + enabledLinuxFeaturePackageFiles, + loadLinuxFeaturePatchDescriptors, + stageEnabledLinuxFeaturePackageResources, +} = require("../../scripts/lib/linux-features.js"); + +const DEVICE_KIT_RELATIVE = path.join( + "node_modules", + "@worklouder", + "device-kit-oai", +); +const WORK_LOUDER_KIT_RELATIVE = path.join( + DEVICE_KIT_RELATIVE, + "node_modules", + "@worklouder", + "wl-device-kit", +); +const NODE_HID_RELATIVE = path.join( + WORK_LOUDER_KIT_RELATIVE, + "node_modules", + "node-hid", +); +const FIXTURE_NODE_HID_LOADER = + "module.exports = require('pkg-prebuilds')(__dirname); // bundled loader\n"; +const FIXTURE_NODE_HID_OPTIONS = + "module.exports = { name: 'HID', tags: ['backend'] }; // bundled options\n"; + +function tempDirectory(t, prefix) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + return directory; +} + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function writeFile(filePath, contents, mode) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents, mode == null ? undefined : { mode }); +} + +function sha256(contents) { + return crypto.createHash("sha256").update(contents).digest("hex"); +} + +function makeElf(arch, marker = arch) { + const machines = { x64: 62, arm64: 183 }; + const machine = machines[arch]; + if (machine == null) { + throw new Error(`Unsupported ELF fixture architecture: ${arch}`); + } + const contents = Buffer.alloc(128); + contents.set([0x7f, 0x45, 0x4c, 0x46], 0); + contents[4] = 2; + contents[5] = 1; + contents[6] = 1; + contents.writeUInt16LE(3, 16); + contents.writeUInt16LE(machine, 18); + contents.writeUInt32LE(1, 20); + contents.write(marker, 64, "utf8"); + return contents; +} + +function bindingRelativePath(arch) { + return path.join( + "prebuilds", + `HID_hidraw-linux-${arch}`, + "node-napi-v4.node", + ); +} + +function shippedArtifact() { + return JSON.parse( + fs.readFileSync(path.join(__dirname, "native-artifacts.json"), "utf8"), + ); +} + +function fixtureArtifact(binaries = {}) { + const x64 = binaries.x64 ?? makeElf("x64", "fixture-x64"); + const arm64 = binaries.arm64 ?? makeElf("arm64", "fixture-arm64"); + return { + ...shippedArtifact(), + loaderContract: { + main: "./nodehid.js", + napiVersions: [4], + files: { + "nodehid.js": sha256(FIXTURE_NODE_HID_LOADER), + "binding-options.js": sha256(FIXTURE_NODE_HID_OPTIONS), + }, + }, + prebuilds: { + x64: { + path: bindingRelativePath("x64"), + sha256: sha256(x64), + }, + arm64: { + path: bindingRelativePath("arm64"), + sha256: sha256(arm64), + }, + }, + }; +} + +function createBundledFixture(t, options = {}) { + const root = tempDirectory(t, "codex-micro-bundled-"); + const extractedDir = path.join(root, "app-extracted"); + const deviceKitDir = path.join(extractedDir, DEVICE_KIT_RELATIVE); + const workLouderKitDir = path.join(extractedDir, WORK_LOUDER_KIT_RELATIVE); + const nodeHidDir = path.join(extractedDir, NODE_HID_RELATIVE); + + writeJson(path.join(deviceKitDir, "package.json"), { + name: "@worklouder/device-kit-oai", + version: "0.4.0", + }); + writeJson(path.join(workLouderKitDir, "package.json"), { + name: "@worklouder/wl-device-kit", + version: "0.12.0", + }); + writeJson(path.join(nodeHidDir, "package.json"), { + name: "node-hid", + version: options.bundledVersion ?? "3.3.0", + license: "(MIT OR X11)", + main: "./nodehid.js", + binary: { napi_versions: [4] }, + }); + writeFile( + path.join(nodeHidDir, "nodehid.js"), + options.loader ?? FIXTURE_NODE_HID_LOADER, + ); + writeFile( + path.join(nodeHidDir, "binding-options.js"), + FIXTURE_NODE_HID_OPTIONS, + ); + writeFile( + path.join(nodeHidDir, "prebuilds/HID-darwin-arm64/node-napi-v4.node"), + "bundled Mach-O bytes", + ); + + return { extractedDir, nodeHidDir }; +} + +function createMaterializedPackage(t, options = {}) { + const packageDir = path.join( + tempDirectory(t, "codex-micro-node-hid-artifact-"), + "package", + ); + writeJson(path.join(packageDir, "package.json"), { + name: options.name ?? "node-hid", + version: options.version ?? "3.3.0", + license: options.license ?? "(MIT OR X11)", + scripts: { install: "must never execute" }, + }); + writeFile( + path.join(packageDir, "nodehid.js"), + "throw new Error('artifact JavaScript must not be copied');\n", + ); + for (const [arch, binary] of Object.entries(options.binaries ?? {})) { + writeFile(path.join(packageDir, bindingRelativePath(arch)), binary, 0o755); + } + return packageDir; +} + +function materializer(packageDir, artifact, overrides = {}) { + return async () => ({ + packageDir, + integrity: overrides.integrity ?? artifact.integrity, + shasum: overrides.shasum ?? artifact.shasum, + }); +} + +function currentFeatureGateFixture() { + return [ + "const warning=`useFeatureGate hook failed to find a valid StatsigClient`;", + "function Lh(){return zh().isLoading}", + "function Rh(e){return bnt(),Bo(Fh,e)}", + `const microGate=Rh(\`${CODEX_MICRO_GATE_ID}\`);`, + `const microRoute=\`${CODEX_MICRO_ROUTE}\`;`, + "export{zh as c,Lh as flt,Rh as rlt};", + ].join(""); +} + +function currentCodexMicroServiceFixture() { + return [ + "const fs=require(\"node:fs\");", + "var nativeName=`hid-topology-watcher.node`,bindingName=`hid_topology_watcher.node`;", + "function p(){return require(bindingName)}", + "function d(e){return p().watch(e)}", + "function f(){return p().findCodexMicroInterfaces()}", + "class CodexMicroService{", + "start(){try{this.watcher=d(()=>this.handleHidTopologyChanged())}", + "catch(error){this.scheduleTopologyFallbackScan()}}", + "handleHidTopologyChanged(){this.requestTopologyReconciliation()}", + "scheduleTopologyFallbackScan(){this.timer=setTimeout(()=>this.scan(),3e4)}", + "}", + ].join(""); +} + +function evaluatePatchedTopologyWatcher(options = {}) { + const source = applyCodexMicroHotplugPatch(currentCodexMicroServiceFixture()); + const devWatchers = []; + const nativeWatchCalls = []; + const timeouts = new Map(); + const intervals = new Map(); + let nextTimerId = 1; + + const makeTimer = (kind, callback, delay) => { + const timer = { + id: nextTimerId++, + kind, + unreferenced: false, + unref() { + this.unreferenced = true; + }, + }; + (kind === "timeout" ? timeouts : intervals).set(timer, { + callback, + delay, + }); + return timer; + }; + const fakeFs = { + watch(target, watchOptions, listener) { + if (options.watchError != null) { + throw options.watchError; + } + const events = new Map(); + const watcher = { + closeCount: 0, + close() { + this.closeCount += 1; + }, + on(name, callback) { + events.set(name, callback); + return this; + }, + }; + devWatchers.push({ target, watchOptions, listener, events, watcher }); + return watcher; + }, + }; + const nativeHandle = { dispose() {} }; + const nativeBinding = { + findCodexMicroInterfaces() { + return []; + }, + watch(callback) { + nativeWatchCalls.push(callback); + return nativeHandle; + }, + }; + const fakeRequire = (request) => { + if (request === "node:fs") { + return fakeFs; + } + if (request === "hid_topology_watcher.node") { + return nativeBinding; + } + throw new Error(`Unexpected fixture require: ${request}`); + }; + const topologyWatcher = new Function( + "require", + "process", + "setTimeout", + "clearTimeout", + "setInterval", + "clearInterval", + `${source};return d;`, + )( + fakeRequire, + { platform: options.platform ?? "linux" }, + (callback, delay) => makeTimer("timeout", callback, delay), + (timer) => timeouts.delete(timer), + (callback, delay) => makeTimer("interval", callback, delay), + (timer) => intervals.delete(timer), + ); + + return { + devWatchers, + intervals, + nativeHandle, + nativeWatchCalls, + timeouts, + topologyWatcher, + }; +} + +function runOnlyTimer(timers) { + assert.equal(timers.size, 1); + const [timer, entry] = timers.entries().next().value; + if (timer.kind === "timeout") { + timers.delete(timer); + } + entry.callback(); + return { timer, delay: entry.delay }; +} + +test("Codex Micro locally enables only its current upstream feature gate", () => { + const source = currentFeatureGateFixture(); + const hook = exportedFeatureGateHook(source); + assert.deepEqual(hook, { + source: "function Rh(e){return bnt(),Bo(Fh,e)}", + hookName: "Rh", + argumentName: "e", + contextHookName: "bnt", + atomReadName: "Bo", + gateAtomName: "Fh", + }); + assert.equal(matchesCodexMicroFeatureGateContract(source), true); + + const patched = applyCodexMicroFeatureGatePatch(source); + assert.match( + patched, + new RegExp( + `function Rh\\(e\\)\\{return bnt\\(\\),Bo\\(Fh,e\\)\\|\\|` + + `e===\\\`${CODEX_MICRO_GATE_ID}\\\`/\\*${CODEX_MICRO_GATE_MARKER}\\*/\\}`, + ), + ); + assert.equal(applyCodexMicroFeatureGatePatch(patched), patched); + assert.equal(matchesCodexMicroFeatureGateContract(patched), true); +}); + +test("Codex Micro service patch adds disposable Linux hidraw hot-plug discovery", () => { + const source = currentCodexMicroServiceFixture(); + const patched = applyCodexMicroHotplugPatch(source); + + assert.notEqual(patched, source); + assert.match(patched, new RegExp(CODEX_MICRO_HOTPLUG_MARKER)); + assert.match(patched, /process\.platform===`linux`/); + assert.match(patched, /require\(`node:fs`\)\.watch\(`\/dev`/); + assert.match(patched, /\^hidraw/); + assert.match(patched, /dispose\(\)/); + assert.match(patched, /setInterval\(codexLinuxNotify,2e3\)/); + assert.match(patched, /clearInterval\(codexLinuxPollTimer\)/); + assert.match(patched, /if\(codexLinuxDisposed\)return/); + assert.match(patched, /return p\(\)\.watch\(e\)/); + assert.doesNotMatch(patched, /function d\(e\)\{[^}]*let e=/); + assert.doesNotThrow(() => new Function(patched)); + assert.equal(applyCodexMicroHotplugPatch(patched), patched); +}); + +test("Codex Micro Linux hot-plug watcher filters, debounces, and disposes", () => { + const runtime = evaluatePatchedTopologyWatcher(); + let notifications = 0; + const handle = runtime.topologyWatcher(() => { + notifications += 1; + }); + + assert.equal(runtime.nativeWatchCalls.length, 0); + assert.equal(runtime.devWatchers.length, 1); + const devWatcher = runtime.devWatchers[0]; + assert.equal(devWatcher.target, "/dev"); + assert.deepEqual(devWatcher.watchOptions, { persistent: false }); + + devWatcher.listener("rename", "event0"); + assert.equal(runtime.timeouts.size, 0); + devWatcher.listener("rename", "hidraw7"); + assert.equal(runOnlyTimer(runtime.timeouts).delay, 100); + assert.equal(notifications, 1); + + devWatcher.listener("change", null); + assert.equal(runtime.timeouts.size, 1); + handle.dispose(); + assert.equal(runtime.timeouts.size, 0); + assert.equal(runtime.intervals.size, 0); + assert.equal(devWatcher.watcher.closeCount, 1); + + devWatcher.events.get("error")(new Error("late watcher error")); + assert.equal(runtime.intervals.size, 0); + assert.equal(notifications, 1); +}); + +test("Codex Micro Linux hot-plug watcher polls only after watch failure", () => { + const runtime = evaluatePatchedTopologyWatcher({ + watchError: new Error("watch unavailable"), + }); + let notifications = 0; + const handle = runtime.topologyWatcher(() => { + notifications += 1; + }); + + assert.equal(runtime.devWatchers.length, 0); + assert.equal(runtime.intervals.size, 1); + const [interval] = runtime.intervals.keys(); + assert.equal(interval.unreferenced, true); + assert.equal(runOnlyTimer(runtime.timeouts).delay, 100); + assert.equal(notifications, 1); + + assert.equal(runOnlyTimer(runtime.intervals).delay, 2_000); + assert.equal(runOnlyTimer(runtime.timeouts).delay, 100); + assert.equal(notifications, 2); + + handle.dispose(); + assert.equal(runtime.intervals.size, 0); + assert.equal(runtime.timeouts.size, 0); +}); + +test("Codex Micro keeps the native topology watcher outside Linux", () => { + const runtime = evaluatePatchedTopologyWatcher({ platform: "darwin" }); + const callback = () => {}; + + assert.equal(runtime.topologyWatcher(callback), runtime.nativeHandle); + assert.deepEqual(runtime.nativeWatchCalls, [callback]); + assert.equal(runtime.devWatchers.length, 0); + assert.equal(runtime.intervals.size, 0); + assert.equal(runtime.timeouts.size, 0); +}); + +test("Codex Micro service patch rejects unrelated topology watchers", () => { + const unrelated = + "function watchTopology(callback){return loadWatcher().watch(callback)}"; + assert.equal(applyCodexMicroHotplugPatch(unrelated), unrelated); + const ambiguous = + currentCodexMicroServiceFixture() + currentCodexMicroServiceFixture(); + assert.equal(applyCodexMicroHotplugPatch(ambiguous), ambiguous); +}); + +test("Codex Micro service discovery patches exactly one current bundle", (t) => { + const root = tempDirectory(t, "codex-micro-hotplug-"); + const buildDir = path.join(root, ".vite", "build"); + const servicePath = path.join(buildDir, "service-current.js"); + writeFile(servicePath, currentCodexMicroServiceFixture()); + writeFile(path.join(buildDir, "unrelated.js"), "const unrelated=true;"); + + const discovery = findCodexMicroServiceBundle(root); + assert.equal(discovery.target, servicePath); + assert.equal(discovery.result.matched, 1); + assert.equal(discovery.result.changed, 1); + + const result = patchCodexMicroService(root); + assert.equal(result.changed, 1); + assert.equal(result.target, ".vite/build/service-current.js"); + assert.match(fs.readFileSync(servicePath, "utf8"), new RegExp(CODEX_MICRO_HOTPLUG_MARKER)); + + const repeated = patchCodexMicroService(root); + assert.equal(repeated.changed, 0); + assert.equal(repeated.matched, 1); +}); + +test("generic Statsig hook bundles are not accepted as Codex Micro assets", () => { + const generic = currentFeatureGateFixture() + .replace(`const microGate=kh(\`${CODEX_MICRO_GATE_ID}\`);`, "") + .replace(`const microRoute=\`${CODEX_MICRO_ROUTE}\`;`, ""); + assert.equal(exportedFeatureGateHook(generic)?.hookName, "Rh"); + assert.equal(matchesCodexMicroFeatureGateContract(generic), false); + assert.equal(applyCodexMicroFeatureGatePatch(generic), generic); +}); + +test("both the Codex Micro gate id and route are required", () => { + const withoutGate = currentFeatureGateFixture().replace(CODEX_MICRO_GATE_ID, "different-gate"); + const withoutRoute = currentFeatureGateFixture().replace(CODEX_MICRO_ROUTE, "/settings/other"); + assert.equal(matchesCodexMicroFeatureGateContract(withoutGate), false); + assert.equal(matchesCodexMicroFeatureGateContract(withoutRoute), false); +}); + +test("Codex Micro gate drift cannot redirect the patch to an unrelated exported hook", () => { + const drifted = [ + "const warning=`useFeatureGate hook failed to find a valid StatsigClient`;", + "function Ah(e){return changedGateShape(e)}", + "function Uh(e){return touch(),read(atom,e)}", + `const microGate=Ah(\`${CODEX_MICRO_GATE_ID}\`);`, + `const microRoute=\`${CODEX_MICRO_ROUTE}\`;`, + "export{Ah as gate,Uh as unrelated};", + ].join(""); + + assert.equal(exportedFeatureGateHook(drifted)?.hookName, "Uh"); + assert.equal(matchesCodexMicroFeatureGateContract(drifted), false); + assert.equal(applyCodexMicroFeatureGatePatch(drifted), drifted); +}); + +test("Codex Micro hook matching rejects identifier suffix collisions", () => { + const drifted = [ + "const warning=`useFeatureGate hook failed to find a valid StatsigClient`;", + "function Rh(e){return changedGateShape(e)}", + "function h(e){return touch(),read(atom,e)}", + `const microGate=Rh(\`${CODEX_MICRO_GATE_ID}\`);`, + `const microRoute=\`${CODEX_MICRO_ROUTE}\`;`, + "export{Rh as gate,h as unrelated};", + ].join(""); + + assert.equal(exportedFeatureGateHook(drifted)?.hookName, "h"); + assert.equal(matchesCodexMicroFeatureGateContract(drifted), false); + assert.equal(applyCodexMicroFeatureGatePatch(drifted), drifted); +}); + +test("Codex Micro route matching requires the exact current route literal", () => { + const drifted = currentFeatureGateFixture() + .replace(CODEX_MICRO_ROUTE, `${CODEX_MICRO_ROUTE}-v2`); + assert.equal(matchesCodexMicroFeatureGateContract(drifted), false); + assert.equal(applyCodexMicroFeatureGatePatch(drifted), drifted); +}); + +test("Codex Micro gate patch targets only the current app-initial bundle shape", () => { + const descriptor = descriptors.find(({ id }) => id === "webview-feature-gate"); + assert.ok(descriptor); + assert.equal(descriptor.pattern.test("app-initial-C-fROkKo.js"), true); + assert.equal(descriptor.pattern.test("app-initial~old-chunk.js"), false); +}); + +test("the shipped artifact manifest is prebuild-only and pinned for x64 and arm64", () => { + const artifact = shippedArtifact(); + assert.doesNotThrow(() => validateArtifactManifest(artifact)); + assert.equal(artifact.name, "node-hid"); + assert.equal(artifact.version, "3.3.0"); + assert.deepEqual(Object.keys(artifact.prebuilds).sort(), ["arm64", "x64"]); + assert.equal( + artifact.prebuilds.x64.sha256, + "6c7f3b3fcc238a74e7e3237b50b2ff05181e94862b1963e8074ff8fc75885021", + ); + assert.equal( + artifact.prebuilds.arm64.sha256, + "06ea97f377e2246a1e9bf3770186727e72ff3c166579d9c259c6d32a07aeaa60", + ); + assert.equal(fs.existsSync(path.join(__dirname, "source-build")), false); +}); + +for (const arch of ["x64", "arm64"]) { + test(`stages only the verified ${arch} prebuild into the exact nested node-hid`, async (t) => { + const binary = makeElf(arch, `${arch}-verified`); + const artifact = fixtureArtifact({ [arch]: binary }); + const fixture = createBundledFixture(t); + const packageDir = createMaterializedPackage(t, { + binaries: { [arch]: binary }, + }); + + const result = await stageCodexMicroNativeBinding({ + extractedDir: fixture.extractedDir, + arch, + artifactManifest: artifact, + materializePackage: materializer(packageDir, artifact), + }); + const targetPath = path.join(fixture.nodeHidDir, bindingRelativePath(arch)); + assert.deepEqual(fs.readFileSync(targetPath), binary); + assert.equal(fs.statSync(targetPath).mode & 0o777, 0o755); + assert.equal(result.changed, true); + assert.equal(result.alreadyApplied, false); + assert.equal(result.source, "verified-prebuild"); + assert.equal(result.targetPath, targetPath); + assert.equal( + fs.existsSync(path.join(fixture.nodeHidDir, "README.md")), + false, + "artifact package contents other than the selected native binary must not be copied", + ); + }); +} + +test("an already verified binding is idempotent and performs no package fetch", async (t) => { + const binary = makeElf("x64", "already-staged"); + const artifact = fixtureArtifact({ x64: binary }); + const fixture = createBundledFixture(t); + writeFile( + path.join(fixture.nodeHidDir, bindingRelativePath("x64")), + binary, + 0o755, + ); + let materializeCalls = 0; + + const result = await stageCodexMicroNativeBinding({ + extractedDir: fixture.extractedDir, + arch: "x64", + artifactManifest: artifact, + materializePackage: async () => { + materializeCalls += 1; + throw new Error("verified existing binding must avoid materialization"); + }, + }); + assert.equal(materializeCalls, 0); + assert.equal(result.changed, false); + assert.equal(result.alreadyApplied, true); + assert.equal(result.source, "existing-prebuild"); +}); + +test("an unexpected upstream binding fails closed before package materialization", async (t) => { + const expectedBinary = makeElf("x64", "expected-binding"); + const unexpectedBinary = makeElf("x64", "unexpected-upstream-binding"); + const artifact = fixtureArtifact({ x64: expectedBinary }); + const fixture = createBundledFixture(t); + const targetPath = path.join(fixture.nodeHidDir, bindingRelativePath("x64")); + writeFile(targetPath, unexpectedBinary, 0o755); + let materializeCalls = 0; + + await assert.rejects( + stageCodexMicroNativeBinding({ + extractedDir: fixture.extractedDir, + arch: "x64", + artifactManifest: artifact, + materializePackage: async () => { + materializeCalls += 1; + throw new Error("unexpected upstream bindings must fail before materialization"); + }, + }), + /existing node-hid native binding hash mismatch/i, + ); + assert.equal(materializeCalls, 0); + assert.deepEqual(fs.readFileSync(targetPath), unexpectedBinary); +}); + +test("upstream node-hid version or loader drift fails before package materialization", async (t) => { + for (const options of [ + { bundledVersion: "3.3.1", expected: /version mismatch/i }, + { loader: "module.exports = 'drift';\n", expected: /loader contract hash mismatch/i }, + ]) { + const fixture = createBundledFixture(t, options); + let materializeCalls = 0; + await assert.rejects( + stageCodexMicroNativeBinding({ + extractedDir: fixture.extractedDir, + arch: "x64", + artifactManifest: fixtureArtifact(), + materializePackage: async () => { + materializeCalls += 1; + }, + }), + options.expected, + ); + assert.equal(materializeCalls, 0); + } +}); + +for (const scenario of [ + { + label: "archive integrity", + materialized: { integrity: "sha512-unverified" }, + expected: /integrity mismatch/i, + }, + { + label: "archive shasum", + materialized: { shasum: "0".repeat(40) }, + expected: /shasum mismatch/i, + }, + { + label: "package identity", + metadata: { name: "not-node-hid" }, + expected: /identity mismatch|artifact name mismatch/i, + }, + { + label: "package version", + metadata: { version: "3.3.1" }, + expected: /version mismatch/i, + }, +]) { + test(`rejects a materialized artifact with wrong ${scenario.label}`, async (t) => { + const binary = makeElf("x64", scenario.label); + const artifact = fixtureArtifact({ x64: binary }); + const fixture = createBundledFixture(t); + const packageDir = createMaterializedPackage(t, { + ...scenario.metadata, + binaries: { x64: binary }, + }); + await assert.rejects( + stageCodexMicroNativeBinding({ + extractedDir: fixture.extractedDir, + arch: "x64", + artifactManifest: artifact, + materializePackage: materializer(packageDir, artifact, scenario.materialized), + }), + scenario.expected, + ); + assert.equal( + fs.existsSync(path.join(fixture.nodeHidDir, bindingRelativePath("x64"))), + false, + ); + }); +} + +test("rejects a hash-valid prebuild with the wrong ELF architecture", async (t) => { + const arm64Binary = makeElf("arm64", "arm64-under-x64-path"); + const artifact = fixtureArtifact(); + artifact.prebuilds.x64.sha256 = sha256(arm64Binary); + const fixture = createBundledFixture(t); + const packageDir = createMaterializedPackage(t, { + binaries: { x64: arm64Binary }, + }); + await assert.rejects( + stageCodexMicroNativeBinding({ + extractedDir: fixture.extractedDir, + arch: "x64", + artifactManifest: artifact, + materializePackage: materializer(packageDir, artifact), + }), + /ELF architecture arm64 does not match x64/i, + ); +}); + +test("unsupported architectures fail before discovery or materialization", async () => { + let materializeCalls = 0; + await assert.rejects( + stageCodexMicroNativeBinding({ + extractedDir: "/does/not/matter", + arch: "riscv64", + artifactManifest: shippedArtifact(), + materializePackage: async () => { + materializeCalls += 1; + }, + }), + /unsupported.*architecture.*riscv64/i, + ); + assert.equal(materializeCalls, 0); + assert.throws(() => selectPrebuild(shippedArtifact(), "riscv64"), /unsupported/i); +}); + +test("inspectElf accepts only supported 64-bit little-endian machines", () => { + assert.equal(inspectElf(makeElf("x64")).arch, "x64"); + assert.equal(inspectElf(makeElf("arm64")).arch, "arm64"); + assert.throws(() => inspectElf(Buffer.from("not ELF")), /ELF/i); + const elf32 = makeElf("x64"); + elf32[4] = 1; + assert.throws(() => inspectElf(elf32), /64-bit/i); + const bigEndian = makeElf("x64"); + bigEndian[5] = 2; + assert.throws(() => inspectElf(bigEndian), /little-endian/i); +}); + +test("udev policy imports USB properties before narrow USB and Bluetooth matches", () => { + const source = fs.readFileSync( + path.join(__dirname, "resources", "70-codex-micro.rules"), + "utf8", + ); + const activeRules = source + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")); + assert.equal(activeRules.length, 2); + const [usbRule, bluetoothRule] = activeRules; + for (const rule of activeRules) { + assert.match(rule, /SUBSYSTEM=="hidraw"/); + assert.match(rule, /KERNEL=="hidraw\*"/); + assert.match(rule, /TAG\+="uaccess"/); + assert.match(rule, /MODE="0660"/); + } + assert.match(usbRule, /IMPORT\{builtin\}="usb_id"/); + assert.ok( + usbRule.indexOf('IMPORT{builtin}="usb_id"') + < usbRule.indexOf('ENV{ID_VENDOR_ID}=="303a"'), + "usb_id must be imported before its properties are matched", + ); + assert.match(usbRule, /ENV\{ID_MODEL_ID\}=="8360"/); + assert.match(usbRule, /ENV\{ID_USB_INTERFACE_NUM\}=="00"/); + assert.match(bluetoothRule, /KERNELS=="0005:303A:8360\.\*"/); + assert.doesNotMatch(source, /MODE="0666"/); + assert.doesNotMatch(source, /SUBSYSTEM=="usb"/); +}); + +test("disabled Codex Micro performs no patch or package work", (t) => { + const root = tempDirectory(t, "codex-micro-disabled-"); + const configPath = path.join(root, "features.json"); + writeJson(configPath, { enabled: [] }); + const options = { + featuresRoot: path.resolve(__dirname, ".."), + featuresConfigPath: configPath, + }; + assert.deepEqual(loadLinuxFeaturePatchDescriptors(options), []); + for (const packageFormat of ["deb", "rpm", "pacman"]) { + assert.deepEqual( + enabledLinuxFeaturePackageDependencies({ ...options, packageFormat }), + [], + ); + assert.deepEqual(enabledLinuxFeaturePackageFiles({ ...options, packageFormat }), []); + } +}); + +test("native formats stage the exact rule and feature-only dependencies", (t) => { + const root = tempDirectory(t, "codex-micro-packages-"); + const configPath = path.join(root, "features.json"); + writeJson(configPath, { enabled: ["codex-micro"] }); + const expectedDependencies = { + deb: ["libudev1", "libusb-1.0-0"], + rpm: [ + "libudev.so.1%{codex_elf_suffix}", + "libusb-1.0.so.0%{codex_elf_suffix}", + ], + pacman: ["libusb", "systemd-libs"], + }; + const expectedRule = fs.readFileSync( + path.join(__dirname, "resources", "70-codex-micro.rules"), + ); + + for (const packageFormat of ["deb", "rpm", "pacman"]) { + const packageRoot = path.join(root, packageFormat); + const options = { + featuresRoot: path.resolve(__dirname, ".."), + featuresConfigPath: configPath, + packageFormat, + }; + const plan = stageEnabledLinuxFeaturePackageResources(packageRoot, options); + const target = path.join( + packageRoot, + "usr/lib/udev/rules.d/70-codex-micro.rules", + ); + assert.deepEqual(plan.dependencies, expectedDependencies[packageFormat]); + assert.deepEqual( + enabledLinuxFeaturePackageDependencies(options), + expectedDependencies[packageFormat], + ); + assert.deepEqual( + enabledLinuxFeaturePackageFiles(options), + ["/usr/lib/udev/rules.d/70-codex-micro.rules"], + ); + assert.deepEqual(fs.readFileSync(target), expectedRule); + assert.equal(fs.statSync(target).mode & 0o777, 0o644); + } +}); + +test("the nested discovery path cannot be substituted with a hoisted node-hid", (t) => { + const root = tempDirectory(t, "codex-micro-hoisted-"); + writeJson(path.join(root, "node_modules/node-hid/package.json"), { + name: "node-hid", + version: "3.3.0", + }); + assert.throws( + () => discoverBundledNodeHid(root), + /device-kit-oai package is missing/i, + ); +}); + +test("discovery rejects symlinked ancestors before reading package metadata", (t) => { + for (const scenario of [ + { + label: "device-kit-oai", + scopedModules: (fixture) => path.join( + fixture.extractedDir, + "node_modules", + "@worklouder", + ), + packageName: "device-kit-oai", + }, + { + label: "wl-device-kit", + scopedModules: (fixture) => path.join( + fixture.extractedDir, + DEVICE_KIT_RELATIVE, + "node_modules", + "@worklouder", + ), + packageName: "wl-device-kit", + }, + ]) { + const fixture = createBundledFixture(t); + const scopedModules = scenario.scopedModules(fixture); + const outside = path.join( + path.dirname(fixture.extractedDir), + `outside-${scenario.label}`, + ); + fs.renameSync(scopedModules, outside); + writeFile(path.join(outside, scenario.packageName, "package.json"), "{"); + fs.symlinkSync(outside, scopedModules, "dir"); + + assert.throws( + () => discoverBundledNodeHid(fixture.extractedDir), + /path must not contain symlinks/i, + ); + } +}); + +test("native binding staging rejects valid existing bindings behind symlinked parents", async (t) => { + const binary = makeElf("x64", "symlinked-target-parent"); + const artifact = fixtureArtifact({ x64: binary }); + const fixture = createBundledFixture(t); + const outside = path.join(path.dirname(fixture.extractedDir), "outside-prebuilds"); + fs.mkdirSync(outside); + writeFile( + path.join(outside, "HID_hidraw-linux-x64", "node-napi-v4.node"), + binary, + 0o755, + ); + const prebuildsDir = path.join(fixture.nodeHidDir, "prebuilds"); + fs.rmSync(prebuildsDir, { recursive: true }); + fs.symlinkSync(outside, prebuildsDir, "dir"); + + let materializeCalls = 0; + await assert.rejects( + stageCodexMicroNativeBinding({ + extractedDir: fixture.extractedDir, + arch: "x64", + artifactManifest: artifact, + materializePackage: async () => { + materializeCalls += 1; + throw new Error("symlinked existing binding must fail before materialization"); + }, + }), + /path must not contain symlinks/i, + ); + assert.equal(materializeCalls, 0); +}); diff --git a/linux-features/codex-wrapper-updater/patch.js b/linux-features/codex-wrapper-updater/patch.js index 23d8375cd..05e07e990 100644 --- a/linux-features/codex-wrapper-updater/patch.js +++ b/linux-features/codex-wrapper-updater/patch.js @@ -127,15 +127,18 @@ function applyWrapperUpdateSettingsPatch(source) { if (!next.includes("Check for ChatGPT Desktop for Linux updates")) { const toggleNeedle = - `children:$.jsx(LinuxToggle,{settingKey:KEYS.autoUpdateOnExit,label:"Install updates when you close ChatGPT",description:"When on, a ready update waits for ChatGPT to close and then installs. When off, updates wait until you click Update."})`; + `$.jsx(LinuxToggle,{settingKey:KEYS.autoUpdateOnExit,label:"Install updates when you close ChatGPT",description:"When on, a ready update waits for ChatGPT to close and then installs. When off, updates wait until you click Update."})`; if (!next.includes(toggleNeedle)) { throw new Error("could not find Linux update toggle"); } const pickerToggle = `$.jsx(LinuxToggle,{settingKey:KEYS.featurePickerOnUpdate,label:"Ask which features to enable on update",description:"When on, clicking Update opens a checklist to pick optional Linux features before rebuilding. Turn off to keep your current feature selection without prompting.",defaultValue:!0},"featurePickerOnUpdate")`; const wrapperToggle = - `children:[$.jsx(LinuxToggle,{settingKey:KEYS.autoUpdateOnExit,label:"Install updates when you close ChatGPT",description:"When on, a ready update waits for ChatGPT to close and then installs. When off, updates wait until you click Update."},"autoUpdateOnExit"),$.jsx(LinuxToggle,{settingKey:KEYS.wrapperUpdates,label:"Check for ChatGPT Desktop for Linux updates",description:"Check for Linux wrapper updates from codex-desktop-linux in addition to upstream ChatGPT app updates.",defaultValue:!1},"wrapperUpdates"),${pickerToggle}]`; - next = next.replace(toggleNeedle, wrapperToggle); + `$.jsx(LinuxToggle,{settingKey:KEYS.wrapperUpdates,label:"Check for ChatGPT Desktop for Linux updates",description:"Check for Linux wrapper updates from codex-desktop-linux in addition to upstream ChatGPT app updates.",defaultValue:!1},"wrapperUpdates")`; + const singleToggleNeedle = `children:${toggleNeedle}`; + next = next.includes(singleToggleNeedle) + ? next.replace(singleToggleNeedle, `children:[${toggleNeedle},${wrapperToggle},${pickerToggle}]`) + : next.replace(toggleNeedle, `${toggleNeedle},${wrapperToggle},${pickerToggle}`); } else if (!next.includes("Ask which features to enable on update")) { const existingWrapperToggle = `$.jsx(LinuxToggle,{settingKey:KEYS.wrapperUpdates,label:"Check for ChatGPT Desktop for Linux updates",description:"Check for Linux wrapper updates from codex-desktop-linux in addition to upstream ChatGPT app updates.",defaultValue:!1},"wrapperUpdates")`; diff --git a/linux-features/conversation-mode/patch.js b/linux-features/conversation-mode/patch.js index e013a3678..d32919a65 100644 --- a/linux-features/conversation-mode/patch.js +++ b/linux-features/conversation-mode/patch.js @@ -2,10 +2,7 @@ const HANDLER_NAME = "linux-read-aloud"; const RUNTIME_VERSION = "conversation-mode-v26"; -const CURRENT_DICTATION_ASSET_PATTERN = - /^app-initial~app-main~onboarding-page-[A-Za-z0-9_-]+\.js$/; -const CURRENT_COMPOSER_ASSET_PATTERN = - /^app-initial~app-main~new-thread-panel-page~appgen-library-page~hotkey-window-thread-page~ho~iufn7mg3-[^.]+\.js$/; +const CURRENT_APP_INITIAL_ASSET_PATTERN = /^app-initial-[A-Za-z0-9_-]+\.js$/; function warn(message, patchName) { console.warn(`WARN: ${message} - skipping ${patchName}`); @@ -124,7 +121,10 @@ function objectPropVar(objectSource, name, fallback) { } function currentComposerBinding(source) { - const propsPattern = new RegExp(`function ${JS_IDENT}\\(\\{([^{}]*voiceControls:${JS_IDENT}[^{}]*)\\}\\)\\{`, "g"); + const propsPattern = new RegExp( + `\\{([^{}]*voiceControls:${JS_IDENT}[^{}]*)\\}=${JS_IDENT}(?:,|;)`, + "g", + ); for (const propsMatch of source.matchAll(propsPattern)) { const propsObject = propsMatch[1]; const voiceControlsVar = objectPropVar(propsObject, "voiceControls", null); @@ -136,7 +136,7 @@ function currentComposerBinding(source) { continue; } const functionBodyStart = propsMatch.index + propsMatch[0].length; - const composerPrefix = source.slice(functionBodyStart, functionBodyStart + 5000); + const composerPrefix = source.slice(functionBodyStart, functionBodyStart + 25000); const conversationId = composerPrefix.match(new RegExp(`conversationId:(${JS_IDENT}),hostId:${JS_IDENT}`))?.[1] ?? null; if (conversationId == null) { @@ -259,9 +259,10 @@ function applyDictationEndpointPatch(source) { return source; } - const micConstraintsPattern = /([A-Za-z_$][\w$]*)=await ([A-Za-z_$][\w$]*)\(\{channelCount:1\}\)/u; + const micConstraintsPattern = + /stream:([A-Za-z_$][\w$]*)\(\{channelCount:1\}\)\.then\(/u; const cleanupPattern = - /([A-Za-z_$][\w$]*)&&\(\1\.ondataavailable=null,\1\.onstop=null\),([A-Za-z_$][\w$]*)\.current=null,([A-Za-z_$][\w$]*)\(\);/u; + /([A-Za-z_$][\w$]*)&&\(\1\.ondataavailable=null,\1\.onstop=null\),([A-Za-z_$][\w$]*)\.current=null,([A-Za-z_$][\w$]*)\(\),([A-Za-z_$][\w$]*)\(\),/u; const actionRef = source.match(/let [A-Za-z_$][\w$]*=([A-Za-z_$][\w$]*)\.current\?\?`insert`/)?.[1] ?? null; const recorderPattern = /let ([A-Za-z_$][\w$]*)=new MediaRecorder\(([A-Za-z_$][\w$]*)\);if\(([A-Za-z_$][\w$]*)\.current=\1,([A-Za-z_$][\w$]*)\.current=\[\],\1\.ondataavailable=([A-Za-z_$][\w$]*)=>\{\5\.data\.size>0&&\4\.current\.push\(\5\.data\)\},\1\.onstop=\(\)=>\{([A-Za-z_$][\w$]*)\(\)\},\1\.start\(\),([A-Za-z_$][\w$]*)\(!0\)/u; @@ -281,11 +282,11 @@ function applyDictationEndpointPatch(source) { let patched = source.replace( micConstraintsPattern, - "$1=await $2({channelCount:1,echoCancellation:!0,noiseSuppression:!0,autoGainControl:!0})", + "stream:$1({channelCount:1,echoCancellation:!0,noiseSuppression:!0,autoGainControl:!0}).then(", ); patched = patched.replace( cleanupPattern, - "$1?.codexLinuxConversationCleanup?.(),$1&&($1.ondataavailable=null,$1.onstop=null),$2.current=null,$3();", + "$1?.codexLinuxConversationCleanup?.(),$1&&($1.ondataavailable=null,$1.onstop=null),$2.current=null,$3(),$4(),", ); patched = patched.replace( recorderPattern, @@ -360,7 +361,7 @@ module.exports = { phase: "webview-asset", order: 20690, ciPolicy: "optional", - pattern: CURRENT_DICTATION_ASSET_PATTERN, + pattern: CURRENT_APP_INITIAL_ASSET_PATTERN, missingDescription: "current primary dictation bundle", skipDescription: "conversation mode dictation endpoint patch", apply: applyDictationEndpointPatch, @@ -370,7 +371,7 @@ module.exports = { phase: "webview-asset", order: 20700, ciPolicy: "optional", - pattern: CURRENT_COMPOSER_ASSET_PATTERN, + pattern: CURRENT_APP_INITIAL_ASSET_PATTERN, missingDescription: "current primary composer bundle", skipDescription: "conversation mode composer control patch", apply: applyComposerPatch, @@ -380,7 +381,7 @@ module.exports = { phase: "webview-asset", order: 20710, ciPolicy: "optional", - pattern: /^app-initial~app-main~onboarding-page-[A-Za-z0-9_-]+\.js$/, + pattern: CURRENT_APP_INITIAL_ASSET_PATTERN, missingDescription: "current primary thread assistant bundle", skipDescription: "conversation mode assistant observer patch", apply: applyAssistantRenderPatch, diff --git a/linux-features/conversation-mode/test.js b/linux-features/conversation-mode/test.js index 97dd620cf..eda54bba9 100644 --- a/linux-features/conversation-mode/test.js +++ b/linux-features/conversation-mode/test.js @@ -88,19 +88,18 @@ const mainBundleSource = const explicitButtonMainBundleSource = "function codexLinuxReadAloudHandle(e={}){return e.action===`config`?codexLinuxReadAloudConfig():e.action===`setup`?codexLinuxReadAloudSetup(e):e.action===`stop`?codexLinuxReadAloudStop():e.action===`speak`&&e.source===`button`?codexLinuxReadAloudSpeak(e.text,{requireEnabled:!1}):codexLinuxReadAloudReport({spoken:!1,reason:`not-explicit`})}var h={handlers:{\"linux-read-aloud\":async(e)=>codexLinuxReadAloudHandle(e),\"native-desktop-apps\":async()=>({apps:[]})}};"; -const currentComposerAsset = - "app-initial~app-main~new-thread-panel-page~appgen-library-page~hotkey-window-thread-page~ho~iufn7mg3-current.js"; -const currentDictationAsset = - "app-initial~app-main~onboarding-page-current.js"; +const currentAppInitialAsset = "app-initial-current.js"; +const currentComposerAsset = currentAppInitialAsset; +const currentDictationAsset = currentAppInitialAsset; const dictationSource = - "function Lke({onTranscriptInsert:i,onTranscriptSend:a}){let h={current:null},g={current:null},y={current:[]},b={current:null};let P=async({action:t,handlers:r})=>{let a=`hello`;a.length>0&&(df.getInstance().dispatchMessage(`global-dictation-record-history-item`,{text:a}),t===`send`?r.onTranscriptSend(a):r.onTranscriptInsert(a))},F=async()=>{let e=b.current??`insert`,r=h.current,i=y.current;y.current=[],r&&(r.ondataavailable=null,r.onstop=null),h.current=null,A();await P({action:e,audio:i,handlers:{onTranscriptInsert:i,onTranscriptSend:a}})},L=e=>{b.current=e;let t=h.current;t.state!==`inactive`&&t.stop()};return{startDictation:async()=>{let e=await _Oe({channelCount:1});let t=new MediaRecorder(e);if(h.current=t,y.current=[],t.ondataavailable=e=>{e.data.size>0&&y.current.push(e.data)},t.onstop=()=>{F()},t.start(),u(!0),b.current!=null){t.stop();return}},stopDictation:L}}"; + "function Sit({onTranscriptInsert:i,onTranscriptSend:a}){let h={current:null},g={current:null},y={current:[]},b={current:null};let P=async({action:t,handlers:r})=>{let a=`hello`;a.length>0&&(df.getInstance().dispatchMessage(`global-dictation-record-history-item`,{text:a}),t===`send`?r.onTranscriptSend(a):r.onTranscriptInsert(a))},F=async()=>{let e=b.current??`insert`,r=h.current,i=y.current;y.current=[],r&&(r.ondataavailable=null,r.onstop=null),h.current=null,j(),Q(),u(!1);await P({action:e,audio:i,handlers:{onTranscriptInsert:i,onTranscriptSend:a}})},L=e=>{b.current=e;let t=h.current;t.state!==`inactive`&&t.stop()};return{startDictation:async()=>{let e=Cit(),g.current=e;let t=await e.stream;let n=new MediaRecorder(t);if(h.current=n,y.current=[],n.ondataavailable=e=>{e.data.size>0&&y.current.push(e.data)},n.onstop=()=>{F()},n.start(),u(!0),b.current!=null){n.stop();return}},stopDictation:L}}function Cit(){let e=!1,t=null,n=()=>{e=!0,t?.getTracks().forEach(e=>{e.stop()}),t=null};return{dispose:n,stream:Knt({channelCount:1}).then(r=>(t=r,e&&n(),r))}}"; const currentComposerControlSource = - "function Vka({isResponseInProgress:x,onStop:T,submitBlockReason:E,voiceControls:A}){let j=Nn(Bk);let M=RZ(),N=Rk(j),P=LEa(j.value,t),{canRetryDictation:B,dictationShortcutLabel:V,isDictating:U,isDictationButtonVisible:W,isDictationSupported:G,isTranscribing:ee,isVoiceFooterVisible:te,recordingDurationMs:ne,retryDictation:K,startDictation:re,stopDictation:ie,restrictedSession:ae,waveformCanvasRef:oe}=A;let je=(0,x7.jsx)(_ka,{conversationId:N,hostId:g,cwdOverride:_}),ke=(0,x7.jsx)(Twe,{isTranscribing:ee,recordingDurationMs:ne,waveformCanvasRef:oe,stopDictation:ie}),Ae=(0,x7.jsx)(Ewe,{isVisible:W,disabled:!G||ae.thread.phase!==`inactive`,isTranscribing:ee,canRetryDictation:B,shortcutLabel:V,retryDictation:K,startDictation:re,stopDictation:ie});return Ae}"; + "function Vka(e){let{isResponseInProgress:x,onStop:T,submitBlockReason:E,voiceControls:A}=e,j=Nn(Bk),M=RZ(),N=Rk(j),P=LEa(j.value,t),{canRetryDictation:B,dictationShortcutLabel:V,isDictating:U,isDictationButtonVisible:W,isDictationSupported:G,isTranscribing:ee,isVoiceFooterVisible:te,recordingDurationMs:ne,retryDictation:K,startDictation:re,stopDictation:ie,realtimeSession:ae,waveformCanvasRef:oe}=A;let je=(0,x7.jsx)(_ka,{conversationId:N,hostId:g,cwdOverride:_}),ke=(0,x7.jsx)(Twe,{isTranscribing:ee,recordingDurationMs:ne,waveformCanvasRef:oe,stopDictation:ie});let Ae=(0,x7.jsx)(Ewe,{isVisible:W,disabled:!G,isTranscribing:ee,canRetryDictation:B,shortcutLabel:V,retryDictation:K,startDictation:re,stopDictation:ie});return Ae}"; const assistantRenderSource = - "return (0,$.jsx)(Ov,{item:n,alwaysShowActions:M,assistantCopyText:p,turnId:m,after:g,conversationId:o,cwd:u,renderCodeBlocksAsWritingBlocks:V})"; + "return (0,t8.jsx)(K6c,{item:n,alwaysShowActions:re,assistantCopyText:b,turnId:x,processTargets:S,autoReviewStats:A,hookStats:j,threadDetailLevel:p,after:T,conversationId:d,cwd:g,renderCodeBlocksAsWritingBlocks:we})"; const conversationGlobals = [ "codexLinuxConversationAvailable", @@ -121,42 +120,32 @@ test("dictation endpoint descriptor targets the current dictation bundle", () => const descriptor = featurePatches.find((patch) => patch.id === "dictation-endpoint"); assert.ok(descriptor); assert.equal(descriptor.pattern.test(currentDictationAsset), true); - assert.equal(descriptor.pattern.test(currentComposerAsset), false); - assert.equal(descriptor.pattern.test("app-initial~app-main~onboarding-page-BUwCKIcU.js"), true); - assert.equal( - descriptor.pattern.test( - "app-initial~app-main~onboarding-page~debug-window-page~debug-modal-jrWqnMas.js", - ), - false, - ); + assert.equal(descriptor.pattern.test("app-initial-BHB6SClA.js"), true); + assert.equal(descriptor.pattern.test("app-initial~app-main~onboarding-page-BUwCKIcU.js"), false); assert.equal(descriptor.pattern.test("use-dictation-BUwCKIcU.js"), false); assert.equal(descriptor.pattern.test("use-dictation-hotkey-BUwCKIcU.js"), false); }); -test("composer descriptor targets only the current primary app bundle", () => { +test("composer descriptor targets the current app-initial bundle", () => { const descriptor = featurePatches.find((patch) => patch.id === "composer-control"); assert.ok(descriptor); assert.equal(descriptor.pattern.test(currentComposerAsset), true); + assert.equal(descriptor.pattern.test("app-initial-BHB6SClA.js"), true); assert.equal(descriptor.pattern.test("app-initial~app-main~page-hSvsQcNf.js"), false); assert.equal(descriptor.pattern.test("composer-old.js"), false); }); -test("current DMG co-locates dictation and assistant ownership apart from the composer", () => { +test("current DMG co-locates dictation, composer, and assistant ownership", () => { const dictation = featurePatches.find((patch) => patch.id === "dictation-endpoint"); const composer = featurePatches.find((patch) => patch.id === "composer-control"); const assistant = featurePatches.find((patch) => patch.id === "assistant-observer"); - const dictationAsset = "app-initial~app-main~onboarding-page-CIkoyvFz.js"; - const composerAsset = - "app-initial~app-main~new-thread-panel-page~appgen-library-page~hotkey-window-thread-page~ho~iufn7mg3-DRU9Ekz0.js"; - const adjacentComposerAsset = - "app-initial~app-main~new-thread-panel-page~appgen-library-page~hotkey-window-thread-page~ho~lhgjoyjn-CMTECkzu.js"; - - assert.equal(dictation.pattern.test(dictationAsset), true); - assert.equal(dictation.pattern.test(composerAsset), false); - assert.equal(assistant.pattern.test(dictationAsset), true); - assert.equal(composer.pattern.test(composerAsset), true); - assert.equal(composer.pattern.test(dictationAsset), false); - assert.equal(composer.pattern.test(adjacentComposerAsset), false); + const asset = "app-initial-BHB6SClA.js"; + + assert.equal(dictation.pattern.test(asset), true); + assert.equal(composer.pattern.test(asset), true); + assert.equal(assistant.pattern.test(asset), true); + assert.equal(dictation.pattern.test("onboarding-page-Bv4pLarm.js"), false); + assert.equal(composer.pattern.test("new-thread-panel-page-Xl0DC1bk.js"), false); }); function fetchBodies(events) { @@ -2037,7 +2026,7 @@ test("dictation endpoint patch adds VAD stop-on-silence and send action", () => assert.match(patched, /codexLinuxConversationShouldSendTranscript/); assert.match(patched, /t!==`discard`/); assert.match(patched, /t===`send`\?r\.onTranscriptSend\(a\):r\.onTranscriptInsert\(a\)/); - assert.match(patched, /stop:\(\)=>\{b\.current=`send`;t\.state!==`inactive`&&t\.stop\(\)\}/); + assert.match(patched, /stop:\(\)=>\{b\.current=`send`;n\.state!==`inactive`&&n\.stop\(\)\}/); }); test("dictation endpoint patch fails soft and atomically when the current recorder contract drifts", () => { @@ -2131,7 +2120,7 @@ test("composer control preserves the current async startDictation contract", asy return originalResult; }, stopDictation() {}, - restrictedSession: { thread: { phase: "inactive" } }, + realtimeSession: {}, waveformCanvasRef: {}, }; const render = () => context.renderCurrentComposer({ @@ -2193,8 +2182,8 @@ test("composer patch ignores adjacent composer chunks", () => { test("assistant render patch observes assistant text for automatic speech", () => { const patched = twice(applyAssistantRenderPatch, assistantRenderSource); - assert.match(patched, /codexLinuxConversationAssistant\?\.\(n,p,o,m,typeof c!="undefined"\?c:null\)/); - assert.match(patched, /\$\.Fragment/); + assert.match(patched, /codexLinuxConversationAssistant\?\.\(n,b,d,x,typeof c!="undefined"\?c:null\)/); + assert.match(patched, /t8\.Fragment/); }); test("assistant render patch preserves the current JSX runtime alias", () => { @@ -2209,7 +2198,8 @@ test("assistant render patch preserves the current JSX runtime alias", () => { test("assistant observer targets only the current primary thread bundle", () => { const descriptor = featurePatches.find((patch) => patch.id === "assistant-observer"); assert.ok(descriptor); - assert.equal(descriptor.pattern.test("app-initial~app-main~onboarding-page-D4eTO0KG.js"), true); + assert.equal(descriptor.pattern.test("app-initial-BHB6SClA.js"), true); + assert.equal(descriptor.pattern.test("app-initial~app-main~onboarding-page-D4eTO0KG.js"), false); assert.equal(descriptor.pattern.test("local-conversation-turn-old.js"), false); assert.equal(descriptor.pattern.test("local-conversation-thread-old.js"), false); assert.equal(descriptor.pattern.test("index-old.js"), false); @@ -2220,7 +2210,7 @@ test("current assistant observer drift is reported as skipped instead of already try { const assetsDir = path.join(root, "webview", "assets"); fs.mkdirSync(assetsDir, { recursive: true }); - const assetPath = path.join(assetsDir, "app-initial~app-main~onboarding-page-current.js"); + const assetPath = path.join(assetsDir, currentAppInitialAsset); const drifted = "console.log(`current assistant renderer drifted`);"; fs.writeFileSync(assetPath, drifted); const descriptor = featurePatches.find((patch) => patch.id === "assistant-observer"); @@ -2253,9 +2243,8 @@ test("conversation mode patches matching app assets and records report entries", fs.writeFileSync(path.join(tempApp, "package.json"), JSON.stringify({ name: "codex" })); fs.writeFileSync( path.join(assetsDir, currentDictationAsset), - `${dictationSource};${assistantRenderSource}`, + `${dictationSource};${currentComposerControlSource};${assistantRenderSource}`, ); - fs.writeFileSync(path.join(assetsDir, currentComposerAsset), currentComposerControlSource); const report = createPatchReport(); const { warnings } = captureWarns(() => patchExtractedApp(tempApp, { report })); @@ -2272,7 +2261,7 @@ test("conversation mode patches matching app assets and records report entries", /codexLinuxConversationEndpoint/, ); assert.match( - fs.readFileSync(path.join(assetsDir, currentComposerAsset), "utf8"), + fs.readFileSync(path.join(assetsDir, currentAppInitialAsset), "utf8"), /codexLinuxConversationToggle/, ); assert.match( diff --git a/linux-features/deferred-update-build/README.md b/linux-features/deferred-update-build/README.md new file mode 100644 index 000000000..bf766ccc6 --- /dev/null +++ b/linux-features/deferred-update-build/README.md @@ -0,0 +1,38 @@ +# Deferred update builds + +This disabled-by-default Linux feature adds a **Build updates automatically** +toggle to Linux Settings. + +When the toggle is on, the native updater keeps its standard behavior and +builds a detected upstream DMG in the background. When it is off, background +checks still download and verify the latest DMG, but leave it pending until the +user chooses **Check for updates**. + +The updater revalidates the upstream DMG before using a pending download. If a +newer DMG replaces it or the cached file is removed, the same check downloads +the current DMG before continuing. Disabling this feature immediately restores +automatic builds, including for an already deferred candidate. + +App-launch `--if-stale` checks treat a deferred candidate as stable. A fresh +check performs no upstream DMG request; after the check interval, an unchanged HEAD +reuses the valid cached DMG without GET. Offline background checks preserve the +pending candidate. + +Enable it in the gitignored `linux-features/features.json` file: + +```json +{ + "enabled": ["deferred-update-build"] +} +``` + +The setting patch is optional and fail-soft. Missing or drifted Linux Settings +assets are reported without writing a partial replacement. When the feature is +enabled during an updater rebuild, the normal enabled-feature acceptance gate +rejects drift and preserves the installed app. + +Run its focused tests with: + +```bash +node --test linux-features/deferred-update-build/test.js +``` diff --git a/linux-features/deferred-update-build/feature.json b/linux-features/deferred-update-build/feature.json new file mode 100644 index 000000000..7db0c12d0 --- /dev/null +++ b/linux-features/deferred-update-build/feature.json @@ -0,0 +1,9 @@ +{ + "id": "deferred-update-build", + "title": "Deferred update builds", + "description": "Adds a Linux setting that can defer local package builds until Check for updates is selected.", + "defaultEnabled": false, + "entrypoints": { + "patchDescriptors": "./patch.js" + } +} diff --git a/linux-features/deferred-update-build/patch.js b/linux-features/deferred-update-build/patch.js new file mode 100644 index 000000000..672daadee --- /dev/null +++ b/linux-features/deferred-update-build/patch.js @@ -0,0 +1,90 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +const AUTO_BUILD_UPDATES_SETTING_KEY = "codex-linux-auto-build-updates"; +const LINUX_DESKTOP_SETTINGS_ASSET = "linux-desktop-settings-linux.js"; + +const AUTO_INSTALL_TOGGLE = + `$.jsx(LinuxToggle,{settingKey:KEYS.autoUpdateOnExit,` + + `label:"Install updates when you close ChatGPT",` + + `description:"When on, a ready update waits for ChatGPT to close and then installs. ` + + `When off, updates wait until you click Update."})`; + +const AUTO_BUILD_TOGGLE = + `$.jsx(LinuxToggle,{settingKey:KEYS.autoBuildUpdates,` + + `label:"Build updates automatically",` + + `description:"When on, background checks build detected updates. When off, they only notify you; ` + + `Check for updates starts the build."},"autoBuildUpdates")`; + +function applyDeferredUpdateBuildSettingsPatch(source) { + if (source.includes(`autoBuildUpdates:${JSON.stringify(AUTO_BUILD_UPDATES_SETTING_KEY)}`)) { + return source; + } + + const keyNeedle = `autoUpdateOnExit:"codex-linux-auto-update-on-exit"`; + if (!source.includes(keyNeedle)) { + throw new Error("could not find Linux update settings key"); + } + if (!source.includes(AUTO_INSTALL_TOGGLE)) { + throw new Error("could not find Linux update settings control"); + } + + let next = source.replace( + keyNeedle, + `autoBuildUpdates:${JSON.stringify(AUTO_BUILD_UPDATES_SETTING_KEY)},${keyNeedle}`, + ); + const singleControl = `children:${AUTO_INSTALL_TOGGLE}`; + next = next.includes(singleControl) + ? next.replace(singleControl, `children:[${AUTO_BUILD_TOGGLE},${AUTO_INSTALL_TOGGLE}]`) + : next.replace(AUTO_INSTALL_TOGGLE, `${AUTO_BUILD_TOGGLE},${AUTO_INSTALL_TOGGLE}`); + return next; +} + +function patchDeferredUpdateBuildSettingsAssets(extractedDir) { + try { + const assetsDir = path.join(extractedDir, "webview", "assets"); + if (!fs.existsSync(assetsDir)) { + return { matched: false, changed: 0, reason: `missing webview assets directory ${assetsDir}` }; + } + + const settingsPath = path.join(assetsDir, LINUX_DESKTOP_SETTINGS_ASSET); + if (!fs.existsSync(settingsPath)) { + return { matched: false, changed: 0, reason: `${LINUX_DESKTOP_SETTINGS_ASSET} is not present` }; + } + + const current = fs.readFileSync(settingsPath, "utf8"); + const patched = applyDeferredUpdateBuildSettingsPatch(current); + if (patched === current) { + return { matched: true, changed: 0 }; + } + fs.writeFileSync(settingsPath, patched, "utf8"); + return { matched: true, changed: 1 }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`WARN: Deferred update build settings patch skipped: ${message}`); + return { matched: false, changed: 0, reason: message }; + } +} + +module.exports = { + AUTO_BUILD_UPDATES_SETTING_KEY, + applyDeferredUpdateBuildSettingsPatch, + patchDeferredUpdateBuildSettingsAssets, + descriptors: [ + { + id: "settings-toggle", + phase: "extracted-app:post-webview", + order: 20_910, + ciPolicy: "optional", + apply: (extractedDir) => patchDeferredUpdateBuildSettingsAssets(extractedDir), + status: (result, warnings) => { + if (result?.matched === false) { + return { status: "skipped-optional", reason: result.reason ?? warnings[0] ?? null }; + } + return (result?.changed ?? 0) > 0 ? "applied" : "already-applied"; + }, + }, + ], +}; diff --git a/linux-features/deferred-update-build/test.js b/linux-features/deferred-update-build/test.js new file mode 100644 index 000000000..489e5d341 --- /dev/null +++ b/linux-features/deferred-update-build/test.js @@ -0,0 +1,117 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const { + AUTO_BUILD_UPDATES_SETTING_KEY, + applyDeferredUpdateBuildSettingsPatch, + patchDeferredUpdateBuildSettingsAssets, +} = require("./patch.js"); +const { + enabledLinuxFeatureIds, + loadLinuxFeaturePatchDescriptors, +} = require("../../scripts/lib/linux-features.js"); + +const featuresRoot = path.resolve(__dirname, ".."); +const AUTO_INSTALL_TOGGLE = + `$.jsx(LinuxToggle,{settingKey:KEYS.autoUpdateOnExit,label:"Install updates when you close ChatGPT",description:"When on, a ready update waits for ChatGPT to close and then installs. When off, updates wait until you click Update."})`; + +function settingsSource(children = `children:${AUTO_INSTALL_TOGGLE}`) { + return `var KEYS={autoUpdateOnExit:"codex-linux-auto-update-on-exit"};function Settings(){return $.jsx(SettingsGroup,{${children}})}`; +} + +function withTempFeatureConfig(enabled, fn) { + const originalConfig = process.env.CODEX_LINUX_FEATURES_CONFIG; + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-deferred-build-config-")); + process.env.CODEX_LINUX_FEATURES_CONFIG = path.join(tempDir, "features.json"); + try { + fs.writeFileSync(process.env.CODEX_LINUX_FEATURES_CONFIG, JSON.stringify({ enabled }, null, 2)); + return fn(); + } finally { + if (originalConfig == null) delete process.env.CODEX_LINUX_FEATURES_CONFIG; + else process.env.CODEX_LINUX_FEATURES_CONFIG = originalConfig; + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withoutWarnings(fn) { + const originalWarn = console.warn; + console.warn = () => {}; + try { + return fn(); + } finally { + console.warn = originalWarn; + } +} + +test("adds the automatic build key and toggle to core Linux settings", () => { + const patched = applyDeferredUpdateBuildSettingsPatch(settingsSource()); + assert.match(patched, new RegExp(`autoBuildUpdates:"${AUTO_BUILD_UPDATES_SETTING_KEY}"`)); + assert.match(patched, /label:"Build updates automatically"/); + assert.match(patched, /children:\[\$\.jsx\(LinuxToggle/); + assert.equal(applyDeferredUpdateBuildSettingsPatch(patched), patched); +}); + +test("composes with another feature that already made the settings controls an array", () => { + const existing = + `${AUTO_INSTALL_TOGGLE},` + + `$.jsx(LinuxToggle,{settingKey:KEYS.wrapperUpdates,label:"Wrapper updates"},"wrapperUpdates")`; + const patched = applyDeferredUpdateBuildSettingsPatch(settingsSource(`children:[${existing}]`)); + assert.match(patched, /label:"Build updates automatically"/); + assert.match(patched, /label:"Wrapper updates"/); + assert.match(patched, /label:"Install updates when you close ChatGPT"/); +}); + +test("settings asset patch is fail-soft and leaves drifted content unchanged", () => { + const appDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-deferred-build-drift-")); + const assetsDir = path.join(appDir, "webview", "assets"); + const settingsPath = path.join(assetsDir, "linux-desktop-settings-linux.js"); + fs.mkdirSync(assetsDir, { recursive: true }); + fs.writeFileSync(settingsPath, "var KEYS={};function Settings(){return null}"); + try { + assert.deepEqual(withoutWarnings(() => patchDeferredUpdateBuildSettingsAssets(appDir)), { + matched: false, + changed: 0, + reason: "could not find Linux update settings key", + }); + assert.equal(fs.readFileSync(settingsPath, "utf8"), "var KEYS={};function Settings(){return null}"); + } finally { + fs.rmSync(appDir, { recursive: true, force: true }); + } +}); + +test("feature stays disabled until explicitly enabled", () => { + withTempFeatureConfig([], () => { + assert.deepEqual(enabledLinuxFeatureIds({ featuresRoot }), []); + assert.deepEqual( + loadLinuxFeaturePatchDescriptors({ featuresRoot }).filter((descriptor) => + descriptor.id.startsWith("feature:deferred-update-build:"), + ), + [], + ); + }); +}); + +test("enabled feature exposes one optional settings descriptor", () => { + withTempFeatureConfig(["deferred-update-build"], () => { + const descriptors = loadLinuxFeaturePatchDescriptors({ featuresRoot }).filter((descriptor) => + descriptor.id.startsWith("feature:deferred-update-build:"), + ); + assert.deepEqual( + descriptors.map((descriptor) => [descriptor.id, descriptor.phase, descriptor.ciPolicy]), + [["feature:deferred-update-build:settings-toggle", "extracted-app:post-webview", "optional"]], + ); + }); +}); + +test("feature-owned updater policy declares the feature-owned setting key", () => { + const policy = JSON.parse(fs.readFileSync(path.join(__dirname, "updater-policy.json"), "utf8")); + assert.deepEqual(policy, { + schemaVersion: 1, + autoBuildUpdatesSettingKey: AUTO_BUILD_UPDATES_SETTING_KEY, + }); +}); diff --git a/linux-features/deferred-update-build/updater-policy.json b/linux-features/deferred-update-build/updater-policy.json new file mode 100644 index 000000000..075dedad7 --- /dev/null +++ b/linux-features/deferred-update-build/updater-policy.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "autoBuildUpdatesSettingKey": "codex-linux-auto-build-updates" +} diff --git a/linux-features/directory-only-working-tree-watch/README.md b/linux-features/directory-only-working-tree-watch/README.md index bff619d7c..f1b1be172 100644 --- a/linux-features/directory-only-working-tree-watch/README.md +++ b/linux-features/directory-only-working-tree-watch/README.md @@ -22,6 +22,25 @@ produces an event. There are no default name-based exclusions. A tracked directory named `build` or `node_modules` remains watched even if its name resembles generated output. +## Current upstream working-tree route + +OpenAI Desktop `26.730.61639` has a Linux-specific Parcel working-tree path +that calls `@parcel/watcher` directly instead of the local `startFileWatch()` +method this feature intercepts. When the feature is selected, the current-DMG +patch reroutes that one local working-tree subscription through +`startFileWatch()`, where the existing directory-only watcher takes ownership. +Remote watches and non-working-tree file watches retain their upstream routes. + +The matcher correlates the Parcel helper, Git execution-host factory, local +host, route host, route options, and path API by semantic role rather than by +minified identifier spelling. Missing, duplicate, misplaced, partial, or +uncorrelated contracts report enabled-feature drift and leave the current +bundles unchanged. + +After both bundle changes are prepared and validated, the patch writes them as +one transaction. A write error restores and byte-verifies every attempted +bundle; an unverified rollback is reported as a patch integrity failure. + By default, the watcher uses at most 8192 inotify watches per app process across all active working trees, or one eighth of the kernel's `fs.inotify.max_user_watches` value when that is lower. The configurable ceiling diff --git a/linux-features/directory-only-working-tree-watch/patch.js b/linux-features/directory-only-working-tree-watch/patch.js index 41b1b1696..b669aba1d 100644 --- a/linux-features/directory-only-working-tree-watch/patch.js +++ b/linux-features/directory-only-working-tree-watch/patch.js @@ -2,12 +2,81 @@ const fs = require("node:fs"); const path = require("node:path"); +const { + findMatchingBrace, +} = require("../../scripts/patches/lib/minified-js.js"); +const { + PatchIntegrityError, +} = require("../../scripts/patches/integrity-error.js"); const HELPER_NAME = "codexLinuxStartDirectoryOnlyWorkingTreeWatch"; +const PARCEL_WATCH_MARKER = "codexLinuxDirectoryOnlyParcelWorkingTreeWatch"; const DEFAULT_MAX_WATCHES = 8192; const DEFAULT_IGNORED_DIRECTORY_NAMES = []; -const LOCAL_FILE_WATCH_METHOD = - /async startFileWatch\((?[A-Za-z_$][\w$]*)\)\{(?=let [^{}]{0,180}?await this\.platformPath\(\),[^{}]{0,180}?\(0,[A-Za-z_$][\w$]*\.watch\)\(this\.getFileSystemPath\(\k\.path\),\{recursive:\k\.recursive\})/gu; +const IDENTIFIER_PATTERN = "[A-Za-z_$][\\w$]*"; +const LOCAL_FILE_WATCH_METHOD_PREFIX = + `async startFileWatch\\((?${IDENTIFIER_PATTERN})\\)\\{`; +const LOCAL_FILE_WATCH_CURRENT_BODY = + "(?=let [^{}]{0,180}?await this\\.platformPath\\(\\)," + + "[^{}]{0,180}?\\(0,[A-Za-z_$][\\w$]*\\.watch\\)\\(" + + "this\\.getFileSystemPath\\(\\k\\.path\\)," + + "\\{recursive:\\k\\.recursive\\})"; +const LOCAL_FILE_WATCH_METHOD = new RegExp( + `${LOCAL_FILE_WATCH_METHOD_PREFIX}${LOCAL_FILE_WATCH_CURRENT_BODY}`, + "gu", +); +const CURRENT_LOCAL_HOST_CLASS = new RegExp( + `var (?${IDENTIFIER_PATTERN})=class\\{` + + "runsInsideWsl;hostConfig=\\{id:`local`,display_name:`Local`,kind:`local`\\};" + + "id=`local`;isLocal=!0;", + "gu", +); +const PARCEL_WORKING_TREE_WATCH = + /process\.platform===`linux`\?[A-Za-z_$][\w$]*\((?[A-Za-z_$][\w$]*),\{ignoredPaths:\[[A-Za-z_$][\w$]*\.posix\.join\(\k\.path,`\.git`\)\]\}\):(?[A-Za-z_$][\w$]*)\.startFileWatch\(\k\)/gu; +const CURRENT_PARCEL_HELPER = new RegExp( + "async function " + + `(?${IDENTIFIER_PATTERN})\\(` + + `(?${IDENTIFIER_PATTERN}),` + + `(?${IDENTIFIER_PATTERN})\\)\\{return new ` + + `(?${IDENTIFIER_PATTERN})\\(await import\\(` + + "`@parcel/watcher`" + + `\\),\\k,\\k\\)\\.start\\(\\)\\}`, + "gu", +); +const CURRENT_GIT_ROUTE_PREFIX_PATTERN = + "case`git`:\\{let " + + `(?${IDENTIFIER_PATTERN})=new ` + + `(?${IDENTIFIER_PATTERN});return\\{git:\\{createExecutionHost:` + + `(?${IDENTIFIER_PATTERN})=>\\{if\\(` + + `(?${IDENTIFIER_PATTERN})==null\\)` + + "throw Error\\(`Git hosts require a main RPC connection`\\);return new " + + `(?${IDENTIFIER_PATTERN})\\(` + + "\\k,\\k\\)\\},"; +const CURRENT_PARCEL_ROUTE_PATTERN = + "startWorkingTreeWatch:\\(" + + `(?${IDENTIFIER_PATTERN}),` + + `(?${IDENTIFIER_PATTERN})\\)=>` + + "\\k\\.isLocal\\?process\\.platform===`linux`\\?" + + `(?${IDENTIFIER_PATTERN})\\(\\k,\\{ignoredPaths:\\[` + + `(?${IDENTIFIER_PATTERN})\\.posix\\.join\\(` + + "\\k\\.path,`\\.git`\\)\\]\\}\\):" + + "\\k\\.startFileWatch\\(\\k\\):" + + "\\k\\.startFileWatch\\(\\k\\)"; +const CURRENT_DIRECTORY_ROUTE_PATTERN = + "startWorkingTreeWatch:\\(" + + `(?${IDENTIFIER_PATTERN}),` + + `(?${IDENTIFIER_PATTERN})\\)=>` + + `\\k\\.isLocal\\?/\\*${PARCEL_WATCH_MARKER}\\*/` + + "\\k\\.startFileWatch\\(\\k\\):" + + "\\k\\.startFileWatch\\(\\k\\)"; +const CURRENT_PARCEL_ROUTE_CONTRACT = new RegExp( + `(?${CURRENT_GIT_ROUTE_PREFIX_PATTERN})${CURRENT_PARCEL_ROUTE_PATTERN}`, + "gu", +); +const CURRENT_DIRECTORY_ROUTE_CONTRACT = new RegExp( + `(?${CURRENT_GIT_ROUTE_PREFIX_PATTERN})${CURRENT_DIRECTORY_ROUTE_PATTERN}`, + "gu", +); function codexLinuxStartDirectoryOnlyWorkingTreeWatch(host, options, configuration) { return (async () => { @@ -2084,6 +2153,9 @@ function codexLinuxStartDirectoryOnlyWorkingTreeWatch(host, options, configurati })(); } +const DIRECTORY_WATCH_HELPER_SOURCE = + `${codexLinuxStartDirectoryOnlyWorkingTreeWatch.toString()};`; + function normalizedSettings(context = {}) { const settings = context.feature?.settings ?? {}; const hasSetting = (name) => Object.prototype.hasOwnProperty.call(settings, name); @@ -2147,114 +2219,426 @@ function normalizedSettings(context = {}) { }; } +function countSubstring(source, needle) { + return source.split(needle).length - 1; +} + +function patternMatches(source, pattern) { + pattern.lastIndex = 0; + return [...source.matchAll(pattern)]; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function directoryOnlyLocalBranch(optionsName, settings) { + return ( + `if(process.platform===\`linux\`&&${optionsName}.recursive&&` + + `${optionsName}.renameEventHandling===\`changed-path-with-parent-directory\`)` + + `return ${HELPER_NAME}(this,${optionsName},${JSON.stringify(settings)});` + ); +} + +function completedLocalFileWatchMethod(settings) { + const branch = + "if\\(process\\.platform===`linux`&&\\k\\.recursive&&" + + "\\k\\.renameEventHandling===`changed-path-with-parent-directory`\\)" + + `return ${HELPER_NAME}\\(this,\\k,` + + `${escapeRegExp(JSON.stringify(settings))}\\);`; + return new RegExp( + `${LOCAL_FILE_WATCH_METHOD_PREFIX}${branch}${LOCAL_FILE_WATCH_CURRENT_BODY}`, + "gu", + ); +} + +function currentLocalHostClassForMethod(source, classMatches, methodMatches) { + if (classMatches.length !== 1 || methodMatches.length !== 1) return null; + const [classMatch] = classMatches; + const [methodMatch] = methodMatches; + const classTokenStart = source.indexOf("=class{", classMatch.index); + if (classTokenStart < 0) return null; + const classOpen = classTokenStart + "=class".length; + const classClose = findMatchingBrace(source, classOpen); + return classClose >= 0 && + methodMatch.index > classOpen && + methodMatch.index < classClose + ? classMatch.groups?.localHostClass ?? null + : null; +} + +function classifyCurrentBundle(bundlePath, source, settings = normalizedSettings()) { + const pristineLocalMatches = patternMatches(source, LOCAL_FILE_WATCH_METHOD); + const completedLocalMatches = patternMatches(source, completedLocalFileWatchMethod(settings)); + const currentLocalHostMatches = patternMatches(source, CURRENT_LOCAL_HOST_CLASS); + const localHostClass = currentLocalHostClassForMethod( + source, + currentLocalHostMatches, + [...pristineLocalMatches, ...completedLocalMatches], + ); + const helperDefinitionCount = countSubstring(source, `function ${HELPER_NAME}(`); + const helperExactCount = countSubstring(source, DIRECTORY_WATCH_HELPER_SOURCE); + const branchCallCount = countSubstring(source, `return ${HELPER_NAME}(this,`); + const markerCount = countSubstring(source, PARCEL_WATCH_MARKER); + const rawRouteLookalikeCount = patternMatches(source, PARCEL_WORKING_TREE_WATCH).length; + const pristineRouteMatches = patternMatches(source, CURRENT_PARCEL_ROUTE_CONTRACT); + const completedRouteMatches = patternMatches(source, CURRENT_DIRECTORY_ROUTE_CONTRACT); + const parcelHelperMatches = patternMatches(source, CURRENT_PARCEL_HELPER); + const correlatedPristineRouteCount = pristineRouteMatches.filter((route) => + parcelHelperMatches.some((helper) => + helper.groups?.helperName === route.groups?.routeHelper && + route.groups?.localHostClass === localHostClass + ) + ).length; + const correlatedCompletedRouteCount = completedRouteMatches.filter( + (route) => route.groups?.localHostClass === localHostClass, + ).length; + const parcelImportCount = countSubstring(source, "@parcel/watcher"); + const relevant = + pristineLocalMatches.length > 0 || + completedLocalMatches.length > 0 || + currentLocalHostMatches.length > 0 || + helperDefinitionCount > 0 || + helperExactCount > 0 || + branchCallCount > 0 || + markerCount > 0 || + rawRouteLookalikeCount > 0 || + pristineRouteMatches.length > 0 || + completedRouteMatches.length > 0 || + parcelHelperMatches.length > 0 || + parcelImportCount > 0; + return { + branchCallCount, + bundlePath, + completedLocalCount: completedLocalMatches.length, + completedRouteCount: completedRouteMatches.length, + correlatedCompletedRouteCount, + correlatedPristineRouteCount, + currentLocalHostCount: currentLocalHostMatches.length, + helperDefinitionCount, + helperExactCount, + localHostClass, + markerCount, + parcelHelperCount: parcelHelperMatches.length, + parcelImportCount, + pristineLocalCount: pristineLocalMatches.length, + pristineRouteCount: pristineRouteMatches.length, + rawRouteLookalikeCount, + relevant, + source, + startsWithExactHelper: source.startsWith(DIRECTORY_WATCH_HELPER_SOURCE), + }; +} + +function hasPristineLocalContract(record) { + return record.pristineLocalCount === 1 && + record.completedLocalCount === 0 && + record.currentLocalHostCount === 1 && + record.localHostClass != null && + record.helperDefinitionCount === 0 && + record.helperExactCount === 0 && + record.branchCallCount === 0; +} + +function hasCompletedLocalContract(record) { + return record.pristineLocalCount === 0 && + record.completedLocalCount === 1 && + record.currentLocalHostCount === 1 && + record.localHostClass != null && + record.helperDefinitionCount === 1 && + record.helperExactCount === 1 && + record.branchCallCount === 1 && + record.startsWithExactHelper; +} + +function hasNoParcelRouteContract(record) { + return record.markerCount === 0 && + record.rawRouteLookalikeCount === 0 && + record.pristineRouteCount === 0 && + record.completedRouteCount === 0 && + record.parcelHelperCount === 0 && + record.parcelImportCount === 0; +} + +function hasPristineWorkerRouteContract(record) { + return record.pristineRouteCount === 1 && + record.correlatedPristineRouteCount === 1 && + record.completedRouteCount === 0 && + record.markerCount === 0 && + record.rawRouteLookalikeCount === 1 && + record.parcelHelperCount === 1 && + record.parcelImportCount === 1; +} + +function hasCompletedWorkerRouteContract(record) { + return record.pristineRouteCount === 0 && + record.completedRouteCount === 1 && + record.correlatedCompletedRouteCount === 1 && + record.markerCount === 1 && + record.rawRouteLookalikeCount === 0 && + record.parcelHelperCount === 1 && + record.parcelImportCount === 1; +} + +function currentContractReason(records, bundleCount) { + const relevant = records.filter(({ relevant }) => relevant); + const targetNames = relevant.map(({ bundlePath }) => path.basename(bundlePath)); + const localCount = relevant.reduce( + (count, record) => count + record.pristineLocalCount, + 0, + ); + const helpers = relevant.reduce( + (count, record) => count + record.helperDefinitionCount, + 0, + ); + const branches = relevant.reduce( + (count, record) => count + record.branchCallCount, + 0, + ); + const parcelContractCount = relevant.reduce( + (count, record) => count + record.pristineRouteCount + record.completedRouteCount, + 0, + ); + const workerParcelContractCount = relevant + .filter(({ bundlePath }) => path.basename(bundlePath) === "worker.js") + .reduce( + (count, record) => count + record.pristineRouteCount + record.completedRouteCount, + 0, + ); + const correlatedRouteCount = relevant.reduce( + (count, record) => count + record.correlatedPristineRouteCount, + 0, + ); + const lookalikeCount = relevant.reduce( + (count, record) => count + record.rawRouteLookalikeCount, + 0, + ); + const markerCount = relevant.reduce((count, record) => count + record.markerCount, 0); + return ( + "Current 26.730.61639 working-tree contract rejected: " + + `Found ${localCount} local startFileWatch implementations, ${helpers} helpers, ` + + `${branches} branches, and ${parcelContractCount} Parcel route contracts ` + + `(${workerParcelContractCount} in worker.js) across ${relevant.length} relevant bundles ` + + `(${targetNames.join(", ") || "none"}) of ${bundleCount}; ` + + `${correlatedRouteCount} route/helper correlations, ${lookalikeCount} raw route ` + + `lookalikes, and ${markerCount} completed route markers` + ); +} + +function directoryWorkingTreeRoute(groups) { + return ( + `startWorkingTreeWatch:(${groups.routeHost},${groups.routeOptions})=>` + + `${groups.routeHost}.isLocal?/*${PARCEL_WATCH_MARKER}*/` + + `${groups.localHost}.startFileWatch(${groups.routeOptions}):` + + `${groups.routeHost}.startFileWatch(${groups.routeOptions})` + ); +} + +function replaceCurrentParcelRoute(source) { + CURRENT_PARCEL_ROUTE_CONTRACT.lastIndex = 0; + return source.replace(CURRENT_PARCEL_ROUTE_CONTRACT, (...args) => { + const groups = args[args.length - 1]; + return `${groups.routePrefix}${directoryWorkingTreeRoute(groups)}`; + }); +} + +function preparePristineBundle(record, settings) { + const [localMatch] = patternMatches(record.source, LOCAL_FILE_WATCH_METHOD); + const optionsName = localMatch.groups.options; + const methodStart = localMatch.index + localMatch[0].length; + const withBranch = + record.source.slice(0, methodStart) + + directoryOnlyLocalBranch(optionsName, settings) + + record.source.slice(methodStart); + const withRoute = path.basename(record.bundlePath) === "worker.js" + ? replaceCurrentParcelRoute(withBranch) + : withBranch; + return DIRECTORY_WATCH_HELPER_SOURCE + withRoute; +} + function patchWorkerSource(source, settings) { - const helperCount = source.split(`function ${HELPER_NAME}(`).length - 1; - const branchMarker = `return ${HELPER_NAME}(this,`; - const branchCount = source.split(branchMarker).length - 1; - if (helperCount === 1 && branchCount === 1) { + const currentSettings = settings ?? normalizedSettings(); + const hasWorkerSignals = + patternMatches(source, CURRENT_PARCEL_HELPER).length > 0 || + source.includes(PARCEL_WATCH_MARKER) || + patternMatches(source, PARCEL_WORKING_TREE_WATCH).length > 0; + const bundlePath = hasWorkerSignals ? "worker.js" : "src-current.js"; + const record = classifyCurrentBundle(bundlePath, source, currentSettings); + const routePristine = bundlePath === "worker.js" + ? hasPristineWorkerRouteContract(record) + : hasNoParcelRouteContract(record); + const routeCompleted = bundlePath === "worker.js" + ? hasCompletedWorkerRouteContract(record) + : hasNoParcelRouteContract(record); + + if (hasCompletedLocalContract(record) && routeCompleted) { return { source, matched: 1, changed: 0, reason: null }; } - if (helperCount !== 0 || branchCount !== 0) { - return { - source, - matched: 0, - changed: 0, - reason: `Found ${helperCount} helper definitions and ${branchCount} working-tree branches`, - }; - } - - LOCAL_FILE_WATCH_METHOD.lastIndex = 0; - const matches = [...source.matchAll(LOCAL_FILE_WATCH_METHOD)]; - if (matches.length !== 1) { - return { - source, - matched: 0, - changed: 0, - reason: `Found ${matches.length} local startFileWatch implementations`, - }; + if (hasPristineLocalContract(record) && routePristine) { + const patchedSource = preparePristineBundle(record, currentSettings); + const completed = classifyCurrentBundle(bundlePath, patchedSource, currentSettings); + const completedRoute = bundlePath === "worker.js" + ? hasCompletedWorkerRouteContract(completed) + : hasNoParcelRouteContract(completed); + if (hasCompletedLocalContract(completed) && completedRoute) { + return { source: patchedSource, matched: 1, changed: 1, reason: null }; + } } - const match = matches[0]; - const optionsName = match.groups.options; - const branch = - `if(process.platform===\`linux\`&&${optionsName}.recursive&&` + - `${optionsName}.renameEventHandling===\`changed-path-with-parent-directory\`)` + - `return ${HELPER_NAME}(this,${optionsName},${JSON.stringify(settings)});`; - const methodStart = match.index + match[0].length; - const withBranch = source.slice(0, methodStart) + branch + source.slice(methodStart); - const helper = `${codexLinuxStartDirectoryOnlyWorkingTreeWatch.toString()};`; - return { source: helper + withBranch, matched: 1, changed: 1, reason: null }; + return { + source, + matched: 0, + changed: 0, + reason: currentContractReason([record], 1), + }; } -function findLocalFileWatchBundle(extractedDir, settings) { +function findLocalFileWatchBundles(extractedDir, settings) { const buildDir = path.join(extractedDir, ".vite", "build"); if (!fs.existsSync(buildDir)) { - return { target: null, result: null, reason: ".vite/build directory not found" }; + return { targets: [], reason: ".vite/build directory not found" }; } const bundlePaths = fs.readdirSync(buildDir, { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.endsWith(".js")) .map((entry) => path.join(buildDir, entry.name)) .sort(); - const alreadyPatched = []; - const rawMatches = []; - let rawMatchCount = 0; - - for (const bundlePath of bundlePaths) { - const source = fs.readFileSync(bundlePath, "utf8"); - const helperCount = source.split(`function ${HELPER_NAME}(`).length - 1; - const branchCount = source.split(`return ${HELPER_NAME}(this,`).length - 1; - if (helperCount > 0 || branchCount > 0) { - alreadyPatched.push({ bundlePath, source, result: patchWorkerSource(source, settings) }); - continue; - } - LOCAL_FILE_WATCH_METHOD.lastIndex = 0; - const matches = [...source.matchAll(LOCAL_FILE_WATCH_METHOD)].length; - if (matches > 0) rawMatches.push({ bundlePath, source, matches }); - rawMatchCount += matches; + const records = bundlePaths.map((bundlePath) => { + const originalBytes = fs.readFileSync(bundlePath); + const record = classifyCurrentBundle(bundlePath, originalBytes.toString("utf8"), settings); + return record.relevant ? { ...record, originalBytes } : record; + }); + const relevant = records.filter(({ relevant }) => relevant); + const workerRecords = relevant.filter( + ({ bundlePath }) => path.basename(bundlePath) === "worker.js", + ); + const srcRecords = relevant.filter(({ bundlePath }) => + /^src-[A-Za-z0-9_-]+\.js$/u.test(path.basename(bundlePath)), + ); + const exactPair = relevant.length === 2 && + workerRecords.length === 1 && + srcRecords.length === 1; + if (!exactPair) { + return { targets: [], reason: currentContractReason(records, bundlePaths.length) }; } - if (alreadyPatched.length > 0) { - if (alreadyPatched.length !== 1 || rawMatchCount !== 0) { - return { - target: null, - result: null, - reason: - `Found directory-watch patch markers in ${alreadyPatched.length} bundles ` + - `and ${rawMatchCount} unpatched local startFileWatch implementations`, - }; - } - const target = alreadyPatched[0]; - return { target: target.bundlePath, result: target.result, reason: target.result.reason }; + const worker = workerRecords[0]; + const src = srcRecords[0]; + const pristine = + hasPristineLocalContract(worker) && + hasPristineWorkerRouteContract(worker) && + hasPristineLocalContract(src) && + hasNoParcelRouteContract(src); + const completed = + hasCompletedLocalContract(worker) && + hasCompletedWorkerRouteContract(worker) && + hasCompletedLocalContract(src) && + hasNoParcelRouteContract(src); + if (!pristine && !completed) { + return { targets: [], reason: currentContractReason(records, bundlePaths.length) }; } - if (rawMatchCount !== 1 || rawMatches.length !== 1) { - return { - target: null, - result: null, - reason: `Found ${rawMatchCount} local startFileWatch implementations across ${bundlePaths.length} build bundles`, - }; + const targets = [src, worker].map((record) => ({ + bundlePath: record.bundlePath, + originalBytes: record.originalBytes, + result: completed + ? { source: record.source, matched: 1, changed: 0, reason: null } + : { + source: preparePristineBundle(record, settings), + matched: 1, + changed: 1, + reason: null, + }, + })); + const preparedAreComplete = targets.every(({ bundlePath, result }) => { + const prepared = classifyCurrentBundle(bundlePath, result.source, settings); + return hasCompletedLocalContract(prepared) && ( + path.basename(bundlePath) === "worker.js" + ? hasCompletedWorkerRouteContract(prepared) + : hasNoParcelRouteContract(prepared) + ); + }); + if (!preparedAreComplete) { + return { targets: [], reason: currentContractReason(records, bundlePaths.length) }; } + return { targets, reason: null }; +} - const target = rawMatches[0]; - const result = patchWorkerSource(target.source, settings); - return { target: target.bundlePath, result, reason: result.reason }; +function writePreparedBundleTargets( + targets, + { + writeFileSync = fs.writeFileSync, + readFileSync = fs.readFileSync, + } = {}, +) { + const attempted = []; + try { + for (const target of targets.filter(({ result }) => result.changed === 1)) { + attempted.push(target); + writeFileSync(target.bundlePath, target.result.source, "utf8"); + } + } catch (error) { + const rollbackWriteFailures = []; + for (const target of [...attempted].reverse()) { + try { + writeFileSync(target.bundlePath, target.originalBytes); + } catch (rollbackError) { + rollbackWriteFailures.push({ bundlePath: target.bundlePath, error: rollbackError }); + } + } + + const rollbackVerificationFailures = []; + for (const target of attempted) { + try { + const restored = readFileSync(target.bundlePath); + const restoredBytes = Buffer.isBuffer(restored) ? restored : Buffer.from(restored); + if (!restoredBytes.equals(target.originalBytes)) { + rollbackVerificationFailures.push( + new Error(`rollback byte verification failed for ${target.bundlePath}`), + ); + } + } catch (rollbackError) { + rollbackVerificationFailures.push( + new Error( + `rollback byte verification failed for ${target.bundlePath}: ${rollbackError.message}`, + { cause: rollbackError }, + ), + ); + } + } + + if (rollbackVerificationFailures.length > 0) { + const rollbackWriteFailure = rollbackWriteFailures[0]; + const writeFailureContext = rollbackWriteFailure == null + ? "" + : `; rollback write also failed for ${rollbackWriteFailure.bundlePath}: ` + + rollbackWriteFailure.error.message; + throw new PatchIntegrityError( + "Directory-only working-tree bundle rollback could not restore original bytes: " + + `${rollbackVerificationFailures[0].message}${writeFailureContext}`, + { cause: error }, + ); + } + + throw error; + } } function patchWorker(extractedDir, context = {}) { - const discovery = findLocalFileWatchBundle(extractedDir, normalizedSettings(context)); - if (discovery.target == null || discovery.result?.matched !== 1) { - const reason = discovery.reason ?? "Local startFileWatch implementation not found"; + const discovery = findLocalFileWatchBundles(extractedDir, normalizedSettings(context)); + if (discovery.targets.length !== 2) { + const reason = discovery.reason ?? "Current local startFileWatch bundles not found"; console.warn(`WARN: ${reason} - skipping directory-only working-tree watch feature`); - return { matched: discovery.result?.matched ?? 0, changed: 0, reason }; + return { matched: 0, changed: 0, reason }; } - const result = discovery.result; - if (result.changed === 1) fs.writeFileSync(discovery.target, result.source, "utf8"); + + writePreparedBundleTargets(discovery.targets, context); + const changed = discovery.targets.reduce((count, { result }) => count + result.changed, 0); return { - matched: result.matched, - changed: result.changed, - reason: result.reason, - target: path.relative(extractedDir, discovery.target), + matched: discovery.targets.length, + changed, + reason: null, + targets: discovery.targets.map(({ bundlePath }) => path.relative(extractedDir, bundlePath)), }; } @@ -2266,10 +2650,10 @@ const descriptors = [ ciPolicy: "optional", apply: patchWorker, status: (result, warnings) => { - if (result?.matched !== 1) { + if (result?.matched !== 2) { return { status: "skipped-optional", reason: result?.reason ?? warnings[0] ?? null }; } - return result.changed === 1 ? "applied" : "already-applied"; + return result.changed > 0 ? "applied" : "already-applied"; }, }, ]; @@ -2279,9 +2663,11 @@ module.exports = { DEFAULT_MAX_WATCHES, HELPER_NAME, LOCAL_FILE_WATCH_METHOD, + PARCEL_WATCH_MARKER, + PARCEL_WORKING_TREE_WATCH, codexLinuxStartDirectoryOnlyWorkingTreeWatch, descriptors, - findLocalFileWatchBundle, + findLocalFileWatchBundles, normalizedSettings, patchWorker, patchWorkerSource, diff --git a/linux-features/directory-only-working-tree-watch/test.js b/linux-features/directory-only-working-tree-watch/test.js index eec30ee0d..784f18d64 100644 --- a/linux-features/directory-only-working-tree-watch/test.js +++ b/linux-features/directory-only-working-tree-watch/test.js @@ -10,8 +10,19 @@ const os = require("node:os"); const path = require("node:path"); const test = require("node:test"); +const { + createPatchReport, + enabledFeatureFailuresFromReport, +} = require("../../scripts/lib/patch-report.js"); +const { + applyExtractedAppPatchDescriptors, +} = require("../../scripts/patches/engine.js"); + const { DEFAULT_IGNORED_DIRECTORY_NAMES, + HELPER_NAME, + PARCEL_WATCH_MARKER, + PARCEL_WORKING_TREE_WATCH, codexLinuxStartDirectoryOnlyWorkingTreeWatch, descriptors, normalizedSettings, @@ -23,7 +34,8 @@ const BUDGET_KEY = Symbol.for("codex-linux.directory-only-working-tree-watch.bud function localWorkerSource() { return [ - "var LocalHost=class{", + "var LocalHost=class{runsInsideWsl;hostConfig={id:`local`,display_name:`Local`," + + "kind:`local`};id=`local`;isLocal=!0;", "async platformPath(){return E.default.posix}", "async startFileWatch(e){let t=jH(),n=!1,r=await this.platformPath(),", "i=(0,w.watch)(this.getFileSystemPath(e.path),{recursive:e.recursive},()=>{});", @@ -32,6 +44,89 @@ function localWorkerSource() { ].join(""); } +// Exact relevant fragments from OpenAI Desktop 26.730.61639. Keep these +// independent of patch.js so production matcher drift cannot rewrite the +// fixture into a passing shape. +const CURRENT_WORKER_LOCAL_FILE_WATCH = [ + "async startFileWatch(e){let t=sV(),n=!1,r=await this.platformPath(),", + "i=(0,w.watch)(this.getFileSystemPath(e.path),{recursive:e.recursive},(t,n)=>{", + "let i=n==null?null:r.join(e.path,...n.toString().split(this.runsInsideWsl?", + "E.default.win32.sep:E.default.sep)),a=i==null?[]:[i];i!=null&&t===`rename`&&", + "e.renameEventHandling===`changed-path-with-parent-directory`&&a.push(r.dirname(i)),", + "e.onChange({changedPaths:a})}),a=e=>{n||(n=!0,i.close(),t.resolve(e))};", + "return i.on(`error`,e=>{a({reason:`watch-error`,error:e})}),{coverage:{recursive:", + "e.recursive,typedPathChanges:!1},path:e.path,closed:t.promise,dispose:async()=>{", + "a({reason:`disposed`})}}}", +].join(""); + +const CURRENT_SRC_LOCAL_FILE_WATCH = [ + "async startFileWatch(e){let t=Kb(),n=!1,r=await this.platformPath(),", + "a=(0,c.watch)(this.getFileSystemPath(e.path),{recursive:e.recursive},(t,n)=>{", + "let a=n==null?null:r.join(e.path,...n.toString().split(this.runsInsideWsl?", + "i.default.win32.sep:i.default.sep)),o=a==null?[]:[a];a!=null&&t===`rename`&&", + "e.renameEventHandling===`changed-path-with-parent-directory`&&o.push(r.dirname(a)),", + "e.onChange({changedPaths:o})}),o=e=>{n||(n=!0,a.close(),t.resolve(e))};", + "return a.on(`error`,e=>{o({reason:`watch-error`,error:e})}),{coverage:{recursive:", + "e.recursive,typedPathChanges:!1},path:e.path,closed:t.promise,dispose:async()=>{", + "o({reason:`disposed`})}}}", +].join(""); + +const CURRENT_WORKER_REMOTE_FILE_WATCH = [ + "async startFileWatch(e){let{onChange:t,...n}=e,r=await this.callHost(e=>", + "e.startFileWatch(n,t)),i=!1,a=()=>{i||(i=!0,r[Symbol.dispose]())},o,s;try{", + "[o,s]=await Promise.all([r.coverage,r.path])}catch(e){throw a(),e}let c=r.closed()", + ".finally(a);return{coverage:o,path:s,closed:c,dispose:async()=>{try{await r.dispose()}", + "finally{a()}}}}", +].join(""); + +const CURRENT_SRC_REMOTE_FILE_WATCH = [ + "async startFileWatch(e){let t=await this.startFileWatchSession({onChange:e.onChange,", + "path:e.path,watchId:e.watchId});return{coverage:t.coverage,path:t.path,closed:t.closed,", + "dispose:async()=>{await t.dispose()}}}", +].join(""); + +const CURRENT_PARCEL_HELPER = + "async function rye(e,t){return new iye(await import(`@parcel/watcher`),e,t).start()}"; +const CURRENT_GIT_ROUTE_PREFIX = + "case`git`:{let e=new Yue;return{git:{createExecutionHost:e=>{if(n==null)" + + "throw Error(`Git hosts require a main RPC connection`);return new $ue(n,e)},"; +const CURRENT_PARCEL_ROUTE = + "startWorkingTreeWatch:(t,n)=>t.isLocal?process.platform===`linux`?" + + "rye(n,{ignoredPaths:[E.posix.join(n.path,`.git`)]}):e.startFileWatch(n):" + + "t.startFileWatch(n)"; +const CURRENT_DIRECTORY_ROUTE = + `startWorkingTreeWatch:(t,n)=>t.isLocal?/*${PARCEL_WATCH_MARKER}*/` + + "e.startFileWatch(n):t.startFileWatch(n)"; +const CURRENT_GIT_ROUTE_SUFFIX = "}}}case`open-in`:"; + +function currentWorkerSource(route = CURRENT_PARCEL_ROUTE) { + return [ + "var Yue=class{runsInsideWsl;hostConfig={id:`local`,display_name:`Local`," + + "kind:`local`};id=`local`;isLocal=!0;", + CURRENT_WORKER_LOCAL_FILE_WATCH, + "};var CurrentWorkerRemote=class{", + CURRENT_WORKER_REMOTE_FILE_WATCH, + "};", + CURRENT_PARCEL_HELPER, + "function currentDependencies(r,n){switch(r){", + CURRENT_GIT_ROUTE_PREFIX, + route, + CURRENT_GIT_ROUTE_SUFFIX, + "return{openIn:null}}}", + ].join(""); +} + +function currentSrcSource() { + return [ + "var CurrentSrcRemote=class{", + CURRENT_SRC_REMOTE_FILE_WATCH, + "};var $ne=class{runsInsideWsl;hostConfig={id:`local`,display_name:`Local`," + + "kind:`local`};id=`local`;isLocal=!0;", + CURRENT_SRC_LOCAL_FILE_WATCH, + "};", + ].join(""); +} + function configuration(overrides = {}) { return { maxWatches: 8192, @@ -165,6 +260,58 @@ test("feature patch targets only the local recursive working-tree host", () => { assert.equal(second.source, first.source); }); +test("routes the current OpenAI Parcel working tree through the existing feature host", () => { + const settings = normalizedSettings(); + const first = patchWorkerSource(currentWorkerSource(), settings); + + assert.equal(first.matched, 1); + assert.equal(first.changed, 1); + assert.equal(first.source.split(PARCEL_WATCH_MARKER).length - 1, 1); + PARCEL_WORKING_TREE_WATCH.lastIndex = 0; + assert.equal(PARCEL_WORKING_TREE_WATCH.test(first.source), false); + assert.ok(first.source.includes(CURRENT_DIRECTORY_ROUTE)); + assert.ok(first.source.includes(CURRENT_PARCEL_HELPER)); + assert.ok(first.source.includes(CURRENT_WORKER_REMOTE_FILE_WATCH)); + assert.match( + first.source, + /e\.recursive&&e\.renameEventHandling===`changed-path-with-parent-directory`\)return codexLinuxStartDirectoryOnlyWorkingTreeWatch/u, + ); + + assert.deepEqual( + patchWorkerSource(first.source, settings), + { source: first.source, matched: 1, changed: 0, reason: null }, + ); +}); + +test("correlates current route roles without pinning minified aliases", () => { + const parcelHelper = + "async function parcelStart(root,settings){return new ParcelWatcher(" + + "await import(`@parcel/watcher`),root,settings).start()}"; + const gitRoutePrefix = + "case`git`:{let localHost=new LocalHost;return{git:{" + + "createExecutionHost:executionOptions=>{if(mainConnection==null)" + + "throw Error(`Git hosts require a main RPC connection`);" + + "return new RemoteHost(mainConnection,executionOptions)},"; + const parcelRoute = + "startWorkingTreeWatch:(host,options)=>host.isLocal?process.platform===`linux`?" + + "parcelStart(options,{ignoredPaths:[pathApi.posix.join(options.path,`.git`)]}):" + + "localHost.startFileWatch(options):host.startFileWatch(options)"; + const source = currentWorkerSource(parcelRoute) + .replace(CURRENT_PARCEL_HELPER, parcelHelper) + .replace(CURRENT_GIT_ROUTE_PREFIX, gitRoutePrefix) + .replace("var Yue=class{", "var LocalHost=class{"); + + const first = patchWorkerSource(source, normalizedSettings()); + assert.equal(first.matched, 1); + assert.equal(first.changed, 1); + assert.ok(first.source.includes(parcelHelper)); + assert.ok(first.source.includes( + `startWorkingTreeWatch:(host,options)=>host.isLocal?/*${PARCEL_WATCH_MARKER}*/` + + "localHost.startFileWatch(options):host.startFileWatch(options)", + )); + assert.doesNotMatch(first.source, /parcelStart\(options,/u); +}); + test("feature patch reports drift instead of patching an ambiguous worker", () => { const source = `${localWorkerSource()}${localWorkerSource()}`; const result = patchWorkerSource(source, normalizedSettings()); @@ -175,37 +322,416 @@ test("feature patch reports drift instead of patching an ambiguous worker", () = assert.equal(descriptors[0].status(result, []).status, "skipped-optional"); }); -test("feature discovers the local host in the current hashed build bundle", async () => { +test("feature patches the current local host copies in src and worker bundles", async () => { await withTempTree((root) => { const buildDir = path.join(root, ".vite", "build"); const workerPath = path.join(buildDir, "worker.js"); - const localHostPath = path.join(buildDir, "src-current.js"); + const localHostPath = path.join(buildDir, "src-Bn_6ASpg.js"); fs.mkdirSync(buildDir, { recursive: true }); - fs.writeFileSync(workerPath, "var gitWorker={startFileWatch(){}};"); - fs.writeFileSync(localHostPath, localWorkerSource()); + fs.writeFileSync(workerPath, currentWorkerSource()); + fs.writeFileSync(localHostPath, currentSrcSource()); const first = patchWorker(root); - assert.equal(first.matched, 1); - assert.equal(first.changed, 1); - assert.equal(first.target, path.join(".vite", "build", "src-current.js")); - assert.equal(fs.readFileSync(workerPath, "utf8"), "var gitWorker={startFileWatch(){}};"); - const patched = fs.readFileSync(localHostPath, "utf8"); - assert.match(patched, /function codexLinuxStartDirectoryOnlyWorkingTreeWatch\(/); - assert.doesNotThrow(() => new Function(patched)); + assert.equal(first.matched, 2); + assert.equal(first.changed, 2); + assert.deepEqual(first.targets, [ + path.join(".vite", "build", "src-Bn_6ASpg.js"), + path.join(".vite", "build", "worker.js"), + ]); + for (const bundlePath of [localHostPath, workerPath]) { + const patched = fs.readFileSync(bundlePath, "utf8"); + assert.match(patched, /function codexLinuxStartDirectoryOnlyWorkingTreeWatch\(/); + assert.doesNotThrow(() => new Function(patched)); + } + assert.ok(fs.readFileSync(workerPath, "utf8").includes(CURRENT_DIRECTORY_ROUTE)); const second = patchWorker(root); - assert.equal(second.matched, 1); + assert.equal(second.matched, 2); assert.equal(second.changed, 0); - assert.equal(second.target, path.join(".vite", "build", "src-current.js")); + assert.deepEqual(second.targets, first.targets); + }); +}); + +test("second bundle write failure restores every attempted target and permits retry", async () => { + await withTempTree((root) => { + const buildDir = path.join(root, ".vite", "build"); + const srcPath = path.join(buildDir, "src-Bn_6ASpg.js"); + const workerPath = path.join(buildDir, "worker.js"); + fs.mkdirSync(buildDir, { recursive: true }); + fs.writeFileSync(srcPath, currentSrcSource()); + fs.writeFileSync(workerPath, currentWorkerSource()); + const originalBytes = new Map([ + [srcPath, fs.readFileSync(srcPath)], + [workerPath, fs.readFileSync(workerPath)], + ]); + let writeCount = 0; + const baseDescriptor = descriptors.find(({ id }) => id === "worker-directory-watch"); + const descriptor = { + ...baseDescriptor, + id: "feature:directory-only-working-tree-watch:worker-directory-watch", + name: "feature:directory-only-working-tree-watch:worker-directory-watch", + sourceKind: "feature", + featureId: "directory-only-working-tree-watch", + apply: (extractedDir, context) => patchWorker(extractedDir, { + ...context, + writeFileSync(filePath, source, encoding) { + writeCount += 1; + if (writeCount === 2) { + fs.writeFileSync(filePath, "partially-written", encoding); + throw new Error("simulated second bundle write failure"); + } + fs.writeFileSync(filePath, source, encoding); + }, + }), + }; + const report = createPatchReport(); + report.enabledFeatures = ["directory-only-working-tree-watch"]; + + captureWarnings(() => applyExtractedAppPatchDescriptors( + root, + [descriptor], + {}, + report, + descriptor.phase, + )); + assert.equal(writeCount, 4); + for (const [filePath, expected] of originalBytes) { + assert.deepEqual(fs.readFileSync(filePath), expected); + } + const [failure] = enabledFeatureFailuresFromReport(report); + assert.equal(failure?.name, descriptor.id); + assert.equal(failure?.status, "skipped-optional"); + assert.match(failure?.reason ?? "", /simulated second bundle write failure/u); + + const retry = patchWorker(root); + assert.equal(retry.matched, 2); + assert.equal(retry.changed, 2); + const idempotent = patchWorker(root); + assert.equal(idempotent.matched, 2); + assert.equal(idempotent.changed, 0); + }); +}); + +test("rollback byte-verification failure reports failed-integrity", async () => { + await withTempTree((root) => { + const buildDir = path.join(root, ".vite", "build"); + const srcPath = path.join(buildDir, "src-Bn_6ASpg.js"); + const workerPath = path.join(buildDir, "worker.js"); + fs.mkdirSync(buildDir, { recursive: true }); + fs.writeFileSync(srcPath, currentSrcSource()); + fs.writeFileSync(workerPath, currentWorkerSource()); + const originalWorker = fs.readFileSync(workerPath); + let writeCount = 0; + const baseDescriptor = descriptors.find(({ id }) => id === "worker-directory-watch"); + const descriptor = { + ...baseDescriptor, + id: "feature:directory-only-working-tree-watch:worker-directory-watch", + name: "feature:directory-only-working-tree-watch:worker-directory-watch", + sourceKind: "feature", + featureId: "directory-only-working-tree-watch", + apply: (extractedDir, context) => patchWorker(extractedDir, { + ...context, + writeFileSync(filePath, source, encoding) { + writeCount += 1; + if (writeCount === 2) { + fs.writeFileSync(filePath, "partially-written", encoding); + throw new Error("simulated second bundle write failure"); + } + if (writeCount === 4) { + fs.writeFileSync(filePath, "rollback-corrupt", encoding); + return; + } + fs.writeFileSync(filePath, source, encoding); + }, + }), + }; + const report = createPatchReport(); + report.enabledFeatures = ["directory-only-working-tree-watch"]; + + assert.throws( + () => captureWarnings(() => applyExtractedAppPatchDescriptors( + root, + [descriptor], + {}, + report, + descriptor.phase, + )), + (error) => + error?.code === "PATCH_INTEGRITY_FAILURE" && + /rollback could not restore original bytes.*rollback byte verification failed/iu.test( + error.message, + ), + ); + assert.equal(writeCount, 4); + assert.equal(fs.readFileSync(srcPath, "utf8"), "rollback-corrupt"); + assert.deepEqual(fs.readFileSync(workerPath), originalWorker); + const [failure] = enabledFeatureFailuresFromReport(report); + assert.equal(failure?.name, descriptor.id); + assert.equal(failure?.status, "failed-integrity"); + assert.match(failure?.reason ?? "", /rollback byte verification failed/iu); + }); +}); + +test("current-DMG route drift leaves every bundle byte-identical", async () => { + await withTempTree((root) => { + const directLocalRoute = + "startWorkingTreeWatch:(t,n)=>t.isLocal?e.startFileWatch(n):t.startFileWatch(n)"; + const currentSettings = normalizedSettings(); + const completedWorker = patchWorkerSource( + currentWorkerSource(), + currentSettings, + ).source; + const completedSrc = patchWorkerSource(currentSrcSource(), currentSettings).source; + const completedHelper = completedWorker.slice(0, completedWorker.indexOf("var Yue=class{")); + const completedBranch = + "if(process.platform===`linux`&&e.recursive&&" + + "e.renameEventHandling===`changed-path-with-parent-directory`)" + + `return ${HELPER_NAME}(this,e,${JSON.stringify(currentSettings)});`; + const staleSettings = { ...currentSettings, maxWatches: 4096 }; + const staleWorker = patchWorkerSource(currentWorkerSource(), staleSettings).source; + const staleSrc = patchWorkerSource(currentSrcSource(), staleSettings).source; + const dualOwnerRoute = + `startWorkingTreeWatch:(t,n)=>t.isLocal?/*${PARCEL_WATCH_MARKER}*/` + + "(rye(n,{ignoredPaths:[E.posix.join(n.path,`.git`)]}),e.startFileWatch(n)):" + + "t.startFileWatch(n)"; + const unrelatedLookalike = + "function unrelated(n,e){return process.platform===`linux`?" + + "rye(n,{ignoredPaths:[E.posix.join(n.path,`.git`)]}):e.startFileWatch(n)}"; + const cases = [ + { + name: "missing route", + sources: new Map([ + ["src-current.js", currentSrcSource()], + ["worker.js", currentWorkerSource(directLocalRoute)], + ]), + reason: /0 Parcel route contracts/u, + }, + { + name: "duplicate route", + sources: new Map([ + ["src-current.js", currentSrcSource()], + [ + "worker.js", + `${currentWorkerSource()}${CURRENT_GIT_ROUTE_PREFIX}` + + `${CURRENT_PARCEL_ROUTE}${CURRENT_GIT_ROUTE_SUFFIX}`, + ], + ]), + reason: /2 Parcel route contracts/u, + }, + { + name: "uncorrelated helper alias", + sources: new Map([ + ["src-current.js", currentSrcSource()], + ["worker.js", currentWorkerSource(CURRENT_PARCEL_ROUTE.replace("rye(n,", "Qve(n,"))], + ]), + reason: /0 route\/helper correlations/u, + }, + { + name: "uncorrelated local host class", + sources: new Map([ + ["src-current.js", currentSrcSource()], + ["worker.js", currentWorkerSource().replace("let e=new Yue", "let e=new OtherHost")], + ]), + reason: /0 route\/helper correlations/u, + }, + { + name: "local method moved outside the routed class", + sources: new Map([ + ["src-current.js", currentSrcSource()], + [ + "worker.js", + currentWorkerSource().replace( + CURRENT_WORKER_LOCAL_FILE_WATCH, + `};var OtherHost=class extends BaseHost{${CURRENT_WORKER_LOCAL_FILE_WATCH}`, + ), + ], + ]), + reason: /0 route\/helper correlations/u, + }, + { + name: "uncorrelated main connection alias", + sources: new Map([ + ["src-current.js", currentSrcSource()], + ["worker.js", currentWorkerSource().replace("new $ue(n,e)", "new $ue(other,e)")], + ]), + reason: /0 route\/helper correlations/u, + }, + { + name: "marker beside a live Parcel route", + sources: new Map([ + ["src-current.js", currentSrcSource()], + ["worker.js", `/*${PARCEL_WATCH_MARKER}*/${currentWorkerSource()}`], + ]), + reason: /current 26\.730\.61639 working-tree contract rejected/iu, + }, + { + name: "marker in the src bundle", + sources: new Map([ + ["src-current.js", `/*${PARCEL_WATCH_MARKER}*/${currentSrcSource()}`], + ["worker.js", currentWorkerSource()], + ]), + reason: /current 26\.730\.61639 working-tree contract rejected/iu, + }, + { + name: "dual Parcel and directory ownership", + sources: new Map([ + ["src-current.js", currentSrcSource()], + ["worker.js", currentWorkerSource(dualOwnerRoute)], + ]), + reason: /current 26\.730\.61639 working-tree contract rejected/iu, + }, + { + name: "unrelated raw route lookalike", + sources: new Map([ + ["src-current.js", currentSrcSource()], + ["worker.js", `${currentWorkerSource(directLocalRoute)}${unrelatedLookalike}`], + ]), + reason: /current 26\.730\.61639 working-tree contract rejected/iu, + }, + { + name: "route outside worker.js", + sources: new Map([ + [ + "src-current.js", + `${currentSrcSource()}${CURRENT_PARCEL_HELPER}${CURRENT_GIT_ROUTE_PREFIX}` + + `${CURRENT_PARCEL_ROUTE}${CURRENT_GIT_ROUTE_SUFFIX}`, + ], + ["worker.js", currentWorkerSource(directLocalRoute)], + ]), + reason: /1 Parcel route contracts \(0 in worker\.js\)/u, + }, + { + name: "partially completed pair", + sources: new Map([ + ["src-current.js", currentSrcSource()], + ["worker.js", completedWorker], + ]), + reason: /current 26\.730\.61639 working-tree contract rejected/iu, + }, + { + name: "damaged completed helper", + sources: new Map([ + ["src-current.js", completedSrc], + [ + "worker.js", + completedWorker.replace( + "const GIT_QUERY_TIMEOUT_MS = 5000;", + "const GIT_QUERY_TIMEOUT_MS = 5001;", + ), + ], + ]), + reason: /current 26\.730\.61639 working-tree contract rejected/iu, + }, + { + name: "altered completed platform guard", + sources: new Map([ + ["src-current.js", completedSrc], + [ + "worker.js", + completedWorker.replace( + "if(process.platform===`linux`&&e.recursive&&", + "if(process.platform===`darwin`&&e.recursive&&", + ), + ], + ]), + reason: /current 26\.730\.61639 working-tree contract rejected/iu, + }, + { + name: "duplicate completed helper", + sources: new Map([ + ["src-current.js", completedSrc], + ["worker.js", `${completedHelper}${completedWorker}`], + ]), + reason: /current 26\.730\.61639 working-tree contract rejected/iu, + }, + { + name: "duplicate completed branch", + sources: new Map([ + ["src-current.js", completedSrc], + ["worker.js", completedWorker.replace(completedBranch, completedBranch.repeat(2))], + ]), + reason: /current 26\.730\.61639 working-tree contract rejected/iu, + }, + { + name: "stale completed settings", + sources: new Map([ + ["src-current.js", staleSrc], + ["worker.js", staleWorker], + ]), + reason: /current 26\.730\.61639 working-tree contract rejected/iu, + }, + ]; + + for (const [index, entry] of cases.entries()) { + const buildDir = path.join(root, `case-${index}`, ".vite", "build"); + fs.mkdirSync(buildDir, { recursive: true }); + for (const [name, source] of entry.sources) { + fs.writeFileSync(path.join(buildDir, name), source); + } + + const { value: result } = captureWarnings(() => patchWorker(path.dirname(path.dirname(buildDir)))); + assert.equal(result.matched, 0, entry.name); + assert.equal(result.changed, 0, entry.name); + assert.match(result.reason, entry.reason, entry.name); + for (const [name, source] of entry.sources) { + assert.equal(fs.readFileSync(path.join(buildDir, name), "utf8"), source, entry.name); + } + } + }); +}); + +test("current-DMG route drift is an enabled-feature acceptance failure", async () => { + await withTempTree((root) => { + const buildDir = path.join(root, ".vite", "build"); + const sources = new Map([ + ["src-Bn_6ASpg.js", currentSrcSource()], + [ + "worker.js", + currentWorkerSource( + "startWorkingTreeWatch:(t,n)=>t.isLocal?e.startFileWatch(n):t.startFileWatch(n)", + ), + ], + ]); + fs.mkdirSync(buildDir, { recursive: true }); + for (const [name, source] of sources) { + fs.writeFileSync(path.join(buildDir, name), source); + } + + const baseDescriptor = descriptors.find(({ id }) => id === "worker-directory-watch"); + const descriptor = { + ...baseDescriptor, + id: "feature:directory-only-working-tree-watch:worker-directory-watch", + name: "feature:directory-only-working-tree-watch:worker-directory-watch", + sourceKind: "feature", + featureId: "directory-only-working-tree-watch", + }; + const report = createPatchReport(); + report.enabledFeatures = ["directory-only-working-tree-watch"]; + captureWarnings(() => applyExtractedAppPatchDescriptors( + root, + [descriptor], + {}, + report, + descriptor.phase, + )); + + const [failure] = enabledFeatureFailuresFromReport(report); + assert.equal(failure?.name, descriptor.id); + assert.equal(failure?.status, "skipped-optional"); + assert.match(failure?.reason ?? "", /current|Parcel|route|contract/iu); + for (const [name, source] of sources) { + assert.equal(fs.readFileSync(path.join(buildDir, name), "utf8"), source); + } }); }); -test("feature rejects multiple local host implementations across build bundles", async () => { +test("feature rejects local host copies outside the current src and worker pair", async () => { await withTempTree((root) => { const buildDir = path.join(root, ".vite", "build"); fs.mkdirSync(buildDir, { recursive: true }); fs.writeFileSync(path.join(buildDir, "src-first.js"), localWorkerSource()); fs.writeFileSync(path.join(buildDir, "src-second.js"), localWorkerSource()); + fs.writeFileSync(path.join(buildDir, "worker.js"), localWorkerSource()); const originalWarn = console.warn; console.warn = () => {}; @@ -217,7 +743,7 @@ test("feature rejects multiple local host implementations across build bundles", } assert.equal(result.matched, 0); assert.equal(result.changed, 0); - assert.match(result.reason, /Found 2 local startFileWatch implementations across 2 build bundles/); + assert.match(result.reason, /Found 3 local startFileWatch implementations/); }); }); diff --git a/linux-features/frameless-titlebar/patch.js b/linux-features/frameless-titlebar/patch.js index f42983b44..e257da95a 100644 --- a/linux-features/frameless-titlebar/patch.js +++ b/linux-features/frameless-titlebar/patch.js @@ -1,5 +1,26 @@ "use strict"; +const { + delegatePatchMarker, + patchDelegationState, +} = require("../../scripts/patches/lib/composition-delegation.js"); + +const FEATURE_ID = "frameless-titlebar"; +const LINUX_NATIVE_TITLEBAR_PATCH_ID = "linux-native-titlebar"; +const LINUX_NATIVE_TITLEBAR_PATCH_MARKER = + "/*codexLinuxNativeTitlebarPatch*/"; +const LINUX_WINDOW_CONTROLS_SAFE_AREA_PATCH_ID = + "linux-window-controls-safe-area"; +const LINUX_WINDOW_CONTROLS_SAFE_AREA_MARKER = + "/*codexLinuxWindowControlsSafeAreaPatch*/"; + +function regexMatchCount(source, pattern) { + const flags = pattern.flags.includes("g") + ? pattern.flags + : `${pattern.flags}g`; + return source.match(new RegExp(pattern.source, flags))?.length ?? 0; +} + function applyFramelessTitlebarBranchPatch(currentSource) { let patchedTitlebar = false; const combinedLinuxTitlebarRegex = @@ -62,13 +83,102 @@ function applyFramelessTitlebarOverlaySyncPatch(currentSource) { return patchedSource; } +function hasCompleteFramelessTitlebarMainComposition(source) { + const delegation = patchDelegationState( + source, + LINUX_NATIVE_TITLEBAR_PATCH_ID, + { + allowedFeatureIds: [FEATURE_ID], + enabledFeatureIds: [FEATURE_ID], + ownerMarker: LINUX_NATIVE_TITLEBAR_PATCH_MARKER, + }, + ); + if (delegation.state !== "enabled" || delegation.featureId !== FEATURE_ID) { + return false; + } + + const helper = + /function codexLinuxTitleBarOverlay\(e=1\)\{return\{color:([A-Za-z_$][\w$]*)\.nativeTheme\.shouldUseDarkColors\?`#111111`:([A-Za-z_$][\w$]*),symbolColor:\1\.nativeTheme\.shouldUseDarkColors\?([A-Za-z_$][\w$]*):([A-Za-z_$][\w$]*),height:Math\.round\(30\*e\)\}\}/u; + const primary = + /case`quickChat`:case`primary`:return [^;]{0,2000}?:[A-Za-z_$][\w$]*===`win32`\?\{titleBarStyle:`hidden`,titleBarOverlay:[A-Za-z_$][\w$]*\([A-Za-z_$][\w$]*\),\.\.\.[A-Za-z_$][\w$]*===`quickChat`\?\{resizable:!0\}:\{\}\}:[A-Za-z_$][\w$]*===`linux`\?\{titleBarStyle:`hidden`,\.\.\.[A-Za-z_$][\w$]*===`quickChat`\?\{resizable:!0\}:\{\}\}:/u; + const zoom = + /setWindowZoom\([^)]*\)\{[\s\S]{0,800}?process\.platform===`win32`&&\(this\.windowZooms\.set\(([A-Za-z_$][\w$]*)\.id,([A-Za-z_$][\w$]*)\),\1\.setTitleBarOverlay\([A-Za-z_$][\w$]*\(\2\)\)\)/u; + const sync = + /install[A-Za-z_$][\w$]*TitleBarOverlaySync\(([A-Za-z_$][\w$]*),([A-Za-z_$][\w$]*)\)\{if\(process\.platform!==`win32`\|\|\2!==`primary`&&\2!==`quickChat`\)return;let [A-Za-z_$][\w$]*=\(\)=>\{[\s\S]{0,300}?\1\.setTitleBarOverlay\([A-Za-z_$][\w$]*\(this\.windowZooms\.get\(\1\.id\)\)\)/u; + const zoomOwner = + /setWindowZoom\([^)]*\)\{[\s\S]{0,800}?this\.windowAppearances\.get\(/u; + const syncOwner = + /install[A-Za-z_$][\w$]*TitleBarOverlaySync\([^)]*\)\{/u; + const zoomOwnerCount = regexMatchCount(source, zoomOwner); + const syncOwnerCount = regexMatchCount(source, syncOwner); + + return ( + regexMatchCount(source, helper) === 1 && + regexMatchCount(source, primary) === 1 && + zoomOwnerCount === 1 && + regexMatchCount(source, zoom) === 1 && + syncOwnerCount === 1 && + regexMatchCount(source, sync) === 1 + ); +} + function applyFramelessTitlebarMainPatch(currentSource) { - return applyFramelessTitlebarOverlaySyncPatch( + const delegation = patchDelegationState( + currentSource, + LINUX_NATIVE_TITLEBAR_PATCH_ID, + { + allowedFeatureIds: [FEATURE_ID], + enabledFeatureIds: [FEATURE_ID], + ownerMarker: LINUX_NATIVE_TITLEBAR_PATCH_MARKER, + }, + ); + if (delegation.state !== "none") { + if ( + delegation.state === "enabled" && + delegation.featureId === FEATURE_ID && + hasCompleteFramelessTitlebarMainComposition(currentSource) + ) { + return currentSource; + } + console.warn( + "WARN: Could not validate delegated frameless titlebar main-process composition - leaving bundle unchanged", + ); + return currentSource; + } + + const hasOwnerMarker = currentSource.includes( + LINUX_NATIVE_TITLEBAR_PATCH_MARKER, + ); + if (!hasOwnerMarker) { + console.warn( + "WARN: Could not find completed Linux native titlebar patch for frameless composition - leaving bundle unchanged", + ); + return currentSource; + } + + const patchedSource = applyFramelessTitlebarOverlaySyncPatch( applyFramelessTitlebarBranchPatch(currentSource), ); + const delegatedSource = delegatePatchMarker( + patchedSource, + LINUX_NATIVE_TITLEBAR_PATCH_MARKER, + LINUX_NATIVE_TITLEBAR_PATCH_ID, + FEATURE_ID, + ); + if ( + delegatedSource != null && + hasCompleteFramelessTitlebarMainComposition(delegatedSource) + ) { + return delegatedSource; + } + + console.warn( + "WARN: Could not complete delegated frameless titlebar main-process composition - leaving bundle unchanged", + ); + return currentSource; } -function applyFramelessTitlebarWebviewPatch(currentSource) { +function applyFramelessTitlebarWebviewTransforms(currentSource) { let foundApplicationMenuLayout = false; let patchedSource = currentSource.replace( /applicationMenu:Object\.freeze\(\{left:0,right:\d+\}\)/g, @@ -141,12 +251,120 @@ function applyFramelessTitlebarWebviewPatch(currentSource) { return patchedSource; } +function applyFramelessTitlebarWebviewPatch(currentSource) { + const delegation = patchDelegationState( + currentSource, + LINUX_WINDOW_CONTROLS_SAFE_AREA_PATCH_ID, + { + allowedFeatureIds: [FEATURE_ID], + enabledFeatureIds: [FEATURE_ID], + ownerMarker: LINUX_WINDOW_CONTROLS_SAFE_AREA_MARKER, + }, + ); + if (delegation.state !== "none") { + if ( + delegation.state === "enabled" && + delegation.featureId === FEATURE_ID && + hasCompleteFramelessWindowControlsSafeAreaComposition(currentSource) + ) { + return currentSource; + } + console.warn( + "WARN: Could not validate delegated frameless Linux window-controls safe-area composition - leaving asset unchanged", + ); + return currentSource; + } + + const hasOwnerMarker = currentSource.includes( + LINUX_WINDOW_CONTROLS_SAFE_AREA_MARKER, + ); + if (!hasOwnerMarker) { + console.warn( + "WARN: Could not find completed Linux window-controls safe-area patch for frameless composition - leaving asset unchanged", + ); + return currentSource; + } + + const patchedSource = applyFramelessTitlebarWebviewTransforms(currentSource); + const delegatedSource = delegatePatchMarker( + patchedSource, + LINUX_WINDOW_CONTROLS_SAFE_AREA_MARKER, + LINUX_WINDOW_CONTROLS_SAFE_AREA_PATCH_ID, + FEATURE_ID, + ); + if ( + delegatedSource != null && + hasCompleteFramelessWindowControlsSafeAreaComposition(delegatedSource) + ) { + return delegatedSource; + } + + console.warn( + "WARN: Could not complete delegated frameless Linux window-controls safe-area composition - leaving asset unchanged", + ); + return currentSource; +} + +function hasCompleteFramelessWindowControlsSafeAreaComposition(source) { + const delegation = patchDelegationState( + source, + LINUX_WINDOW_CONTROLS_SAFE_AREA_PATCH_ID, + { + allowedFeatureIds: [FEATURE_ID], + enabledFeatureIds: [FEATURE_ID], + ownerMarker: LINUX_WINDOW_CONTROLS_SAFE_AREA_MARKER, + }, + ); + if (delegation.state !== "enabled" || delegation.featureId !== FEATURE_ID) { + return false; + } + + const prop = "codexLinuxUseWindowControlsSafeArea"; + const overrideMatches = source.match( + new RegExp(`${prop}:!1,side:\`end\``, "gu"), + ) ?? []; + const insetMatches = [ + ...source.matchAll( + /applicationMenu:Object\.freeze\(\{left:0,right:([^}]+)\}\)/gu, + ), + ]; + const slotSignatureMatches = source.match( + new RegExp( + `function [A-Za-z_$][\\w$]*\\(\\{entries:[A-Za-z_$][\\w$]*,fitWidth:[A-Za-z_$][\\w$]*,side:[A-Za-z_$][\\w$]*,slotWidth:[A-Za-z_$][\\w$]*,${prop}\\}\\)`, + "gu", + ), + ) ?? []; + const paddingMatches = source.match( + new RegExp( + `"pe-2":([A-Za-z_$][\\w$]*)===\`start\`&&[A-Za-z_$][\\w$]*\\|\\|\\1===\`end\`&&!${prop},"pe-\\(--spacing-token-safe-header-right\\)":\\1===\`end\`&&${prop}`, + "gu", + ), + ) ?? []; + const nativeBrowserGateMatches = source.match( + /([A-Za-z_$][\w$]*)\.includes\(`win`\)\|\|([A-Za-z_$][\w$]*)\.includes\(`windows`\)\?([A-Za-z_$][\w$]*)\?\?([A-Za-z_$][\w$]*)\.applicationMenu:\4\.default/gu, + ) ?? []; + const nativeChromeMappingMatches = source.match( + /case`win32`:return`application-menu`;case`linux`:return`native`/gu, + ) ?? []; + + return ( + overrideMatches.length === 1 && + insetMatches.length > 0 && + insetMatches.every((match) => match[1] === "0") && + slotSignatureMatches.length === 1 && + paddingMatches.length === 1 && + nativeBrowserGateMatches.length === 1 && + nativeChromeMappingMatches.length === 1 + ); +} + const patches = [ { id: "main-process", phase: "main-bundle", order: 20_720, ciPolicy: "optional", + composesPatches: [LINUX_NATIVE_TITLEBAR_PATCH_ID], apply: applyFramelessTitlebarMainPatch, }, { @@ -154,6 +372,7 @@ const patches = [ phase: "webview-asset", order: 20_730, ciPolicy: "optional", + composesPatches: [LINUX_WINDOW_CONTROLS_SAFE_AREA_PATCH_ID], pattern: /^app-initial-[^.]+\.js$/, missingDescription: "main app chrome bundle", skipDescription: "frameless titlebar webview layout patch", @@ -167,4 +386,5 @@ module.exports = { applyFramelessTitlebarMainPatch, applyFramelessTitlebarOverlaySyncPatch, applyFramelessTitlebarWebviewPatch, + applyFramelessTitlebarWebviewTransforms, }; diff --git a/linux-features/frameless-titlebar/test.js b/linux-features/frameless-titlebar/test.js index 3858ecf5d..82f318e53 100644 --- a/linux-features/frameless-titlebar/test.js +++ b/linux-features/frameless-titlebar/test.js @@ -10,13 +10,32 @@ const test = require("node:test"); const { loadLinuxFeaturePatchDescriptors, } = require("../../scripts/lib/linux-features.js"); +const { + applyLinuxNativeTitlebarPatch, +} = require("../../scripts/patches/impl/main-process/window.js"); +const { + applyLinuxWindowControlsSafeAreaPatch, +} = require("../../scripts/patches/impl/webview/index.js"); const { applyFramelessTitlebarBranchPatch, applyFramelessTitlebarMainPatch, applyFramelessTitlebarOverlaySyncPatch, applyFramelessTitlebarWebviewPatch, + applyFramelessTitlebarWebviewTransforms, } = require("./patch.js"); +const CORE_CONTEXT = { + enabledFeatureIds: ["frameless-titlebar"], + patchCompositionDelegates: { + "linux-native-titlebar": ["frameless-titlebar"], + "linux-window-controls-safe-area": ["frameless-titlebar"], + }, +}; +const FEATURE_CONTEXT = { + enabledFeatureIds: ["frameless-titlebar"], + feature: { id: "frameless-titlebar" }, +}; + function applyPatchTwice(patchFn, source) { const patched = patchFn(source); assert.equal(patchFn(patched), patched); @@ -37,10 +56,41 @@ function captureWarnings(callback) { function copyFeatureTo(featuresRoot) { const featureDir = path.join(featuresRoot, "frameless-titlebar"); + const helperDir = path.join( + featuresRoot, + "..", + "scripts", + "patches", + "lib", + ); fs.mkdirSync(featureDir, { recursive: true }); + fs.mkdirSync(helperDir, { recursive: true }); for (const name of ["feature.json", "README.md", "patch.js"]) { fs.copyFileSync(path.join(__dirname, name), path.join(featureDir, name)); } + fs.copyFileSync( + path.join( + __dirname, + "..", + "..", + "scripts", + "patches", + "lib", + "composition-delegation.js", + ), + path.join(helperDir, "composition-delegation.js"), + ); +} + +function nativeTitlebarCompositionFixture() { + return [ + "function A2(e){return e===`avatarOverlay`}", + "function I2({platform:e,appearance:t,opaqueWindowsEnabled:n,prefersDarkColors:r}){return n&&!A2(t)&&(e===`darwin`||e===`win32`)?{backgroundColor:r?a2:o2,backgroundMaterial:e===`win32`?`none`:null}:e===`linux`&&!A2(t)?{backgroundColor:r?a2:o2,backgroundMaterial:null}:{backgroundColor:i2,backgroundMaterial:null}}", + "function j9(e=1){return{color:i2,symbolColor:c.nativeTheme.shouldUseDarkColors?v2:_2,height:Math.round(g2*e)}}", + "case`quickChat`:case`primary`:return n===`darwin`?{titleBarStyle:`hiddenInset`,trafficLightPosition:A9(r),...e===`quickChat`?{hasShadow:!0,resizable:!0,transparent:!0}:{},...t?{}:{vibrancy:`menu`}}:n===`win32`||n===`linux`?{titleBarStyle:`hidden`,titleBarOverlay:j9(r),...e===`quickChat`?{resizable:!0}:{}}:{titleBarStyle:`default`,...e===`quickChat`?{resizable:!0}:{}};", + "setWindowZoom(e,t){let n=c.BrowserWindow.fromWebContents(e),r=n&&this.windowAppearances.get(n.id);n==null||r!==`primary`&&r!==`quickChat`||(process.platform===`darwin`?n.setWindowButtonPosition(A9(t)):(process.platform===`win32`||process.platform===`linux`)&&(this.windowZooms.set(n.id,t),n.setTitleBarOverlay(j9(t))))}", + "installApplicationMenuTitleBarOverlaySync(e,t){if(process.platform!==`win32`&&process.platform!==`linux`||t!==`primary`&&t!==`quickChat`)return;let n=()=>{e.isDestroyed()||e.setTitleBarOverlay(j9(this.windowZooms.get(e.id)))};return c.nativeTheme.on(`updated`,n),n(),()=>{c.nativeTheme.off(`updated`,n)}}", + ].join(""); } test("frameless-titlebar stays disabled until listed in features.json", () => { @@ -107,7 +157,13 @@ test("frameless-titlebar removes current Linux overlay controls from primary and ].join(""); let patched; const warnings = captureWarnings(() => { - patched = applyPatchTwice(applyFramelessTitlebarMainPatch, source); + patched = applyPatchTwice( + (currentSource) => + applyFramelessTitlebarOverlaySyncPatch( + applyFramelessTitlebarBranchPatch(currentSource), + ), + source, + ); }); assert.deepEqual(warnings, []); @@ -142,6 +198,33 @@ test("frameless-titlebar removes current Linux overlay controls from primary and assert.doesNotMatch(patched, /process\.platform===`linux`[^;]{0,300}setTitleBarOverlay/); }); +test("frameless-titlebar descriptors require their completed core owners", () => { + const mainSource = + "case`primary`:return n===`linux`?{titleBarStyle:`hidden`}:{};"; + const webviewSource = + "applicationMenu:Object.freeze({left:0,right:138})"; + + assert.deepEqual( + captureWarnings(() => { + assert.equal(applyFramelessTitlebarMainPatch(mainSource), mainSource); + }), + [ + "WARN: Could not find completed Linux native titlebar patch for frameless composition - leaving bundle unchanged", + ], + ); + assert.deepEqual( + captureWarnings(() => { + assert.equal( + applyFramelessTitlebarWebviewPatch(webviewSource), + webviewSource, + ); + }), + [ + "WARN: Could not find completed Linux window-controls safe-area patch for frameless composition - leaving asset unchanged", + ], + ); +}); + test("frameless-titlebar composes with the current native-titlebar patch shape", () => { const source = "case`quickChat`:case`primary`:return n===`darwin`?{titleBarStyle:`hiddenInset`}:n===`win32`||n===`linux`?{titleBarStyle:`hidden`,titleBarOverlay:n===`linux`?codexLinuxTitleBarOverlay(r):j9(r),...e===`quickChat`?{resizable:!0}:{}}:{titleBarStyle:`default`,...e===`quickChat`?{resizable:!0}:{}};"; @@ -162,6 +245,76 @@ test("frameless-titlebar composes with the current native-titlebar patch shape", assert.doesNotMatch(patched, /titleBarOverlay:n===`linux`/); }); +test("native-titlebar remains complete after frameless-titlebar composition", () => { + const corePatched = applyLinuxNativeTitlebarPatch( + nativeTitlebarCompositionFixture(), + ); + const composed = applyFramelessTitlebarMainPatch(corePatched); + let rerun; + const warnings = captureWarnings(() => { + rerun = applyLinuxNativeTitlebarPatch(composed, CORE_CONTEXT); + }); + + assert.equal(rerun, composed); + assert.deepEqual(warnings, []); +}); + +test("frameless-titlebar owns validation after native-titlebar delegation", () => { + const corePatched = applyLinuxNativeTitlebarPatch( + nativeTitlebarCompositionFixture(), + ); + const composed = applyFramelessTitlebarMainPatch(corePatched); + const incompleteCore = composed.replace( + "function codexLinuxTitleBarOverlay", + "function codexLinuxTitleBarOverlayMissing", + ); + let rerun; + const coreWarnings = captureWarnings(() => { + rerun = applyLinuxNativeTitlebarPatch(incompleteCore, CORE_CONTEXT); + }); + assert.equal(rerun, incompleteCore); + assert.deepEqual(coreWarnings, []); + const featureWarnings = captureWarnings(() => { + rerun = applyFramelessTitlebarMainPatch( + incompleteCore, + FEATURE_CONTEXT, + ); + }); + assert.equal(rerun, incompleteCore); + assert.deepEqual(featureWarnings, [ + "WARN: Could not validate delegated frameless titlebar main-process composition - leaving bundle unchanged", + ]); + + const featureVariants = [ + composed.replace( + "process.platform===`win32`&&(this.windowZooms.set", + "(process.platform===`win32`||process.platform===`linux`)&&(this.windowZooms.set", + ), + composed.replace( + "if(process.platform!==`win32`||t!==`primary`", + "if(process.platform!==`win32`&&process.platform!==`linux`||t!==`primary`", + ), + composed.replace( + /setWindowZoom[\s\S]*?(?=installApplicationMenuTitleBarOverlaySync)/u, + "", + ), + composed.replace( + /installApplicationMenuTitleBarOverlaySync[\s\S]*$/u, + "", + ), + ]; + + for (const source of featureVariants) { + const warnings = captureWarnings(() => { + rerun = applyFramelessTitlebarMainPatch(source, FEATURE_CONTEXT); + }); + assert.equal(rerun, source); + assert.deepEqual(warnings, [ + "WARN: Could not validate delegated frameless titlebar main-process composition - leaving bundle unchanged", + ]); + } +}); + test("frameless-titlebar reports current main-process drift", () => { const titlebarSource = "n===`linux`?{titleBarStyle:`hidden`,titleBarOverlay:codexLinuxTitleBarOverlay(r),...e===`quickChat`?{resizable:!1}:{}}:"; @@ -191,8 +344,8 @@ test("frameless-titlebar maps Linux window controls chrome to native webview lay "function usesChrome(){return document.documentElement.dataset.codexWindowChrome===`application-menu`}", ].join(""); - const patchedLayout = applyPatchTwice(applyFramelessTitlebarWebviewPatch, layoutSource); - const patchedChrome = applyPatchTwice(applyFramelessTitlebarWebviewPatch, chromeSource); + const patchedLayout = applyPatchTwice(applyFramelessTitlebarWebviewTransforms, layoutSource); + const patchedChrome = applyPatchTwice(applyFramelessTitlebarWebviewTransforms, chromeSource); assert.equal( (patchedLayout.match(/applicationMenu:Object\.freeze\(\{left:0,right:0\}\)/g) ?? []).length, @@ -208,13 +361,115 @@ test("frameless-titlebar maps Linux window controls chrome to native webview lay test("frameless-titlebar retains standard end padding after the core safe-area patch", () => { assert.equal( applyPatchTwice( - applyFramelessTitlebarWebviewPatch, + applyFramelessTitlebarWebviewTransforms, "jsx(slot,{codexLinuxUseWindowControlsSafeArea:!t,side:`end`})", ), "jsx(slot,{codexLinuxUseWindowControlsSafeArea:!1,side:`end`})", ); }); +test("frameless-titlebar composes idempotently with the core safe-area patch", () => { + const source = [ + "var eV=Object.freeze({default:Object.freeze({left:0,right:0}),applicationMenu:Object.freeze({left:0,right:0})});", + "function ol({isHeaderEdgeScroll:e,isApplicationMenuBarEnabled:t}){return jsx(sl,{entries:h,fitWidth:r,slotWidth:u,side:`end`})}", + "function sl({entries:e,fitWidth:t,side:n,slotWidth:r}){let i=e.some(({align:e})=>e===`end`),o=a({\"pe-2\":n===`start`&&i||n===`end`});return jsx(o)}", + "let newer=i.includes(`win`)||r.includes(`windows`)||i.includes(`linux`)?t??eV.applicationMenu:eV.default;", + "function chrome(e){switch(e){case`win32`:case`linux`:return`application-menu`;default:return`native`}}", + ].join(""); + const corePatched = applyLinuxWindowControlsSafeAreaPatch(source); + const composed = applyFramelessTitlebarWebviewPatch(corePatched); + + assert.equal( + applyLinuxWindowControlsSafeAreaPatch(composed, CORE_CONTEXT), + composed, + ); + assert.equal( + applyFramelessTitlebarWebviewPatch(composed, FEATURE_CONTEXT), + composed, + ); + assert.deepEqual( + captureWarnings(() => + applyLinuxWindowControlsSafeAreaPatch(composed, CORE_CONTEXT)), + [], + ); +}); + +test("frameless-titlebar owns safe-area validation after core delegation", () => { + const stockSource = [ + "var eV=Object.freeze({default:Object.freeze({left:0,right:0}),applicationMenu:Object.freeze({left:0,right:0})});", + "function ol({isHeaderEdgeScroll:e,isApplicationMenuBarEnabled:t}){return jsx(sl,{entries:h,fitWidth:r,slotWidth:u,side:`end`})}", + "function sl({entries:e,fitWidth:t,side:n,slotWidth:r}){let i=e.some(({align:e})=>e===`end`),o=a({\"pe-2\":n===`start`&&i||n===`end`});return jsx(o)}", + "let newer=i.includes(`win`)||r.includes(`windows`)||i.includes(`linux`)?t??eV.applicationMenu:eV.default;", + "function chrome(e){switch(e){case`win32`:case`linux`:return`application-menu`;default:return`native`}}", + ].join(""); + const composed = applyFramelessTitlebarWebviewPatch( + applyLinuxWindowControlsSafeAreaPatch(stockSource), + ); + const damagedVariants = [ + composed.replace( + ",codexLinuxUseWindowControlsSafeArea}){", + "}){", + ), + composed.replace( + "i.includes(`win`)||r.includes(`windows`)?t??eV.applicationMenu:eV.default", + "i.includes(`win`)||r.includes(`windows`)||i.includes(`linux`)?t??eV.applicationMenu:eV.default", + ), + composed.replace( + "case`win32`:return`application-menu`;case`linux`:return`native`", + "case`win32`:case`linux`:return`application-menu`", + ), + ]; + + for (const source of damagedVariants) { + assert.notEqual(source, composed); + const coreWarnings = captureWarnings(() => { + assert.equal( + applyLinuxWindowControlsSafeAreaPatch(source, CORE_CONTEXT), + source, + ); + }); + assert.deepEqual(coreWarnings, []); + + const featureWarnings = captureWarnings(() => { + assert.equal( + applyFramelessTitlebarWebviewPatch(source, FEATURE_CONTEXT), + source, + ); + }); + assert.deepEqual(featureWarnings, [ + "WARN: Could not validate delegated frameless Linux window-controls safe-area composition - leaving asset unchanged", + ]); + } +}); + +test("frameless-titlebar rejects a non-numeric inset hidden beside a valid delegated owner", () => { + const stockSource = [ + "var eV=Object.freeze({default:Object.freeze({left:0,right:0}),applicationMenu:Object.freeze({left:0,right:0})});", + "var fV=Object.freeze({applicationMenu:Object.freeze({left:0,right:0})});", + "function ol({isHeaderEdgeScroll:e,isApplicationMenuBarEnabled:t}){return jsx(sl,{entries:h,fitWidth:r,slotWidth:u,side:`end`})}", + "function sl({entries:e,fitWidth:t,side:n,slotWidth:r}){let i=e.some(({align:e})=>e===`end`),o=a({\"pe-2\":n===`start`&&i||n===`end`});return jsx(o)}", + "let newer=i.includes(`win`)||r.includes(`windows`)||i.includes(`linux`)?t??eV.applicationMenu:eV.default;", + "function chrome(e){switch(e){case`win32`:case`linux`:return`application-menu`;default:return`native`}}", + ].join(""); + const composed = applyFramelessTitlebarWebviewPatch( + applyLinuxWindowControlsSafeAreaPatch(stockSource), + ); + const damaged = composed.replace( + "applicationMenu:Object.freeze({left:0,right:0})", + "applicationMenu:Object.freeze({left:0,right:dynamicInset})", + ); + + const warnings = captureWarnings(() => { + assert.equal( + applyFramelessTitlebarWebviewPatch(damaged, FEATURE_CONTEXT), + damaged, + ); + }); + assert.deepEqual(warnings, [ + "WARN: Could not validate delegated frameless Linux window-controls safe-area composition - leaving asset unchanged", + ]); +}); + test("frameless-titlebar reports each current webview sub-contract drift", () => { const source = [ "var eV=Object.freeze({default:Object.freeze({left:0,right:0}),applicationMenu:Object.freeze({left:0,right:138})});", @@ -224,7 +479,7 @@ test("frameless-titlebar reports each current webview sub-contract drift", () => "let newer=i.includes(`win`)||r.includes(`windows`)||i.includes(`linux`)?t??eV.appMenu:eV.default;", ].join(""); - const warnings = captureWarnings(() => applyFramelessTitlebarWebviewPatch(source)); + const warnings = captureWarnings(() => applyFramelessTitlebarWebviewTransforms(source)); assert.deepEqual(warnings, [ "WARN: Could not find application menu browser gate - skipping frameless webview platform patch", @@ -234,13 +489,13 @@ test("frameless-titlebar reports each current webview sub-contract drift", () => "function chrome(e){switch(e){case`win32`:return`application-menu`;case`linux`:return`overlay-v2`;default:return`native`}}", "function usesChrome(){return document.documentElement.dataset.codexWindowChrome===`application-menu`}", ].join(""); - assert.deepEqual(captureWarnings(() => applyFramelessTitlebarWebviewPatch(chromeDrift)), [ + assert.deepEqual(captureWarnings(() => applyFramelessTitlebarWebviewTransforms(chromeDrift)), [ "WARN: Could not find Linux window controls chrome mapping - skipping frameless webview chrome patch", ]); assert.deepEqual( captureWarnings(() => - applyFramelessTitlebarWebviewPatch( + applyFramelessTitlebarWebviewTransforms( "jsx(slot,{codexLinuxUseWindowControlsSafeArea:shouldReserveControls,side:`end`})", )), ["WARN: Could not disable the Linux window controls safe area - skipping frameless header padding patch"], diff --git a/linux-features/open-target-discovery/patch.js b/linux-features/open-target-discovery/patch.js index a7b75cb53..6e1c1fcfb 100644 --- a/linux-features/open-target-discovery/patch.js +++ b/linux-features/open-target-discovery/patch.js @@ -86,22 +86,32 @@ function findDeclarationBlock(source, marker) { return null; } - const blockStart = Math.max( + const statementStart = Math.max( source.lastIndexOf("var ", markerStart), source.lastIndexOf("let ", markerStart), source.lastIndexOf("const ", markerStart), ); const objectStart = source.lastIndexOf("{", markerStart); const objectBlock = findBalancedBlock(source, objectStart); - if (blockStart === -1 || objectBlock == null) { + if (statementStart === -1 || objectBlock == null) { return null; } + const bindingPrefixStart = Math.max(statementStart, objectStart - 256); + const bindingPrefix = source.slice(bindingPrefixStart, objectStart); + const bindingMatch = bindingPrefix.match( + /([A-Za-z_$][\w$]*)=[A-Za-z_$][\w$]*\($/u, + ); + if (bindingMatch == null) { + return null; + } + const blockStart = bindingPrefixStart + bindingMatch.index; const callEndMatch = source.slice(objectBlock.end).match(/^\s*\);/u); const blockEnd = callEndMatch == null ? objectBlock.end : objectBlock.end + callEndMatch[0].length; return { start: blockStart, end: blockEnd, + statementStart, text: source.slice(blockStart, blockEnd), }; } @@ -232,7 +242,7 @@ function applyFileManagerDiscoveryPatch(currentSource, deps) { return currentSource; } - let patchedSource = insertOpenTargetHelpers(currentSource, block.start, deps); + let patchedSource = insertOpenTargetHelpers(currentSource, block.statementStart, deps); if (patchedSource !== currentSource) { block = findDeclarationBlock(patchedSource, "id:`fileManager`"); if (block == null) { @@ -331,7 +341,7 @@ function applyTerminalDiscoveryPatch(currentSource, deps) { const platformsIndex = patchedSource.indexOf("platforms:{", patchedTerminalIndex); const platformsBlock = platformsIndex === -1 ? null : findBalancedBlock(patchedSource, patchedSource.indexOf("{", platformsIndex)); - if (platformsBlock == null || platformsBlock.text.includes("linux:{")) { + if (platformsBlock == null) { warn("Could not apply terminal open-target patch"); return currentSource; } @@ -349,10 +359,8 @@ function applyIdeDiscoveryPatch(currentSource, deps) { const { fsVar, pathVar } = deps; const editorFactoryIndex = currentSource.search(/function\s+[A-Za-z_$][\w$]*\(\{id:[A-Za-z_$][\w$]*,label:[A-Za-z_$][\w$]*,icon:[A-Za-z_$][\w$]*,darwinDetect:/u); const jetBrainsFactoryIndex = currentSource.search(/function\s+[A-Za-z_$][\w$]*\(\{id:[A-Za-z_$][\w$]*,label:[A-Za-z_$][\w$]*,icon:[A-Za-z_$][\w$]*,toolboxTarget:/u); - const hasEditorFactory = editorFactoryIndex !== -1; - const hasJetBrainsFactory = jetBrainsFactoryIndex !== -1; const hasZedTarget = currentSource.includes("id:`zed`"); - if (!hasEditorFactory && !hasJetBrainsFactory && !hasZedTarget) { + if (editorFactoryIndex === -1 && jetBrainsFactoryIndex === -1 && !hasZedTarget) { warn("Could not find IDE open-target factories"); return currentSource; } @@ -375,11 +383,6 @@ function applyIdeDiscoveryPatch(currentSource, deps) { deps, ); - const ideCoreHelpers = patchedSource.includes("function codexLinuxIdeCommand(") - ? "" - : `function codexLinuxIdeCommand(e){let t={cursor:[\`cursor\`],vscode:[\`code\`,\`codium\`],vscodeInsiders:[\`code-insiders\`],windsurf:[\`windsurf\`],antigravity:[\`antigravity\`],zed:[\`zed\`,\`zeditor\`,\`zedit\`,\`zed-cli\`],intellij:[\`idea\`],webstorm:[\`webstorm\`],pycharm:[\`pycharm\`],goland:[\`goland\`],clion:[\`clion\`],rustrover:[\`rustrover\`],rider:[\`rider\`],phpstorm:[\`phpstorm\`],androidStudio:[\`studio\`,\`studio.sh\`]}[e]??[];for(let e of t){let t=codexLinuxFindExecutable(e);if(t)return t}return null}` + - `function codexLinuxIdePlatform(e,t,n,r,i){let a=codexLinuxIdeCommand(e);return a?{label:t,icon:n,kind:\`editor\`,hidden:r,detect:()=>a,args:i,supportsSsh:!0}:void 0}` + - `function codexLinuxJetBrainsIdePlatform(e,t,n,r){let i=codexLinuxIdeCommand(e);return i?{label:t,icon:n,kind:\`editor\`,detect:()=>i,args:r}:void 0}`; const dynamicDiscoveryHelpers = patchedSource.includes("function codexLinuxDiscoveredIdeTargets(") ? "" : `function codexLinuxSplitDesktopExec(e){let t=[],n=\`\`,r=null,i=!1;for(let a=0;af:void 0,kind:\`editor\`,detect:()=>i.command,args:e=>codexLinuxDesktopArgs(i.args,e),open:async({command:e,path:t})=>{await codexLinuxLaunchDesktopEntry(o,t,e,i.args)}}}})}return e}`; - const helpers = ideCoreHelpers + dynamicDiscoveryHelpers; + const helpers = dynamicDiscoveryHelpers; if (helpers.length > 0) { const helperInsertionIndex = patchedSource.includes("function codexLinuxFindExecutable(") ? patchedSource.indexOf("function codexLinuxFindExecutable(") @@ -428,32 +431,6 @@ function applyIdeDiscoveryPatch(currentSource, deps) { patchedSource = patchedSource.slice(0, ideHelperInsertionIndex) + helpers + patchedSource.slice(ideHelperInsertionIndex); } - patchedSource = patchedSource.replace( - /(function\s+[A-Za-z_$][\w$]*\(\{id:([A-Za-z_$][\w$]*),label:([A-Za-z_$][\w$]*),icon:([A-Za-z_$][\w$]*),darwinDetect:[^)]*?hidden:([A-Za-z_$][\w$]*)\}\)\{return\{id:\2,platforms:\{[^]*?win32:[^]*?args:([A-Za-z_$][\w$]*),supportsSsh:!0\}:void 0)(\}\}\})/u, - "$1,linux:codexLinuxIdePlatform($2,$3,$4,$5,$6)$7", - ); - - patchedSource = patchedSource.replace( - /(function\s+[A-Za-z_$][\w$]*\(\{id:([A-Za-z_$][\w$]*),label:([A-Za-z_$][\w$]*),icon:([A-Za-z_$][\w$]*),toolboxTarget:[^)]*?\}\)\{return\{id:\2,platforms:\{[^]*?args:([A-Za-z_$][\w$]*)\}:void 0)(\}\}\})/u, - "$1,linux:codexLinuxJetBrainsIdePlatform($2,$3,$4,$5)$6", - ); - - const patchedZedIndex = patchedSource.indexOf("id:`zed`"); - if (patchedZedIndex !== -1) { - const zedPlatformsIndex = patchedSource.indexOf("platforms:{", patchedZedIndex); - const zedPlatformsBlock = findBalancedBlock(patchedSource, patchedSource.indexOf("{", zedPlatformsIndex)); - if (zedPlatformsBlock != null && !zedPlatformsBlock.text.includes("linux:{")) { - const argsVar = zedPlatformsBlock.text.match(/win32:\{[^}]*args:([A-Za-z_$][\w$]*)/u)?.[1]; - if (argsVar != null) { - const linuxZed = `,linux:{label:\`Zed\`,icon:\`apps/zed.png\`,kind:\`editor\`,detect:()=>codexLinuxIdeCommand(\`zed\`),args:${argsVar}}`; - patchedSource = - patchedSource.slice(0, zedPlatformsBlock.end - 1) + - linuxZed + - patchedSource.slice(zedPlatformsBlock.end - 1); - } - } - } - if (!patchedSource.includes("...codexLinuxDiscoveredIdeTargets()")) { const targetArraySearchStart = Math.min( ...[editorFactoryIndex, jetBrainsFactoryIndex, zedDeclarationIndex].filter((index) => index >= 0), @@ -475,16 +452,6 @@ function applyIdeDiscoveryPatch(currentSource, deps) { } } - if (hasEditorFactory && !patchedSource.includes("linux:codexLinuxIdePlatform(")) { - warn("Could not apply generic IDE factory patch"); - } - if (hasJetBrainsFactory && !patchedSource.includes("linux:codexLinuxJetBrainsIdePlatform(")) { - warn("Could not apply JetBrains IDE factory patch"); - } - if (hasZedTarget && !patchedSource.includes("linux:{label:`Zed`")) { - warn("Could not apply Zed IDE target patch"); - } - return patchedSource; } @@ -595,7 +562,6 @@ function applyOpenInTargetRegistryCommandPatch(currentSource, { warnOnMissing = warnOnMissing && ( currentSource.includes("get-target-command") || - currentSource.includes("getOpenInTargetCommand") || currentSource.includes("allAvailableTargets") ) ) { @@ -618,30 +584,49 @@ function applyOpenInTargetRegistryCommandPatch(currentSource, { warnOnMissing = return currentSource.slice(0, insertionIndex) + helper + currentSource.slice(insertionIndex); } +function findCurrentOpenTargetCommandMatch(source) { + return source.match( + /async#([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\{let\{command:([A-Za-z_$][\w$]*)\}=await this\.#([A-Za-z_$][\w$]*)\(\)\(\{method:`get-target-command`,params:([A-Za-z_$][\w$]*)\(this\.settingsStore,\2\)\}\);if\(\3==null\)throw Error\(`Open target "\$\{\2\}" is not available`\);return \3\}/u, + ); +} + +function findPatchedOpenTargetCommandMatch(source) { + return source.match( + /async#([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\{if\(process\.platform===`linux`\)\{let _codexLinuxOpenTargetCommand=await codexLinuxOpenTargetRegistryCommand\(this\.settingsStore,\2\);if\(_codexLinuxOpenTargetCommand==null\)throw Error\(`Open target "\$\{\2\}" is not available`\);return _codexLinuxOpenTargetCommand\}let\{command:([A-Za-z_$][\w$]*)\}=await this\.#([A-Za-z_$][\w$]*)\(\)\(\{method:`get-target-command`,params:([A-Za-z_$][\w$]*)\(this\.settingsStore,\2\)\}\);if\(\3==null\)throw Error\(`Open target "\$\{\2\}" is not available`\);return \3\}/u, + ); +} + function applyOpenInTargetCommandPatch(currentSource) { - currentSource = applyOpenInTargetRegistryCommandPatch(currentSource, { warnOnMissing: false }); - if (currentSource.includes("codexLinuxOpenTargetRegistryCommand(this.getSettingsStore(),e)")) { + const patchedMatch = findPatchedOpenTargetCommandMatch(currentSource); + if (patchedMatch != null) { return currentSource; } - if (!currentSource.includes("async function codexLinuxOpenTargetRegistryCommand(")) { + if (currentSource.includes("_codexLinuxOpenTargetCommand")) { + warn("Found partially patched open target command lookup"); return currentSource; } - const currentShapeMatch = currentSource.match( - /async getOpenInTargetCommand\(e\)\{let\{command:t\}=await this\.getOpenInWorker\(\)\(\{method:`get-target-command`,params:([A-Za-z_$][\w$]*)\(this\.getSettingsStore\(\),e\)\}\);if\(t==null\)throw Error\(`Open target "\$\{e\}" is not available`\);return t\}/u, - ); - if (currentShapeMatch != null) { - const [needle, paramsFn] = currentShapeMatch; - return currentSource.replace( - needle, - `async getOpenInTargetCommand(e){if(process.platform===\`linux\`){let t=await codexLinuxOpenTargetRegistryCommand(this.getSettingsStore(),e);if(t==null)throw Error(\`Open target "\${e}" is not available\`);return t}let{command:n}=await this.getOpenInWorker()({method:\`get-target-command\`,params:${paramsFn}(this.getSettingsStore(),e)});if(n==null)throw Error(\`Open target "\${e}" is not available\`);return n}`, - ); + const currentShapeMatch = findCurrentOpenTargetCommandMatch(currentSource); + if (currentShapeMatch == null) { + if ( + currentSource.includes("get-target-command") && + currentSource.includes("Open in worker unavailable") + ) { + warn("Could not find current open target command lookup"); + } + return currentSource; } - if (currentSource.includes("getOpenInTargetCommand")) { - warn("Could not find getOpenInTargetCommand worker fallback"); + const sourceWithRegistry = applyOpenInTargetRegistryCommandPatch(currentSource, { warnOnMissing: false }); + if (!sourceWithRegistry.includes("async function codexLinuxOpenTargetRegistryCommand(")) { + return currentSource; } - return currentSource; + + const [needle, commandMethod, targetVar, commandVar, workerMethod, paramsFn] = currentShapeMatch; + return sourceWithRegistry.replace( + needle, + `async#${commandMethod}(${targetVar}){if(process.platform===\`linux\`){let _codexLinuxOpenTargetCommand=await codexLinuxOpenTargetRegistryCommand(this.settingsStore,${targetVar});if(_codexLinuxOpenTargetCommand==null)throw Error(\`Open target "\${${targetVar}}" is not available\`);return _codexLinuxOpenTargetCommand}let{command:${commandVar}}=await this.#${workerMethod}()({method:\`get-target-command\`,params:${paramsFn}(this.settingsStore,${targetVar})});if(${commandVar}==null)throw Error(\`Open target "\${${targetVar}}" is not available\`);return ${commandVar}}`, + ); } function applyOpenInTargetsAvailabilityPatch(currentSource) { @@ -788,6 +773,20 @@ function applyMainBundlePatch(currentSource) { return currentSource; } + if ( + currentSource.includes("get-target-command") && + currentSource.includes("Open in worker unavailable") && + findPatchedOpenTargetCommandMatch(currentSource) == null && + findCurrentOpenTargetCommandMatch(currentSource) == null + ) { + warn( + currentSource.includes("_codexLinuxOpenTargetCommand") + ? "Found partially patched open target command lookup" + : "Could not find current open target command lookup", + ); + return currentSource; + } + const deps = { electronVar, fsVar: "codexLinuxNodeFs()", diff --git a/linux-features/open-target-discovery/test.js b/linux-features/open-target-discovery/test.js index 5898a7ff5..57d25b735 100644 --- a/linux-features/open-target-discovery/test.js +++ b/linux-features/open-target-discovery/test.js @@ -34,10 +34,12 @@ const mainBundlePrefix = "let n=require(`electron`),i=require(`node:path`),o=require(`node:fs`),u=require(`node:child_process`);"; const fileManagerBundle = "function jl(e){return e}function il(e){return [e]}var lu=jl({id:`fileManager`,label:`Finder`,icon:`apps/finder.png`,kind:`fileManager`,darwin:{detect:()=>`open`,args:e=>il(e)},win32:{label:`File Explorer`,icon:`apps/file-explorer.png`,detect:uu,args:e=>il(e),open:async({path:e})=>du(e)}});function uu(){}"; +const currentDmgFileManagerBundle = + "function U1(e){return e}function xc(e){return[e]}var i0=(e,t)=>t==null?[e]:[e],Ofe={id:`emacs`,platforms:{darwin:{label:`Emacs`,icon:`apps/emacs.png`,kind:`editor`,detect:()=>`emacs`,args:e=>[e]},linux:{label:`Emacs`,icon:`apps/emacs.png`,kind:`editor`,detect:()=>`emacs`,args:e=>[e]}}},kfe=U1({id:`fileManager`,label:`Finder`,icon:`apps/finder.png`,kind:`fileManager`,darwin:{detect:()=>`/usr/bin/open`,args:e=>xc(e)},win32:{label:`File Explorer`,icon:`apps/file-explorer.png`,detect:Afe,args:e=>xc(e),open:async({path:e})=>a0(e)},linux:{label:`File Manager`,icon:`apps/file-explorer.png`,detect:()=>`file-manager`,args:e=>[e],open:async({path:e})=>a0(e)}});function Afe(){}async function a0(){}"; const terminalOpenTargetBundle = "var uh={id:`terminal`,platforms:{darwin:{label:`Terminal`,icon:`apps/terminal.png`,kind:`terminal`,detect:()=>`open`,args:e=>[`-a`,`Terminal`,e]},win32:{label:`Terminal`,icon:`apps/microsoft-terminal.png`,kind:`terminal`,detect:vh,iconPath:()=>null,args:yh,open:({command:e,path:t})=>bh(e,yh(t))}}};function vh(){return `wt.exe`}function yh(e){return[`-d`,e]}async function bh(){}"; const ideOpenTargetsBundle = - "function ih({id:e,label:t,icon:n,darwinDetect:r,win32Detect:i,darwinEnv:a,darwinArgs:o,hidden:s}){return{id:e,platforms:{darwin:r?{label:t,icon:n,kind:`editor`,hidden:s,detect:r,env:a,args:o??ah,supportsSsh:!0}:void 0,win32:i?{label:t,icon:n,kind:`editor`,hidden:s,detect:i,args:ah,supportsSsh:!0}:void 0}}}var ah=(e,t)=>t?[`${e}:${t.line}:${t.column}`]:[e];var Og=ih({id:`vscode`,label:`VS Code`,icon:`apps/vscode.png`,darwinDetect:()=>`open`,win32Detect:()=>`Code.exe`});var jh=ih({id:`cursor`,label:`Cursor`,icon:`apps/cursor.png`,darwinDetect:()=>`open`,win32Detect:()=>`Cursor.exe`});function sg({id:e,label:t,icon:n,toolboxTarget:r,macExecutable:i,windowsPathCommands:a,windowsInstallDirPrefixes:o,windowsInstallExecutables:s}){return{id:e,platforms:{darwin:{label:t,icon:n,kind:`editor`,detect:()=>`open`,args:mg},win32:a&&o&&s?{label:t,icon:n,kind:`editor`,detect:()=>`idea.exe`,args:mg}:void 0}}}function mg(e,t){return t?[`--line`,t.line.toString(),`--column`,t.column.toString(),e]:[e]}var $h=sg({id:`intellij`,label:`IntelliJ IDEA`,icon:`apps/intellij.png`,toolboxTarget:`intellij`,macExecutable:`idea`,windowsPathCommands:[`idea`],windowsInstallDirPrefixes:[`idea`],windowsInstallExecutables:[`idea`]});var Wg={id:`zed`,platforms:{darwin:{label:`Zed`,icon:`apps/zed.png`,kind:`editor`,detect:Gg,args:hg},win32:{label:`Zed`,icon:`apps/zed.png`,kind:`editor`,detect:Kg,args:hg}}};function Gg(){}function Kg(){}function hg(e,t){return t?[`${e}:${t.line}:${t.column}`]:[e]}var Xg=[Og,jh,Wg,$h];"; + "function ih({id:e,label:t,icon:n,darwinDetect:r,win32Detect:i,linuxDetect:a,darwinEnv:o,darwinArgs:s,hidden:l}){return{id:e,platforms:{darwin:r?{label:t,icon:n,kind:`editor`,hidden:l,detect:r,env:o,args:s??ah,supportsSsh:!0}:void 0,win32:i?{label:t,icon:n,kind:`editor`,hidden:l,detect:i,args:ah,supportsSsh:!0}:void 0,linux:a?{label:t,icon:n,kind:`editor`,hidden:l,detect:a,args:ah,supportsSsh:!0}:void 0}}}var ah=(e,t)=>t?[`${e}:${t.line}:${t.column}`]:[e];var Og=ih({id:`vscode`,label:`VS Code`,icon:`apps/vscode.png`,darwinDetect:()=>`open`,win32Detect:()=>`Code.exe`,linuxDetect:()=>codexLinuxFindExecutable(`code`)});var jh=ih({id:`cursor`,label:`Cursor`,icon:`apps/cursor.png`,darwinDetect:()=>`open`,win32Detect:()=>`Cursor.exe`,linuxDetect:()=>codexLinuxFindExecutable(`cursor`)});function sg({id:e,label:t,icon:n,toolboxTarget:r,macExecutable:i,windowsPathCommands:a,windowsInstallDirPrefixes:o,windowsInstallExecutables:s}){return{id:e,platforms:{darwin:{label:t,icon:n,kind:`editor`,detect:()=>`open`,args:mg},win32:a&&o&&s?{label:t,icon:n,kind:`editor`,detect:()=>`idea.exe`,args:mg}:void 0}}}function mg(e,t){return t?[`--line`,t.line.toString(),`--column`,t.column.toString(),e]:[e]}var $h=sg({id:`intellij`,label:`IntelliJ IDEA`,icon:`apps/intellij.png`,toolboxTarget:`intellij`,macExecutable:`idea`,windowsPathCommands:[`idea`],windowsInstallDirPrefixes:[`idea`],windowsInstallExecutables:[`idea`]});var Wg={id:`zed`,platforms:{darwin:{label:`Zed`,icon:`apps/zed.png`,kind:`editor`,detect:Gg,args:hg},win32:{label:`Zed`,icon:`apps/zed.png`,kind:`editor`,detect:Kg,args:hg},linux:{label:`Zed`,icon:`apps/zed.png`,kind:`editor`,detect:()=>codexLinuxFindExecutable(`zed`),args:hg}}};function Gg(){}function Kg(){}function hg(e,t){return t?[`${e}:${t.line}:${t.column}`]:[e]}var Xg=[Og,jh,Wg,$h];"; const openTargetsBundle = `${mainBundlePrefix}${fileManagerBundle}${terminalOpenTargetBundle}${ideOpenTargetsBundle}`; const collidingPathAliasBundle = "let n=require(`electron`),o=require(`node:path`),c=require(`node:fs`),u=require(`node:child_process`);" + @@ -51,7 +53,7 @@ const currentAppRegistryFunction = const currentAppOpenTargetPrelude = `var PN=[],FN=async e=>\`shortcut:\${e}\`,BN=new WeakMap;function RN(e){return e.map(({id:e,label:t,icon:n,kind:r,hidden:i,supportsSsh:a})=>({id:e,label:t,icon:n,kind:r,hidden:i,supportsSsh:a}))}function HN(e){return RN(QN(e))}function UN(e,t){let n=QN(e).find(e=>e.id===t);return n?.configuredCommand==null||n.configuredIcon==null?{target:t}:{target:t,customTarget:{command:n.configuredCommand,icon:n.configuredIcon}}}${currentAppRegistryFunction}async function LN(e,t,{detectedCommand:r,targets:c=PN}={}){let l=c.find(t=>t.id===e);if(!l)throw Error(\`Unknown open target "\${e}"\`);let u=r??await l.detect(FN);if(!u)throw Error(\`Open target "\${e}" is not available\`);return u}var WRONG={};async function unrelated(e){return await e.detect(WRONG)}function zN(){return{error(){},warning(){}}}`; const currentAppOpenInCommandBundle = - `${currentAppOpenTargetPrelude}class App{constructor(e,t){this.settingsStore=e;this.requestOpenInWorker=t}getSettingsStore(){return this.settingsStore}getOpenInWorker(){return this.requestOpenInWorker}async getOpenInTargetCommand(e){let{command:t}=await this.getOpenInWorker()({method:\`get-target-command\`,params:UN(this.getSettingsStore(),e)});if(t==null)throw Error(\`Open target "\${e}" is not available\`);return t}}`; + `${currentAppOpenTargetPrelude}class App{constructor(e,t){this.settingsStore=e;this.requestOpenInWorker=t}async openTarget(e){return this.#t(e)}async#t(e){let{command:t}=await this.#n()({method:\`get-target-command\`,params:UN(this.settingsStore,e)});if(t==null)throw Error(\`Open target "\${e}" is not available\`);return t}#n(){if(this.requestOpenInWorker==null)throw Error(\`Open in worker unavailable\`);return this.requestOpenInWorker}}`; const currentAppOpenInAvailabilityBundle = `${currentAppOpenTargetPrelude}async function WN(e,t){let n=await Promise.all(HN(e).map(async n=>{let r=UN(e,n.id),[i,a]=await Promise.all([t({method:\`get-target-command\`,params:r}).then(e=>e.command).catch(e=>(zN().error(\`Failed to detect open target\`,{safe:{},sensitive:{id:n.id,error:e}}),null)),process.platform===\`win32\`?t({method:\`load-target-icon\`,params:r}).then(e=>e.icon).catch(e=>(zN().warning(\`Failed to resolve open target icon\`,{safe:{},sensitive:{id:n.id,error:e}}),n.icon)):n.icon]);return{command:i,metadata:{...n,icon:a}}}));return{allAvailableTargets:n.flatMap(({command:e,metadata:t})=>e==null?[]:[t.id]),targetMetadata:n.map(({metadata:e})=>e)}}`; const currentAppOpenInBridgeBundle = @@ -194,16 +196,29 @@ function withLinuxFeatureRootEnv(root, fn) { } } -test("open-target discovery directly adds file manager, terminal, and IDE support", () => { +test("open-target discovery upgrades file manager and terminal support and adds dynamic IDEs", () => { const patched = applyPatchTwice(applyMainBundlePatch, openTargetsBundle); assert.match(patched, /codexLinuxOpenFileManager\(e\)/); assert.match(patched, /linux:\{label:`Terminal`/); - assert.match(patched, /linux:codexLinuxIdePlatform\(/); - assert.match(patched, /linux:codexLinuxJetBrainsIdePlatform\(/); assert.match(patched, /\.\.\.codexLinuxDiscoveredIdeTargets\(\)/); }); +test("open-target discovery keeps the current DMG file manager declarator idempotent", () => { + const source = `${mainBundlePrefix}${currentDmgFileManagerBundle}`; + const patched = applyMainBundlePatch(source); + + assert.equal(applyMainBundlePatch(patched), patched); + assert.match( + patched, + /Ofe=\{id:`emacs`,platforms:\{darwin:\{[^}]+\},linux:\{label:`Emacs`/u, + ); + assert.match( + patched, + /kfe=U1\(\{id:`fileManager`[\s\S]+linux:\{label:`File Manager`[\s\S]+codexLinuxOpenFileManager\(e\)/u, + ); +}); + test("open-target discovery prefers xdg-terminal-exec for Terminal", () => { withTempDir((tmp) => { const binDir = path.join(tmp, "bin"); @@ -1109,11 +1124,12 @@ test("open-target discovery patches current app command lookup through its regis }, ); - assert.equal(await app.getOpenInTargetCommand("linux-desktop-agent"), "main-command"); - await assert.rejects(() => app.getOpenInTargetCommand("broken"), /not available/); + assert.equal(await app.openTarget("linux-desktop-agent"), "main-command"); + await assert.rejects(() => app.openTarget("broken"), /not available/); assert.equal(workerCalls, 0); assert.match(patched, /n\.detect\(FN\)/); assert.doesNotMatch(patched, /n\.detect\(WRONG\)|n\.detect\(void 0\)/); + assert.match(patched, /_codexLinuxOpenTargetCommand/); const darwinApp = new Function("process", `${patched};return new App(arguments[1],arguments[2]);`)( { platform: "darwin" }, @@ -1123,10 +1139,62 @@ test("open-target discovery patches current app command lookup through its regis return { command: "worker-command" }; }, ); - assert.equal(await darwinApp.getOpenInTargetCommand("linux-desktop-agent"), "worker-command"); + assert.equal(await darwinApp.openTarget("linux-desktop-agent"), "worker-command"); assert.equal(workerCalls, 1); }); +test("open-target discovery rejects current command lookup drift before changing the main bundle", () => { + const source = + mainBundlePrefix + + fileManagerBundle + + terminalOpenTargetBundle + + ideOpenTargetsBundle + + currentAppOpenInCommandBundle.replace( + "params:UN(this.settingsStore,e)", + "params:UN(this.otherStore,e)", + ); + const { value, warnings } = captureWarns(() => applyMainBundlePatch(source)); + + assert.equal(value, source); + assert.ok(warnings.some((warning) => warning.includes("open target command lookup"))); +}); + +test("open-target discovery rejects a partial current command marker byte-identically", () => { + const source = + mainBundlePrefix + + fileManagerBundle + + terminalOpenTargetBundle + + ideOpenTargetsBundle + + currentAppOpenInCommandBundle.replace( + "async#t(e){", + "async#t(e){let _codexLinuxOpenTargetCommand=null;", + ); + const { value, warnings } = captureWarns(() => applyMainBundlePatch(source)); + + assert.equal(value, source); + assert.ok(warnings.some((warning) => warning.includes("partially patched open target command lookup"))); +}); + +test("open-target discovery rejects a corrupted patched command guard byte-identically", () => { + const original = + mainBundlePrefix + + fileManagerBundle + + terminalOpenTargetBundle + + ideOpenTargetsBundle + + currentAppOpenInCommandBundle; + const patched = applyMainBundlePatch(original); + const source = patched.replace( + "if(process.platform===`linux`){let _codexLinuxOpenTargetCommand=", + "if(process.platform===`darwin`){let _codexLinuxOpenTargetCommand=", + ); + assert.notEqual(source, patched); + + const { value, warnings } = captureWarns(() => applyMainBundlePatch(source)); + + assert.equal(value, source); + assert.ok(warnings.some((warning) => warning.includes("partially patched open target command lookup"))); +}); + test("open-target discovery patches current app availability through its registry", async () => { let workerCalls = 0; const settingsStore = currentAppSettingsStore([ @@ -1439,7 +1507,10 @@ test("open-target discovery participates in feature loading and patch reports", const assetsDir = path.join(tempApp, "webview", "assets"); fs.mkdirSync(buildDir, { recursive: true }); fs.mkdirSync(assetsDir, { recursive: true }); - fs.writeFileSync(path.join(buildDir, "main.js"), openTargetsBundle); + fs.writeFileSync( + path.join(buildDir, "main.js"), + openTargetsBundle + currentAppOpenInCommandBundle, + ); fs.writeFileSync(path.join(tempApp, "package.json"), JSON.stringify({ name: "codex" })); const report = createPatchReport(); @@ -1448,12 +1519,55 @@ test("open-target discovery participates in feature loading and patch reports", assert.match(patched, /linux:\{label:`Terminal`/); assert.match(patched, /\.\.\.codexLinuxDiscoveredIdeTargets\(\)/); + assert.match(patched, /_codexLinuxOpenTargetCommand/); assert.ok( report.patches.some((patch) => patch.name === "feature:open-target-discovery:main-bundle-open-target-discovery" && patch.status === "applied", ), ); + + const secondReport = createPatchReport(); + captureWarns(() => patchExtractedApp(tempApp, { report: secondReport })); + assert.equal(fs.readFileSync(path.join(buildDir, "main.js"), "utf8"), patched); + assert.ok( + secondReport.patches.some((patch) => + patch.name === "feature:open-target-discovery:main-bundle-open-target-discovery" && + patch.status === "already-applied", + ), + ); + } finally { + fs.rmSync(tempApp, { recursive: true, force: true }); + } + }); + }); +}); + +test("open-target discovery reports current command lookup drift as an enabled feature failure", () => { + withTempFeatureConfig(["open-target-discovery"], (root) => { + withLinuxFeatureRootEnv(root, () => { + const tempApp = fs.mkdtempSync(path.join(os.tmpdir(), "codex-open-target-drift-")); + try { + const buildDir = path.join(tempApp, ".vite", "build"); + fs.mkdirSync(buildDir, { recursive: true }); + fs.writeFileSync( + path.join(buildDir, "main.js"), + openTargetsBundle + + currentAppOpenInCommandBundle.replace( + "params:UN(this.settingsStore,e)", + "params:UN(this.otherStore,e)", + ), + ); + fs.writeFileSync(path.join(tempApp, "package.json"), JSON.stringify({ name: "codex" })); + + const report = createPatchReport(); + captureWarns(() => patchExtractedApp(tempApp, { report })); + const featurePatch = report.patches.find( + (patch) => patch.name === "feature:open-target-discovery:main-bundle-open-target-discovery", + ); + + assert.equal(featurePatch?.status, "skipped-optional"); + assert.match(featurePatch?.reason ?? "", /open target command lookup/); } finally { fs.rmSync(tempApp, { recursive: true, force: true }); } @@ -1461,7 +1575,7 @@ test("open-target discovery participates in feature loading and patch reports", }); }); -test("open-target discovery does not add a second built-in Zed target", () => { +test("open-target discovery leaves the upstream built-in Zed target unchanged", () => { const zedAlreadyLinux = openTargetsBundle.replace( "win32:{label:`Zed`,icon:`apps/zed.png`,kind:`editor`,detect:Kg,args:hg}}", "win32:{label:`Zed`,icon:`apps/zed.png`,kind:`editor`,detect:Kg,args:hg},linux:{label:`Zed`,icon:`apps/zed.png`,kind:`editor`,detect:Gg,args:hg}}", @@ -1469,4 +1583,5 @@ test("open-target discovery does not add a second built-in Zed target", () => { const patched = applyPatchTwice(applyMainBundlePatch, zedAlreadyLinux); assert.equal((patched.match(/linux:\{label:`Zed`/g) || []).length, 1); + assert.doesNotMatch(patched, /codexLinuxIdeCommand/); }); diff --git a/linux-features/project-group-last-updated-sort/patch.js b/linux-features/project-group-last-updated-sort/patch.js index 9b12427e4..8969cb622 100644 --- a/linux-features/project-group-last-updated-sort/patch.js +++ b/linux-features/project-group-last-updated-sort/patch.js @@ -1,14 +1,14 @@ "use strict"; const currentGroupSorter = - "function Fe({groups:e,items:t,projectOrder:n}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return ue(e.map((e,t)=>({group:e,index:t,recencyAt:Re(e,r)})).sort((e,t)=>t.recencyAt-e.recencyAt||e.index-t.index).map(({group:e})=>e),n)}"; + "function p5o({groups:e,items:t,projectOrder:n}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return G6i(e.map((e,t)=>({group:e,index:t,recencyAt:e.threadKeys.reduce((e,t)=>Math.max(e,r.get(t)??0),e.projectUpdatedAt??0)})).sort((e,t)=>t.recencyAt-e.recencyAt||e.index-t.index).map(({group:e})=>e),n)}"; const patchedGroupSorter = - "function Fe({groups:e,items:t,projectOrder:n,sortMode:codexLinuxProjectSortMode}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return((codexLinuxRecencySortedGroups)=>codexLinuxProjectSortMode===`updated_at`?codexLinuxRecencySortedGroups:ue(codexLinuxRecencySortedGroups,n))(e.map((e,t)=>({group:e,index:t,recencyAt:Re(e,r)})).sort((e,t)=>t.recencyAt-e.recencyAt||e.index-t.index).map(({group:e})=>e))}"; + "function p5o({groups:e,items:t,projectOrder:n,sortMode:codexLinuxProjectSortMode}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return((codexLinuxRecencySortedGroups)=>codexLinuxProjectSortMode===`updated_at`?codexLinuxRecencySortedGroups:G6i(codexLinuxRecencySortedGroups,n))(e.map((e,t)=>({group:e,index:t,recencyAt:e.threadKeys.reduce((e,t)=>Math.max(e,r.get(t)??0),e.projectUpdatedAt??0)})).sort((e,t)=>t.recencyAt-e.recencyAt||e.index-t.index).map(({group:e})=>e))}"; const currentGroupSorterCall = - "T=Fe({groups:Pe({groups:S,items:c}),items:c,projectOrder:f(t,o.PROJECT_ORDER)})"; + "N=p5o({groups:A,items:f,projectOrder:jm(t,_u.PROJECT_ORDER)})"; const patchedGroupSorterCall = - "T=Fe({groups:Pe({groups:S,items:c}),items:c,projectOrder:f(t,o.PROJECT_ORDER),sortMode:t(C).projectSortMode})"; + "N=p5o({groups:A,items:f,projectOrder:jm(t,_u.PROJECT_ORDER),sortMode:M})"; function countOccurrences(source, needle) { return source.split(needle).length - 1; @@ -52,8 +52,7 @@ const descriptors = [ phase: "webview-asset", order: 20_900, ciPolicy: "optional", - pattern: - /^app-initial~app-main~onboarding-page~projects-index-page~quick-chat-window-page~codex-micro~[A-Za-z0-9_-]+\.js$/, + pattern: /^app-initial-[A-Za-z0-9_-]+\.js$/, missingDescription: "project group sort webview bundle", skipDescription: "project group Last updated sorting feature patch", apply: applyProjectGroupLastUpdatedSortPatch, diff --git a/linux-features/project-group-last-updated-sort/test.js b/linux-features/project-group-last-updated-sort/test.js index bbbe9d435..9afceec25 100644 --- a/linux-features/project-group-last-updated-sort/test.js +++ b/linux-features/project-group-last-updated-sort/test.js @@ -18,13 +18,13 @@ const { } = require("./patch.js"); const currentProjectSource = [ - "function ue(e,t){let n=new Map(t.map((e,t)=>[e,t]));return[...e].sort((e,t)=>(n.get(e.projectId)??2**53-1)-(n.get(t.projectId)??2**53-1))}", - "function Re(e,t){let n=e.projectUpdatedAt??0;for(let r of e.threadKeys)n=Math.max(n,t.get(r)??0);return n}", - "function Fe({groups:e,items:t,projectOrder:n}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return ue(e.map((e,t)=>({group:e,index:t,recencyAt:Re(e,r)})).sort((e,t)=>t.recencyAt-e.recencyAt||e.index-t.index).map(({group:e})=>e),n)}", + "function U6i(e,t){let n=new Set(e.map(e=>e.projectId)),r=(t??[]).filter(e=>n.has(e)),i=new Set(r);return[...e.map(e=>e.projectId).filter(e=>!i.has(e)),...r]}", + "function G6i(e,t){let n=U6i(e,t),r=new Map(n.map((e,t)=>[e,t]));return[...e].sort((e,t)=>(r.get(e.projectId)??2**53-1)-(r.get(t.projectId)??2**53-1))}", + "function p5o({groups:e,items:t,projectOrder:n}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return G6i(e.map((e,t)=>({group:e,index:t,recencyAt:e.threadKeys.reduce((e,t)=>Math.max(e,r.get(t)??0),e.projectUpdatedAt??0)})).sort((e,t)=>t.recencyAt-e.recencyAt||e.index-t.index).map(({group:e})=>e),n)}", "const prioritySortId=`sidebarElectron.sortMenu.priority`;", "const updatedSortId=`sidebarElectron.sortMenu.updated`;", "const manualSortId=`sidebarElectron.sortMenu.manual`;", - "T=Fe({groups:Pe({groups:S,items:c}),items:c,projectOrder:f(t,o.PROJECT_ORDER)});", + "let{chatSortMode:j,projectSortMode:M}=t(xH),N=p5o({groups:A,items:f,projectOrder:jm(t,_u.PROJECT_ORDER)});", ].join(""); function captureWarns(fn) { @@ -70,7 +70,7 @@ function withFeatureConfig(enabled, fn) { function evaluateGroupSorter(source) { const context = {}; const sorterSource = source.slice(0, source.indexOf("const prioritySortId")); - vm.runInNewContext(`${sorterSource};globalThis.sortProjectGroups=Fe`, context); + vm.runInNewContext(`${sorterSource};globalThis.sortProjectGroups=p5o`, context); return context.sortProjectGroups; } @@ -154,15 +154,15 @@ test("patch passes the selected project sort mode into the group sorter", () => const patched = applyPatchTwice(currentProjectSource); assert.ok( patched.includes( - "projectOrder:f(t,o.PROJECT_ORDER),sortMode:t(C).projectSortMode", + "projectOrder:jm(t,_u.PROJECT_ORDER),sortMode:M", ), ); }); test("drift leaves the asset byte-identical", () => { const source = currentProjectSource.replace( - "function Fe({groups:e,items:t,projectOrder:n})", - "function Fe({groups:e,items:t,projectOrder:n,unknown:o})", + "function p5o({groups:e,items:t,projectOrder:n})", + "function p5o({groups:e,items:t,projectOrder:n,unknown:o})", ); const { value, warnings } = captureWarns(() => applyProjectGroupLastUpdatedSortPatch(source), @@ -175,7 +175,7 @@ test("drift leaves the asset byte-identical", () => { test("missing current call site leaves the asset byte-identical", () => { const source = currentProjectSource.replace( - "projectOrder:f(t,o.PROJECT_ORDER)", + "projectOrder:jm(t,_u.PROJECT_ORDER)", "projectOrder:unknownProjectOrder", ); const { value, warnings } = captureWarns(() => @@ -208,7 +208,7 @@ test("descriptor targets and patches only the current project sidebar chunk", () const assetsDir = path.join(tempDir, "webview", "assets"); const assetPath = path.join( assetsDir, - "app-initial~app-main~onboarding-page~projects-index-page~quick-chat-window-page~codex-micro~iqsnin5k-demo.js", + "app-initial-Biw83Aiz.js", ); fs.mkdirSync(assetsDir, { recursive: true }); fs.writeFileSync(assetPath, currentProjectSource); diff --git a/linux-features/read-aloud/patch.js b/linux-features/read-aloud/patch.js index 7fa163337..77cb0bd06 100644 --- a/linux-features/read-aloud/patch.js +++ b/linux-features/read-aloud/patch.js @@ -793,7 +793,7 @@ module.exports = { phase: "webview-asset", order: 20620, ciPolicy: "optional", - pattern: /^app-initial~app-main~onboarding-page-[A-Za-z0-9_-]+\.js$/, + pattern: /^app-initial-[A-Za-z0-9_-]+\.js$/, missingDescription: "current primary thread assistant bundle", skipDescription: "read aloud assistant runtime patch", apply: applyWebviewPatch, diff --git a/linux-features/read-aloud/test.js b/linux-features/read-aloud/test.js index aaee3fbb2..b8da389d8 100644 --- a/linux-features/read-aloud/test.js +++ b/linux-features/read-aloud/test.js @@ -1000,12 +1000,12 @@ test("assistant render patch preserves the current JSX runtime alias", () => { }); test("assistant render patch covers the current shared assistant message call", () => { - const source = "return (0,DX.jsx)(Jft,{item:n,alwaysShowActions:V,assistantCopyText:_,turnId:v,processTargets:b,hookStats:D,threadDetailLevel:u,completedThreadGoal:O,after:C,electronAfter:w,conversationId:l,cwd:p,hostId:m,reportEntityType:h,markdownMediaCacheKey:e,projectlessOutputDirectory:q,forceCodeBlockWordWrap:ie,hasArtifacts:J,onAddSelectedTextToChat:r,onFileLinkOpen:E,onFork:F,renderCodeBlocksAsWritingBlocks:ie,showActionRow:H,showTimestampWithoutActions:U,showProcessBadges:i})"; + const source = "return (0,t8.jsx)(K6c,{item:n,alwaysShowActions:re,assistantCopyText:b,turnId:x,processTargets:S,autoReviewStats:A,hookStats:j,threadDetailLevel:p,completedThreadGoal:M,after:T,electronAfter:E,conversationId:d,getVisualizeTurnTriggerType:f,cwd:g,hostId:_,reportEntityType:v,markdownMediaCacheKey:e,projectlessOutputDirectory:de,forceCodeBlockWordWrap:we,hasArtifacts:fe,onAddResponseTextAnnotation:r,onFileLinkOpen:k,onFork:B,renderCodeBlocksAsWritingBlocks:we,showActionRow:ie,showTimestampWithoutActions:ae,timestampHoverOnly:oe,showProcessBadges:i,allowCopyWhileStreaming:q})"; const patched = twice(applyAssistantRenderPatch, source); - assert.match(patched, /DX\.Fragment/); - assert.match(patched, /\(0,DX\.jsx\)\("button"/); - assert.match(patched, /globalThis\.codexLinuxReadAloudClick\?\.\(n,_,l,e\.currentTarget\)/); + assert.match(patched, /t8\.Fragment/); + assert.match(patched, /\(0,t8\.jsx\)\("button"/); + assert.match(patched, /globalThis\.codexLinuxReadAloudClick\?\.\(n,b,d,e\.currentTarget\)/); }); test("assistant runtime descriptor targets current shared assistant bundles", () => { @@ -1013,7 +1013,7 @@ test("assistant runtime descriptor targets current shared assistant bundles", () assert.ok(descriptor); assert.equal( descriptor.pattern.test( - "app-initial~app-main~onboarding-page-zcfEkMl-.js", + "app-initial-BHB6SClA.js", ), true, ); @@ -1021,6 +1021,7 @@ test("assistant runtime descriptor targets current shared assistant bundles", () "index-current.js", "local-conversation-thread-current.js", "local-conversation-turn-current.js", + "app-initial~app-main~onboarding-page-zcfEkMl-.js", "app-initial~app-main~onboarding-page~hotkey-window-thread-page~editor-diff-page~thread-app-~current.js", ]) { assert.equal(descriptor.pattern.test(legacyName), false, legacyName); @@ -1034,7 +1035,7 @@ test("assistant runtime descriptor fails soft and atomically when the current re fs.mkdirSync(assetsDir, { recursive: true }); const assetPath = path.join( assetsDir, - "app-initial~app-main~onboarding-page-zcfEkMl-.js", + "app-initial-BHB6SClA.js", ); const source = "console.log(`assistant render contract moved`);"; fs.writeFileSync(assetPath, source); @@ -1062,7 +1063,7 @@ test("assistant runtime descriptor reports applied then already-applied for the fs.mkdirSync(assetsDir, { recursive: true }); const assetPath = path.join( assetsDir, - "app-initial~app-main~onboarding-page-zcfEkMl-.js", + "app-initial-BHB6SClA.js", ); fs.writeFileSync( assetPath, diff --git a/linux-features/record-and-replay/patch.js b/linux-features/record-and-replay/patch.js index 5a0a6330a..64761f747 100644 --- a/linux-features/record-and-replay/patch.js +++ b/linux-features/record-and-replay/patch.js @@ -1,9 +1,12 @@ "use strict"; -const { requireName } = require("../../scripts/patches/lib/minified-js.js"); - const RECORD_REPLAY_PLUGIN_NAME = "record-and-replay"; const HUD_RUNTIME_VERSION = 5; +const RECORD_REPLAY_MODULE_EXPRESSIONS = Object.freeze({ + childProcessVar: 'require("node:child_process")', + fsVar: 'require("node:fs")', + pathVar: 'require("node:path")', +}); function warn(message, patchName) { console.warn(`WARN: ${message} - skipping ${patchName}`); @@ -198,15 +201,8 @@ function recordReplayChronicleTrayPatchedPattern() { } function hasCompleteRecordReplayMainBridgePatch(source) { - const childProcessVar = requireName(source, "node:child_process"); - const fsVar = requireName(source, "node:fs"); - const pathVar = requireName(source, "node:path"); - if (childProcessVar == null || fsVar == null || pathVar == null) { - return false; - } - - const helperPayload = recordReplayHelperSource({ childProcessVar, fsVar, pathVar }); - const bridgePayload = recordReplayBridgeSource({ childProcessVar, fsVar, pathVar }); + const helperPayload = recordReplayHelperSource(RECORD_REPLAY_MODULE_EXPRESSIONS); + const bridgePayload = recordReplayBridgeSource(RECORD_REPLAY_MODULE_EXPRESSIONS); const bridgeInsertion = `${bridgePayload},"get-global-state":async({key:`; return countOccurrences(source, helperPayload) === 1 && countOccurrences(source, bridgePayload) === 1 @@ -260,23 +256,15 @@ function applyRecordReplayMainBridgePatch(currentSource) { } let patchedSource = currentSource; - const childProcessVar = requireName(currentSource, "node:child_process"); - const fsVar = requireName(currentSource, "node:fs"); - const pathVar = requireName(currentSource, "node:path"); - if (childProcessVar == null || fsVar == null || pathVar == null) { - warn("Could not find Node module aliases", patchName); - return currentSource; - } - const handlerNeedle = `"get-global-state":async({key:`; if (!currentSource.includes(handlerNeedle)) { warn("Could not find global-state bridge insertion point", patchName); return currentSource; } - patchedSource = `${recordReplayHelperSource({ childProcessVar, fsVar, pathVar })}\n${patchedSource.replace( + patchedSource = `${recordReplayHelperSource(RECORD_REPLAY_MODULE_EXPRESSIONS)}\n${patchedSource.replace( handlerNeedle, - `${recordReplayBridgeSource({ childProcessVar, fsVar, pathVar })},${handlerNeedle}`, + `${recordReplayBridgeSource(RECORD_REPLAY_MODULE_EXPRESSIONS)},${handlerNeedle}`, )}`; return applyRecordReplayChronicleTrayPatch(patchedSource); } diff --git a/linux-features/record-and-replay/test.js b/linux-features/record-and-replay/test.js index 82eb559e6..ca502a50b 100644 --- a/linux-features/record-and-replay/test.js +++ b/linux-features/record-and-replay/test.js @@ -15,6 +15,9 @@ const { loadLinuxFeaturePatchDescriptors, stageEnabledLinuxFeatureInstall, } = require("../../scripts/lib/linux-features.js"); +const { + applyLinuxExternalOpenEnvPatch, +} = require("../../scripts/patches/impl/main-process/browser.js"); const { applyRecordReplayDictationTranscriptPatch, applyRecordReplayGlobalDictationTranscriptPatch, @@ -202,7 +205,10 @@ test("record-and-replay bridge patch is idempotent and uses execFile", () => { assert.match(patched, /"linux-record-replay-import-skill":async/); assert.match(patched, /\.execFile\(n,e,\{encoding:"utf8",timeout:t,maxBuffer:16777216\}/); assert.match(patched, /codexLinuxRecordReplayWriteTempJson/); - assert.match(patched, /finally\{try\{fs\.unlinkSync\(c\)\}catch\{\}\}/); + assert.match( + patched, + /finally\{try\{require\("node:fs"\)\.unlinkSync\(c\)\}catch\{\}\}/, + ); assert.match(patched, /"browser-trace"/); assert.match(patched, /"--trace-file"/); assert.doesNotMatch(patched, /exec\(/); @@ -214,6 +220,27 @@ test("record-and-replay bridge patch is idempotent and uses execFile", () => { assert.doesNotMatch(patched, /"--mode"/); }); +test("record-and-replay bridge remains complete after external-open composition", () => { + const source = [ + '"use strict";let electron=require("electron");', + 'const cp=require("node:child_process"),fs=require("node:fs"),path=require("node:path");', + "var tray={getChronicleSidecarControlState:()=>tt().skysight?$9:Se.appServerConnectionRegistry.getMaybeConnection(`local`)?.getChronicleSidecarControlState()??$9,toggleChronicleSidecar:async()=>{if(tt().skysight)return $9;let e=Se.appServerConnectionRegistry.getMaybeConnection(V);return e==null?$9:e.getChronicleSidecarControlState().running?e.pauseChronicleSidecar():e.resumeChronicleSidecar()}};", + 'var bridge={"get-global-state":async({key:e})=>null};', + ].join(""); + const recordPatched = applyRecordReplayMainBridgePatch(source); + const composed = applyLinuxExternalOpenEnvPatch(recordPatched); + const { value, warnings } = captureWarns(() => + applyRecordReplayMainBridgePatch(composed), + ); + + assert.equal(value, composed); + assert.deepEqual(warnings, []); + assert.match( + composed, + /require\("node:child_process"\)\.execFile\(/, + ); +}); + test("record-and-replay rejects incomplete current bridge variants byte-identically", () => { const source = [ 'const cp=require("node:child_process"),fs=require("node:fs"),path=require("node:path");', @@ -221,16 +248,13 @@ test("record-and-replay rejects incomplete current bridge variants byte-identica 'var bridge={"get-global-state":async({key:e})=>null};', ].join(""); const patched = applyRecordReplayMainBridgePatch(source); - const bridgePayload = recordReplayBridgeSource({ - childProcessVar: "cp", - fsVar: "fs", - pathVar: "path", - }); - const helperPayload = recordReplayHelperSource({ - childProcessVar: "cp", - fsVar: "fs", - pathVar: "path", - }); + const moduleExpressions = { + childProcessVar: 'require("node:child_process")', + fsVar: 'require("node:fs")', + pathVar: 'require("node:path")', + }; + const bridgePayload = recordReplayBridgeSource(moduleExpressions); + const helperPayload = recordReplayHelperSource(moduleExpressions); const trayStart = patched.indexOf("var tray="); const trayEnd = patched.indexOf(";var bridge=", trayStart); const trayStatement = patched.slice(trayStart, trayEnd + 1); diff --git a/linux-features/remote-mobile-control/README.md b/linux-features/remote-mobile-control/README.md index cd9091ef6..7e0c54037 100644 --- a/linux-features/remote-mobile-control/README.md +++ b/linux-features/remote-mobile-control/README.md @@ -126,7 +126,7 @@ feature descriptor to appear exactly once in this table. | `linux-remote-control-status-read-guard` | `shared-boundary` | Sends `remoteControl/status/read` only to the local host, never Remote SSH or remote-control environment hosts. | | `linux-remote-control-status-wait` | `shared-boundary` | Gives the selected host a Linux-specific connection convergence window without changing host ownership. | | `linux-remote-control-enable-for-host-params` | `shared-boundary` | Uses the current enable/disable RPC parameter contract without choosing which host is targeted. | -| `linux-remote-control-enablement-bridge` | `shared-boundary` | Loads outbound clients while auto-connecting only the remote-control environment owned by this Desktop. | +| `linux-remote-control-enablement-bridge` | `shared-boundary` | Loads outbound clients and auto-connects the remote-control environment owned by this Desktop without overwriting saved choices for other hosts. | | `linux-remote-mobile-active-status` | `mobile-host` | Derives mobile active state from the local thread runtime. | Remote SSH behavior is nested inside the shared settings descriptor rather than diff --git a/linux-features/remote-mobile-control/patch.js b/linux-features/remote-mobile-control/patch.js index 8c3187168..f054c0bff 100644 --- a/linux-features/remote-mobile-control/patch.js +++ b/linux-features/remote-mobile-control/patch.js @@ -18,18 +18,13 @@ const DEVICE_KEY_REQUIRE_NEEDLE = /(?:var|let|const)\s+[A-Za-z_$][\w$]*=\(0,[A-Za-z_$][\w$]*\.createRequire\)\(__filename\),[A-Za-z_$][\w$]*=`remote-control-device-key\.node`/u; const REMOTE_CONTROL_SETTINGS_VISIBILITY_NEEDLE = /function ([A-Za-z_$][\w$]*)\(\{remoteControlConnectionsState:([A-Za-z_$][\w$]*),slingshotEnabled:([A-Za-z_$][\w$]*)\}\)\{return \3&&\(\2\?\.available\?\?!0\)(?:&&\2\?\.accessRequired!==!0)?\}/u; -const REMOTE_CONTROL_SETTINGS_UX_MARKER = "codexLinuxRemoteControlSettingsTabs"; -const REMOTE_CONTROL_SETTINGS_TABS_HELPER = - "function codexLinuxRemoteControlSettingsTabs(e){return e}"; -const REMOTE_CONTROL_SETTINGS_TABS_OLD_HELPER = - "function codexLinuxRemoteControlSettingsTabs(e){return typeof navigator!=`undefined`&&navigator.userAgent.includes(`Linux`)?e.filter(e=>e.key!==`access-other-devices`):e}"; +const REMOTE_CONTROL_OUTBOUND_TAB_GATE_MARKER = "codexLinuxRemoteControlOutboundTabGate"; const REMOTE_CONTROL_SSH_INSTALL_ACTION_MARKER = "codexLinuxRemoteControlSshInstallActions"; const REMOTE_CONTROL_SSH_INSTALL_RELEASE_MARKER = "codexLinuxRemoteControlSshInstallRelease"; const REMOTE_CONNECTIONS_REFRESH_MARKER = "codexLinuxRemoteConnectionsRefreshNow"; const REMOTE_MOBILE_CHROME_BRIDGE_MARKER = "codexLinuxRemoteMobileBrowserBackends"; const REMOTE_CONTROL_LOAD_GATE_MARKER = "codexLinuxRemoteControlLoadGateEnabled"; const REMOTE_CONTROL_FEATURE_SYNC_MARKER = "codexLinuxRemoteControlFeatureSyncEnabled"; -const REMOTE_CONTROL_FEATURE_SYNC_HOST_SCOPE_MARKER = "codexLinuxRemoteControlFeatureSyncHostScoped"; const REMOTE_CONTROL_LOAD_GATE_NEEDLE = /function ([A-Za-z_$][\w$]*)\(\)\{return ([A-Za-z_$][\w$]*)\(`1042620455`\)\}/u; const REMOTE_MOBILE_THREAD_RUNTIME_MARKER = "codexLinuxRemoteMobileThreadRuntimeStatus"; @@ -49,9 +44,13 @@ const REMOTE_CONTROL_STATUS_WAIT_MARKER = "codexLinuxRemoteControlStatusWaitMs"; const REMOTE_CONTROL_REVOKE_SETUP_RESET_MARKER = "codexLinuxRemoteControlResetMobileSetupAfterRevoke"; const REMOTE_CONTROL_VISIBILITY_MARKER = "codexLinuxRemoteControlVisibilityEnabled"; const REMOTE_CONTROL_COPY_MARKER = "codexLinuxRemoteControlCopy"; -const REMOTE_MOBILE_APP_SERVER_REMOTE_CONTROL_MARKER = "codexLinuxRemoteMobileAppServerArgs"; -const REMOTE_MOBILE_APP_SERVER_ARGS_NEEDLE = - "[`-c`,`features.code_mode_host=true`,`app-server`,`--analytics-default-enabled`]"; +const REMOTE_MOBILE_APP_SERVER_REMOTE_CONTROL_MARKER = "codexLinuxRemoteMobileLocalAppServerArgs"; +const REMOTE_MOBILE_APP_SERVER_BASE_ARGS_NEEDLE = "[`-c`,`features.code_mode_host=true`]"; +const REMOTE_MOBILE_APP_SERVER_LAUNCH_TAIL = "`app-server`,`--analytics-default-enabled`]}"; +const REMOTE_MOBILE_APP_SERVER_REMOTE_CONTROL_HELPER = + "function codexLinuxRemoteMobileLocalAppServerArgs(){return process.platform===`linux`?[`--remote-control`]:[]}"; +const REMOTE_MOBILE_APP_SERVER_PATCHED_LAUNCH_TAIL = + "`app-server`,...codexLinuxRemoteMobileLocalAppServerArgs(),`--analytics-default-enabled`]}"; const REMOTE_CONTROL_APP_INITIAL_ASSET_PATTERN = /^app-initial-[^.]+\.js$/u; const REMOTE_CONTROL_LINUX_COPY_REPLACEMENTS = [ ["defaultMessage:`Mac`", "defaultMessage:`Linux`"], @@ -214,17 +213,45 @@ function applyLinuxRemoteControlClientRevocationRecoveryPatch(source) { function applyLinuxRemoteMobileAppServerRemoteControlPatch(source) { if (source.includes(REMOTE_MOBILE_APP_SERVER_REMOTE_CONTROL_MARKER)) { + if (!hasLinuxRemoteMobileLocalAppServerRemoteControlPatch(source)) { + console.warn( + "WARN: Found an incomplete local Desktop app-server remote-control patch - refusing to accept partial state", + ); + } return source; } - if (!source.includes(REMOTE_MOBILE_APP_SERVER_ARGS_NEEDLE)) { + const baseArgsMatches = [ + ...source.matchAll( + new RegExp(`([A-Za-z_$][\\w$]*)=${escapeRegExp(REMOTE_MOBILE_APP_SERVER_BASE_ARGS_NEEDLE)}`, "gu"), + ), + ]; + if (baseArgsMatches.length !== 1) { return source; } - const helper = - "function codexLinuxRemoteMobileAppServerArgs(){return process.platform===`linux`?[`-c`,`features.code_mode_host=true`,`app-server`,`--remote-control`,`--analytics-default-enabled`]:[`-c`,`features.code_mode_host=true`,`app-server`,`--analytics-default-enabled`]}"; - const replaced = source - .split(REMOTE_MOBILE_APP_SERVER_ARGS_NEEDLE) - .join("codexLinuxRemoteMobileAppServerArgs()"); + const baseArgsVariable = baseArgsMatches[0][1]; + const launchFunctionMatches = [ + ...source.matchAll( + new RegExp( + `function ([A-Za-z_$][\\w$]*)\\(\\)\\{return\\[\\.\\.\\.${escapeRegExp(baseArgsVariable)},`, + "gu", + ), + ), + ]; + if (launchFunctionMatches.length !== 1) { + return source; + } + + const launchFunctionIndex = launchFunctionMatches[0].index; + const nextFunctionIndex = source.indexOf("}function", launchFunctionIndex); + const launchTailIndex = source.indexOf(REMOTE_MOBILE_APP_SERVER_LAUNCH_TAIL, launchFunctionIndex); + if (launchTailIndex < 0 || (nextFunctionIndex >= 0 && launchTailIndex >= nextFunctionIndex)) { + return source; + } + + const replaced = `${source.slice(0, launchTailIndex)}${REMOTE_MOBILE_APP_SERVER_PATCHED_LAUNCH_TAIL}${source.slice( + launchTailIndex + REMOTE_MOBILE_APP_SERVER_LAUNCH_TAIL.length, + )}`; // Insert after a leading "use strict" so prepending the helper does not // demote the directive to a plain expression and de-strict the bundle. const insertAt = replaced.startsWith('"use strict";') @@ -232,7 +259,14 @@ function applyLinuxRemoteMobileAppServerRemoteControlPatch(source) { : replaced.startsWith("'use strict';") ? "'use strict';".length : 0; - return `${replaced.slice(0, insertAt)}${helper}${replaced.slice(insertAt)}`; + return `${replaced.slice(0, insertAt)}${REMOTE_MOBILE_APP_SERVER_REMOTE_CONTROL_HELPER}${replaced.slice(insertAt)}`; +} + +function hasLinuxRemoteMobileLocalAppServerRemoteControlPatch(source) { + return ( + source.includes(REMOTE_MOBILE_APP_SERVER_REMOTE_CONTROL_HELPER) && + source.includes(REMOTE_MOBILE_APP_SERVER_PATCHED_LAUNCH_TAIL) + ); } function applyLinuxRemoteMobileAppServerRemoteControlExtractedAppPatch(extractedDir) { @@ -254,22 +288,26 @@ function applyLinuxRemoteMobileAppServerRemoteControlExtractedAppPatch(extracted const filePath = path.join(buildDir, candidate); const source = fs.readFileSync(filePath, "utf8"); if ( - !source.includes(REMOTE_MOBILE_APP_SERVER_ARGS_NEEDLE) && + !source.includes(REMOTE_MOBILE_APP_SERVER_BASE_ARGS_NEEDLE) && !source.includes(REMOTE_MOBILE_APP_SERVER_REMOTE_CONTROL_MARKER) ) { continue; } - matched += 1; const patched = applyLinuxRemoteMobileAppServerRemoteControlPatch(source); if (patched !== source) { + matched += 1; fs.writeFileSync(filePath, patched, "utf8"); changed += 1; + } else if (hasLinuxRemoteMobileLocalAppServerRemoteControlPatch(source)) { + matched += 1; } } if (matched === 0) { - const reason = "no default app-server launch args found"; - console.warn("WARN: Could not find default app-server launch args - skipping remote mobile app-server remote-control patch"); + const reason = "no local Desktop app-server base args found"; + console.warn( + "WARN: Could not find local Desktop app-server base args - skipping remote mobile app-server remote-control patch", + ); return { matched, changed, reason }; } return { matched, changed }; @@ -369,85 +407,50 @@ function applyLinuxRemoteControlFeatureSyncPatch(source) { if (!source.includes("set-experimental-feature-enablement-for-host")) { return source; } - - // The current per-host feature enablement helper copies the supported - // defaults, then adds remote_plugin without remote_control. Current app - // servers use remote_plugin for remote marketplace data, so Linux adds only - // remote_control while preserving the upstream remote_plugin assignment. - let patched = source; - let changed = false; - const enablementRegex = - /(for\(let ([A-Za-z_$][\w$]*) of [A-Za-z_$][\w$]*\)\{let ([A-Za-z_$][\w$]*)=[A-Za-z_$][\w$]*\[\2\];\3!=null&&\(([A-Za-z_$][\w$]*)\[\2\]=\3\)\})return \4\[([A-Za-z_$][\w$]*)\]=([A-Za-z_$][\w$]*),\4\}/u; - if (!patched.includes(REMOTE_CONTROL_FEATURE_SYNC_MARKER)) { - const match = patched.match(enablementRegex); - if (match != null) { - const [, loopBlock, , , enablementVar, remotePluginVar, remotePluginValue] = match; - const replacement = - `${loopBlock}return typeof navigator!=\`undefined\`&&navigator.userAgent.includes(\`Linux\`)` + - `?(${REMOTE_CONTROL_FEATURE_SYNC_MARKER}(arguments[2],arguments[3])&&(${enablementVar}.remote_control=!0),${enablementVar}[${remotePluginVar}]=${remotePluginValue},${enablementVar})` + - `:(${enablementVar}[${remotePluginVar}]=${remotePluginValue},${enablementVar})}` + - `function ${REMOTE_CONTROL_FEATURE_SYNC_MARKER}(e,t){return e==null||t==null||e===t}`; - patched = patched.replace(enablementRegex, replacement); - changed = true; - } - } - - const scoped = applyLinuxRemoteControlFeatureSyncHostScopePatch(patched); - if (scoped !== patched) { - patched = scoped; - changed = true; - } - - if (changed || patched.includes(REMOTE_CONTROL_FEATURE_SYNC_MARKER)) { - return patched; - } - - console.warn("WARN: Could not find app-server feature sync list - skipping Linux remote-control feature sync patch"); - return source; -} - -function applyLinuxRemoteControlFeatureSyncHostScopePatch(source) { - if (source.includes(REMOTE_CONTROL_FEATURE_SYNC_HOST_SCOPE_MARKER)) { + if (source.includes(`function ${REMOTE_CONTROL_FEATURE_SYNC_MARKER}(`)) { return source; } - const builderCallRegex = - /let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*),([A-Za-z_$][\w$]*|![01])\),([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\.get\(([A-Za-z_$][\w$]*)\),/u; - const builderCallMatch = source.match(builderCallRegex); - if (builderCallMatch == null) { + const id = "[A-Za-z_$][\\w$]*"; + const syncSetupRegex = new RegExp( + `let (${id})=(${id})\\((${id}),(${id}),(${id})\\),` + + `(${id})=(${id})\\.get\\((${id})\\),(${id})=new Set\\(`, + "u", + ); + const setupMatch = source.match(syncSetupRegex); + const enablementVar = setupMatch?.[1]; + const localHostVar = setupMatch?.[6]; + const activeHostsVar = setupMatch?.[9]; + if (enablementVar == null || localHostVar == null || activeHostsVar == null) { + console.warn("WARN: Could not find app-server feature sync setup - skipping Linux remote-control feature sync patch"); return source; } - const [ - , - enablementVar, - builderFn, - featureConfigVar, - remotePluginValueVar, - localHostVar, - ] = builderCallMatch; - const id = "[A-Za-z_$][\\w$]*"; const flatMapRegex = new RegExp( - `\\(0,(${id})\\.(${id})\\)\\((${id})\\.get\\((${id})\\),${enablementVar}\\)\\?\\[\\]:` + - `\\(\\3\\.set\\(\\4,${enablementVar}\\),\\[(${id})\\(\\x60set-experimental-feature-enablement-for-host\\x60,` + - `\\{hostId:\\4,enablement:${enablementVar}\\}\\)`, + `Array\\.from\\(${activeHostsVar}\\)\\.flatMap\\((${id})=>` + + `\\(0,(${id})\\.default\\)\\((${id})\\.get\\(\\1\\),${enablementVar}\\)\\?\\[\\]:` + + `\\(\\3\\.set\\(\\1,${enablementVar}\\),\\[(${id})\\(\\x60set-experimental-feature-enablement-for-host\\x60,` + + `\\{hostId:\\1,enablement:${enablementVar}\\}\\)`, "u", ); const match = source.match(flatMapRegex); if (match == null) { + console.warn("WARN: Could not find app-server feature sync list - skipping Linux remote-control feature sync patch"); return source; } - const [needle, compareNamespaceVar, compareFnVar, cacheMapVar, hostVar, requestFnVar] = match; - const helperName = "codexLinuxRemoteControlFeatureSyncForHost"; + const [needle, hostVar, compareNamespaceVar, cacheMapVar, requestFnVar] = match; const scopedEnablement = - `${helperName}(${builderFn},${featureConfigVar},${remotePluginValueVar},${hostVar},${localHostVar})`; + `${REMOTE_CONTROL_FEATURE_SYNC_MARKER}(${enablementVar},${localHostVar},${hostVar})`; const replacement = - `(0,${compareNamespaceVar}.${compareFnVar})(${cacheMapVar}.get(${hostVar}),${scopedEnablement})?[]:` + + `Array.from(${activeHostsVar}).flatMap(${hostVar}=>(0,${compareNamespaceVar}.default)` + + `(${cacheMapVar}.get(${hostVar}),${scopedEnablement})?[]:` + `(${cacheMapVar}.set(${hostVar},${scopedEnablement}),[${requestFnVar}(\`set-experimental-feature-enablement-for-host\`,` + - `{hostId:${hostVar},enablement:${scopedEnablement}})/*${REMOTE_CONTROL_FEATURE_SYNC_HOST_SCOPE_MARKER}*/`; + `{hostId:${hostVar},enablement:${scopedEnablement}})`; const helper = - `function ${helperName}(e,t,n,r,i){return e(t,n,r,i)}`; + `function ${REMOTE_CONTROL_FEATURE_SYNC_MARKER}(e,t,n){return ` + + `typeof navigator!=\`undefined\`&&navigator.userAgent.includes(\`Linux\`)&&t===n` + + `?{...e,remote_control:!0}:e}`; return `${source.replace(needle, replacement)}\n${helper}`; } @@ -473,18 +476,6 @@ function applyLinuxRemoteControlVisibilityPatch(source) { ); } -function wrapRemoteControlTabs(source, firstKey) { - const key = firstKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const pattern = new RegExp( - `tabs:(\\[\\{key:\`${key}\`[\\s\\S]*?\\}\\]),selectedKey:([A-Za-z_$][\\w$]*),variant:\`underline\`,onSelect:([A-Za-z_$][\\w$]*)\\}`, - "g", - ); - return source.replace( - pattern, - "tabs:codexLinuxRemoteControlSettingsTabs($1),selectedKey:$2,variant:`underline`,onSelect:$3}", - ); -} - function replaceLinuxRemoteControlCopy(source) { let patched = source; let changed = false; @@ -528,28 +519,17 @@ function applyLinuxRemoteControlSshInstallActionPatch(source) { } const actionGateRegex = - /let ([A-Za-z_$][\w$]*)=([^;]+?)&&\(([A-Za-z_$][\w$]*)\?\.code===`remote-codex-not-found`\|\|\3\?\.code===`update-required`\);([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)==null\|\|\1\?null:([A-Za-z_$][\w$]*)\(\{action:\5\.action,/u; + /let ([A-Za-z_$][\w$]*)=\([^;]{1,160}\)&&\(([A-Za-z_$][\w$]*)\?\.code===`remote-codex-not-found`\|\|\2\?\.code===`update-required`\)(?=,[A-Za-z_$][\w$]*;)/u; const match = source.match(actionGateRegex); - if (match != null) { - const [, gateVar, , , renderedActionVar, connectionActionVar, renderActionFn] = match; - return source.replace( - actionGateRegex, - `let ${gateVar}=/*${REMOTE_CONTROL_SSH_INSTALL_ACTION_MARKER}*/!1;${renderedActionVar}=${connectionActionVar}==null?null:${renderActionFn}({action:${connectionActionVar}.action,`, - ); - } - - const currentActionGateRegex = - /let ([A-Za-z_$][\w$]*)=([^;,]+?)&&\(([A-Za-z_$][\w$]*)\?\.code===`remote-codex-not-found`\|\|\3\?\.code===`update-required`\),([\s\S]*?)([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)==null\|\|\1\?null:([A-Za-z_$][\w$]*)\(\{action:\6\.action,/u; - const currentMatch = source.match(currentActionGateRegex); - if (currentMatch == null) { + if (match == null) { console.warn("WARN: Could not find remote-control SSH install action gate - skipping Linux install action patch"); return source; } - const [, gateVar, , , betweenGateAndAction, renderedActionVar, connectionActionVar, renderActionFn] = currentMatch; + const [, gateVar] = match; return source.replace( - currentActionGateRegex, - `let ${gateVar}=/*${REMOTE_CONTROL_SSH_INSTALL_ACTION_MARKER}*/!1,${betweenGateAndAction}${renderedActionVar}=${connectionActionVar}==null?null:${renderActionFn}({action:${connectionActionVar}.action,`, + actionGateRegex, + `let ${gateVar}=/*${REMOTE_CONTROL_SSH_INSTALL_ACTION_MARKER}*/!1`, ); } @@ -561,197 +541,105 @@ function applyLinuxRemoteControlSshInstallReleasePatch(source) { return source; } - const actionBuilderRegex = - /function ([A-Za-z_$][\w$]*)\(\{action:([A-Za-z_$][\w$]*),disabled:([A-Za-z_$][\w$]*),hostId:([A-Za-z_$][\w$]*),installCodexPending:([A-Za-z_$][\w$]*),onAuthenticate:([A-Za-z_$][\w$]*),onInstallCodex:([A-Za-z_$][\w$]*)(?:,onRestart:([A-Za-z_$][\w$]*))?\}\)\{if\(\2==null\)return null;switch\(\2\.kind\)\{case`install-codex`:return\{disabled:\3,label:\2\.label,loading:\5,loadingLabel:\2\.loadingLabel,renderInElectronOnly:!0,tooltipText:\2\.tooltipText,onClick:\(\)=>\7\(\4\)\}/u; - const actionCallRegex = - /let ([A-Za-z_$][\w$]*)=([^;]+?)&&\(([A-Za-z_$][\w$]*)\?\.code===`remote-codex-not-found`\|\|\3\?\.code===`update-required`\);([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)==null\|\|\1\?null:([A-Za-z_$][\w$]*)\(\{action:\5\.action,disabled:([A-Za-z_$][\w$]*),hostId:([A-Za-z_$][\w$]*)\.hostId,installCodexPending:([A-Za-z_$][\w$]*),(?:onRestart:([A-Za-z_$][\w$]*),)?onAuthenticate:([A-Za-z_$][\w$]*),onInstallCodex:([A-Za-z_$][\w$]*)\}\)/u; - const mutationRegex = - /([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)=>\{([A-Za-z_$][\w$]*)\.mutate\(\{hostId:\2\},\{onSuccess:\(\{state:([A-Za-z_$][\w$]*),error:([A-Za-z_$][\w$]*)\}\)=>\{([A-Za-z_$][\w$]*)\(\2,\4,\5\)\}\}\)\}/u; - const localVersionRegex = - /function ([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\{let ([A-Za-z_$][\w$]*)=\(0,([A-Za-z_$][\w$]*)\.c\)\((\d+)\),\{connection:([A-Za-z_$][\w$]*),disabled:([A-Za-z_$][\w$]*),installCodexPending:([A-Za-z_$][\w$]*),([\s\S]*?)onAuthenticate:([A-Za-z_$][\w$]*),([\s\S]*?)onInstallCodex:([A-Za-z_$][\w$]*),([\s\S]*?)\}=\2,([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\),\{appServerVersion:([A-Za-z_$][\w$]*),error:([A-Za-z_$][\w$]*),installedCodexVersion:([A-Za-z_$][\w$]*),state:([A-Za-z_$][\w$]*)\}=([A-Za-z_$][\w$]*)\(\6\.hostId\),([A-Za-z_$][\w$]*)=\6\.displayName/u; - const currentActionCallRegex = - /let ([A-Za-z_$][\w$]*)=([^;,]+?)&&\(([A-Za-z_$][\w$]*)\?\.code===`remote-codex-not-found`\|\|\3\?\.code===`update-required`\),([\s\S]*?)([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)==null\|\|\1\?null:([A-Za-z_$][\w$]*)\(\{action:\6\.action,disabled:([A-Za-z_$][\w$]*),hostId:([A-Za-z_$][\w$]*)\.hostId,installCodexPending:([A-Za-z_$][\w$]*),(?:onRestart:([A-Za-z_$][\w$]*),)?onAuthenticate:([A-Za-z_$][\w$]*),onInstallCodex:([A-Za-z_$][\w$]*)\}\)/u; - const currentLocalVersionRegex = - /\{appServerVersion:([A-Za-z_$][\w$]*),error:([A-Za-z_$][\w$]*),installedCodexVersion:([A-Za-z_$][\w$]*),state:([A-Za-z_$][\w$]*)\}=([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\.hostId\),([A-Za-z_$][\w$]*)=\6\.displayName/u; - - const actionBuilderMatch = source.match(actionBuilderRegex); - const actionCallMatch = source.match(actionCallRegex); - const mutationMatch = source.match(mutationRegex); - const localVersionMatch = source.match(localVersionRegex); + const id = "[A-Za-z_$][\\w$]*"; + const currentActionBuilderRegex = new RegExp( + `function (${id})\\(\\{action:(${id}),disabled:(${id}),hostId:(${id}),` + + `installCodexPending:(${id}),onAuthenticate:(${id}),onInstallCodex:(${id}),` + + `onReconnect:(${id}),onRestart:(${id})\\}\\)\\{if\\(\\2==null\\)return null;` + + `switch\\(\\2\\.kind\\)\\{case\\x60install-codex\\x60:return\\{disabled:\\3,label:\\2\\.label,` + + `loading:\\5,loadingLabel:\\2\\.loadingLabel,renderInElectronOnly:!0,` + + `tooltipText:\\2\\.tooltipText,onClick:\\(\\)=>\\7\\(\\4\\)\\}`, + "u", + ); + const currentActionCallRegex = new RegExp( + `(${id})\\(\\{action:(${id})\\.action,disabled:(${id}),hostId:(${id})\\.hostId,` + + `installCodexPending:(${id}),onReconnect:(${id}),onRestart:(${id}),` + + `onAuthenticate:(${id}),onInstallCodex:(${id})\\}\\)`, + "u", + ); + const currentLocalVersionRegex = new RegExp( + `\\{appServerVersion:(${id}),error:(${id}),installedCodexVersion:(${id}),state:(${id})\\}` + + `=(${id})\\((${id})\\.hostId\\),(${id})=\\6\\.displayName`, + "u", + ); + const currentMutationRegex = new RegExp( + `(${id})=(${id})=>\\{(${id})\\.mutate\\(\\{hostId:\\2\\},` + + `\\{onSuccess:\\(\\{state:(${id}),error:(${id})\\}\\)=>\\{(${id})\\(\\2,\\4,\\5\\)\\}\\}\\)\\}`, + "u", + ); + const currentActionBuilderMatch = source.match(currentActionBuilderRegex); const currentActionCallMatch = source.match(currentActionCallRegex); const currentLocalVersionMatch = source.match(currentLocalVersionRegex); + const currentMutationMatch = source.match(currentMutationRegex); if ( - actionBuilderMatch != null && - mutationMatch != null && - currentActionCallMatch != null && - currentLocalVersionMatch != null - ) { - const [ - , - builderFn, - builderActionVar, - builderDisabledVar, - builderHostVar, - builderPendingVar, - builderAuthVar, - builderInstallVar, - builderRestartVar, - ] = actionBuilderMatch; - const builderRestartPart = builderRestartVar == null ? "" : `,onRestart:${builderRestartVar}`; - const actionBuilderReplacement = - `function ${builderFn}({action:${builderActionVar},disabled:${builderDisabledVar},hostId:${builderHostVar},installCodexPending:${builderPendingVar},` + - `installCodexRelease:codexLinuxRemoteControlSshInstallReleaseTarget,onAuthenticate:${builderAuthVar},onInstallCodex:${builderInstallVar}${builderRestartPart}}){` + - `if(${builderActionVar}==null)return null;switch(${builderActionVar}.kind){case\`install-codex\`:return{disabled:${builderDisabledVar},label:${builderActionVar}.label,loading:${builderPendingVar},` + - `loadingLabel:${builderActionVar}.loadingLabel,renderInElectronOnly:!0,tooltipText:${builderActionVar}.tooltipText,onClick:()=>${builderInstallVar}(${builderHostVar},codexLinuxRemoteControlSshInstallReleaseTarget)}`; - - const [ - , - gateVar, - gateExpression, - errorVar, - betweenGateAndAction, - renderedActionVar, - connectionActionVar, - renderActionFn, - disabledVar, - connectionVar, - pendingVar, - restartVar, - authenticateVar, - installVar, - ] = currentActionCallMatch; - const restartPart = restartVar == null ? "" : `onRestart:${restartVar},`; - const actionCallReplacement = - `let ${gateVar}=${gateExpression}&&(${errorVar}?.code===\`remote-codex-not-found\`||${errorVar}?.code===\`update-required\`),` + - `${betweenGateAndAction}${renderedActionVar}=${connectionActionVar}==null||${gateVar}?null:${renderActionFn}({action:${connectionActionVar}.action,disabled:${disabledVar},hostId:${connectionVar}.hostId,` + - `installCodexPending:${pendingVar},installCodexRelease:${REMOTE_CONTROL_SSH_INSTALL_RELEASE_MARKER}(${errorVar}),${restartPart}onAuthenticate:${authenticateVar},onInstallCodex:${installVar}})`; - - const [ - , - currentAppServerVersionVar, - currentErrorVar, - currentInstalledVersionVar, - currentStateVar, - currentConnectionStateFn, - currentConnectionVar, - currentDisplayNameVar, - ] = currentLocalVersionMatch; - const currentLocalVersionReplacement = - `{appServerVersion:${currentAppServerVersionVar},error:${currentErrorVar},installedCodexVersion:${currentInstalledVersionVar},state:${currentStateVar}}=${currentConnectionStateFn}(${currentConnectionVar}.hostId),` + - `{appServerVersion:codexLinuxRemoteControlSshInstallLocalVersion}=${currentConnectionStateFn}(\`local\`);` + - `codexLinuxRemoteControlSshInstallDefaultRelease=codexLinuxRemoteControlValidRelease(codexLinuxRemoteControlSshInstallLocalVersion)??codexLinuxRemoteControlSshInstallDefaultRelease;` + - `let ${currentDisplayNameVar}=${currentConnectionVar}.displayName`; - - const [ - , - mutationHandlerVar, - mutationHostVar, - mutationVar, - mutationStateVar, - mutationErrorVar, - syncStateFn, - ] = mutationMatch; - const mutationReplacement = - `${mutationHandlerVar}=(${mutationHostVar},codexLinuxRemoteControlSshInstallTargetRelease)=>{` + - `let codexLinuxRemoteControlSshInstallRequest={hostId:${mutationHostVar}},` + - `codexLinuxRemoteControlSshInstallResolvedRelease=codexLinuxRemoteControlSshInstallTargetRelease??codexLinuxRemoteControlSshInstallDefaultRelease;` + - `codexLinuxRemoteControlSshInstallResolvedRelease!=null&&(codexLinuxRemoteControlSshInstallRequest.release=codexLinuxRemoteControlSshInstallResolvedRelease),` + - `${mutationVar}.mutate(codexLinuxRemoteControlSshInstallRequest,{onSuccess:({state:${mutationStateVar},error:${mutationErrorVar}})=>{${syncStateFn}(${mutationHostVar},${mutationStateVar},${mutationErrorVar})}})}`; - - const helper = [ - "let codexLinuxRemoteControlSshInstallDefaultRelease=null;", - "function codexLinuxRemoteControlValidRelease(e){return typeof e==`string`&&e.trim().length>0?e.trim():null}", - `function ${REMOTE_CONTROL_SSH_INSTALL_RELEASE_MARKER}(e){return e?.code===\`update-required\`?codexLinuxRemoteControlValidRelease(e.minRequiredVersion):null}`, - ].join(""); - - return helper + source - .replace(currentLocalVersionRegex, currentLocalVersionReplacement) - .replace(actionBuilderRegex, actionBuilderReplacement) - .replace(currentActionCallRegex, actionCallReplacement) - .replace(mutationRegex, mutationReplacement); - } - if ( - actionBuilderMatch == null || - actionCallMatch == null || - mutationMatch == null || - localVersionMatch == null + currentActionBuilderMatch == null || + currentActionCallMatch == null || + currentLocalVersionMatch == null || + currentMutationMatch == null ) { console.warn("WARN: Could not find remote-control SSH install release needles - skipping Linux install release patch"); return source; } - const [ - , - rowComponentFn, - rowPropsVar, - rowCacheVar, - rowCompilerVar, - rowCacheSize, - rowConnectionVar, - rowDisabledVar, - rowInstallPendingVar, - rowBetweenPendingAndAuth, - rowAuthenticateVar, - rowBetweenAuthAndInstall, - rowInstallVar, - rowTrailingProps, - rowFormatVar, - rowFormatFn, - rowAppServerVersionVar, - rowErrorVar, - rowInstalledVersionVar, - rowStateVar, - rowConnectionStateFn, - rowDisplayNameVar, - ] = localVersionMatch; - const localVersionReplacement = - `function ${rowComponentFn}(${rowPropsVar}){let ${rowCacheVar}=(0,${rowCompilerVar}.c)(${rowCacheSize}),` + - `{connection:${rowConnectionVar},disabled:${rowDisabledVar},installCodexPending:${rowInstallPendingVar},` + - `${rowBetweenPendingAndAuth}onAuthenticate:${rowAuthenticateVar},${rowBetweenAuthAndInstall}` + - `onInstallCodex:${rowInstallVar},${rowTrailingProps}}=${rowPropsVar},${rowFormatVar}=${rowFormatFn}(),` + - `{appServerVersion:${rowAppServerVersionVar},error:${rowErrorVar},installedCodexVersion:${rowInstalledVersionVar},state:${rowStateVar}}=${rowConnectionStateFn}(${rowConnectionVar}.hostId),` + - `{appServerVersion:codexLinuxRemoteControlSshInstallLocalVersion}=${rowConnectionStateFn}(\`local\`);` + - `codexLinuxRemoteControlSshInstallDefaultRelease=codexLinuxRemoteControlValidRelease(codexLinuxRemoteControlSshInstallLocalVersion)??codexLinuxRemoteControlSshInstallDefaultRelease;` + - `let ${rowDisplayNameVar}=${rowConnectionVar}.displayName`; - const [ , builderFn, - builderActionVar, - builderDisabledVar, - builderHostVar, - builderPendingVar, - builderAuthVar, - builderInstallVar, - builderRestartVar, - ] = actionBuilderMatch; - const builderRestartPart = builderRestartVar == null ? "" : `,onRestart:${builderRestartVar}`; + actionVar, + disabledVar, + hostVar, + pendingVar, + authenticateVar, + installVar, + reconnectVar, + restartVar, + ] = currentActionBuilderMatch; const actionBuilderReplacement = - `function ${builderFn}({action:${builderActionVar},disabled:${builderDisabledVar},hostId:${builderHostVar},installCodexPending:${builderPendingVar},` + - `installCodexRelease:codexLinuxRemoteControlSshInstallReleaseTarget,onAuthenticate:${builderAuthVar},onInstallCodex:${builderInstallVar}${builderRestartPart}}){` + - `if(${builderActionVar}==null)return null;switch(${builderActionVar}.kind){case\`install-codex\`:return{disabled:${builderDisabledVar},label:${builderActionVar}.label,loading:${builderPendingVar},` + - `loadingLabel:${builderActionVar}.loadingLabel,renderInElectronOnly:!0,tooltipText:${builderActionVar}.tooltipText,onClick:()=>${builderInstallVar}(${builderHostVar},codexLinuxRemoteControlSshInstallReleaseTarget)}`; + `function ${builderFn}({action:${actionVar},disabled:${disabledVar},hostId:${hostVar},` + + `installCodexPending:${pendingVar},installCodexRelease:codexLinuxRemoteControlSshInstallReleaseTarget,` + + `onAuthenticate:${authenticateVar},onInstallCodex:${installVar},onReconnect:${reconnectVar},onRestart:${restartVar}}){` + + `if(${actionVar}==null)return null;switch(${actionVar}.kind){case\`install-codex\`:return{` + + `disabled:${disabledVar},label:${actionVar}.label,loading:${pendingVar},loadingLabel:${actionVar}.loadingLabel,` + + `renderInElectronOnly:!0,tooltipText:${actionVar}.tooltipText,` + + `onClick:()=>${installVar}(${hostVar},codexLinuxRemoteControlSshInstallReleaseTarget)}`; const [ , - gateVar, - loadGateVar, - errorVar, - renderedActionVar, + actionFn, connectionActionVar, - renderActionFn, - disabledVar, + callDisabledVar, connectionVar, - pendingVar, - restartVar, - authenticateVar, - installVar, - ] = actionCallMatch; - const restartPart = restartVar == null ? "" : `onRestart:${restartVar},`; + callPendingVar, + callReconnectVar, + callRestartVar, + callAuthenticateVar, + callInstallVar, + ] = currentActionCallMatch; const actionCallReplacement = - `let ${gateVar}=${loadGateVar}&&(${errorVar}?.code===\`remote-codex-not-found\`||${errorVar}?.code===\`update-required\`);` + - `${renderedActionVar}=${connectionActionVar}==null||${gateVar}?null:${renderActionFn}({action:${connectionActionVar}.action,disabled:${disabledVar},hostId:${connectionVar}.hostId,` + - `installCodexPending:${pendingVar},installCodexRelease:${REMOTE_CONTROL_SSH_INSTALL_RELEASE_MARKER}(${errorVar}),${restartPart}onAuthenticate:${authenticateVar},onInstallCodex:${installVar}})`; + `${actionFn}({action:${connectionActionVar}.action,disabled:${callDisabledVar},` + + `hostId:${connectionVar}.hostId,installCodexPending:${callPendingVar},` + + `installCodexRelease:${REMOTE_CONTROL_SSH_INSTALL_RELEASE_MARKER}(codexLinuxRemoteControlSshInstallError),` + + `onReconnect:${callReconnectVar},onRestart:${callRestartVar},` + + `onAuthenticate:${callAuthenticateVar},onInstallCodex:${callInstallVar}})`; + + const [ + , + appServerVersionVar, + errorVar, + installedVersionVar, + stateVar, + connectionStateFn, + localConnectionVar, + displayNameVar, + ] = currentLocalVersionMatch; + const localVersionReplacement = + `{appServerVersion:${appServerVersionVar},error:${errorVar},` + + `installedCodexVersion:${installedVersionVar},state:${stateVar}}=` + + `${connectionStateFn}(${localConnectionVar}.hostId),` + + `{appServerVersion:codexLinuxRemoteControlSshInstallLocalVersion}=${connectionStateFn}(\`local\`),` + + `codexLinuxRemoteControlSshInstallError=${errorVar},` + + `${displayNameVar}=(codexLinuxRemoteControlSshInstallDefaultRelease=` + + `codexLinuxRemoteControlValidRelease(codexLinuxRemoteControlSshInstallLocalVersion)??` + + `codexLinuxRemoteControlSshInstallDefaultRelease,${localConnectionVar}.displayName)`; const [ , @@ -761,48 +649,61 @@ function applyLinuxRemoteControlSshInstallReleasePatch(source) { mutationStateVar, mutationErrorVar, syncStateFn, - ] = mutationMatch; + ] = currentMutationMatch; const mutationReplacement = `${mutationHandlerVar}=(${mutationHostVar},codexLinuxRemoteControlSshInstallTargetRelease)=>{` + `let codexLinuxRemoteControlSshInstallRequest={hostId:${mutationHostVar}},` + - `codexLinuxRemoteControlSshInstallResolvedRelease=codexLinuxRemoteControlSshInstallTargetRelease??codexLinuxRemoteControlSshInstallDefaultRelease;` + - `codexLinuxRemoteControlSshInstallResolvedRelease!=null&&(codexLinuxRemoteControlSshInstallRequest.release=codexLinuxRemoteControlSshInstallResolvedRelease),` + - `${mutationVar}.mutate(codexLinuxRemoteControlSshInstallRequest,{onSuccess:({state:${mutationStateVar},error:${mutationErrorVar}})=>{${syncStateFn}(${mutationHostVar},${mutationStateVar},${mutationErrorVar})}})}`; + `codexLinuxRemoteControlSshInstallResolvedRelease=` + + `codexLinuxRemoteControlSshInstallTargetRelease??codexLinuxRemoteControlSshInstallDefaultRelease;` + + `codexLinuxRemoteControlSshInstallResolvedRelease!=null&&` + + `(codexLinuxRemoteControlSshInstallRequest.release=codexLinuxRemoteControlSshInstallResolvedRelease),` + + `${mutationVar}.mutate(codexLinuxRemoteControlSshInstallRequest,{onSuccess:({state:${mutationStateVar},` + + `error:${mutationErrorVar}})=>{${syncStateFn}(${mutationHostVar},${mutationStateVar},${mutationErrorVar})}})}`; const helper = [ - "let codexLinuxRemoteControlSshInstallDefaultRelease=null;", + "let codexLinuxRemoteControlSshInstallDefaultRelease=null,codexLinuxRemoteControlSshInstallError=null;", "function codexLinuxRemoteControlValidRelease(e){return typeof e==`string`&&e.trim().length>0?e.trim():null}", `function ${REMOTE_CONTROL_SSH_INSTALL_RELEASE_MARKER}(e){return e?.code===\`update-required\`?codexLinuxRemoteControlValidRelease(e.minRequiredVersion):null}`, ].join(""); return helper + source - .replace(localVersionRegex, localVersionReplacement) - .replace(actionBuilderRegex, actionBuilderReplacement) - .replace(actionCallRegex, actionCallReplacement) - .replace(mutationRegex, mutationReplacement); + .replace(currentLocalVersionRegex, localVersionReplacement) + .replace(currentActionBuilderRegex, actionBuilderReplacement) + .replace(currentActionCallRegex, actionCallReplacement) + .replace(currentMutationRegex, mutationReplacement); } function applyLinuxRemoteControlSettingsUxPatch(source) { let patched = applyLinuxRemoteControlSshInstallReleasePatch(replaceLinuxRemoteControlCopy(source).patched); patched = applyLinuxRemoteControlSshInstallActionPatch(patched); - if (patched.includes(REMOTE_CONTROL_SETTINGS_TABS_OLD_HELPER)) { - patched = patched.replace(REMOTE_CONTROL_SETTINGS_TABS_OLD_HELPER, REMOTE_CONTROL_SETTINGS_TABS_HELPER); - } + patched = applyLinuxRemoteControlOutboundTabGatePatch(patched); - if (!patched.includes(REMOTE_CONTROL_SETTINGS_UX_MARKER)) { - const helperNeedle = /function ([A-Za-z_$][\w$]*)\(e,t\)\{return e\.displayName\.localeCompare\(t\.displayName\)\}/u; - const helperMatch = patched.match(helperNeedle); - if (helperMatch == null) { - console.warn("WARN: Could not find remote-control settings helper needle - skipping Linux remote-control settings UX patch"); - return patched; - } - patched = patched.replace(helperNeedle, `${REMOTE_CONTROL_SETTINGS_TABS_HELPER}${helperMatch[0]}`); + return patched; +} + +function applyLinuxRemoteControlOutboundTabGatePatch(source) { + if (source.includes(REMOTE_CONTROL_OUTBOUND_TAB_GATE_MARKER)) { + return source; } - patched = wrapRemoteControlTabs(patched, "control-this-mac"); - patched = wrapRemoteControlTabs(patched, "access-other-devices"); + const gateMatch = source.match(/([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(`782640499`\)/u); + if (gateMatch == null) { + return source; + } + const [, hiddenGateVar] = gateMatch; + const negatedGate = new RegExp( + `([A-Za-z_$][\\w$]*)=!${hiddenGateVar}(?=,[A-Za-z_$][\\w$]*=[A-Za-z_$][\\w$]*==null)`, + "u", + ); + const patched = source.replace( + negatedGate, + `$1=/*${REMOTE_CONTROL_OUTBOUND_TAB_GATE_MARKER}*/(typeof navigator!=\`undefined\`&&navigator.userAgent.includes(\`Linux\`)||!${hiddenGateVar})`, + ); + if (patched === source) { + console.warn("WARN: Could not find remote-control outbound tab gate consumer - skipping Linux outbound tab gate patch"); + } return patched; } @@ -1254,9 +1155,15 @@ function applyLinuxRemoteControlEnablementBridgePatch(source) { patched = applyLinuxRemoteControlEnableForHostParamsPatch(patched); - const markerIndex = patched.indexOf("[remote-connections/slingshot-gate-bridge]"); + const markerIndex = patched.indexOf("[remote-connections/gate-bridge]"); const enablementIndex = patched.indexOf("set-remote-control-connections-enabled"); if (markerIndex < 0 || enablementIndex < 0) { + if ( + !patched.includes(REMOTE_CONTROL_ENABLEMENT_BRIDGE_MARKER) || + !patched.includes(REMOTE_CONTROL_SELF_AUTO_CONNECT_MARKER) + ) { + console.warn("WARN: Could not find current remote-control enablement bridge anchors - skipping Linux remote-control bridge patch"); + } return patched; } if (Math.abs(markerIndex - enablementIndex) > 4_500) { @@ -1272,11 +1179,14 @@ function applyLinuxRemoteControlEnablementBridgePatch(source) { if (!patched.includes(REMOTE_CONTROL_ENABLEMENT_BRIDGE_MARKER)) { const currentBridgePattern = - /function ([A-Za-z_$][\w$]*)\(\)\{let ([A-Za-z_$][\w$]*)=\(0,([A-Za-z_$][\w$]*)\.c\)\(6\),\{checkGate:([A-Za-z_$][\w$]*),isLoading:([A-Za-z_$][\w$]*)\}=([A-Za-z_$][\w$]*)\(\),([A-Za-z_$][\w$]*);\2\[0\]===\4\?\7=\2\[1\]:\(\7=\4\(`1042620455`\),\2\[0\]=\4,\2\[1\]=\7\);let ([A-Za-z_$][\w$]*)=\7,([A-Za-z_$][\w$]*),([A-Za-z_$][\w$]*);return /u; + /function ([A-Za-z_$][\w$]*)\(\)\{let ([A-Za-z_$][\w$]*)=\(0,([A-Za-z_$][\w$]*)\.c\)\(6\),\{checkGate:([A-Za-z_$][\w$]*),isLoading:([A-Za-z_$][\w$]*)\}=([A-Za-z_$][\w$]*)\(\),([A-Za-z_$][\w$]*);\2\[0\]===\4\?\7=\2\[1\]:\(\7=\4\(`1042620455`\)\|\|\4\(`2055603567`\),\2\[0\]=\4,\2\[1\]=\7\);let ([A-Za-z_$][\w$]*)=\7,([A-Za-z_$][\w$]*),([A-Za-z_$][\w$]*);return /u; let patchedRegion = region.replace( currentBridgePattern, - (_needle, functionName, cacheVar, compilerVar, checkGateVar, isLoadingVar, gateHookVar, gateValueVar, enabledVar, callbackVar, depsVar) => - `function ${functionName}(){let ${cacheVar}=(0,${compilerVar}.c)(6),{checkGate:${checkGateVar},isLoading:${isLoadingVar}}=${gateHookVar}(),${gateValueVar};${cacheVar}[0]===${checkGateVar}?${gateValueVar}=${cacheVar}[1]:(${gateValueVar}=${checkGateVar}(\`1042620455\`),${cacheVar}[0]=${checkGateVar},${cacheVar}[1]=${gateValueVar});let ${enabledVar}=${gateValueVar}||/*${REMOTE_CONTROL_ENABLEMENT_BRIDGE_MARKER}*/typeof navigator!=\`undefined\`&&navigator.userAgent.includes(\`Linux\`),${callbackVar},${depsVar};return `, + (needle, _functionName, _cacheVar, _compilerVar, _checkGateVar, _isLoadingVar, _gateHookVar, gateValueVar, enabledVar) => + needle.replace( + `let ${enabledVar}=${gateValueVar},`, + `let ${enabledVar}=${gateValueVar}||/*${REMOTE_CONTROL_ENABLEMENT_BRIDGE_MARKER}*/typeof navigator!=\`undefined\`&&navigator.userAgent.includes(\`Linux\`),`, + ), ); if (patchedRegion === region) { console.warn("WARN: Could not find remote-control enablement bridge needle - skipping Linux remote-control bridge patch"); @@ -1291,10 +1201,10 @@ function applyLinuxRemoteControlEnablementBridgePatch(source) { } const selfAutoConnectReplacement = (desktopHostRequestFn, enabledVar, errorVar, loggerVar, logPrefixVar) => - `${desktopHostRequestFn}(\`set-remote-control-connections-enabled\`,{params:{enabled:${enabledVar}}}).then(async e=>{if(${enabledVar}&&typeof navigator!=\`undefined\`&&navigator.userAgent.includes(\`Linux\`)){let t=e?.remoteControlConnections??e?.sharedObjects?.remote_control_connections??e?.connections??[],n=e?.sharedObjects?.local_remote_control_installation_id??e?.local_remote_control_installation_id??e?.localRemoteControlInstallationId??e?.installationId??e?.installation_id??null;if(t.length===0)try{let e=await ${desktopHostRequestFn}(\`refresh-remote-control-connections\`,{params:{}});t=e?.remoteControlConnections??e?.sharedObjects?.remote_control_connections??e?.connections??[],n=n??e?.sharedObjects?.local_remote_control_installation_id??e?.local_remote_control_installation_id??e?.localRemoteControlInstallationId??e?.installationId??e?.installation_id??null}catch(e){${loggerVar}.warning(\`\${${logPrefixVar}} self_auto_connect_refresh_failed\`,{safe:{},sensitive:{error:e}})}if(n==null)try{let e=await ${desktopHostRequestFn}(\`get-global-state\`,{params:{key:\`electron-local-remote-control-installation-id\`}});n=e?.value??e?.state?.value??e?.globalState?.[\`electron-local-remote-control-installation-id\`]??null}catch(e){${loggerVar}.warning(\`\${${logPrefixVar}} self_auto_connect_identity_failed\`,{safe:{},sensitive:{error:e}})}let r=t.filter(e=>typeof e?.hostId==\`string\`&&e.hostId.startsWith(\`remote-control:\`)),i=new Set(r.filter(e=>n!=null&&(e.installationId??e.installation_id)===n).map(e=>e.hostId));await Promise.all(r.map(e=>${desktopHostRequestFn}(\`set-remote-connection-auto-connect\`,{params:{hostId:e.hostId,autoConnect:i.has(e.hostId)}}).catch(t=>{${loggerVar}.warning(\`\${${logPrefixVar}} self_auto_connect_failed\`,{safe:{autoConnect:i.has(e.hostId)},sensitive:{hostId:e.hostId,error:t}})})))}}/*${REMOTE_CONTROL_SELF_AUTO_CONNECT_MARKER}*/).catch(${errorVar}=>{${loggerVar}.warning(\`\${${logPrefixVar}} sync_failed\`,{safe:{enabled:${enabledVar}},sensitive:{error:${errorVar}}})})`; + `${desktopHostRequestFn}(\`set-remote-control-connections-enabled\`,{params:{enabled:${enabledVar}}}).then(async e=>{if(${enabledVar}&&typeof navigator!=\`undefined\`&&navigator.userAgent.includes(\`Linux\`)){let t=e?.remoteControlConnections??e?.sharedObjects?.remote_control_connections??e?.connections??[],n=e?.sharedObjects?.local_remote_control_installation_id??e?.local_remote_control_installation_id??e?.localRemoteControlInstallationId??e?.installationId??e?.installation_id??null;if(t.length===0)try{let e=await ${desktopHostRequestFn}(\`refresh-remote-control-connections\`,{params:{}});t=e?.remoteControlConnections??e?.sharedObjects?.remote_control_connections??e?.connections??[],n=n??e?.sharedObjects?.local_remote_control_installation_id??e?.local_remote_control_installation_id??e?.localRemoteControlInstallationId??e?.installationId??e?.installation_id??null}catch(e){${loggerVar}.warning(\`\${${logPrefixVar}} self_auto_connect_refresh_failed\`,{safe:{},sensitive:{error:e}})}if(n==null)try{let e=await ${desktopHostRequestFn}(\`get-global-state\`,{params:{key:\`electron-local-remote-control-installation-id\`}});n=e?.value??e?.state?.value??e?.globalState?.[\`electron-local-remote-control-installation-id\`]??null}catch(e){${loggerVar}.warning(\`\${${logPrefixVar}} self_auto_connect_identity_failed\`,{safe:{},sensitive:{error:e}})}let r=t.filter(e=>typeof e?.hostId==\`string\`&&e.hostId.startsWith(\`remote-control:\`)),i=new Set(r.filter(e=>n!=null&&(e.installationId??e.installation_id)===n).map(e=>e.hostId));await Promise.all(r.filter(e=>i.has(e.hostId)).map(e=>${desktopHostRequestFn}(\`set-remote-connection-auto-connect\`,{params:{hostId:e.hostId,autoConnect:!0}}).catch(t=>{${loggerVar}.warning(\`\${${logPrefixVar}} self_auto_connect_failed\`,{safe:{autoConnect:!0},sensitive:{hostId:e.hostId,error:t}})})))}}/*${REMOTE_CONTROL_SELF_AUTO_CONNECT_MARKER}*/).catch(${errorVar}=>{${loggerVar}.warning(\`\${${logPrefixVar}} sync_failed\`,{safe:{enabled:${enabledVar}},sensitive:{error:${errorVar}}})})`; const selfAutoConnectPattern = - /([A-Za-z_$][\w$]*)\(`set-remote-control-connections-enabled`,\{params:\{enabled:([A-Za-z_$][\w$]*)\}\}\)\.catch\(([A-Za-z_$][\w$]*)=>\{([A-Za-z_$][\w$]*)\.warning\(`\$\{([A-Za-z_$][\w$]*)\} sync_failed`,\{safe:\{(?:enabled|slingshotEnabled):\2\},sensitive:\{error:\3\}\}\)\}\)/u; + /([A-Za-z_$][\w$]*)\(`set-remote-control-connections-enabled`,\{params:\{enabled:([A-Za-z_$][\w$]*)\}\}\)\.catch\(([A-Za-z_$][\w$]*)=>\{([A-Za-z_$][\w$]*)\.warning\(`\$\{([A-Za-z_$][\w$]*)\} sync_failed`,\{safe:\{remoteControlConnectionsEnabled:\2\},sensitive:\{error:\3\}\}\)\}\)/u; const selfAutoConnectRegion = region.replace( selfAutoConnectPattern, (_needle, desktopHostRequestFn, enabledVar, errorVar, loggerVar, logPrefixVar) => @@ -1596,6 +1506,8 @@ module.exports = [ module.exports.applyLinuxRemoteControlDeviceKeyPatch = applyLinuxRemoteControlDeviceKeyPatch; module.exports.applyLinuxRemoteMobileAppServerRemoteControlPatch = applyLinuxRemoteMobileAppServerRemoteControlPatch; +module.exports.hasLinuxRemoteMobileLocalAppServerRemoteControlPatch = + hasLinuxRemoteMobileLocalAppServerRemoteControlPatch; module.exports.applyLinuxRemoteMobileChromeBridgePatch = applyLinuxRemoteMobileChromeBridgePatch; module.exports.applyLinuxRemoteMobileCompletedItemRecoveryPatch = applyLinuxRemoteMobileCompletedItemRecoveryPatch; diff --git a/linux-features/remote-mobile-control/stage.sh b/linux-features/remote-mobile-control/stage.sh index ae4fdd73b..244375670 100755 --- a/linux-features/remote-mobile-control/stage.sh +++ b/linux-features/remote-mobile-control/stage.sh @@ -14,7 +14,24 @@ printf '%s\n' "remote-mobile-control" > "$feature_marker" install -m 0755 "$SCRIPT_DIR/linux-features/remote-mobile-control/cold-start-hook.sh" "$cold_start_hook" if [ -d "$WORK_DIR/app-extracted/.vite/build" ] && - grep -R -q "codexLinuxRemoteMobileAppServerArgs" "$WORK_DIR/app-extracted/.vite/build" 2>/dev/null; then + node - "$WORK_DIR/app-extracted/.vite/build" "$patch_module" <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const [buildDir, patchModulePath] = process.argv.slice(2); +const { hasLinuxRemoteMobileLocalAppServerRemoteControlPatch } = require(patchModulePath); + +if (typeof hasLinuxRemoteMobileLocalAppServerRemoteControlPatch !== "function") { + process.exit(1); +} + +const found = fs.readdirSync(buildDir).some((name) => { + if (!/\.m?js$/u.test(name)) return false; + return hasLinuxRemoteMobileLocalAppServerRemoteControlPatch(fs.readFileSync(path.join(buildDir, name), "utf8")); +}); +process.exit(found ? 0 : 1); +NODE +then rm -f "$desktop_remote_control_marker" printf '%s\n' "version=1" "owner=desktop" > "$desktop_remote_control_marker" else diff --git a/linux-features/remote-mobile-control/test.js b/linux-features/remote-mobile-control/test.js index 2ef0afa25..c89d58be4 100644 --- a/linux-features/remote-mobile-control/test.js +++ b/linux-features/remote-mobile-control/test.js @@ -32,6 +32,7 @@ const { applyLinuxRemoteControlEnablementBridgePatch, applyLinuxRemoteMobileActiveStatusPatch, applyLinuxRemoteMobileAppServerRemoteControlPatch, + hasLinuxRemoteMobileLocalAppServerRemoteControlPatch, applyLinuxRemoteMobileChromeBridgePatch, applyLinuxRemoteMobileCompletedItemRecoveryPatch, applyLinuxRemoteMobileConversationHydrationPatch, @@ -216,18 +217,9 @@ function syntheticRemoteConnectionVisibilityBundle() { function syntheticAppMainFeatureSyncBundle() { return [ - "var GF=[`apps`,`memories`,`plugins`,`tool_call_mcp_elicitation`,`tool_suggest`],vI=`remote_plugin`;", - "function KF(){let e=(0,Z.c)(6),t=K(G),[n]=ts(`statsig_default_enable_features`),r=Lc(),i=Io(),a,o;", - "return e[0]!==r?(a=()=>{let r=qF(n,!0);qn(`set-experimental-feature-enablement-for-host`,{hostId:t,enablement:r}).catch(n=>{q.error(`Failed to sync experimental feature enablement`,{sensitive:{error:n}})})},o=[r],e[0]=r,e[1]=a,e[2]=o):(a=e[1],o=e[2]),null}", - "function qF(e,t){let n={};for(let r of GF){let i=e[r];i!=null&&(n[r]=i)}return n[vI]=t,n}", - ].join(""); -} - -function syntheticCurrentAppMainFeatureSyncBundle() { - return [ - "var gI=[`apps`,`memories`,`plugins`,`tool_call_mcp_elicitation`,`tool_suggest`],vI=`remote_plugin`,Ir=`local-host`,Vt=`hosts`,Ro=`features-query`,G={error(){}};", - "function yI(){let e=new Map,o=()=>{if(ln(`set-default-feature-overrides`,{overrides:features??null}),features==null)return;let i=bI(features,!0),o=store.get(Ir),s=new Set(store.get(Vt).filter(e=>e===o||xn(store,e).state===`connected`));for(let t of e.keys())s.has(t)||e.delete(t);let c=store.get(Vt).filter(e=>s.has(e)).flatMap(t=>(0,dv.default)(e.get(t),i)?[]:(e.set(t,i),[ln(`set-experimental-feature-enablement-for-host`,{hostId:t,enablement:i}).catch(n=>{e.delete(t),G.error(`Failed to sync experimental feature enablement`,{safe:{hostId:t},sensitive:{error:n}})})]));c.length!==0&&Promise.all(c).then(()=>{query.invalidateQueries({queryKey:Ro})})};return o()}", - "function bI(e,t){let n={};for(let t of gI){let r=e[t];r!=null&&(n[t]=r)}return n[vI]=t,n}", + "var gI=[`apps_mcp_path_override`,`auth_elicitation`,`tool_suggest`],vI=`remote_plugin`,Ir=`local-host`,Vt=`hosts`,Ro=`features-query`,remotePlugin=!0,mcp=!0,G={error(){}};", + "function yI(){let e=new Map,o=()=>{if(ln(`set-default-feature-overrides`,{overrides:features??null}),features==null)return;let i=bI(features,remotePlugin,mcp),a=store.get(Ir),s=new Set(store.get(Vt).filter(e=>e===a||xn(store,e).state===`connected`));for(let t of e.keys())s.has(t)||e.delete(t);let c=Array.from(s).flatMap(t=>(0,dv.default)(e.get(t),i)?[]:(e.set(t,i),[ln(`set-experimental-feature-enablement-for-host`,{hostId:t,enablement:i}).catch(n=>{e.delete(t),G.error(`Failed to sync experimental feature enablement`,{safe:{hostId:t},sensitive:{error:n}})})]));c.length!==0&&Promise.all(c).then(()=>{query.invalidateQueries({queryKey:Ro})})};return o()}", + "function bI(e,t,n){let r={memories:!1};for(let t of gI){let n=e[t];n!=null&&(r[t]=n)}return r.mcp_2026_07_28=n,r[vI]=t,r}", ].join(""); } @@ -265,23 +257,11 @@ function syntheticMobileSetupDialogCopyBundle() { ].join(""); } -function syntheticSettingsBundle() { - return [ - "const o=`linux`,Q={jsx(){},jsxs(){}};", - "tabs:[{key:`control-this-mac`,name:o===`windows`?(0,Q.jsx)(z,{id:`settings.remoteConnections.tabs.controlThisMac.windows`,defaultMessage:`Control this PC`,description:`Tab label for settings that let other devices control this Windows device`}):(0,Q.jsx)(z,{id:`settings.remoteConnections.tabs.controlThisMac`,defaultMessage:`Control this Mac`,description:`Tab label for settings that let other devices control this computer`})},{key:`access-other-devices`,name:(0,Q.jsx)(z,{id:`settings.remoteConnections.tabs.accessOtherDevices`,defaultMessage:`Control other devices`,description:`Tab label for settings that let this computer control other devices`})},{key:`ssh`,name:(0,Q.jsx)(z,{id:`settings.remoteConnections.tabs.ssh`,defaultMessage:`SSH`,description:`Tab label for SSH remote connections`})}],selectedKey:je,variant:`underline`,onSelect:se}", - "tabs:[{key:`access-other-devices`,name:(0,Q.jsx)(z,{id:`settings.remoteConnections.tabs.accessOtherDevices`,defaultMessage:`Control other devices`,description:`Tab label for settings that let this computer control other devices`})},{key:`ssh`,name:(0,Q.jsx)(z,{id:`settings.remoteConnections.tabs.ssh`,defaultMessage:`SSH`,description:`Tab label for SSH remote connections`})}],selectedKey:je,variant:`underline`,onSelect:se}", - "const a=`Control this Mac from your phone or other device`,b=`Add device to control this Mac remotely`,c=`Devices that can control this Mac`,d=`Keep Mac awake`,e=`Allow this Mac to be discovered and controlled`,f=`Control other devices from this Mac`,g=`Authorize this Mac to control other devices signed in to your ChatGPT account`,h=`Devices you can control from this Mac`;", - "let xe=!Pe&&(Te?.code===`remote-codex-not-found`||Te?.code===`update-required`);Ce=Ae==null||xe?null:Re({action:Ae.action,connection:Ee});", - "function nr(e,t){return e.displayName.localeCompare(t.displayName)}", - "function rr({selectedConnectionsTab:e,showControlThisMacTab:t,showRemoteControlConnectionsSection:n,showTabbedSshPage:r}){return n?e===`control-this-mac`&&!t||e===`ssh`&&!r?`access-other-devices`:e:`ssh`}", - ].join(""); -} - function syntheticSshInstallSettingsBundle() { return [ - "function pn({action:e,disabled:t,hostId:n,installCodexPending:r,onAuthenticate:i,onInstallCodex:a}){if(e==null)return null;switch(e.kind){case`install-codex`:return{disabled:t,label:e.label,loading:r,loadingLabel:e.loadingLabel,renderInElectronOnly:!0,tooltipText:e.tooltipText,onClick:()=>a(n)};case`login`:return{label:e.label,onClick:()=>i(n)};case`settings`:return null}}", + "function pn({action:e,disabled:t,hostId:n,installCodexPending:r,onAuthenticate:i,onInstallCodex:a,onReconnect:o,onRestart:s}){if(e==null)return null;switch(e.kind){case`install-codex`:return{disabled:t,label:e.label,loading:r,loadingLabel:e.loadingLabel,renderInElectronOnly:!0,tooltipText:e.tooltipText,onClick:()=>a(n)};case`login`:return{label:e.label,onClick:()=>i(n)};case`restart`:return{label:e.label,onClick:s};case`reconnect`:return{label:e.label,onClick:o};case`settings`:return null}}", "let et=R(`install-remote-codex`),vt=(e,t,n)=>{globalThis.__states.push({hostId:e,state:t,error:n})},bt=e=>{et.mutate({hostId:e},{onSuccess:({state:t,error:n})=>{vt(e,t,n)}})};", - "function un(e){let t=(0,$.c)(86),{connection:n,disabled:r,installCodexPending:i,onAuthenticate:a,onEdit:o,onInstallCodex:s,onLogoutConnection:c,onRemove:l,onShowDetails:u,onToggleConnection:d}=e,f=ee(),{appServerVersion:p,error:m,installedCodexVersion:h,state:g}=De(n.hostId),_=n.displayName,v;let T=w,E=oe(`2153867414`),D,O,k,A,j,M;if(t[8]!==p||t[9]!==n.hostId||t[10]!==r||t[11]!==m||t[12]!==i||t[13]!==h||t[14]!==f||t[15]!==a||t[16]!==s||t[17]!==E||t[18]!==g){k=fn({appServerVersion:p,installedCodexVersion:h,state:g}),D=g===`connected`||m?.code===`login-required`||m?.code===`update-required`||m?.code===`restart-required`;let{statusError:e,isRestartAvailableNotice:o,statusState:c}=dn({error:m,restartAvailableNotice:k,state:g});A=e,O=o,j=c==null?null:Ne(f,{canLogin:!0,error:A,state:c,surface:`connections-row`});let l=!E&&(A?.code===`remote-codex-not-found`||A?.code===`update-required`);M=j==null||l?null:pn({action:j.action,disabled:r,hostId:n.hostId,installCodexPending:i,onAuthenticate:a,onInstallCodex:s}),t[8]=p,t[9]=n.hostId,t[10]=r,t[11]=m,t[12]=i,t[13]=h,t[14]=f,t[15]=a,t[16]=s,t[17]=E,t[18]=g,t[19]=D,t[20]=O,t[21]=k,t[22]=A,t[23]=j,t[24]=M}else D=t[19],O=t[20],k=t[21],A=t[22],j=t[23],M=t[24];return M}", + "function un(e){let{connection:n,disabled:r,installCodexPending:i,onAuthenticate:a,onInstallCodex:s,onReconnect:c,onRestart:l}=e,{appServerVersion:p,error:m,installedCodexVersion:h,state:g}=De(n.hostId),_=n.displayName,j=Ne(),E=!1;let D=(n.kind||!E)&&(m?.code===`remote-codex-not-found`||m?.code===`update-required`),M;return M=j==null||D?null:pn({action:j.action,disabled:r,hostId:n.hostId,installCodexPending:i,onReconnect:c,onRestart:l,onAuthenticate:a,onInstallCodex:s}),M}", "function nr(e,t){return e.displayName.localeCompare(t.displayName)}", ].join(""); } @@ -295,18 +275,14 @@ function syntheticSettingsRefreshBundle() { ].join(""); } -function syntheticAppServerLaunchBundle() { +function syntheticLegacyWslAppServerLaunchBundle() { return "var Uz=`Codex Desktop`,Wz=[`-c`,`features.code_mode_host=true`,`app-server`,`--analytics-default-enabled`],Gz={appServerVersion:`current`};"; } -function syntheticCurrentSettingsBundle() { +function syntheticCurrentLocalAppServerLaunchBundle() { return [ - "const i=`linux`,Q={jsx(){},jsxs(){}};", - "tabs:[{key:`control-this-mac`,name:i===`windows`?(0,Q.jsx)(N,{id:`settings.remoteConnections.tabs.controlThisMac.windows`,defaultMessage:`Control this PC`,description:`Tab label for settings that let other devices control this Windows device`}):(0,Q.jsx)(N,{id:`settings.remoteConnections.tabs.controlThisMac`,defaultMessage:`Control this Mac`,description:`Tab label for settings that let other devices control this computer`})},{key:`access-other-devices`,name:(0,Q.jsx)(N,{id:`settings.remoteConnections.tabs.accessOtherDevices`,defaultMessage:`Control other devices`,description:`Tab label for settings that let this computer control other devices`})},{key:`ssh`,name:(0,Q.jsx)(N,{id:`settings.remoteConnections.tabs.ssh`,defaultMessage:`SSH`,description:`Tab label for SSH remote connections`})}],selectedKey:Pe,variant:`underline`,onSelect:le}", - "tabs:[{key:`access-other-devices`,name:(0,Q.jsx)(N,{id:`settings.remoteConnections.tabs.accessOtherDevices`,defaultMessage:`Control other devices`,description:`Tab label for settings that let this computer control other devices`})},{key:`ssh`,name:(0,Q.jsx)(N,{id:`settings.remoteConnections.tabs.ssh`,defaultMessage:`SSH`,description:`Tab label for SSH remote connections`})}],selectedKey:Pe,variant:`underline`,onSelect:le}", - "const a=`Control this Mac from your phone or other device`,b=`Add device to control this Mac remotely`,c=`Devices that can control this Mac`,d=`Keep Mac awake`,e=`Allow this Mac to be discovered and controlled`,f=`Control other devices from this Mac`,g=`Authorize this Mac to control other devices signed in to your ChatGPT account`,h=`Devices you can control from this Mac`;", - "function $n(e,t){return e.displayName.localeCompare(t.displayName)}", - "function er({selectedConnectionsTab:e,showControlThisMacTab:t,showRemoteControlConnectionsSection:n,showTabbedSshPage:r}){return n?e===`control-this-mac`&&!t||e===`ssh`&&!r?`access-other-devices`:e:`ssh`}", + "var Fz=`Codex Desktop`,Iz=[`-c`,`features.code_mode_host=true`],Lz=[{configKey:`chatgpt_base_url`,envVar:`CODEX_APP_SERVER_CHATGPT_BASE_URL`},{configKey:`openai_base_url`,envVar:`CODEX_APP_SERVER_OPENAI_BASE_URL`}];", + "function uB(){return[...Iz,...Lz.flatMap(({configKey:e,envVar:t})=>{let n=process.env[t]?.trim();return n==null||n===``?[]:[`-c`,`${e}=${JSON.stringify(n)}`]}),`app-server`,`--analytics-default-enabled`]}", ].join(""); } @@ -413,8 +389,8 @@ function syntheticAppMainActiveStatusBundle() { function syntheticAppMainEnablementBridgeBundle() { return [ - "function OF(){let e=(0,Z.c)(6),{checkGate:t,isLoading:n}=sc(),r;e[0]===t?r=e[1]:(r=t(`1042620455`),e[0]=t,e[1]=r);let i=r,a,o;return e[2]!==n||e[3]!==i?(a=()=>{n||$o(`set-remote-control-connections-enabled`,{params:{enabled:i}}).catch(e=>{q.warning(`${DF} sync_failed`,{safe:{slingshotEnabled:i},sensitive:{error:e}})})},o=[n,i],e[2]=n,e[3]=i,e[4]=a,e[5]=o):(a=e[4],o=e[5]),(0,Q.useEffect)(a,o),null}", - "var DF=`[remote-connections/slingshot-gate-bridge]`;", + "function OF(){let e=(0,Z.c)(6),{checkGate:t,isLoading:n}=sc(),r;e[0]===t?r=e[1]:(r=t(`1042620455`)||t(`2055603567`),e[0]=t,e[1]=r);let i=r,a,o;return e[2]!==n||e[3]!==i?(a=()=>{n||$o(`set-remote-control-connections-enabled`,{params:{enabled:i}}).catch(e=>{q.warning(`${DF} sync_failed`,{safe:{remoteControlConnectionsEnabled:i},sensitive:{error:e}})})},o=[n,i],e[2]=n,e[3]=i,e[4]=a,e[5]=o):(a=e[4],o=e[5]),(0,Q.useEffect)(a,o),null}", + "var DF=`[remote-connections/gate-bridge]`;", ].join(""); } @@ -566,7 +542,10 @@ test("remote mobile stage hook is idempotent and stages its markers and executab }; fs.mkdirSync(buildDir, { recursive: true }); - fs.writeFileSync(path.join(buildDir, "main.js"), "globalThis.codexLinuxRemoteMobileAppServerArgs=true;"); + fs.writeFileSync( + path.join(buildDir, "main.js"), + applyLinuxRemoteMobileAppServerRemoteControlPatch(syntheticCurrentLocalAppServerLaunchBundle()), + ); const first = runStageHook(env); const second = runStageHook(env); @@ -585,7 +564,7 @@ test("remote mobile stage hook is idempotent and stages its markers and executab } }); -test("remote mobile stage hook removes a stale ownership marker when the patch marker is missing", () => { +test("remote mobile stage hook removes a stale ownership marker when only the legacy WSL patch marker exists", () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-remote-mobile-stage-")); try { const installDir = path.join(tempRoot, "package", "opt", "codex-desktop"); @@ -595,7 +574,7 @@ test("remote mobile stage hook removes a stale ownership marker when the patch m fs.mkdirSync(buildDir, { recursive: true }); fs.mkdirSync(path.dirname(marker), { recursive: true }); - fs.writeFileSync(path.join(buildDir, "main.js"), "globalThis.someOtherPatch=true;"); + fs.writeFileSync(path.join(buildDir, "main.js"), "globalThis.codexLinuxRemoteMobileAppServerArgs=true;"); fs.writeFileSync(marker, "stale\n"); const result = runStageHook({ @@ -614,6 +593,33 @@ test("remote mobile stage hook removes a stale ownership marker when the patch m } }); +test("remote mobile stage hook rejects an incomplete local Desktop patch marker", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-remote-mobile-stage-")); + try { + const installDir = path.join(tempRoot, "package", "opt", "codex-desktop"); + const workDir = path.join(tempRoot, "work"); + const buildDir = path.join(workDir, "app-extracted", ".vite", "build"); + const marker = path.join(installDir, ".codex-linux", "desktop-app-server-remote-control-enabled"); + + fs.mkdirSync(buildDir, { recursive: true }); + fs.writeFileSync(path.join(buildDir, "src.js"), "globalThis.codexLinuxRemoteMobileLocalAppServerArgs=true;"); + + const result = runStageHook({ + ARCH: "x64", + CODEX_UPSTREAM_APP_DIR: path.join(tempRoot, "upstream-app"), + INSTALL_DIR: installDir, + SCRIPT_DIR: REPO_ROOT, + WORK_DIR: workDir, + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(marker), false); + assert.match(result.stderr, /Desktop app-server remote-control marker not found/); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + test("remote mobile stage hook replaces an ownership marker symlink without following it", () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-remote-mobile-stage-")); try { @@ -625,7 +631,10 @@ test("remote mobile stage hook replaces an ownership marker symlink without foll fs.mkdirSync(buildDir, { recursive: true }); fs.mkdirSync(path.dirname(marker), { recursive: true }); - fs.writeFileSync(path.join(buildDir, "main.js"), "globalThis.codexLinuxRemoteMobileAppServerArgs=true;"); + fs.writeFileSync( + path.join(buildDir, "main.js"), + applyLinuxRemoteMobileAppServerRemoteControlPatch(syntheticCurrentLocalAppServerLaunchBundle()), + ); fs.writeFileSync(target, "preserved\n"); fs.symlinkSync(target, marker); @@ -1234,29 +1243,88 @@ test("Linux remote-control client recovery handles bare missing key material err }); test("Linux remote mobile app-server launch enables remote control on the Desktop app-server", () => { - const source = syntheticAppServerLaunchBundle(); + const source = syntheticCurrentLocalAppServerLaunchBundle(); const patched = applyLinuxRemoteMobileAppServerRemoteControlPatch(source); assert.notEqual(patched, source); - assert.match(patched, /codexLinuxRemoteMobileAppServerArgs/); + assert.match(patched, /codexLinuxRemoteMobileLocalAppServerArgs/); assert.match( patched, - /process\.platform===`linux`\?\[`-c`,`features\.code_mode_host=true`,`app-server`,`--remote-control`,`--analytics-default-enabled`\]:\[`-c`,`features\.code_mode_host=true`,`app-server`,`--analytics-default-enabled`\]/, + /process\.platform===`linux`\?\[`--remote-control`\]:\[\]/, ); - assert.doesNotMatch( + assert.match( patched, - /Wz=\[`-c`,`features\.code_mode_host=true`,`app-server`,`--analytics-default-enabled`\]/, + /return\[\.\.\.Iz,\.\.\.Lz\.flatMap\(.+`app-server`,\.\.\.codexLinuxRemoteMobileLocalAppServerArgs\(\),`--analytics-default-enabled`\]\}/, ); - assert.match(patched, /Wz=codexLinuxRemoteMobileAppServerArgs\(\)/); assert.equal(applyLinuxRemoteMobileAppServerRemoteControlPatch(patched), patched); + assert.equal(hasLinuxRemoteMobileLocalAppServerRemoteControlPatch(patched), true); +}); + +test("Linux remote mobile app-server launch rejects an incomplete local patch marker", () => { + const source = "globalThis.codexLinuxRemoteMobileLocalAppServerArgs=true;"; + + assert.equal(applyLinuxRemoteMobileAppServerRemoteControlPatch(source), source); + assert.equal(hasLinuxRemoteMobileLocalAppServerRemoteControlPatch(source), false); +}); + +test("Linux remote mobile app-server launch does not treat the legacy WSL path as the Desktop transport", () => { + const source = syntheticLegacyWslAppServerLaunchBundle(); + + assert.equal(applyLinuxRemoteMobileAppServerRemoteControlPatch(source), source); +}); + +test("Linux remote mobile extracted-app patch modifies only the local Desktop transport", () => { + const tempApp = fs.mkdtempSync(path.join(os.tmpdir(), "codex-remote-mobile-local-transport-")); + try { + const buildDir = path.join(tempApp, ".vite", "build"); + const wslFile = path.join(buildDir, "main-test.js"); + const localFile = path.join(buildDir, "src-test.js"); + fs.mkdirSync(buildDir, { recursive: true }); + fs.writeFileSync(wslFile, syntheticLegacyWslAppServerLaunchBundle()); + fs.writeFileSync(localFile, syntheticCurrentLocalAppServerLaunchBundle()); + + const descriptor = remoteMobilePatchDescriptors.find( + ({ id }) => id === "linux-remote-mobile-app-server-remote-control", + ); + const result = descriptor.apply(tempApp); + + assert.deepEqual(result, { matched: 1, changed: 1 }); + assert.equal(fs.readFileSync(wslFile, "utf8"), syntheticLegacyWslAppServerLaunchBundle()); + assert.match(fs.readFileSync(localFile, "utf8"), /codexLinuxRemoteMobileLocalAppServerArgs/); + } finally { + fs.rmSync(tempApp, { recursive: true, force: true }); + } +}); + +test("Linux remote mobile extracted-app patch rejects WSL-only and partial local matches", () => { + const tempApp = fs.mkdtempSync(path.join(os.tmpdir(), "codex-remote-mobile-partial-transport-")); + try { + const buildDir = path.join(tempApp, ".vite", "build"); + fs.mkdirSync(buildDir, { recursive: true }); + fs.writeFileSync(path.join(buildDir, "main-test.js"), syntheticLegacyWslAppServerLaunchBundle()); + fs.writeFileSync(path.join(buildDir, "src-test.js"), "globalThis.codexLinuxRemoteMobileLocalAppServerArgs=true;"); + + const descriptor = remoteMobilePatchDescriptors.find( + ({ id }) => id === "linux-remote-mobile-app-server-remote-control", + ); + const result = descriptor.apply(tempApp); + + assert.deepEqual(result, { + matched: 0, + changed: 0, + reason: "no local Desktop app-server base args found", + }); + } finally { + fs.rmSync(tempApp, { recursive: true, force: true }); + } }); test("Linux remote mobile app-server launch keeps a leading use strict directive first", () => { - const source = `"use strict";${syntheticAppServerLaunchBundle()}`; + const source = `"use strict";${syntheticCurrentLocalAppServerLaunchBundle()}`; const patched = applyLinuxRemoteMobileAppServerRemoteControlPatch(source); assert.notEqual(patched, source); - assert.match(patched, /^"use strict";function codexLinuxRemoteMobileAppServerArgs/); + assert.match(patched, /^"use strict";function codexLinuxRemoteMobileLocalAppServerArgs/); assert.equal(applyLinuxRemoteMobileAppServerRemoteControlPatch(patched), patched); }); @@ -1453,17 +1521,18 @@ test("Linux remote-control feature sync forces remote_control and preserves remo const patched = applyLinuxRemoteControlFeatureSyncPatch(source); assert.notEqual(patched, source); - assert.match(patched, /\.remote_control=!0/); - assert.match(patched, /n\[vI\]=t/); + assert.match(patched, /r\[vI\]=t/); assert.match(patched, /codexLinuxRemoteControlFeatureSyncEnabled/); - assert.match(patched, /navigator\.userAgent\.includes\(`Linux`\)\?\(/); - assert.match(patched, /\?\(codexLinuxRemoteControlFeatureSyncEnabled\(arguments\[2\],arguments\[3\]\)&&\(n\.remote_control=!0\),n\[vI\]=t,n\)/); - assert.match(patched, /:\(n\[vI\]=t,n\)\}/); + assert.match(patched, /codexLinuxRemoteControlFeatureSyncEnabled\(i,a,t\)/); + assert.match( + patched, + /navigator\.userAgent\.includes\(`Linux`\)&&t===n\?\{\.\.\.e,remote_control:!0\}:e/, + ); assert.equal(applyLinuxRemoteControlFeatureSyncPatch(patched), patched); }); test("Linux remote-control feature sync does not advertise SSH hosts to mobile", async () => { - const source = syntheticCurrentAppMainFeatureSyncBundle(); + const source = syntheticAppMainFeatureSyncBundle(); const patched = applyLinuxRemoteControlFeatureSyncPatch(source); assert.notEqual(patched, source); @@ -1565,23 +1634,16 @@ test("Linux mobile setup dialog copy does not refer to Mac-only setup", () => { assert.equal(applyLinuxRemoteControlCopyPatch(patched), patched); }); -test("Linux remote-control settings UX patch keeps outbound tab visible and removes Mac copy", () => { - const source = syntheticSettingsBundle(); +test("Linux remote-control settings UX patch applies settings copy and SSH install actions", () => { + const source = syntheticRemoteConnectionsSettingsCopyBundle() + syntheticSshInstallSettingsBundle(); const patched = applyLinuxRemoteControlSettingsUxPatch(source); assert.notEqual(patched, source); - assert.match(patched, /codexLinuxRemoteControlSettingsTabs/); assert.match(patched, /codexLinuxRemoteControlSshInstallActions/); - assert.match(patched, /function codexLinuxRemoteControlSettingsTabs\(e\)\{return e\}/); - assert.doesNotMatch(patched, /e\.filter\(e=>e\.key!==`access-other-devices`\)/); - assert.match(patched, /key:`access-other-devices`/); - assert.match(patched, /Ce=Ae==null\?null:Re\(\{action:Ae\.action/); assert.match(patched, /Control this Linux desktop/); - assert.match(patched, /Control this Linux desktop from your phone or other device/); - assert.match(patched, /Add device to control this Linux desktop remotely/); assert.match(patched, /Devices that can control this Linux desktop/); - assert.match(patched, /Keep Linux desktop awake/); - assert.match(patched, /Allow this Linux desktop to be discovered and controlled/); + assert.match(patched, /Keep this Linux desktop awake/); + assert.match(patched, /SSH connections from this Linux desktop/); assert.doesNotMatch(patched, /Control this Mac/); assert.doesNotMatch(patched, /this Mac/); assert.equal(applyLinuxRemoteControlSettingsUxPatch(patched), patched); @@ -1688,18 +1750,37 @@ test("Linux remote-control SSH install prefers update-required minRequiredVersio assert.deepEqual(JSON.parse(JSON.stringify(context.__mutations)), [{ hostId: "remote-ssh:dev", release: "0.137.0" }]); }); -test("Linux remote-control settings UX patch handles current minified helper names", () => { - const source = syntheticCurrentSettingsBundle(); +test("Linux remote-control settings UX patch bypasses outbound tab hide gate on Linux", () => { + const source = [ + "function $n(e,t){return e.displayName.localeCompare(t.displayName)}", + "function Uo(){let l=Pe(`782640499`),u=Pe(on),z=Ge(),B=!l,Se=f==null,Ce=p==null,Ke=z&&!0,qe=B&&(z||!1),Je=z&&!0;return qe}", + ].join(""); const patched = applyLinuxRemoteControlSettingsUxPatch(source); assert.notEqual(patched, source); - assert.match(patched, /codexLinuxRemoteControlSettingsTabs/); - assert.match(patched, /function codexLinuxRemoteControlSettingsTabs\(e\)\{return e\}/); - assert.match(patched, /tabs:codexLinuxRemoteControlSettingsTabs/); - assert.match(patched, /key:`access-other-devices`/); - assert.match(patched, /Control this Linux desktop/); - assert.doesNotMatch(patched, /Control this Mac/); + assert.match(patched, /codexLinuxRemoteControlOutboundTabGate/); + assert.match(patched, /B=\/\*codexLinuxRemoteControlOutboundTabGate\*\/\(typeof navigator!=`undefined`&&navigator\.userAgent\.includes\(`Linux`\)\|\|!l\)/); + assert.doesNotMatch(patched, /B=!l/); assert.equal(applyLinuxRemoteControlSettingsUxPatch(patched), patched); + + const context = { + Ge: () => true, + Pe: () => true, + f: [], + navigator: { userAgent: "Linux" }, + on: "gate", + p: [], + }; + vm.runInNewContext(`${patched};globalThis.__visible=Uo();`, context); + assert.equal(context.__visible, true); +}); + +test("Linux remote-control settings UX patch warns when outbound tab gate consumer drifts", () => { + const source = "function Uo(){let l=Pe(`782640499`),u=Pe(on),z=Ge(),B=l,Se=f==null;return B&&z}"; + const { result, warnings } = captureWarnings(() => applyLinuxRemoteControlSettingsUxPatch(source)); + + assert.equal(result, source); + assert.ok(warnings.some((warning) => warning.includes("outbound tab gate consumer"))); }); test("Linux remote-connections refresh patch shortens polling and refreshes on resume signals", () => { @@ -2558,14 +2639,14 @@ test("Linux remote-control status wait ignores matching atom initializer decoys" }); test("Linux remote-control settings UX patch warns when SSH release handling drifts after partial patching", () => { - const source = (syntheticSettingsBundle() + syntheticSshInstallSettingsBundle()).replace( + const source = (syntheticRemoteConnectionsSettingsCopyBundle() + syntheticSshInstallSettingsBundle()).replace( "installedCodexVersion:h", "installedVersion:h", ); const { result, warnings } = captureWarnings(() => applyLinuxRemoteControlSettingsUxPatch(source)); assert.notEqual(result, source); - assert.match(result, /codexLinuxRemoteControlSettingsTabs/); + assert.match(result, /Control this Linux desktop/); assert.ok(warnings.some((warning) => warning.includes("SSH install release needles"))); }); @@ -2578,7 +2659,7 @@ test("remote mobile feature patch report records feature metadata and partial wa fs.mkdirSync(buildDir, { recursive: true }); fs.mkdirSync(assetsDir, { recursive: true }); fs.writeFileSync(path.join(buildDir, "main.js"), syntheticCurrentMainBundle()); - fs.writeFileSync(path.join(buildDir, "src-test.js"), syntheticAppServerLaunchBundle()); + fs.writeFileSync(path.join(buildDir, "src-test.js"), syntheticCurrentLocalAppServerLaunchBundle()); fs.writeFileSync(path.join(tempApp, "package.json"), JSON.stringify({ name: "codex" })); fs.writeFileSync(path.join(assetsDir, "app-test.png"), ""); fs.writeFileSync( @@ -2593,7 +2674,8 @@ test("remote mobile feature patch report records feature metadata and partial wa ); fs.appendFileSync( path.join(assetsDir, CURRENT_APP_MAIN_PAGE_ASSET), - syntheticAppMainFeatureSyncBundle() + syntheticAppMainEnablementBridgeBundle(), + syntheticAppMainFeatureSyncBundle() + + "function OF(){return $o(`set-remote-control-connections-enabled`,{params:{enabled:true}})}", ); fs.appendFileSync( path.join(assetsDir, CURRENT_REMOTE_LOAD_GATE_ASSET), @@ -2609,7 +2691,7 @@ test("remote mobile feature patch report records feature metadata and partial wa ); fs.writeFileSync( path.join(assetsDir, "remote-connections-settings-test.js"), - (syntheticSettingsBundle() + syntheticSshInstallSettingsBundle()).replace( + (syntheticRemoteConnectionsSettingsCopyBundle() + syntheticSshInstallSettingsBundle()).replace( "installedCodexVersion:h", "installedVersion:h", ), @@ -2642,6 +2724,19 @@ test("remote mobile feature patch report records feature metadata and partial wa assert.equal(settingsPatch.status, "applied-with-warnings"); assert.ok(settingsPatch.warnings.some((warning) => warning.includes("SSH install release needles"))); + const enablementBridgePatch = report.patches.find( + (patch) => + patch.name === + "feature:remote-mobile-control:linux-remote-control-enablement-bridge", + ); + assert.equal(enablementBridgePatch.status, "skipped-optional"); + assert.notEqual(enablementBridgePatch.status, "already-applied"); + assert.ok( + enablementBridgePatch.warnings.some((warning) => + warning.includes("current remote-control enablement bridge anchors"), + ), + ); + assert.equal( report.patches.some((patch) => patch.name === "linux-app-server-conversation-hydration"), false, @@ -2720,7 +2815,7 @@ test("Linux remote-control enablement bridge loads remote-control clients on Lin const calls = []; const context = { - DF: "[remote-connections/slingshot-gate-bridge]", + DF: "[remote-connections/gate-bridge]", navigator: { userAgent: "X11; Linux x86_64" }, q: { warning() {} }, Q: { useEffect(callback) { callback(); } }, @@ -2740,7 +2835,7 @@ test("Linux remote-control enablement bridge loads remote-control clients on Lin test("Linux remote-control enablement bridge rejects distant anchors", () => { const source = [ - "var DF=`[remote-connections/slingshot-gate-bridge]`;", + "var DF=`[remote-connections/gate-bridge]`;", "x".repeat(4_501), "function OF(){return $o(`set-remote-control-connections-enabled`,{params:{enabled:true}})}", ].join(""); @@ -2752,6 +2847,74 @@ test("Linux remote-control enablement bridge rejects distant anchors", () => { assert.ok(warnings.some((warning) => warning.includes("anchors are too far apart"))); }); +test("Linux remote-control enablement bridge reports current anchor drift instead of false success", () => { + const source = + "function OF(){return $o(`set-remote-control-connections-enabled`,{params:{enabled:true}})}"; + const { result, warnings } = captureWarnings(() => + applyLinuxRemoteControlEnablementBridgePatch(source), + ); + + assert.equal(result, source); + assert.ok( + warnings.some((warning) => + warning.includes("current remote-control enablement bridge anchors"), + ), + ); +}); + +test("Linux remote-control enablement bridge preserves the current second gate off Linux", () => { + const patched = applyLinuxRemoteControlEnablementBridgePatch( + syntheticAppMainEnablementBridgeBundle(), + ); + const calls = []; + const checkedGates = []; + const context = { + DF: "[remote-connections/gate-bridge]", + navigator: { userAgent: "Macintosh" }, + q: { warning() {} }, + Q: { useEffect(callback) { callback(); } }, + sc: () => ({ + checkGate(gate) { + checkedGates.push(gate); + return gate === "2055603567"; + }, + isLoading: false, + }), + Z: { c: () => [] }, + $o: (method, { params }) => { + calls.push({ method, params }); + return Promise.resolve(); + }, + }; + vm.runInNewContext(`${patched};OF();`, context); + + assert.deepEqual(checkedGates, ["1042620455", "2055603567"]); + assert.equal(calls.length, 1); + assert.equal(calls[0].params.enabled, true); +}); + +test("Linux remote-control enablement bridge waits for current gates to load", () => { + const patched = applyLinuxRemoteControlEnablementBridgePatch( + syntheticAppMainEnablementBridgeBundle(), + ); + const calls = []; + const context = { + DF: "[remote-connections/gate-bridge]", + navigator: { userAgent: "X11; Linux x86_64" }, + q: { warning() {} }, + Q: { useEffect(callback) { callback(); } }, + sc: () => ({ checkGate: () => false, isLoading: true }), + Z: { c: () => [] }, + $o: (method, { params }) => { + calls.push({ method, params }); + return Promise.resolve(); + }, + }; + vm.runInNewContext(`${patched};OF();`, context); + + assert.equal(calls.length, 0); +}); + test("Linux remote-control enablement bridge omits params for current host toggle handler", async () => { const source = syntheticCurrentAppMainEnablementBridgeBundle(); const patched = applyLinuxRemoteControlEnablementBridgePatch(source); @@ -2814,16 +2977,17 @@ test("Linux remote-control enablement bridge warns when host toggle params needl assert.ok(warnings.some((warning) => warning.includes("enable-for-host params needle"))); }); -test("Linux remote-control enablement bridge auto-connects only this Desktop host", async () => { +test("Linux remote-control enablement bridge auto-connects this Desktop host without changing other hosts", async () => { const source = syntheticAppMainEnablementBridgeBundle(); const patched = applyLinuxRemoteControlEnablementBridgePatch(source); assert.doesNotMatch(patched, /safe:\{[^}]*\bhostId:/); assert.match(patched, /sensitive:\{hostId:[^}]+error:/); + assert.match(patched, /codexLinuxRemoteControlSelfAutoConnect/); const calls = []; const context = { - DF: "[remote-connections/slingshot-gate-bridge]", + DF: "[remote-connections/gate-bridge]", navigator: { userAgent: "X11; Linux x86_64" }, Promise, q: { warning() {} }, @@ -2840,7 +3004,7 @@ test("Linux remote-control enablement bridge auto-connects only this Desktop hos return Promise.resolve({ remoteControlConnections: [ { hostId: "remote-control:env_local", installationId: "install_local" }, - { hostId: "remote-control:env_stale", installationId: "install_stale" }, + { hostId: "remote-control:env_other", installationId: "install_other" }, ], }); } @@ -2853,7 +3017,7 @@ test("Linux remote-control enablement bridge auto-connects only this Desktop hos vm.runInNewContext(`${patched};OF();`, context); await new Promise((resolve) => setImmediate(resolve)); - assert.equal(calls.length, 4); + assert.equal(calls.length, 3); assert.equal(calls[0].method, "set-remote-control-connections-enabled"); assert.equal(calls[0].params.enabled, true); assert.equal(calls[1].method, "get-global-state"); @@ -2861,9 +3025,14 @@ test("Linux remote-control enablement bridge auto-connects only this Desktop hos assert.equal(calls[2].method, "set-remote-connection-auto-connect"); assert.equal(calls[2].params.hostId, "remote-control:env_local"); assert.equal(calls[2].params.autoConnect, true); - assert.equal(calls[3].method, "set-remote-connection-auto-connect"); - assert.equal(calls[3].params.hostId, "remote-control:env_stale"); - assert.equal(calls[3].params.autoConnect, false); + assert.equal( + calls.some( + ({ method, params }) => + method === "set-remote-connection-auto-connect" && + params.hostId === "remote-control:env_other", + ), + false, + ); }); test("patched Linux device-key provider can create, sign with, and delete a key", async () => { @@ -3469,7 +3638,10 @@ test("remote mobile control feature participates in ASAR patching and reports", fs.mkdirSync(buildDir, { recursive: true }); fs.mkdirSync(assetsDir, { recursive: true }); fs.writeFileSync(path.join(buildDir, "main.js"), source); - fs.writeFileSync(path.join(buildDir, "workspace-root-drop-handler-test.js"), syntheticAppServerLaunchBundle()); + fs.writeFileSync( + path.join(buildDir, "workspace-root-drop-handler-test.js"), + syntheticCurrentLocalAppServerLaunchBundle(), + ); fs.writeFileSync( path.join(assetsDir, CURRENT_REMOTE_RUNTIME_ASSET), syntheticRemoteConnectionVisibilityBundle() + @@ -3484,8 +3656,8 @@ test("remote mobile control feature participates in ASAR patching and reports", ); fs.writeFileSync( path.join(assetsDir, "remote-connections-settings-test.js"), - syntheticSettingsBundle() + - syntheticRemoteConnectionsSettingsCopyBundle() + + syntheticRemoteConnectionsSettingsCopyBundle() + + "function Uo(){let l=Pe(`782640499`),u=Pe(on),z=Ge(),B=!l,Se=f==null;return B&&z}" + syntheticSettingsRefreshBundle() + syntheticCurrentRevokeSetupResetBundle(), ); @@ -3563,12 +3735,12 @@ test("remote mobile control feature participates in ASAR patching and reports", ); assert.match(patchedFile, /codexLinuxRemoteControlDeviceKeyClient/); assert.match(patchedFile, /n\.kind===`local`&&process\.platform!==`linux`/); - assert.match(patchedAppServerLaunchFile, /codexLinuxRemoteMobileAppServerArgs/); + assert.match(patchedAppServerLaunchFile, /codexLinuxRemoteMobileLocalAppServerArgs/); assert.match(patchedAppServerLaunchFile, /`--remote-control`/); assert.match(patchedRemoteConnectionVisibilityFile, /codexLinuxRemoteControlLoadGateEnabled/); - assert.match(patchedAppMainFile, /\.remote_control=!0/); + assert.match(patchedAppMainFile, /\{\.\.\.e,remote_control:!0\}/); assert.match(patchedVisibilityFile, /navigator\.userAgent\.includes\(`Linux`\)/); - assert.match(patchedRemoteConnectionsSettingsFile, /codexLinuxRemoteControlSettingsTabs/); + assert.match(patchedRemoteConnectionsSettingsFile, /codexLinuxRemoteControlOutboundTabGate/); assert.match(patchedRemoteConnectionsSettingsFile, /codexLinuxRemoteControlResetMobileSetupAfterRevoke/); assert.match(patchedRemoteConnectionsSettingsFile, /codexLinuxRemoteConnectionsRefreshNow/); assert.match(patchedRemoteConnectionsSettingsFile, /Qn=5e3/); @@ -3718,6 +3890,12 @@ test("remote mobile control feature participates in ASAR patching and reports", patch.status === "already-applied", ), ); + assert.ok( + secondReport.patches.some((patch) => + patch.name === "feature:remote-mobile-control:linux-remote-control-enablement-bridge" && + patch.status === "already-applied", + ), + ); } finally { fs.rmSync(tempApp, { recursive: true, force: true }); } diff --git a/linux-features/shallow-repository-watches/README.md b/linux-features/shallow-repository-watches/README.md index ce0dff894..ab99c8e27 100644 --- a/linux-features/shallow-repository-watches/README.md +++ b/linux-features/shallow-repository-watches/README.md @@ -10,8 +10,9 @@ many worktrees, generated directories, or namespaced refs can therefore stall Electron's main thread simply when its task row is hovered. The patch changes only Linux recursive requests. Existing non-recursive watches -and other platforms are untouched. It also reports `recursive: false` through -the existing coverage result so Codex's focus-recovery path remains available. +and other platforms are untouched. The current Linux Parcel working-tree path +is routed through the same shallow host, which reports `recursive: false` so +Codex's focus-recovery path remains available. Enable it in `linux-features/features.json` and rebuild: diff --git a/linux-features/shallow-repository-watches/patch.js b/linux-features/shallow-repository-watches/patch.js index 1bfde4f98..b10cd31be 100644 --- a/linux-features/shallow-repository-watches/patch.js +++ b/linux-features/shallow-repository-watches/patch.js @@ -4,15 +4,15 @@ const fs = require("node:fs"); const path = require("node:path"); const PATCH_MARKER = "codexLinuxShallowRepositoryWatches"; +const PARCEL_WATCH_MARKER = "codexLinuxShallowParcelWorkingTreeWatch"; const LOCAL_FILE_WATCH_METHOD = /async startFileWatch\((?[A-Za-z_$][\w$]*)\)\{(?=let [^{}]{0,180}?await this\.platformPath\(\),[^{}]{0,180}?\(0,[A-Za-z_$][\w$]*\.watch\)\(this\.getFileSystemPath\(\k\.path\),\{recursive:\k\.recursive\})/gu; +const PARCEL_WORKING_TREE_WATCH = + /process\.platform===`linux`\?[A-Za-z_$][\w$]*\((?[A-Za-z_$][\w$]*),\{ignoredPaths:\[[A-Za-z_$][\w$]*\.posix\.join\(\k\.path,`\.git`\)\]\}\):(?[A-Za-z_$][\w$]*)\.startFileWatch\(\k\)/gu; function patchWorkerSource(source) { const markerCount = source.split(PATCH_MARKER).length - 1; - if (markerCount === 1) { - return { source, matched: 1, changed: 0, reason: null }; - } - if (markerCount !== 0) { + if (markerCount > 1) { return { source, matched: 0, @@ -21,102 +21,142 @@ function patchWorkerSource(source) { }; } - LOCAL_FILE_WATCH_METHOD.lastIndex = 0; - const matches = [...source.matchAll(LOCAL_FILE_WATCH_METHOD)]; - if (matches.length !== 1) { + let patchedSource = source; + let changed = 0; + if (markerCount === 0) { + LOCAL_FILE_WATCH_METHOD.lastIndex = 0; + const matches = [...source.matchAll(LOCAL_FILE_WATCH_METHOD)]; + if (matches.length !== 1) { + return { + source, + matched: 0, + changed: 0, + reason: `Found ${matches.length} local startFileWatch implementations`, + }; + } + + const match = matches[0]; + const optionsName = match.groups.options; + const branch = + `if(process.platform===\`linux\`&&${optionsName}.recursive){` + + `/*${PATCH_MARKER}*/` + + `${optionsName}={...${optionsName},recursive:!1}}`; + const methodStart = match.index + match[0].length; + patchedSource = source.slice(0, methodStart) + branch + source.slice(methodStart); + changed = 1; + } + + const parcelMarkerCount = patchedSource.split(PARCEL_WATCH_MARKER).length - 1; + PARCEL_WORKING_TREE_WATCH.lastIndex = 0; + const parcelMatches = [...patchedSource.matchAll(PARCEL_WORKING_TREE_WATCH)]; + if ( + parcelMarkerCount > 1 || + parcelMatches.length > 1 || + (parcelMarkerCount > 0 && parcelMatches.length > 0) + ) { return { source, matched: 0, changed: 0, - reason: `Found ${matches.length} local startFileWatch implementations`, + reason: + `Found ${parcelMatches.length} Parcel working-tree watch branches and ` + + `${parcelMarkerCount} markers`, }; } - - const match = matches[0]; - const optionsName = match.groups.options; - const branch = - `if(process.platform===\`linux\`&&${optionsName}.recursive){` + - `/*${PATCH_MARKER}*/` + - `${optionsName}={...${optionsName},recursive:!1}}`; - const methodStart = match.index + match[0].length; + const finalSource = parcelMatches.length === 0 + ? patchedSource + : patchedSource.replace( + PARCEL_WORKING_TREE_WATCH, + `/*${PARCEL_WATCH_MARKER}*/$.startFileWatch($)`, + ); + if (parcelMatches.length === 1) changed = 1; return { - source: source.slice(0, methodStart) + branch + source.slice(methodStart), + source: finalSource, matched: 1, - changed: 1, + changed, reason: null, }; } -function findLocalFileWatchBundle(extractedDir) { +function findLocalFileWatchBundles(extractedDir) { const buildDir = path.join(extractedDir, ".vite", "build"); if (!fs.existsSync(buildDir)) { - return { target: null, result: null, reason: ".vite/build directory not found" }; + return { candidates: [], reason: ".vite/build directory not found" }; } const bundlePaths = fs.readdirSync(buildDir, { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.endsWith(".js")) .map((entry) => path.join(buildDir, entry.name)) .sort(); - const patched = []; - const raw = []; + const candidates = []; for (const bundlePath of bundlePaths) { const source = fs.readFileSync(bundlePath, "utf8"); const markerCount = source.split(PATCH_MARKER).length - 1; - if (markerCount > 0) { - patched.push({ bundlePath, result: patchWorkerSource(source) }); - continue; - } + const parcelMarkerCount = source.split(PARCEL_WATCH_MARKER).length - 1; LOCAL_FILE_WATCH_METHOD.lastIndex = 0; - const matches = [...source.matchAll(LOCAL_FILE_WATCH_METHOD)].length; - if (matches > 0) raw.push({ bundlePath, matches, source }); - } - - if (patched.length > 0) { - if (patched.length !== 1 || raw.length !== 0) { - return { - target: null, - result: null, - reason: - `Found shallow-watch markers in ${patched.length} bundles and ` + - `${raw.length} unpatched bundles`, - }; + const matchCount = [...source.matchAll(LOCAL_FILE_WATCH_METHOD)].length; + PARCEL_WORKING_TREE_WATCH.lastIndex = 0; + const parcelMatchCount = [...source.matchAll(PARCEL_WORKING_TREE_WATCH)].length; + if (markerCount > 0 || matchCount > 0 || parcelMarkerCount > 0 || parcelMatchCount > 0) { + candidates.push({ + bundlePath, + markerCount, + matchCount, + parcelMarkerCount, + parcelMatchCount, + source, + }); } - return { - target: patched[0].bundlePath, - result: patched[0].result, - reason: patched[0].result.reason, - }; } - const rawMatchCount = raw.reduce((total, candidate) => total + candidate.matches, 0); - if (raw.length !== 1 || rawMatchCount !== 1) { + const rawMatchCount = candidates.reduce((total, candidate) => total + candidate.matchCount, 0); + const markerCount = candidates.reduce((total, candidate) => total + candidate.markerCount, 0); + const parcelContractCount = candidates.reduce( + (total, candidate) => total + candidate.parcelMatchCount + candidate.parcelMarkerCount, + 0, + ); + if ( + candidates.length !== 2 || + !((rawMatchCount === 2 && markerCount === 0) || (rawMatchCount === 0 && markerCount === 2)) || + parcelContractCount !== 1 + ) { return { - target: null, - result: null, + candidates: [], reason: - `Found ${rawMatchCount} local startFileWatch implementations across ` + - `${bundlePaths.length} build bundles`, + `Found ${rawMatchCount} local startFileWatch implementations, ${markerCount} markers, ` + + `and ${parcelContractCount} Parcel working-tree branches across ` + + `${candidates.length} candidate bundles`, }; } - const result = patchWorkerSource(raw[0].source); - return { target: raw[0].bundlePath, result, reason: result.reason }; + return { candidates, reason: null }; } function patchWorker(extractedDir) { - const discovery = findLocalFileWatchBundle(extractedDir); - if (discovery.target == null || discovery.result?.matched !== 1) { - const reason = discovery.reason ?? "Local startFileWatch implementation not found"; + const discovery = findLocalFileWatchBundles(extractedDir); + if (discovery.candidates.length !== 2) { + const reason = discovery.reason ?? "Local startFileWatch implementations not found"; + console.warn(`WARN: ${reason} - skipping shallow repository-watch feature`); + return { matched: 0, changed: 0, reason }; + } + const results = discovery.candidates.map((candidate) => ({ + bundlePath: candidate.bundlePath, + result: patchWorkerSource(candidate.source), + })); + const failed = results.find(({ result }) => result.matched !== 1); + if (failed != null) { + const reason = failed.result.reason ?? "Local startFileWatch implementation not patched"; console.warn(`WARN: ${reason} - skipping shallow repository-watch feature`); - return { matched: discovery.result?.matched ?? 0, changed: 0, reason }; + return { matched: 0, changed: 0, reason }; + } + for (const { bundlePath, result } of results) { + if (result.changed === 1) fs.writeFileSync(bundlePath, result.source, "utf8"); } - const result = discovery.result; - if (result.changed === 1) fs.writeFileSync(discovery.target, result.source, "utf8"); return { - matched: result.matched, - changed: result.changed, - reason: result.reason, - target: path.relative(extractedDir, discovery.target), + matched: results.length, + changed: results.reduce((total, { result }) => total + result.changed, 0), + reason: null, + targets: results.map(({ bundlePath }) => path.relative(extractedDir, bundlePath)), }; } @@ -128,19 +168,20 @@ const descriptors = [ ciPolicy: "optional", apply: patchWorker, status: (result, warnings) => { - if (result?.matched !== 1) { + if (result?.matched !== 2) { return { status: "skipped-optional", reason: result?.reason ?? warnings[0] ?? null }; } - return result.changed === 1 ? "applied" : "already-applied"; + return result.changed > 0 ? "applied" : "already-applied"; }, }, ]; module.exports = { LOCAL_FILE_WATCH_METHOD, + PARCEL_WATCH_MARKER, PATCH_MARKER, descriptors, - findLocalFileWatchBundle, + findLocalFileWatchBundles, patchWorker, patchWorkerSource, }; diff --git a/linux-features/shallow-repository-watches/test.js b/linux-features/shallow-repository-watches/test.js index 563c6bfe7..3d25c551f 100644 --- a/linux-features/shallow-repository-watches/test.js +++ b/linux-features/shallow-repository-watches/test.js @@ -12,8 +12,9 @@ const { } = require("../../scripts/lib/linux-features.js"); const { PATCH_MARKER, + PARCEL_WATCH_MARKER, descriptors, - findLocalFileWatchBundle, + findLocalFileWatchBundles, patchWorker, patchWorkerSource, } = require("./patch.js"); @@ -29,6 +30,14 @@ function localWorkerSource() { ].join(""); } +function parcelWorkingTreeSource() { + return [ + "function create(t,n){return t.isLocal?process.platform===`linux`?", + "Jve(n,{ignoredPaths:[E.posix.join(n.path,`.git`)]}):", + "e.startFileWatch(n):t.startFileWatch(n)}", + ].join(""); +} + function withFeatureConfig(enabled, callback) { const original = process.env.CODEX_LINUX_FEATURES_CONFIG; const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-shallow-watch-config-")); @@ -131,26 +140,98 @@ test("patch preserves non-recursive Linux watches and recursive watches on other assert.deepEqual(darwinSession.coverage, { recursive: true }); }); -test("feature discovers and patches the current hashed build bundle shape", () => { +test("routes the current Linux Parcel working-tree branch through the shallow host", () => { + const first = patchWorkerSource(`${localWorkerSource()}${parcelWorkingTreeSource()}`); + assert.equal(first.matched, 1); + assert.equal(first.changed, 1); + assert.equal(first.source.split(PARCEL_WATCH_MARKER).length - 1, 1); + assert.doesNotMatch(first.source, /process\.platform===`linux`\?Jve/); + assert.match( + first.source, + /codexLinuxShallowParcelWorkingTreeWatch\*\/e\.startFileWatch\(n\)/, + ); + assert.deepEqual( + patchWorkerSource(first.source), + { source: first.source, matched: 1, changed: 0, reason: null }, + ); +}); + +test("feature atomically patches both current build bundles", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-shallow-watch-bundle-")); try { const buildDir = path.join(root, ".vite", "build"); fs.mkdirSync(buildDir, { recursive: true }); fs.writeFileSync(path.join(buildDir, "unrelated.js"), "var worker={startFileWatch(){}};"); fs.writeFileSync(path.join(buildDir, "src-current.js"), localWorkerSource()); + fs.writeFileSync( + path.join(buildDir, "worker.js"), + `${localWorkerSource()}${parcelWorkingTreeSource()}`, + ); - const discovery = findLocalFileWatchBundle(root); - assert.equal(path.basename(discovery.target), "src-current.js"); + const discovery = findLocalFileWatchBundles(root); + assert.deepEqual( + discovery.candidates.map(({ bundlePath }) => path.basename(bundlePath)), + ["src-current.js", "worker.js"], + ); const first = patchWorker(root); assert.deepEqual(first, { - matched: 1, - changed: 1, + matched: 2, + changed: 2, reason: null, - target: path.join(".vite", "build", "src-current.js"), + targets: [ + path.join(".vite", "build", "src-current.js"), + path.join(".vite", "build", "worker.js"), + ], }); const second = patchWorker(root); + assert.equal(second.matched, 2); assert.equal(second.changed, 0); - assert.equal(fs.readFileSync(discovery.target, "utf8").split(PATCH_MARKER).length - 1, 1); + for (const { bundlePath } of discovery.candidates) { + assert.equal(fs.readFileSync(bundlePath, "utf8").split(PATCH_MARKER).length - 1, 1); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("current bundle drift leaves every candidate byte-identical", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-shallow-watch-drift-")); + try { + const buildDir = path.join(root, ".vite", "build"); + fs.mkdirSync(buildDir, { recursive: true }); + const source = localWorkerSource(); + fs.writeFileSync(path.join(buildDir, "worker.js"), source); + + const result = patchWorker(root); + assert.equal(result.matched, 0); + assert.equal(result.changed, 0); + assert.match(result.reason, /1 local startFileWatch implementation/); + assert.equal(fs.readFileSync(path.join(buildDir, "worker.js"), "utf8"), source); + assert.equal(descriptors[0].status(result, []).status, "skipped-optional"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("an extra Parcel working-tree branch leaves current bundles byte-identical", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-shallow-watch-parcel-drift-")); + try { + const buildDir = path.join(root, ".vite", "build"); + fs.mkdirSync(buildDir, { recursive: true }); + const sources = new Map([ + ["src-current.js", localWorkerSource()], + ["worker.js", `${localWorkerSource()}${parcelWorkingTreeSource()}`], + ["worker-extra.js", parcelWorkingTreeSource()], + ]); + for (const [name, source] of sources) fs.writeFileSync(path.join(buildDir, name), source); + + const result = patchWorker(root); + assert.equal(result.matched, 0); + assert.equal(result.changed, 0); + assert.match(result.reason, /2 Parcel working-tree branches across 3 candidate bundles/); + for (const [name, source] of sources) { + assert.equal(fs.readFileSync(path.join(buildDir, name), "utf8"), source); + } } finally { fs.rmSync(root, { recursive: true, force: true }); } diff --git a/linux-features/shared-app-server-socket/README.md b/linux-features/shared-app-server-socket/README.md index 54032d49a..da59fad60 100644 --- a/linux-features/shared-app-server-socket/README.md +++ b/linux-features/shared-app-server-socket/README.md @@ -36,6 +36,17 @@ Legacy locks without owner metadata remain protected for 15 seconds, longer than the authority startup timeout, before they can be reclaimed when no socket exists. +The launcher also cleans up a live authority orphaned by a terminated Desktop +process. Cleanup is limited to a same-user `codex app-server --listen unix://PATH` +process serving the exact locked socket after direct PID 1 adoption or adoption +by a verified same-user `systemd --user` manager whose own parent is PID 1. Once +the authority is ready, its PID and process-start identity are recorded in the +ownership lock. The lock owner, socket inode, listener identity, command line, +and process start identities are rechecked before signaling it. Unknown +listeners, live Desktop owners, changed identities, and pathnames with multiple +live listener inodes remain untouched. The same cleanup runs after Electron exits +and before a later cold start. + ## SSH setup Use a stable socket path when the Desktop instance will be reached over SSH: diff --git a/linux-features/shared-app-server-socket/feature.json b/linux-features/shared-app-server-socket/feature.json index 14a81924d..426fb5c70 100644 --- a/linux-features/shared-app-server-socket/feature.json +++ b/linux-features/shared-app-server-socket/feature.json @@ -6,11 +6,23 @@ "entrypoints": { "patchDescriptors": "./patch.js" }, + "resources": [ + { + "source": "orphan-reaper.js", + "target": ".codex-linux/features/shared-app-server-socket/orphan-reaper.js", + "mode": "0644" + } + ], "runtimeHooks": { "launcher": { "source": "socket-env.sh", "name": "socket-env.sh", "mode": "0755" + }, + "afterExit": { + "source": "socket-env.sh", + "name": "socket-cleanup.sh", + "mode": "0755" } } } diff --git a/linux-features/shared-app-server-socket/orphan-reaper.js b/linux-features/shared-app-server-socket/orphan-reaper.js new file mode 100644 index 000000000..edba05853 --- /dev/null +++ b/linux-features/shared-app-server-socket/orphan-reaper.js @@ -0,0 +1,308 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +const socketPath = process.argv[2]; +if (!socketPath) { + throw new Error("shared app-server orphan cleanup requires a socket path"); +} + +const lockPath = `${socketPath}.lock`; +const expectedUid = typeof process.getuid === "function" ? process.getuid() : null; + +function sameIdentity(left, right) { + return left != null && right.dev === left.dev && right.ino === left.ino; +} + +function readProcess(pid) { + try { + const procPath = `/proc/${pid}`; + const procStat = fs.statSync(procPath); + const rawStat = fs.readFileSync(`${procPath}/stat`, "utf8"); + const commandEnd = rawStat.lastIndexOf(")"); + const commandStart = rawStat.indexOf("("); + if (commandStart < 0 || commandEnd < 0) return null; + const fields = rawStat.slice(commandEnd + 2).trim().split(/\s+/); + const commandLine = fs + .readFileSync(`${procPath}/cmdline`) + .toString("utf8") + .split("\0") + .filter(Boolean); + return { + pid, + uid: procStat.uid, + state: fields[0], + ppid: Number(fields[1]), + startTime: fields[19] ?? null, + comm: rawStat.slice(commandStart + 1, commandEnd), + commandLine, + }; + } catch (error) { + if (error?.code === "ENOENT" || error?.code === "ESRCH") return null; + throw error; + } +} + +function isRunning(processInfo) { + if (processInfo == null || processInfo.state === "Z") return false; + const current = readProcess(processInfo.pid); + return current?.state !== "Z" && current?.startTime === processInfo.startTime; +} + +function ownerIsDead(ownerPid, ownerStartTime) { + const owner = readProcess(ownerPid); + return owner == null || owner.state === "Z" || owner.startTime !== ownerStartTime; +} + +function listenerInodes() { + const inodes = new Set(); + const lines = fs.readFileSync("/proc/net/unix", "utf8").split("\n"); + for (const line of lines) { + const match = line.match( + /^\S+:\s+\S+\s+\S+\s+\S+\s+(\S+)\s+(\S+)\s+(\d+)(?:\s+(.*))?$/, + ); + if ( + match != null && + match[1] === "0001" && + match[2] === "01" && + match[4] === socketPath + ) { + inodes.add(match[3]); + } + } + return [...inodes]; +} + +function listenerProcesses(inode) { + const target = `socket:[${inode}]`; + const listeners = []; + for (const entry of fs.readdirSync("/proc", { withFileTypes: true })) { + if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue; + const pid = Number(entry.name); + let processInfo; + try { + processInfo = readProcess(pid); + if (processInfo == null || (expectedUid != null && processInfo.uid !== expectedUid)) continue; + const fdDir = `/proc/${pid}/fd`; + const ownsListener = fs.readdirSync(fdDir).some((fd) => { + try { + return fs.readlinkSync(`${fdDir}/${fd}`) === target; + } catch (error) { + if (error?.code === "ENOENT" || error?.code === "EACCES") return false; + throw error; + } + }); + if (ownsListener) listeners.push(processInfo); + } catch (error) { + if (error?.code !== "ENOENT" && error?.code !== "EACCES") throw error; + } + } + return listeners; +} + +function isExpectedAuthority(processInfo) { + const listenUrl = `unix://${socketPath}`; + return processInfo.commandLine.some( + (argument, index, commandLine) => + argument === "app-server" && + commandLine[index + 1] === "--listen" && + commandLine[index + 2] === listenUrl, + ); +} + +function isVerifiedSystemdUserManager(processInfo) { + const executable = processInfo.commandLine[0]; + return ( + processInfo.comm === "systemd" && + path.isAbsolute(executable) && + path.basename(executable) === "systemd" && + processInfo.commandLine.includes("--user") + ); +} + +function hasExpectedOrphanAdoption(authority) { + if (authority.ppid === 1) return true; + + const adopter = readProcess(authority.ppid); + return ( + adopter != null && + isRunning(adopter) && + (expectedUid == null || adopter.uid === expectedUid) && + adopter.ppid === 1 && + isVerifiedSystemdUserManager(adopter) + ); +} + +function isExpectedLockedAuthority(lock, authority) { + return ( + authority != null && + authority.startTime === lock.authorityStartTime && + (expectedUid == null || authority.uid === expectedUid) && + hasExpectedOrphanAdoption(authority) && + isExpectedAuthority(authority) + ); +} + +function verifiedOrphanTargets(lock, listeners) { + const authority = readProcess(lock.authorityPid); + if (!isExpectedLockedAuthority(lock, authority)) { + throw new Error("locked authority is not the expected reparented Codex process"); + } + + const targets = new Map(); + for (const listener of listeners) { + if (expectedUid != null && listener.uid !== expectedUid) { + throw new Error(`listener ${listener.pid} has unexpected uid`); + } + if (!isExpectedAuthority(listener)) { + throw new Error(`listener ${listener.pid} is not the expected Codex authority`); + } + if (listener.pid !== authority.pid && listener.ppid !== authority.pid) { + throw new Error(`listener ${listener.pid} does not belong to the locked authority`); + } + targets.set(listener.pid, listener); + } + targets.set(authority.pid, authority); + return [...targets.values()]; +} + +function readLock() { + let descriptor; + try { + descriptor = fs.openSync(lockPath, "r"); + const stat = fs.fstatSync(descriptor); + const contents = fs.readFileSync(descriptor, "utf8"); + const owner = contents.trim().match(/^(\d+) (\S+)(?: (\d+) (\S+))?$/); + if (owner == null) return null; + return { + identity: { dev: stat.dev, ino: stat.ino }, + contents, + ownerPid: Number(owner[1]), + ownerStartTime: owner[2], + authorityPid: owner[3] == null ? null : Number(owner[3]), + authorityStartTime: owner[4] ?? null, + }; + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } finally { + if (descriptor != null) fs.closeSync(descriptor); + } +} + +function unchangedLock(snapshot) { + try { + const stat = fs.lstatSync(lockPath); + return ( + sameIdentity(snapshot.identity, stat) && + fs.readFileSync(lockPath, "utf8") === snapshot.contents + ); + } catch (error) { + if (error?.code === "ENOENT") return false; + throw error; + } +} + +function socketPathState(snapshot) { + try { + return sameIdentity(snapshot, fs.lstatSync(socketPath)) ? "same" : "changed"; + } catch (error) { + if (error?.code === "ENOENT") return "missing"; + throw error; + } +} + +function delay(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function reapOrphan() { + const lock = readLock(); + if (lock == null || !ownerIsDead(lock.ownerPid, lock.ownerStartTime)) return; + + let socket; + try { + socket = fs.lstatSync(socketPath); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + if (!socket.isSocket()) throw new Error("shared app-server path is not a socket"); + if (expectedUid != null && socket.uid !== expectedUid) { + throw new Error("shared app-server socket has unexpected owner"); + } + + const inodes = listenerInodes(); + if (inodes.length === 0) return; + if (lock.authorityPid == null || lock.authorityStartTime == null) { + throw new Error("live shared app-server lock lacks an authority identity"); + } + if (inodes.length !== 1) { + throw new Error("shared app-server path has multiple live listener inodes"); + } + const [inode] = inodes; + const listeners = listenerProcesses(inode); + if (listeners.length === 0) { + throw new Error("live shared app-server listener could not be identified"); + } + const targets = verifiedOrphanTargets(lock, listeners); + + const verifiedInodes = listenerInodes(); + const currentAuthority = readProcess(lock.authorityPid); + if ( + !unchangedLock(lock) || + !ownerIsDead(lock.ownerPid, lock.ownerStartTime) || + socketPathState(socket) !== "same" || + verifiedInodes.length !== 1 || + verifiedInodes[0] !== inode || + !isExpectedLockedAuthority(lock, currentAuthority) || + targets.some((target) => !isRunning(target)) + ) { + throw new Error("shared app-server ownership changed during orphan verification"); + } + + for (const target of targets) { + try { + process.kill(target.pid, "SIGTERM"); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } + } + + const deadline = Date.now() + 3000; + while ( + Date.now() < deadline && + (listenerInodes().includes(inode) || targets.some((target) => isRunning(target))) + ) { + await delay(50); + } + if (listenerInodes().includes(inode) || targets.some((target) => isRunning(target))) { + throw new Error("orphaned shared app-server authority did not stop"); + } + + const remainingInodes = listenerInodes(); + const finalSocketState = socketPathState(socket); + if ( + !unchangedLock(lock) || + !ownerIsDead(lock.ownerPid, lock.ownerStartTime) || + remainingInodes.length !== 0 || + finalSocketState === "changed" + ) { + throw new Error("shared app-server ownership changed before orphan cleanup"); + } + if (finalSocketState === "same") fs.unlinkSync(socketPath); + if (unchangedLock(lock)) fs.unlinkSync(lockPath); + + console.error( + `Stopped orphaned shared app-server authority: ${targets + .map((target) => target.pid) + .join(", ")}`, + ); +} + +reapOrphan().catch((error) => { + console.error(`Shared app-server orphan cleanup refused: ${error.message}`); + process.exitCode = 1; +}); diff --git a/linux-features/shared-app-server-socket/patch.js b/linux-features/shared-app-server-socket/patch.js index d9cdeda0b..43270047e 100644 --- a/linux-features/shared-app-server-socket/patch.js +++ b/linux-features/shared-app-server-socket/patch.js @@ -19,7 +19,7 @@ function findTransportSymbols(source) { const [, namespace, webSocketClass, webSocketUrl] = webSocketMatch; const lifecycleMatch = sshClassSource.match( new RegExp( - `return ${namespace}\\.(${IDENT})\\((${IDENT}),\\{onPongTimeout:[\\s\\S]{0,160}?\\}\\),new ${namespace}\\.(${IDENT})\\(\\2\\)`, + `${namespace}\\.(${IDENT})\\((${IDENT}),\\{onPongTimeout:[\\s\\S]{0,220}?new ${namespace}\\.(${IDENT})\\(\\2\\)`, ), ); if (lifecycleMatch == null) return null; @@ -36,19 +36,20 @@ function findTransportSymbols(source) { function sharedTransportClassSource(symbols) { return ( "class CodexLinuxSharedAppServerSocketTransport{" + - "kind=`websocket`;proxyStreams=new Set;authority=null;authorityError=null;authorityReady=null;lockIdentity=null;socketIdentity=null;disposed=!1;" + + "kind=`websocket`;proxyStreams=new Set;authority=null;authorityError=null;authorityReady=null;lockIdentity=null;ownerStartTime=null;socketIdentity=null;disposed=!1;" + "constructor(e){this.socketPath=e;this.lockPath=`${e}.lock`}" + "supportsReconnect(){return!0}" + "sameIdentity(e,t){return e!=null&&t.dev===e.dev&&t.ino===e.ino}" + "processIdentity(e){let t=require(`node:fs`);try{let n=t.readFileSync(`/proc/${e}/stat`,`utf8`),r=n.lastIndexOf(`)`);if(r<0)return null;return n.slice(r+2).trim().split(/\\s+/)[19]??null}catch(e){return null}}" + + "recordAuthorityIdentity(e){let t=require(`node:fs`),n=e?.pid,r=this.processIdentity(n);if(!Number.isInteger(n)||r==null||this.ownerStartTime==null)throw Error(`shared app-server socket could not identify its authority`);let o;try{o=t.openSync(this.lockPath,`r+`);let e=t.fstatSync(o),i=t.readFileSync(o,`utf8`);if(!this.sameIdentity(this.lockIdentity,e)||i!==`${process.pid} ${this.ownerStartTime}\\n`)throw Error(`shared app-server ownership changed before authority registration`);t.ftruncateSync(o,0),t.writeSync(o,`${process.pid} ${this.ownerStartTime} ${n} ${r}\\n`,0,`utf8`),t.fsyncSync(o)}finally{o!=null&&t.closeSync(o)}}" + "socketPathIsLive(){return new Promise(e=>{let t=require(`node:net`).createConnection({path:this.socketPath}),n=!1,r=o=>{if(n)return;n=!0,clearTimeout(i),t.removeAllListeners(),t.destroy(),e(o)},i=setTimeout(()=>r(!0),500);i.unref?.(),t.once(`connect`,()=>r(!0)),t.once(`error`,e=>r(e?.code!==`ECONNREFUSED`&&e?.code!==`ENOENT`))})}" + - "async reclaimStaleLock(){let e=require(`node:fs`),t;try{t=e.openSync(this.lockPath,`r`);let n=e.fstatSync(t),r=e.readFileSync(t,`utf8`).trim(),o=r.match(/^(\\d+) (\\S+)$/),i=!1;if(o){let t=Number(o[1]),n=this.processIdentity(t);i=n==null?!e.existsSync(`/proc/${t}`):n!==o[2]}else i=Date.now()-n.mtimeMs>15e3;if(!i)return!1;let a=null;try{a=e.lstatSync(this.socketPath);if(!a.isSocket()||await this.socketPathIsLive())return!1}catch(e){if(e?.code!==`ENOENT`)throw e}if(a)try{let t=e.lstatSync(this.socketPath);if(!this.sameIdentity(a,t))return!1;e.unlinkSync(this.socketPath)}catch(e){if(e?.code!==`ENOENT`)throw e}let s=e.lstatSync(this.lockPath);if(!this.sameIdentity(n,s))return!1;e.unlinkSync(this.lockPath);return!0}catch(e){if(e?.code===`ENOENT`)return!0;throw e}finally{t!=null&&e.closeSync(t)}}" + + "async reclaimStaleLock(){let e=require(`node:fs`),t;try{t=e.openSync(this.lockPath,`r`);let n=e.fstatSync(t),r=e.readFileSync(t,`utf8`).trim(),o=r.match(/^(\\d+) (\\S+)(?: \\d+ \\S+)?$/),i=!1;if(o){let t=Number(o[1]),n=this.processIdentity(t);i=n==null?!e.existsSync(`/proc/${t}`):n!==o[2]}else i=Date.now()-n.mtimeMs>15e3;if(!i)return!1;let a=null;try{a=e.lstatSync(this.socketPath);if(!a.isSocket()||await this.socketPathIsLive())return!1}catch(e){if(e?.code!==`ENOENT`)throw e}if(a)try{let t=e.lstatSync(this.socketPath);if(!this.sameIdentity(a,t))return!1;e.unlinkSync(this.socketPath)}catch(e){if(e?.code!==`ENOENT`)throw e}let s=e.lstatSync(this.lockPath);if(!this.sameIdentity(n,s))return!1;e.unlinkSync(this.lockPath);return!0}catch(e){if(e?.code===`ENOENT`)return!0;throw e}finally{t!=null&&e.closeSync(t)}}" + "releaseOwnedPaths(e=!1){let t=require(`node:fs`),n=[];if(this.socketIdentity)try{let e=t.lstatSync(this.socketPath);this.sameIdentity(this.socketIdentity,e)&&t.unlinkSync(this.socketPath),this.socketIdentity=null}catch(e){e?.code===`ENOENT`?this.socketIdentity=null:n.push(e)}if(this.lockIdentity)try{let e=t.lstatSync(this.lockPath);this.sameIdentity(this.lockIdentity,e)&&t.unlinkSync(this.lockPath),this.lockIdentity=null}catch(e){e?.code===`ENOENT`?this.lockIdentity=null:n.push(e)}if(n.length&&!e)throw n[0];n.length&&console.warn(`WARN: shared app-server socket cleanup failed: ${n[0].message}`)}" + "dispose(){this.disposed=!0;for(let e of this.proxyStreams)e.destroy();this.proxyStreams.clear();let e=this.authority;this.authority=null;if(e&&e.exitCode==null&&e.signalCode==null){let t=()=>this.releaseOwnedPaths(!0);e.once(`close`,t);try{e.kill()}catch(e){console.warn(`WARN: shared app-server authority stop failed: ${e.message}`)}}else this.releaseOwnedPaths(!0)}" + - "async acquireOwnership(){let e=require(`node:fs`),t=require(`node:path`);e.mkdirSync(t.dirname(this.socketPath),{recursive:!0,mode:448});for(let t=0;t<2;t++){let n;try{n=e.openSync(this.lockPath,`wx`,384),this.lockIdentity=e.fstatSync(n);let t=this.processIdentity(process.pid);if(t==null)throw Error(`shared app-server socket could not identify its owner`);e.writeSync(n,`${process.pid} ${t}\\n`),e.fsyncSync(n)}catch(e){if(e?.code===`EEXIST`&&t===0&&await this.reclaimStaleLock())continue;if(e?.code===`EEXIST`)throw Error(`shared app-server socket is already owned: ${this.socketPath}`);this.releaseOwnedPaths(!0);throw e}finally{n!=null&&e.closeSync(n)}try{e.lstatSync(this.socketPath);throw Error(`shared app-server socket path already exists: ${this.socketPath}`)}catch(e){if(e?.code!==`ENOENT`){this.releaseOwnedPaths();throw e}}return}}" + + "async acquireOwnership(){let e=require(`node:fs`),t=require(`node:path`);e.mkdirSync(t.dirname(this.socketPath),{recursive:!0,mode:448});for(let t=0;t<2;t++){let n;try{n=e.openSync(this.lockPath,`wx`,384),this.lockIdentity=e.fstatSync(n);let t=this.processIdentity(process.pid);if(t==null)throw Error(`shared app-server socket could not identify its owner`);this.ownerStartTime=t,e.writeSync(n,`${process.pid} ${t}\\n`),e.fsyncSync(n)}catch(e){if(e?.code===`EEXIST`&&t===0&&await this.reclaimStaleLock())continue;if(e?.code===`EEXIST`)throw Error(`shared app-server socket is already owned: ${this.socketPath}`);this.releaseOwnedPaths(!0);throw e}finally{n!=null&&e.closeSync(n)}try{e.lstatSync(this.socketPath);throw Error(`shared app-server socket path already exists: ${this.socketPath}`)}catch(e){if(e?.code!==`ENOENT`){this.releaseOwnedPaths();throw e}}return}}" + "stopAuthority(e){return new Promise(t=>{if(!e||e.exitCode!=null||e.signalCode!=null)return t(!0);let n=!1,r=i=>{if(n)return;n=!0,clearTimeout(a),e.off(`close`,o),e.off(`exit`,o),e.off(`error`,s),t(i)},o=()=>r(!0),s=e=>{this.authorityError??=e},a=setTimeout(()=>r(!1),2e3);a.unref?.(),e.once(`close`,o),e.once(`exit`,o),e.on(`error`,s);try{e.kill()}catch(e){this.authorityError??=e,r(!1)}})}" + "async ensureAuthority(){if(this.disposed)throw Error(`shared app-server socket transport is disposed`);if(this.authorityReady)return this.authorityReady;if(this.authority&&this.authority.exitCode==null&&this.authority.signalCode==null){if(this.authorityError)throw this.authorityError;return}let e=this.startAuthority();this.authorityReady=e;try{return await e}finally{this.authorityReady===e&&(this.authorityReady=null)}}" + - "async startAuthority(){let e=process.env.CODEX_CLI_PATH;if(!e)throw Error(`shared app-server socket requires CODEX_CLI_PATH`);this.authorityError=null,await this.acquireOwnership();if(this.disposed){this.releaseOwnedPaths(!0);throw Error(`shared app-server socket transport was disposed during startup`)}let t=require(`node:fs`),n;try{n=require(`node:child_process`).spawn(e,[`app-server`,`--listen`,`unix://${this.socketPath}`],{env:process.env,stdio:`ignore`}),this.authority=n}catch(e){this.releaseOwnedPaths();throw e}try{await new Promise((e,r)=>{let i=!1,a,o=()=>{clearTimeout(a),clearTimeout(u),n.off(`error`,s),n.off(`exit`,l),n.off(`close`,l)},c=(t,u)=>{if(i)return;i=!0,o(),t?r(t):e(u)},s=e=>{this.authorityError=e,c(e)},l=()=>c(Error(`shared app-server authority exited before socket creation`)),h=()=>{if(i)return;try{let e=t.lstatSync(this.socketPath);if(e.isSocket()){if(typeof process.getuid==`function`&&e.uid!==process.getuid())return c(Error(`shared app-server socket has unexpected owner`));this.socketIdentity={dev:e.dev,ino:e.ino};return c(null)}}catch(e){if(e?.code!==`ENOENT`)return c(e)}a=setTimeout(h,100),a.unref?.()},u=setTimeout(()=>c(Error(`shared app-server socket creation timed out`)),1e4);n.once(`error`,s),n.once(`exit`,l),n.once(`close`,l),h(),u.unref?.()}),n.on(`error`,e=>{this.authorityError=e;for(let t of this.proxyStreams)t.destroy(e)}),n.once(`exit`,()=>{this.authority===n&&(this.authority=null,this.releaseOwnedPaths(!0))})}catch(e){this.authority=null;(await this.stopAuthority(n))&&this.releaseOwnedPaths();throw e}}" + + "async startAuthority(){let e=process.env.CODEX_CLI_PATH;if(!e)throw Error(`shared app-server socket requires CODEX_CLI_PATH`);this.authorityError=null,await this.acquireOwnership();if(this.disposed){this.releaseOwnedPaths(!0);throw Error(`shared app-server socket transport was disposed during startup`)}let t=require(`node:fs`),n;try{n=require(`node:child_process`).spawn(e,[`app-server`,`--listen`,`unix://${this.socketPath}`],{env:process.env,stdio:`ignore`}),this.authority=n}catch(e){this.releaseOwnedPaths();throw e}try{this.recordAuthorityIdentity(n),await new Promise((e,r)=>{let i=!1,a,o=()=>{clearTimeout(a),clearTimeout(u),n.off(`error`,s),n.off(`exit`,l),n.off(`close`,l)},c=(t,u)=>{if(i)return;i=!0,o(),t?r(t):e(u)},s=e=>{this.authorityError=e,c(e)},l=()=>c(Error(`shared app-server authority exited before socket creation`)),h=()=>{if(i)return;try{let e=t.lstatSync(this.socketPath);if(e.isSocket()){if(typeof process.getuid==`function`&&e.uid!==process.getuid())return c(Error(`shared app-server socket has unexpected owner`));this.socketIdentity={dev:e.dev,ino:e.ino};return c(null)}}catch(e){if(e?.code!==`ENOENT`)return c(e)}a=setTimeout(h,100),a.unref?.()},u=setTimeout(()=>c(Error(`shared app-server socket creation timed out`)),1e4);n.once(`error`,s),n.once(`exit`,l),n.once(`close`,l),h(),u.unref?.()}),n.on(`error`,e=>{this.authorityError=e;for(let t of this.proxyStreams)t.destroy(e)}),n.once(`exit`,()=>{this.authority===n&&(this.authority=null,this.releaseOwnedPaths(!0))})}catch(e){this.authority=null;(await this.stopAuthority(n))&&this.releaseOwnedPaths();throw e}}" + "createProxyStream(){let c=process.env.CODEX_CLI_PATH;if(!c)throw Error(`shared app-server socket requires CODEX_CLI_PATH`);let e=require(`node:child_process`).spawn(c,[`app-server`,`proxy`,`--sock`,this.socketPath],{env:process.env,stdio:[`pipe`,`pipe`,`pipe`]}),t=e.stdin,n=e.stdout,r=e.stderr;if(t==null||n==null||r==null)throw e.kill(),Error(`shared app-server proxy stdio was unavailable`);let i=``;r.on(`data`,e=>{i=`${i}${e.toString(`utf8`)}`.slice(-4000)});let a=new(require(`node:stream`).Duplex)({read(){n.resume()},write(e,n,r){t.write(e,n,r)},final(e){t.end(),e()},destroy(t,n){e.kill(),n(t)}});Object.assign(a,{setKeepAlive:()=>a,setNoDelay:()=>a,setTimeout:()=>a});let o=e=>a.destroy(e);t.on(`error`,o),n.on(`data`,e=>{a.push(e)||n.pause()}),n.on(`end`,()=>a.push(null)),e.on(`error`,o),e.on(`close`,(e,n)=>{t.removeListener(`error`,o),e===0?a.push(null):a.destroy(Error(`shared app-server proxy exited (${e??n??`unknown`}): ${i.trim()}`))}),this.proxyStreams.add(a),a.once(`close`,()=>this.proxyStreams.delete(a));return a}" + `async connect(){await this.ensureAuthority();let e={current:null},t=new ${symbols.namespace}.${symbols.webSocketClass}(${symbols.webSocketUrl},{perMessageDeflate:!1,createConnection:()=>(e.current=this.createProxyStream(),e.current)});t.once(\`close\`,()=>e.current?.destroy());try{await new Promise((n,r)=>{let i=setTimeout(()=>o(Error(\`shared app-server websocket open timed out\`)),3e4);i.unref();let a=()=>{clearTimeout(i),t.off(\`error\`,o),t.off(\`close\`,s)},o=e=>{a(),r(e)},s=()=>o(Error(\`shared app-server websocket closed before opening\`));t.once(\`open\`,()=>{a(),n()}),t.once(\`error\`,o),t.once(\`close\`,s)})}catch(n){e.current?.destroy(),t.terminate(),await new Promise(e=>setTimeout(e,0));throw n}${symbols.namespace}.${symbols.keepAlive}(t,{onPongTimeout:()=>t.terminate()});return new ${symbols.namespace}.${symbols.adapterClass}(t)}}` ); diff --git a/linux-features/shared-app-server-socket/socket-env.sh b/linux-features/shared-app-server-socket/socket-env.sh index e86edbddb..43343f5a6 100755 --- a/linux-features/shared-app-server-socket/socket-env.sh +++ b/linux-features/shared-app-server-socket/socket-env.sh @@ -4,4 +4,28 @@ set -eu runtime_root="${XDG_RUNTIME_DIR:-${CODEX_LINUX_APP_STATE_DIR:?}}" runtime_dir="$runtime_root/${CODEX_LINUX_APP_ID:-codex-desktop}/app-server-bridge" socket_path="${CODEX_LINUX_APP_SERVER_BRIDGE_SOCKET:-$runtime_dir/app-server.sock}" -printf 'env CODEX_LINUX_APP_SERVER_BRIDGE_SOCKET=%s\n' "$socket_path" + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +reaper_path="$script_dir/orphan-reaper.js" +node_bin="$(command -v node || true)" + +if [ -n "${CODEX_LINUX_APP_DIR:-}" ]; then + staged_reaper="$CODEX_LINUX_APP_DIR/.codex-linux/features/shared-app-server-socket/orphan-reaper.js" + managed_node="$CODEX_LINUX_APP_DIR/resources/node-runtime/bin/node" + if [ -f "$staged_reaper" ]; then + reaper_path="$staged_reaper" + fi + if [ -x "$managed_node" ]; then + node_bin="$managed_node" + fi +fi + +if [ -n "$node_bin" ] && [ -f "$reaper_path" ]; then + if ! "$node_bin" "$reaper_path" "$socket_path"; then + printf 'WARN: shared app-server orphan cleanup failed closed for %s\n' "$socket_path" >&2 + fi +fi + +if [ "${CODEX_LINUX_FEATURE_HOOK_PHASE:-launcher}" = "launcher" ]; then + printf 'env CODEX_LINUX_APP_SERVER_BRIDGE_SOCKET=%s\n' "$socket_path" +fi diff --git a/linux-features/shared-app-server-socket/test.js b/linux-features/shared-app-server-socket/test.js index a103b2aca..be5535c2f 100755 --- a/linux-features/shared-app-server-socket/test.js +++ b/linux-features/shared-app-server-socket/test.js @@ -23,6 +23,211 @@ const { } = require("./patch.js"); const socketEnvHook = path.join(__dirname, "socket-env.sh"); +const orphanReaper = path.join(__dirname, "orphan-reaper.js"); + +function createProcessSnapshotFs(processesByPid) { + const snapshotsByPid = new Map(); + const processForPath = (procPath) => { + const match = procPath.match(/^\/proc\/(\d+)(?:\/(stat|cmdline))?$/); + if (match == null) throw new Error(`unexpected proc path: ${procPath}`); + const pid = Number(match[1]); + const file = match[2]; + if (file == null) { + const entry = processesByPid.get(pid); + const processInfo = typeof entry === "function" ? entry() : entry; + if (processInfo != null) snapshotsByPid.set(pid, processInfo); + } + const processInfo = snapshotsByPid.get(pid); + if (processInfo == null) { + const error = new Error(`process ${pid} does not exist`); + error.code = "ENOENT"; + throw error; + } + return { processInfo, file }; + }; + + return { + statSync(procPath) { + return { uid: processForPath(procPath).processInfo.uid }; + }, + readFileSync(procPath) { + const { processInfo, file } = processForPath(procPath); + if (file === "stat") { + return `0 (${processInfo.comm ?? path.basename(processInfo.commandLine[0])}) ${processInfo.state} ${processInfo.ppid} ${Array(17) + .fill("0") + .join(" ")} ${processInfo.startTime}`; + } + if (file === "cmdline") return Buffer.from(`${processInfo.commandLine.join("\0")}\0`); + throw new Error(`unexpected proc file read: ${procPath}`); + }, + }; +} + +function loadOrphanReaperVerifier(processesByPid) { + const source = fs.readFileSync(orphanReaper, "utf8"); + const verifierSource = source.replace( + /reapOrphan\(\)\.catch\(\(error\) => \{[\s\S]*?\n\}\);\s*$/, + "globalThis.orphanReaperVerifier = { verifiedOrphanTargets };\n", + ); + assert.notEqual(verifierSource, source, "orphan reaper entrypoint must remain replaceable"); + + const uid = typeof process.getuid === "function" ? process.getuid() : null; + const mockFs = createProcessSnapshotFs(processesByPid); + const context = { + process: { + argv: [process.execPath, orphanReaper, "/test/app-server.sock"], + getuid: () => uid, + }, + require(id) { + if (id === "node:fs") return mockFs; + if (id === "node:path") return path; + throw new Error(`unexpected orphan reaper dependency: ${id}`); + }, + }; + vm.runInNewContext(verifierSource, context, { filename: orphanReaper }); + return context.orphanReaperVerifier.verifiedOrphanTargets; +} + +function loadOrphanReaperAdoptionPredicate() { + const source = fs.readFileSync(orphanReaper, "utf8"); + const predicateSource = source.replace( + /reapOrphan\(\)\.catch\(\(error\) => \{[\s\S]*?\n\}\);\s*$/, + "globalThis.orphanReaperAdoption = { readProcess, hasExpectedOrphanAdoption };\n", + ); + assert.notEqual(predicateSource, source, "orphan reaper entrypoint must remain replaceable"); + + const uid = typeof process.getuid === "function" ? process.getuid() : null; + const context = { + process: { + argv: [process.execPath, orphanReaper, "/test/app-server.sock"], + getuid: () => uid, + }, + require(id) { + if (id === "node:fs") return fs; + if (id === "node:path") return path; + throw new Error(`unexpected orphan reaper dependency: ${id}`); + }, + }; + vm.runInNewContext(predicateSource, context, { filename: orphanReaper }); + return context.orphanReaperAdoption; +} + +const orphanReaperAdoption = loadOrphanReaperAdoptionPredicate(); + +function startOrphanReaperWithChangedAdopter() { + const socketPath = "/test/app-server.sock"; + const lockPath = `${socketPath}.lock`; + const lockContents = "99999999 1 2001 100\n"; + const uid = process.getuid(); + const authority = { + pid: 2001, + uid, + state: "S", + ppid: 1235, + startTime: "100", + comm: "codex", + commandLine: ["/usr/bin/codex", "app-server", "--listen", `unix://${socketPath}`], + }; + const validAdopter = { + pid: 1235, + uid, + state: "S", + ppid: 1, + startTime: "99", + comm: "systemd", + commandLine: ["/nix/store/0123456789abcdef-systemd-257.6/lib/systemd/systemd", "--user"], + }; + const changedAdopter = { ...validAdopter, ppid: 321 }; + let adopterReads = 0; + const processesByPid = new Map([ + [authority.pid, authority], + [validAdopter.pid, () => (adopterReads++ < 2 ? validAdopter : changedAdopter)], + ]); + const procFs = createProcessSnapshotFs(processesByPid); + const socket = { dev: 1, ino: 2, uid, isSocket: () => true }; + const lock = { dev: 3, ino: 4 }; + const listenerInode = "9876"; + const signals = []; + const mockFs = { + openSync(filePath) { + if (filePath === lockPath) return 17; + throw new Error(`unexpected open: ${filePath}`); + }, + fstatSync(descriptor) { + if (descriptor === 17) return lock; + throw new Error(`unexpected descriptor: ${descriptor}`); + }, + closeSync() {}, + statSync: procFs.statSync, + lstatSync(filePath) { + if (filePath === socketPath) return socket; + if (filePath === lockPath) return lock; + const error = new Error(`missing path: ${filePath}`); + error.code = "ENOENT"; + throw error; + }, + readFileSync(filePath) { + if (filePath === 17 || filePath === lockPath) return lockContents; + if (filePath === "/proc/net/unix") { + return `0000000000000000: 00000002 00000000 00010000 0001 01 ${listenerInode} ${socketPath}\n`; + } + return procFs.readFileSync(filePath); + }, + readdirSync(filePath) { + if (filePath === "/proc") { + return [{ name: String(authority.pid), isDirectory: () => true }]; + } + if (filePath === `/proc/${authority.pid}/fd`) return ["5"]; + throw new Error(`unexpected directory read: ${filePath}`); + }, + readlinkSync(filePath) { + if (filePath === `/proc/${authority.pid}/fd/5`) return `socket:[${listenerInode}]`; + throw new Error(`unexpected link read: ${filePath}`); + }, + }; + const source = fs.readFileSync(orphanReaper, "utf8"); + const reaperSource = source.replace( + /reapOrphan\(\)\.catch\(\(error\) => \{[\s\S]*?\n\}\);\s*$/, + "globalThis.reaperPromise = reapOrphan();\n", + ); + assert.notEqual(reaperSource, source, "orphan reaper entrypoint must remain replaceable"); + const context = { + process: { + argv: [process.execPath, orphanReaper, socketPath], + getuid: () => uid, + kill(pid, signal) { + signals.push({ pid, signal }); + return true; + }, + }, + console: { error() {} }, + require(id) { + if (id === "node:fs") return mockFs; + if (id === "node:path") return path; + throw new Error(`unexpected orphan reaper dependency: ${id}`); + }, + }; + vm.runInNewContext(reaperSource, context, { filename: orphanReaper }); + return { reaperPromise: context.reaperPromise, signals }; +} + +function authorityProcess({ pid, ppid }) { + return { + pid, + uid: process.getuid(), + state: "S", + ppid, + startTime: "100", + commandLine: ["/usr/bin/codex", "app-server", "--listen", "unix:///test/app-server.sock"], + }; +} + +function lockedAuthority(authority) { + return { + authorityPid: authority.pid, + authorityStartTime: authority.startTime, + }; +} function withFeatureConfig(enabled, callback) { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "shared-app-server-socket-feature-")); @@ -89,6 +294,7 @@ async function stopChild(child) { function fakeChild() { const child = new EventEmitter(); + child.pid = process.pid; child.exitCode = null; child.signalCode = null; child.stdin = new PassThrough(); @@ -173,16 +379,110 @@ async function closeServer(server) { await new Promise((resolve) => server.close(resolve)); } +function processStartTime(pid) { + try { + const rawStat = fs.readFileSync(`/proc/${pid}/stat`, "utf8"); + const commandEnd = rawStat.lastIndexOf(")"); + if (commandEnd < 0) return null; + return rawStat.slice(commandEnd + 2).trim().split(/\s+/)[19] ?? null; + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +} + +function hasLegitimateOrphanAdoption(pid) { + const authority = orphanReaperAdoption.readProcess(pid); + return authority != null && orphanReaperAdoption.hasExpectedOrphanAdoption(authority); +} + +function unixListenerInodes(socketPath) { + const inodes = new Set(); + for (const line of fs.readFileSync("/proc/net/unix", "utf8").split("\n")) { + const match = line.match( + /^\S+:\s+\S+\s+\S+\s+\S+\s+(\S+)\s+(\S+)\s+(\d+)(?:\s+(.*))?$/, + ); + if ( + match != null && + match[1] === "0001" && + match[2] === "01" && + match[4] === socketPath + ) { + inodes.add(match[3]); + } + } + return [...inodes]; +} + +async function waitForCondition(predicate, description) { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`timed out waiting for ${description}`); +} + +async function spawnOrphanAuthority(socketPath) { + const listenerScript = [ + 'const net=require("node:net");', + 'const socketPath=process.argv.at(-1).replace("unix://","");', + "const server=net.createServer();", + "server.listen(socketPath);", + 'process.on("SIGTERM",()=>server.close(()=>process.exit(0)));', + ].join(""); + const wrapperScript = [ + 'const {spawn}=require("node:child_process");', + "const child=spawn(process.execPath,", + '[ "-e",process.env.LISTENER_SCRIPT,"app-server","--listen",process.env.LISTEN_URL],', + '{stdio:"ignore",env:process.env});', + 'process.on("SIGTERM",()=>{', + ' try{child.kill("SIGTERM")}catch{}', + " child.once('exit',()=>process.exit(0));", + " setTimeout(()=>process.exit(0),1000).unref();", + "});", + "setInterval(()=>{},1000);", + ].join(""); + const bootstrapScript = [ + 'const {spawn}=require("node:child_process");', + "const child=spawn(process.execPath,", + '[ "-e",process.env.WRAPPER_SCRIPT,"app-server","--listen",process.env.LISTEN_URL],', + '{detached:true,stdio:"ignore",env:process.env});', + "process.stdout.write(`${child.pid}\\n`);", + "child.unref();", + ].join(""); + const result = spawnSync(process.execPath, ["-e", bootstrapScript], { + encoding: "utf8", + env: { + ...process.env, + LISTENER_SCRIPT: listenerScript, + LISTEN_URL: `unix://${socketPath}`, + WRAPPER_SCRIPT: wrapperScript, + }, + }); + assert.equal(result.status, 0, result.stderr); + const pid = Number(result.stdout.trim()); + assert.equal(Number.isSafeInteger(pid), true); + const startTime = processStartTime(pid); + assert.notEqual(startTime, null); + await waitForCondition( + () => hasLegitimateOrphanAdoption(pid) && fs.existsSync(socketPath), + "detached authority to be adopted by PID 1 or the systemd user manager", + ); + return { pid, startTime }; +} + function syntheticBundle() { return [ - "var Ky=class{options;kind=`websocket`;logger=r.i(`AppServerTransportSshWebsocket`);proxyStreams=new Set;supportsReconnect(){return!0}", - "async connect(){let t={current:null},r=new n.zn(Fy,{perMessageDeflate:!1,createConnection:()=>", - "(t.current=this.createSshProxyStream(),t.current)});return n.Ln(r,{onPongTimeout:()=>r.terminate()}),new n.Rn(r)}};", - "function n6(e){let t=Jy(e.hostConfig);if(t)return Z.info(`selected app-server transport`),new Ky(t);", + "var gC=class{options;kind=`websocket`;logger=i.i(`AppServerTransportSshWebsocket`);proxyStreams=new Set;hasConnected=!1;supportsReconnect(){return!0}", + "async connect(){let t={current:null},r=new n.kn(qae,{perMessageDeflate:!1,createConnection:()=>", + "(t.current=this.createSshProxyStream(),t.current)});r.once(`close`,()=>{t.current?.destroy()});try{await Xae(r)}catch(e){throw r.once(`error`,()=>void 0),t.current?.destroy(),r.terminate(),e}", + "return n.Dn(r,{onPongTimeout:()=>{r.terminate()}}),this.hasConnected=!0,new n.On(r)}};", + "function b5(e){let t=_C(e.hostConfig);if(t)return v5.info(`[ssh-websocket-v0] selected app-server transport`),new gC(t);", "if(e.transportKind===`remote-control`)return new Remote(e);", - "if(n.io(e.hostConfig))return new Wsl({hostConfig:e.hostConfig,repoRoot:e.repoRoot,resourcesPath:e.resourcesPath,defaultOriginator:e.defaultOriginator});", - "let r=r6(e.hostConfig);if(r){e.desktopAuthAppServerClient;let t=p8(e.hostConfig,r);return new n.Fn({hostConfig:e.hostConfig,websocketUrl:r,getWebsocketProtocols:void 0,...t==null?{}:{socksProxyUrl:t}})}", - "return new n.Nn({hostConfig:e.hostConfig,repoRoot:e.repoRoot,resourcesPath:e.resourcesPath,defaultOriginator:e.defaultOriginator})}function afterFactory(){}", + "if(n.no(e.hostConfig))return new hoe({hostConfig:e.hostConfig,repoRoot:e.repoRoot,resourcesPath:e.resourcesPath,defaultOriginator:e.defaultOriginator});", + "let r=x5(e.hostConfig);if(r){e.desktopAuthAppServerClient;let t=vbe(e.hostConfig,r);return new n.Tn({hostConfig:e.hostConfig,websocketUrl:r,getWebsocketProtocols:void 0,...t==null?{}:{socksProxyUrl:t}})}", + "return new n.Cn({hostConfig:e.hostConfig,repoRoot:e.repoRoot,resourcesPath:e.resourcesPath,defaultOriginator:e.defaultOriginator})}function afterFactory(){}", ].join(""); } @@ -198,14 +498,27 @@ test("shared-app-server-socket stays disabled until explicitly enabled", () => { }); }); -test("feature stages only the socket environment hook", () => { +test("feature stages its socket hooks and orphan reaper", () => { withFeatureConfig(["shared-app-server-socket"], (featuresRoot) => { const appDir = fs.mkdtempSync(path.join(os.tmpdir(), "shared-app-server-socket-app-")); try { const plan = stageEnabledLinuxFeatureInstall(appDir, { featuresRoot }); assert.deepEqual( plan.runtimeHooks.map((hook) => [hook.key, path.basename(hook.target), hook.mode.toString(8)]), - [["launcher", "shared-app-server-socket-socket-env.sh", "755"]], + [ + ["launcher", "shared-app-server-socket-socket-env.sh", "755"], + ["afterExit", "shared-app-server-socket-socket-cleanup.sh", "755"], + ], + ); + assert.deepEqual( + plan.resources.map((resource) => [ + resource.target, + resource.mode.toString(8), + ]), + [[ + ".codex-linux/features/shared-app-server-socket/orphan-reaper.js", + "644", + ]], ); } finally { fs.rmSync(appDir, { recursive: true, force: true }); @@ -229,8 +542,8 @@ test("patch selects the bridge only for the local host and is idempotent", () => assert.match(patched, /reclaimStaleLock/); assert.match(patched, /this\.sameIdentity\(this\.socketIdentity,e\)/); assert.match(patched, /requires CODEX_CLI_PATH/); - assert.match(patched, /new n\.zn\(Fy,/); - assert.match(patched, /new n\.Rn\(/); + assert.match(patched, /new n\.kn\(qae,/); + assert.match(patched, /new n\.On\(/); assert.match(patched, /supportsReconnect\(\)\{return!0\}/); }); @@ -248,7 +561,7 @@ test("patch leaves unsupported bundle shapes unchanged with a warning", () => { test("patch rejects the previous SSH transport class shape", () => { const source = syntheticBundle().replace( - "class{options;kind=`websocket`;logger=r.i(`AppServerTransportSshWebsocket`);", + "class{options;kind=`websocket`;logger=i.i(`AppServerTransportSshWebsocket`);", "class{kind=`websocket`;", ); const warnings = []; @@ -290,6 +603,271 @@ test("socket hook exports an instance-scoped path without starting a process", ( } }); +test("socket hook emits no launcher environment during after-exit cleanup", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "shared-app-server-after-exit-")); + const env = { + ...process.env, + CODEX_LINUX_APP_ID: "codex-bridge-test", + CODEX_LINUX_APP_STATE_DIR: path.join(tempDir, "state"), + CODEX_LINUX_FEATURE_HOOK_PHASE: "after-exit", + XDG_RUNTIME_DIR: tempDir, + }; + delete env.CODEX_LINUX_APP_SERVER_BRIDGE_SOCKET; + try { + const result = spawnSync(socketEnvHook, [], { encoding: "utf8", env }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, ""); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("orphan reaper preserves a live owner and its listener", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "shared-app-server-live-reaper-")); + const socketPath = path.join(tempDir, "app-server.sock"); + const lockPath = `${socketPath}.lock`; + const selfStat = fs.readFileSync(`/proc/${process.pid}/stat`, "utf8"); + const selfStartTime = selfStat.slice(selfStat.lastIndexOf(")") + 2).trim().split(/\s+/)[19]; + const server = await listenUnix(socketPath); + fs.writeFileSync(lockPath, `${process.pid} ${selfStartTime}\n`, { mode: 0o600 }); + try { + const result = spawnSync(process.execPath, [orphanReaper, socketPath], { + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(fs.readFileSync(lockPath, "utf8"), `${process.pid} ${selfStartTime}\n`); + assert.equal(fs.lstatSync(socketPath).isSocket(), true); + } finally { + await closeServer(server); + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("orphan reaper fails closed on an unknown live listener", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "shared-app-server-foreign-reaper-")); + const socketPath = path.join(tempDir, "app-server.sock"); + const lockPath = `${socketPath}.lock`; + const server = await listenUnix(socketPath); + const selfStartTime = processStartTime(process.pid); + fs.writeFileSync(lockPath, `99999999 1 ${process.pid} ${selfStartTime}\n`, { mode: 0o600 }); + try { + const result = spawnSync(process.execPath, [orphanReaper, socketPath], { + encoding: "utf8", + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /not the expected reparented Codex process/); + assert.equal( + fs.readFileSync(lockPath, "utf8"), + `99999999 1 ${process.pid} ${selfStartTime}\n`, + ); + assert.equal(fs.lstatSync(socketPath).isSocket(), true); + } finally { + await closeServer(server); + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("orphan reaper accepts an authority adopted directly by PID 1", () => { + const authority = authorityProcess({ pid: 2001, ppid: 1 }); + const verifiedOrphanTargets = loadOrphanReaperVerifier(new Map([[authority.pid, authority]])); + + assert.deepEqual( + Array.from(verifiedOrphanTargets(lockedAuthority(authority), [])).map((target) => target.pid), + [authority.pid], + ); +}); + +test("orphan reaper accepts an authority adopted by the verified systemd user manager", () => { + const adopter = { + pid: 1235, + uid: process.getuid(), + state: "S", + ppid: 1, + startTime: "99", + comm: "systemd", + commandLine: ["/usr/lib/systemd/systemd", "--user", "--deserialize=10"], + }; + const authority = authorityProcess({ pid: 2001, ppid: adopter.pid }); + const verifiedOrphanTargets = loadOrphanReaperVerifier( + new Map([ + [authority.pid, authority], + [adopter.pid, adopter], + ]), + ); + + assert.deepEqual( + Array.from(verifiedOrphanTargets(lockedAuthority(authority), [])).map((target) => target.pid), + [authority.pid], + ); +}); + +test("orphan reaper accepts an authority adopted by a verified Nix systemd user manager", () => { + const adopter = { + pid: 1235, + uid: process.getuid(), + state: "S", + ppid: 1, + startTime: "99", + comm: "systemd", + commandLine: [ + "/nix/store/0123456789abcdef-systemd-257.6/lib/systemd/systemd", + "--user", + "--deserialize=10", + ], + }; + const authority = authorityProcess({ pid: 2001, ppid: adopter.pid }); + const verifiedOrphanTargets = loadOrphanReaperVerifier( + new Map([ + [authority.pid, authority], + [adopter.pid, adopter], + ]), + ); + + assert.deepEqual( + Array.from(verifiedOrphanTargets(lockedAuthority(authority), [])).map((target) => target.pid), + [authority.pid], + ); +}); + +test("orphan reaper rejects every invalid systemd user manager adopter identity", () => { + const validAdopter = { + pid: 1235, + uid: process.getuid(), + state: "S", + ppid: 1, + startTime: "99", + comm: "systemd", + commandLine: ["/nix/store/0123456789abcdef-systemd-257.6/lib/systemd/systemd", "--user"], + }; + const cases = [ + ["wrong uid", () => ({ ...validAdopter, uid: validAdopter.uid + 1 })], + ["zombie", () => ({ ...validAdopter, state: "Z" })], + ["missing", () => null], + ["reused pid", () => { + let reads = 0; + return () => ({ ...validAdopter, startTime: reads++ === 0 ? "99" : "100" }); + }], + ["non-init parent", () => ({ ...validAdopter, ppid: 321 })], + ["wrong comm", () => ({ ...validAdopter, comm: "init" })], + ["missing --user", () => ({ ...validAdopter, commandLine: [validAdopter.commandLine[0]] })], + ["relative executable", () => ({ ...validAdopter, commandLine: ["systemd", "--user"] })], + ["wrong executable basename", () => ({ + ...validAdopter, + commandLine: ["/nix/store/0123456789abcdef-systemd-257.6/lib/systemd/systemd-wrapper", "--user"], + })], + ]; + + for (const [description, makeAdopter] of cases) { + const authority = authorityProcess({ pid: 2001, ppid: validAdopter.pid }); + const adopter = makeAdopter(); + const processes = new Map([[authority.pid, authority]]); + if (adopter != null) processes.set(validAdopter.pid, adopter); + const verifiedOrphanTargets = loadOrphanReaperVerifier(processes); + + assert.throws( + () => verifiedOrphanTargets(lockedAuthority(authority), []), + /not the expected reparented Codex process/, + description, + ); + } +}); + +test("orphan reaper rechecks adopter identity before signaling", async () => { + const { reaperPromise, signals } = startOrphanReaperWithChangedAdopter(); + + await assert.rejects(reaperPromise, /ownership changed during orphan verification/); + assert.deepEqual(signals, []); +}); + +test("orphan reaper rejects an authority adopted by an unrelated live parent", () => { + const adopter = { + pid: 1235, + uid: process.getuid(), + state: "S", + ppid: 1, + startTime: "99", + comm: "node", + commandLine: ["/usr/bin/node", "supervisor.js"], + }; + const authority = authorityProcess({ pid: 2001, ppid: adopter.pid }); + const verifiedOrphanTargets = loadOrphanReaperVerifier( + new Map([ + [authority.pid, authority], + [adopter.pid, adopter], + ]), + ); + + assert.throws( + () => verifiedOrphanTargets(lockedAuthority(authority), []), + /not the expected reparented Codex process/, + ); +}); + +test("orphan reaper stops an exact reparented authority and removes stale ownership", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "shared-app-server-orphan-reaper-")); + const socketPath = path.join(tempDir, "app-server.sock"); + const lockPath = `${socketPath}.lock`; + const orphan = await spawnOrphanAuthority(socketPath); + fs.writeFileSync(lockPath, `99999999 1 ${orphan.pid} ${orphan.startTime}\n`, { mode: 0o600 }); + try { + const result = spawnSync(process.execPath, [orphanReaper, socketPath], { + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /Stopped orphaned shared app-server authority/); + await waitForCondition( + () => processStartTime(orphan.pid) !== orphan.startTime, + "orphaned authority to exit", + ); + assert.equal(fs.existsSync(socketPath), false); + assert.equal(fs.existsSync(lockPath), false); + } finally { + if (processStartTime(orphan.pid) === orphan.startTime) { + try { + process.kill(orphan.pid, "SIGTERM"); + } catch (error) { + if (error.code !== "ESRCH") throw error; + } + } + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("orphan reaper refuses two live listener inodes for the same pathname", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "shared-app-server-rebind-reaper-")); + const socketPath = path.join(tempDir, "app-server.sock"); + const lockPath = `${socketPath}.lock`; + const orphan = await spawnOrphanAuthority(socketPath); + const lockContents = `99999999 1 ${orphan.pid} ${orphan.startTime}\n`; + fs.writeFileSync(lockPath, lockContents, { mode: 0o600 }); + fs.unlinkSync(socketPath); + const replacement = await listenUnix(socketPath); + try { + await waitForCondition( + () => unixListenerInodes(socketPath).length === 2, + "old and replacement listener inodes", + ); + const result = spawnSync(process.execPath, [orphanReaper, socketPath], { + encoding: "utf8", + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /multiple live listener inodes/); + assert.equal(processStartTime(orphan.pid), orphan.startTime); + assert.equal(fs.readFileSync(lockPath, "utf8"), lockContents); + assert.equal(fs.lstatSync(socketPath).isSocket(), true); + } finally { + await closeServer(replacement); + if (processStartTime(orphan.pid) === orphan.startTime) { + try { + process.kill(orphan.pid, "SIGTERM"); + } catch (error) { + if (error.code !== "ESRCH") throw error; + } + } + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + test("injected transport rejects an existing socket without unlinking it", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "shared-app-server-existing-")); const socketPath = path.join(tempDir, "app-server.sock"); @@ -377,6 +955,10 @@ test("injected transport serializes startup and removes only its owned socket", try { await first.ensureAuthority(); assert.equal(fs.existsSync(`${socketPath}.lock`), true); + assert.match( + fs.readFileSync(`${socketPath}.lock`, "utf8"), + new RegExp(`^${process.pid} \\d+ ${process.pid} \\d+\\n$`), + ); await assert.rejects(second.ensureAuthority(), /already owned/); installReplacementBeforeChildClose = true; @@ -603,6 +1185,11 @@ test("injected transport does not release ownership until authority exit is veri try { await assert.rejects(transport.ensureAuthority(), /creation timed out/); assert.equal(fs.existsSync(`${socketPath}.lock`), true, "unverified child retains ownership lock"); + assert.match( + fs.readFileSync(`${socketPath}.lock`, "utf8"), + new RegExp(`^${process.pid} \\d+ ${process.pid} \\d+\\n$`), + "the lock binds cleanup to the spawned authority before socket readiness", + ); } finally { if (originalCli == null) delete process.env.CODEX_CLI_PATH; else process.env.CODEX_CLI_PATH = originalCli; @@ -811,6 +1398,11 @@ test("socket environment hook shell syntax is valid", () => { assert.equal(result.status, 0, result.stderr); }); +test("orphan reaper JavaScript syntax is valid", () => { + const result = spawnSync(process.execPath, ["--check", orphanReaper], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); +}); + test("documented wrapper attaches to a real Codex authority through the stock proxy", { timeout: 15000 }, async (t) => { const codexCli = process.env.CODEX_CLI_PATH; if (codexCli == null) { diff --git a/linux-features/ssh-command-wrapper/README.md b/linux-features/ssh-command-wrapper/README.md new file mode 100644 index 000000000..51a5a1675 --- /dev/null +++ b/linux-features/ssh-command-wrapper/README.md @@ -0,0 +1,63 @@ +# SSH Command Wrapper + +This opt-in feature adds a **Remote command wrapper** field to each saved SSH +connection in **Settings → Connections → SSH**. Codex appends its generated +remote command as the wrapper's final argument and runs the result on the +configured SSH host. + +For a connection that should route Codex operations through a specific target +host, configure: + +```text +ssh -T target-host -- +``` + +Codex still opens the configured outer connection, but every remote probe, +installation command, and app-server proxy is then routed through +`target-host`. +The wrapper is also part of the SSH startup-gate identity, so connections with +different wrappers do not share startup sequencing. + +The field accepts POSIX-style argv text, including single quotes, double +quotes, and backslash escaping. It is deliberately not a shell-snippet field: +unquoted shell operators (`;`, `&`, `|`, `<`, and `>`) and newlines are +rejected. The limit is 4096 characters in both the input and its canonical +formatted form, and 64 arguments. Wrapper failures use the existing SSH +connection error path and abort setup. + +An empty wrapper leaves upstream SSH behavior unchanged. Saved discovered +aliases and manually entered hosts both support wrappers. + +## Enable + +Add the feature id to the gitignored `linux-features/features.json`, then +rebuild the app: + +```json +{ + "enabled": [ + "ssh-command-wrapper" + ] +} +``` + +```bash +./install.sh ./Codex.dmg +``` + +## Test + +```bash +node --test linux-features/ssh-command-wrapper/test.js +``` + +## Risks + +The feature patches current upstream minified main-process and settings +bundles. Upstream UI or SSH transport changes can cause the optional patch to +be skipped with a warning until its bundle needles are updated. A configured +wrapper can run any executable available on the outer remote host, so only use +values you trust. + +The wrapper argv is stored with the saved SSH connection. Do not put +passwords, access tokens, private-key material, or other secrets in it. diff --git a/linux-features/ssh-command-wrapper/feature.json b/linux-features/ssh-command-wrapper/feature.json new file mode 100644 index 000000000..71fef75af --- /dev/null +++ b/linux-features/ssh-command-wrapper/feature.json @@ -0,0 +1,9 @@ +{ + "id": "ssh-command-wrapper", + "title": "SSH Command Wrapper", + "description": "Adds a per-connection argv wrapper for routing Codex SSH operations through another remote command.", + "defaultEnabled": false, + "entrypoints": { + "patchDescriptors": "./patch.js" + } +} diff --git a/linux-features/ssh-command-wrapper/patch.js b/linux-features/ssh-command-wrapper/patch.js new file mode 100644 index 000000000..68d8cbd26 --- /dev/null +++ b/linux-features/ssh-command-wrapper/patch.js @@ -0,0 +1,380 @@ +"use strict"; + +const MAX_WRAPPER_TEXT_LENGTH = 4096; +const MAX_WRAPPER_ARGS = 64; +const WRAPPER_PROPERTY = "codexLinuxSshCommandWrapper"; +const WRAPPER_TEXT_PROPERTY = "codexLinuxSshCommandWrapperText"; +const MAIN_MARKER = "function codexLinuxSshCommandWrapperArgs("; +const WEBVIEW_MARKER = "function codexLinuxParseSshCommandWrapper("; +const MAIN_HELPER_MARKERS = [ + MAIN_MARKER, + "function codexLinuxSshCommandWrapperQuote(", + "function codexLinuxSshWrapRemoteCommand(", +]; +const WEBVIEW_HELPER_MARKERS = [ + WEBVIEW_MARKER, + "function codexLinuxFormatSshCommandWrapper(", +]; + +function wrapperError(message) { + const error = new Error(message); + error.code = "invalidSshCommandWrapper"; + return error; +} + +function parseCommandWrapper(input) { + if (typeof input !== "string") { + throw wrapperError("Remote command wrapper must be text"); + } + if (input.length > MAX_WRAPPER_TEXT_LENGTH) { + throw wrapperError(`Remote command wrapper must be at most ${MAX_WRAPPER_TEXT_LENGTH} characters`); + } + if (input.includes("\0") || /[\r\n]/u.test(input)) { + throw wrapperError("Remote command wrapper cannot contain NUL or newlines"); + } + + const args = []; + let value = ""; + let quote = null; + let started = false; + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + if (quote === "'") { + if (char === "'") quote = null; + else value += char; + started = true; + continue; + } + if (quote === '"') { + if (char === '"') { + quote = null; + } else if (char === "\\") { + index += 1; + if (index >= input.length) throw wrapperError("Remote command wrapper has a trailing escape"); + const escaped = input[index]; + value += '$`"\\'.includes(escaped) ? escaped : `\\${escaped}`; + } else { + value += char; + } + started = true; + continue; + } + if (char === "'" || char === '"') { + quote = char; + started = true; + continue; + } + if (char === "\\") { + index += 1; + if (index >= input.length) throw wrapperError("Remote command wrapper has a trailing escape"); + value += input[index]; + started = true; + continue; + } + if (/\s/u.test(char)) { + if (started) { + args.push(value); + value = ""; + started = false; + if (args.length > MAX_WRAPPER_ARGS) { + throw wrapperError(`Remote command wrapper must have at most ${MAX_WRAPPER_ARGS} arguments`); + } + } + continue; + } + if (/[;&|<>]/u.test(char)) { + throw wrapperError(`Remote command wrapper contains an unquoted shell operator: ${char}`); + } + value += char; + started = true; + } + if (quote != null) throw wrapperError("Remote command wrapper has an unterminated quote"); + if (started) args.push(value); + return validateCommandWrapperArgs(args); +} + +function quoteShellArg(value) { + if (typeof value !== "string" || value.length === 0) return "''"; + if (/^[A-Za-z0-9_@%+=:,./-]+$/u.test(value)) return value; + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +function formatCommandWrapper(args) { + const valid = validateCommandWrapperArgs(args); + return valid.map(quoteShellArg).join(" "); +} + +function validateCommandWrapperArgs(value) { + if (value == null) return []; + if (!Array.isArray(value) || value.length > MAX_WRAPPER_ARGS) { + throw wrapperError("Remote command wrapper argv is invalid"); + } + let totalLength = 0; + const result = value.map((arg) => { + if (typeof arg !== "string" || /[\0\r\n]/u.test(arg)) { + throw wrapperError("Remote command wrapper argv is invalid"); + } + totalLength += arg.length; + return arg; + }); + if (totalLength > MAX_WRAPPER_TEXT_LENGTH) { + throw wrapperError("Remote command wrapper argv is too long"); + } + if (result.length > 0 && result[0].length === 0) { + throw wrapperError("Remote command wrapper executable cannot be empty"); + } + const formattedLength = result.reduce( + (length, arg, index) => length + quoteShellArg(arg).length + (index === 0 ? 0 : 1), + 0, + ); + if (formattedLength > MAX_WRAPPER_TEXT_LENGTH) { + throw wrapperError(`Remote command wrapper must format to at most ${MAX_WRAPPER_TEXT_LENGTH} characters`); + } + return result; +} + +function wrapRemoteCommand(command, args) { + const valid = validateCommandWrapperArgs(args); + if (valid.length === 0) return command; + return `exec ${valid.map(quoteShellArg).join(" ")} ${quoteShellArg(command)}`; +} + +function countOccurrences(source, needle) { + let count = 0; + let index = 0; + while (true) { + index = source.indexOf(needle, index); + if (index < 0) return count; + count += 1; + index += needle.length; + } +} + +function replacementState(source, config) { + const before = config.replacements.map(([needle]) => countOccurrences(source, needle)); + const after = config.replacements.map(([, replacement]) => countOccurrences(source, replacement)); + const helpers = config.helperMarkers.map((marker) => countOccurrences(source, marker)); + const helperSource = countOccurrences(source, config.helperSource()); + const anchors = config.requiredAnchors.map((anchor) => countOccurrences(source, anchor)); + const fresh = + helpers.every((count) => count === 0) && + anchors.every((count) => count === 1) && + before.every((count) => count === 1) && + after.every((count) => count === 0); + const complete = + helpers.every((count) => count === 1) && + helperSource === 1 && + anchors.every((count) => count === 1) && + before.every((count) => count === 0) && + after.every((count) => count === 1); + return { fresh, complete, before, after, helpers, helperSource, anchors }; +} + +function warnUnexpectedPatchState(label, state) { + console.warn( + `WARN: SSH command-wrapper ${label} patch is partial, ambiguous, or drifted ` + + `(before=[${state.before.join(",")}], after=[${state.after.join(",")}], ` + + `helpers=[${state.helpers.join(",")}], helperSource=${state.helperSource}, ` + + `anchors=[${state.anchors.join(",")}])`, + ); +} + +function replaceExactlyOnce(source, needle, replacement, label) { + const count = countOccurrences(source, needle); + if (count !== 1) { + console.warn(`WARN: Expected exactly one ${label}; found ${count} - skipping SSH command-wrapper patch`); + return null; + } + return source.replace(needle, replacement); +} + +function applyCompletePatch(source, config) { + const initialState = replacementState(source, config); + if (initialState.complete) return source; + if (!initialState.fresh) { + warnUnexpectedPatchState(config.label, initialState); + return source; + } + + let patched = replaceExactlyOnce( + source, + config.helperAnchor, + `${config.helperSource()}${config.helperAnchor}`, + `${config.label} helper insertion`, + ); + if (patched == null) return source; + + for (const [needle, replacement, label] of config.replacements) { + patched = replaceExactlyOnce(patched, needle, replacement, label); + if (patched == null) return source; + } + + const finalState = replacementState(patched, config); + if (!finalState.complete) { + warnUnexpectedPatchState(config.label, finalState); + return source; + } + return patched; +} + +function mainHelperSource() { + return [ + "function codexLinuxSshCommandWrapperArgs(e){if(e==null)return[];if(!Array.isArray(e)||e.length>64)throw Error(`Invalid SSH remote command wrapper`);let t=0,n=e.map(e=>{if(typeof e!=`string`||/[\\0\\r\\n]/u.test(e))throw Error(`Invalid SSH remote command wrapper`);if(t+=e.length,t>4096)throw Error(`Invalid SSH remote command wrapper`);return e});if(n.length>0&&n[0].length===0||n.map(codexLinuxSshCommandWrapperQuote).join(` `).length>4096)throw Error(`Invalid SSH remote command wrapper`);return n}", + "function codexLinuxSshCommandWrapperQuote(e){return e.length>0&&/^[A-Za-z0-9_@%+=:,./-]+$/u.test(e)?e:`'${e.replaceAll(`'`,`'\\\\''`)}'`}", + "function codexLinuxSshWrapRemoteCommand(e,t){let n=codexLinuxSshCommandWrapperArgs(t);return n.length===0?e:`exec ${n.map(codexLinuxSshCommandWrapperQuote).join(` `)} ${codexLinuxSshCommandWrapperQuote(e)}`}", + ].join(""); +} + +function applyMainBundlePatch(source) { + const replacements = [ + [ + "n.Xn({args:[`ssh`,...oS(c),...cS(this.options.sshConnection),Gx(e,s)],spawnInsideWsl:!1})", + `n.Xn({args:[\`ssh\`,...oS(c),...cS(this.options.sshConnection),codexLinuxSshWrapRemoteCommand(Gx(e,s),this.options.sshConnection.${WRAPPER_PROPERTY})],spawnInsideWsl:!1})`, + "SSH management command", + ], + [ + "(0,x.spawn)(n.nr.resolve(`ssh`)??`ssh`,[`-T`,...oS(this.options.getConnectTimeoutSeconds?.()),...cS(this.options.sshConnection),Gx(t,i)],{env:r.t(process.env),stdio:[`pipe`,`pipe`,`pipe`]})", + `(0,x.spawn)(n.nr.resolve(\`ssh\`)??\`ssh\`,[\`-T\`,...oS(this.options.getConnectTimeoutSeconds?.()),...cS(this.options.sshConnection),codexLinuxSshWrapRemoteCommand(Gx(t,i),this.options.sshConnection.${WRAPPER_PROPERTY})],{env:r.t(process.env),stdio:[\`pipe\`,\`pipe\`,\`pipe\`]})`, + "SSH app-server proxy command", + ], + [ + "return t?{sshConnection:{alias:t.sshAlias,host:t.sshHost,port:t.sshPort,identity:t.identity}}:null", + `return t?{sshConnection:{alias:t.sshAlias,host:t.sshHost,port:t.sshPort,identity:t.identity,${WRAPPER_PROPERTY}:t.${WRAPPER_PROPERTY}}}:null`, + "SSH transport host mapping", + ], + [ + "function Wre(e){let t=e.alias?.trim();return t?`alias:${t}`:[`direct`,e.host,String(e.port??``),e.identity?.trim()??``].join(`", + `function Wre(e){let t=e.alias?.trim(),n=JSON.stringify(codexLinuxSshCommandWrapperArgs(e.${WRAPPER_PROPERTY}));return t?\`alias:\${t}:\${n}\`:[\`direct\`,e.host,String(e.port??\`\`),e.identity?.trim()??\`\`,n].join(\``, + "SSH startup-gate identity", + ], + [ + "displayName:e.displayName,autoConnect:!1})", + `displayName:e.displayName,${WRAPPER_PROPERTY}:e.${WRAPPER_PROPERTY},autoConnect:!1})`, + "saved alias runtime mapping", + ], + [ + "sshPort:e.sshPort,identity:e.identity}]),...t.filter", + `sshPort:e.sshPort,identity:e.identity,${WRAPPER_PROPERTY}:e.${WRAPPER_PROPERTY}}]),...t.filter`, + "saved hostname runtime mapping", + ], + [ + "sshPort:e.sshPort,identity:e.identity}:{hostId:e.hostId,connectionAnalyticsId:e.connectionAnalyticsId,displayName:e.displayName,source:`discovered`,alias:e.alias,hostname:null,sshPort:null,identity:null}", + `sshPort:e.sshPort,identity:e.identity,${WRAPPER_PROPERTY}:e.${WRAPPER_PROPERTY}}:{hostId:e.hostId,connectionAnalyticsId:e.connectionAnalyticsId,displayName:e.displayName,source:\`discovered\`,alias:e.alias,hostname:null,sshPort:null,identity:null,${WRAPPER_PROPERTY}:e.${WRAPPER_PROPERTY}}`, + "current saved connection normalization", + ], + [ + "sshPort:t.sshPort,identity:t.identity}:{hostId:t.hostId,connectionAnalyticsId:t.connectionAnalyticsId,displayName:t.displayName,source:`discovered`,alias:n,hostname:null,sshPort:null,identity:null}", + `sshPort:t.sshPort,identity:t.identity,${WRAPPER_PROPERTY}:t.${WRAPPER_PROPERTY}}:{hostId:t.hostId,connectionAnalyticsId:t.connectionAnalyticsId,displayName:t.displayName,source:\`discovered\`,alias:n,hostname:null,sshPort:null,identity:null,${WRAPPER_PROPERTY}:t.${WRAPPER_PROPERTY}}`, + "legacy saved connection normalization", + ], + [ + "identity:e.identity}};return e.homeDir", + `identity:e.identity,${WRAPPER_PROPERTY}:e.${WRAPPER_PROPERTY}}};return e.homeDir`, + "SSH host metadata", + ], + [ + "var O$=n.mu({sshAlias:n._u().nullable(),sshHost:n._u(),sshPort:n.pu().nullable(),identity:n._u().nullable()});", + `var O$=n.mu({sshAlias:n._u().nullable(),sshHost:n._u(),sshPort:n.pu().nullable(),identity:n._u().nullable(),${WRAPPER_PROPERTY}:n.ou(n._u()).optional()});`, + "SSH host metadata schema", + ], + [ + "sshPort:e.sshPort,identity:e.identity,codexCliCommand:[]", + `sshPort:e.sshPort,identity:e.identity,${WRAPPER_PROPERTY}:e.${WRAPPER_PROPERTY},codexCliCommand:[]`, + "SSH host configuration", + ], + ]; + return applyCompletePatch(source, { + label: "main bundle", + helperAnchor: "function Gx(", + helperMarkers: MAIN_HELPER_MARKERS, + requiredAnchors: ["function Gx("], + helperSource: mainHelperSource, + replacements, + }); +} + +function webviewHelperSource() { + return [ + "function codexLinuxParseSshCommandWrapper(e){if(typeof e!=`string`||e.length>4096||/[\\0\\r\\n]/u.test(e))throw Error(`invalid`);let t=[],n=``,r=null,i=!1;for(let a=0;a=e.length)throw Error(`invalid`);let t=e[a];n+=`$\\\\\\\"`.includes(t)?t:`\\\\${t}`}else n+=o;i=!0;continue}if(o===`'`||o===`\\\"`){r=o,i=!0;continue}if(o===`\\\\`){if(++a>=e.length)throw Error(`invalid`);n+=e[a],i=!0;continue}if(/\\s/u.test(o)){if(i&&(t.push(n),n=``,i=!1,t.length>64))throw Error(`invalid`);continue}if(/[;&|<>]/u.test(o))throw Error(`invalid`);n+=o,i=!0}if(r!=null)throw Error(`invalid`);if(i&&t.push(n),t.length>64||t.length>0&&t[0].length===0||codexLinuxFormatSshCommandWrapper(t).length>4096)throw Error(`invalid`);return t}", + "function codexLinuxFormatSshCommandWrapper(e){if(e==null)return``;if(!Array.isArray(e)||e.length>64)throw Error(`invalid`);let t=0,n=e.map(e=>{if(typeof e!=`string`||/[\\0\\r\\n]/u.test(e))throw Error(`invalid`);if(t+=e.length,t>4096)throw Error(`invalid`);return e.length>0&&/^[A-Za-z0-9_@%+=:,./-]+$/u.test(e)?e:`'${e.replaceAll(`'`,`'\\\\''`)}'`}).join(` `);if(n.length>4096||e.length>0&&e[0].length===0)throw Error(`invalid`);return n}", + ].join(""); +} + +function applyWebviewPatch(source) { + const replacements = [ + [ + "authMode:`none`,identity:``}}", + `authMode:\`none\`,identity:\`\`,${WRAPPER_TEXT_PROPERTY}:\`\`}}`, + "new connection draft", + ], + [ + "authMode:e.identity==null?`none`:`identity`,identity:e.identity??``}}", + `authMode:e.identity==null?\`none\`:\`identity\`,identity:e.identity??\`\`,${WRAPPER_TEXT_PROPERTY}:codexLinuxFormatSshCommandWrapper(e.${WRAPPER_PROPERTY})}}`, + "saved connection draft", + ], + [ + "identity:e.authMode===`identity`?e.identity.trim():null}:{hostId:", + `identity:e.authMode===\`identity\`?e.identity.trim():null,${WRAPPER_PROPERTY}:codexLinuxParseSshCommandWrapper(e.${WRAPPER_TEXT_PROPERTY})}:{hostId:`, + "hostname connection save", + ], + [ + "sshPort:null,identity:null}}function Li(", + `sshPort:null,identity:null,${WRAPPER_PROPERTY}:codexLinuxParseSshCommandWrapper(e.${WRAPPER_TEXT_PROPERTY})}}function Li(`, + "alias connection save", + ], + [ + "let r=[],i=e.displayName.trim();i.length===0&&", + `let r=[],i=e.displayName.trim();try{codexLinuxParseSshCommandWrapper(e.${WRAPPER_TEXT_PROPERTY})}catch{r.push(\`invalidSshCommandWrapper\`)}i.length===0&&`, + "wrapper validation", + ], + [ + "children:[D,k,A]})", + `children:[D,k,A,(0,q.jsx)(_.Field,{name:\`${WRAPPER_TEXT_PROPERTY}\`,children:e=>(0,q.jsx)(Wi,{label:(0,q.jsxs)(q.Fragment,{children:[(0,q.jsx)(U,{id:\`settings.remoteConnections.dialog.field.commandWrapper\`,defaultMessage:\`Remote command wrapper\`,description:\`Label for the optional SSH remote command wrapper field\`}),\` \`,(0,q.jsx)(\`span\`,{className:\`font-normal text-token-text-secondary\`,children:(0,q.jsx)(U,{id:\`settings.remoteConnections.dialog.field.commandWrapper.optional\`,defaultMessage:\`(optional)\`,description:\`Marker for the optional SSH remote command wrapper field\`})})]}),description:(0,q.jsx)(U,{id:\`settings.remoteConnections.dialog.field.commandWrapper.description\`,defaultMessage:\`Runs every Codex SSH operation through this argv command and appends the generated remote command as its final argument.\`,description:\`Description for the SSH remote command wrapper field\`}),placeholder:\`ssh -T target-host --\`,value:e.state.value,onChange:e.handleChange,onBlur:e.handleBlur,disabled:l})})]})`, + "wrapper settings field", + ], + [ + "function Gi(e){switch(e){case`displayNameRequired`:", + "function Gi(e){switch(e){case`invalidSshCommandWrapper`:return(0,q.jsx)(U,{id:`settings.remoteConnections.dialog.field.commandWrapper.error`,defaultMessage:`Enter a valid command (quotes and escapes are supported; shell operators are not)`,description:`Error for an invalid SSH remote command wrapper`});case`displayNameRequired`:", + "wrapper validation message", + ], + ]; + return applyCompletePatch(source, { + label: "webview bundle", + helperAnchor: "function Pi(){", + helperMarkers: WEBVIEW_HELPER_MARKERS, + requiredAnchors: ["function Pi(){", "function Bi(e){"], + helperSource: webviewHelperSource, + replacements, + }); +} + +module.exports = { + MAX_WRAPPER_ARGS, + MAX_WRAPPER_TEXT_LENGTH, + applyMainBundlePatch, + applyWebviewPatch, + formatCommandWrapper, + parseCommandWrapper, + quoteShellArg, + validateCommandWrapperArgs, + wrapRemoteCommand, + descriptors: [ + { + id: "main-bundle-ssh-command-wrapper", + phase: "main-bundle", + order: 20700, + ciPolicy: "opt-in", + apply: applyMainBundlePatch, + }, + { + id: "webview-ssh-command-wrapper-settings", + phase: "webview-asset", + order: 20710, + ciPolicy: "opt-in", + pattern: /^remote-connections-settings-[^.]+\.js$/u, + missingDescription: "remote-connections settings webview bundle", + skipDescription: "SSH command-wrapper settings patch", + apply: applyWebviewPatch, + }, + ], +}; diff --git a/linux-features/ssh-command-wrapper/test.js b/linux-features/ssh-command-wrapper/test.js new file mode 100644 index 000000000..48aee64f6 --- /dev/null +++ b/linux-features/ssh-command-wrapper/test.js @@ -0,0 +1,288 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { + loadLinuxFeaturePatchDescriptors, +} = require("../../scripts/lib/linux-features.js"); +const { + applyMainBundlePatchDescriptors, + applyWebviewAssetPatchDescriptors, +} = require("../../scripts/patches/engine.js"); +const { + createPatchReport, +} = require("../../scripts/lib/patch-report.js"); +const { + MAX_WRAPPER_ARGS, + applyMainBundlePatch, + applyWebviewPatch, + descriptors, + formatCommandWrapper, + parseCommandWrapper, + validateCommandWrapperArgs, + wrapRemoteCommand, +} = require("./patch.js"); + +const managementCall = "n.Xn({args:[`ssh`,...oS(c),...cS(this.options.sshConnection),Gx(e,s)],spawnInsideWsl:!1})"; +const proxyCall = "(0,x.spawn)(n.nr.resolve(`ssh`)??`ssh`,[`-T`,...oS(this.options.getConnectTimeoutSeconds?.()),...cS(this.options.sshConnection),Gx(t,i)],{env:r.t(process.env),stdio:[`pipe`,`pipe`,`pipe`]})"; + +const mainFixture = [ + "function Gx(e,t){return e+t}", + `function management(){let u=${managementCall};return u}`, + `function proxy(){let a=${proxyCall};return a}`, + "function uS(e){let t=Hre(e);return t?{sshConnection:{alias:t.sshAlias,host:t.sshHost,port:t.sshPort,identity:t.identity}}:null}", + "function Wre(e){let t=e.alias?.trim();return t?`alias:${t}`:[`direct`,e.host,String(e.port??``),e.identity?.trim()??``].join(`:", + "aliasLoad.then(t=>t==null?null:{...t,hostId:e.hostId,connectionAnalyticsId:e.connectionAnalyticsId,displayName:e.displayName,autoConnect:!1})", + "let direct=[{hostId:e.hostId,sshPort:e.sshPort,identity:e.identity}]),...t.filter", + "let current=e.alias==null?{hostId:e.hostId,connectionAnalyticsId:e.connectionAnalyticsId,displayName:e.displayName,source:`codex-managed`,alias:null,hostname:e.hostname,sshPort:e.sshPort,identity:e.identity}:{hostId:e.hostId,connectionAnalyticsId:e.connectionAnalyticsId,displayName:e.displayName,source:`discovered`,alias:e.alias,hostname:null,sshPort:null,identity:null}", + "let legacy=n==null?{hostId:t.hostId,connectionAnalyticsId:t.connectionAnalyticsId,displayName:t.displayName,source:`codex-managed`,alias:null,hostname:t.sshHost,sshPort:t.sshPort,identity:t.identity}:{hostId:t.hostId,connectionAnalyticsId:t.connectionAnalyticsId,displayName:t.displayName,source:`discovered`,alias:n,hostname:null,sshPort:null,identity:null}", + "let host={metadata:{identity:e.identity}};return e.homeDir", + "var O$=n.mu({sshAlias:n._u().nullable(),sshHost:n._u(),sshPort:n.pu().nullable(),identity:n._u().nullable()});", + "let config={sshPort:e.sshPort,identity:e.identity,codexCliCommand:[]}", +].join(";"); + +const webviewFixture = [ + "function Pi(){return{displayName:``,targetKind:`hostname`,sshHost:``,sshPort:``,authMode:`none`,identity:``}}", + "function Fi(e){return{authMode:e.identity==null?`none`:`identity`,identity:e.identity??``}}", + "function Ii(e){return e.targetKind===`hostname`?{identity:e.authMode===`identity`?e.identity.trim():null}:{hostId:x,sshPort:null,identity:null}}", + "function Li(e){let r=[],i=e.displayName.trim();i.length===0&&r.push(`displayNameRequired`);return r}", + "function Bi(e){let _,q,U,Wi,l,D,k,A,j;j=(0,q.jsx)(x,{children:(0,q.jsxs)(`div`,{children:[D,k,A]})});return j}", + "function Gi(e){switch(e){case`displayNameRequired`:return null}}", +].join(""); + +function withCapturedWarnings(callback) { + const warnings = []; + const originalWarn = console.warn; + console.warn = (message) => warnings.push(String(message)); + try { + return { value: callback(), warnings }; + } finally { + console.warn = originalWarn; + } +} + +function withFeatureConfig(enabled, callback) { + const originalConfig = process.env.CODEX_LINUX_FEATURES_CONFIG; + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ssh-command-wrapper-feature-")); + process.env.CODEX_LINUX_FEATURES_CONFIG = path.join(tempDir, "features.json"); + fs.writeFileSync(process.env.CODEX_LINUX_FEATURES_CONFIG, `${JSON.stringify({ enabled })}\n`); + try { + return callback(path.resolve(__dirname, "..")); + } finally { + if (originalConfig == null) delete process.env.CODEX_LINUX_FEATURES_CONFIG; + else process.env.CODEX_LINUX_FEATURES_CONFIG = originalConfig; + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +test("parses argv text without invoking a shell", () => { + assert.deepEqual(parseCommandWrapper("ssh -T target-host --"), ["ssh", "-T", "target-host", "--"]); + assert.deepEqual(parseCommandWrapper("env 'NAME=hello world' command\\ name \"\""), [ + "env", + "NAME=hello world", + "command name", + "", + ]); + assert.deepEqual(parseCommandWrapper('command "a\\q" "a\\$b"'), ["command", "a\\q", "a$b"]); + assert.deepEqual(parseCommandWrapper(""), []); + assert.deepEqual(parseCommandWrapper(" \t "), []); +}); + +test("rejects snippets and malformed or oversized argv", () => { + for (const value of [ + "ssh target-host; echo unsafe", + "ssh target-host | tee log", + "ssh target-host\nwhoami", + "ssh 'target-host", + "ssh target-host\\", + "'' -T target-host", + `ssh ${"x".repeat(4096)}`, + ]) { + assert.throws(() => parseCommandWrapper(value), { code: "invalidSshCommandWrapper" }); + } + assert.throws( + () => parseCommandWrapper(Array.from({ length: MAX_WRAPPER_ARGS + 1 }, () => "x").join(" ")), + { code: "invalidSshCommandWrapper" }, + ); +}); + +test("round trips quoted argv and preserves an empty wrapper", () => { + const args = ["ssh", "-T", "login node", "--", "apostrophe's", ""]; + assert.deepEqual(parseCommandWrapper(formatCommandWrapper(args)), args); + assert.equal(wrapRemoteCommand("sh -c 'echo ok'", []), "sh -c 'echo ok'"); + assert.equal( + wrapRemoteCommand("sh -c 'echo ok'", ["ssh", "-T", "target-host", "--"]), + "exec ssh -T target-host -- 'sh -c '\\''echo ok'\\'''", + ); +}); + +test("keeps apostrophe-heavy wrappers within the canonical editor limit", () => { + const atLimit = ["ssh", "'".repeat(1022), "x"]; + const formatted = formatCommandWrapper(atLimit); + assert.equal(formatted.length, 4096); + assert.deepEqual(parseCommandWrapper(formatted), atLimit); + + const overLimitInput = `ssh "${"'".repeat(1023)}"`; + assert.throws(() => parseCommandWrapper(overLimitInput), { code: "invalidSshCommandWrapper" }); + assert.throws(() => validateCommandWrapperArgs(["ssh", "'".repeat(1023)]), { + code: "invalidSshCommandWrapper", + }); + assert.throws(() => formatCommandWrapper(["ssh", "'".repeat(1023)]), { + code: "invalidSshCommandWrapper", + }); +}); + +test("validates persisted argv independently of the editor", () => { + assert.deepEqual(validateCommandWrapperArgs(null), []); + assert.deepEqual(validateCommandWrapperArgs(["ssh", "-T"]), ["ssh", "-T"]); + assert.throws(() => validateCommandWrapperArgs("ssh -T"), { code: "invalidSshCommandWrapper" }); + assert.throws(() => validateCommandWrapperArgs(["ssh\nwhoami"]), { + code: "invalidSshCommandWrapper", + }); +}); + +test("patches all main-process transport and persistence paths idempotently", () => { + const patched = applyMainBundlePatch(mainFixture); + assert.notEqual(patched, mainFixture); + assert.equal(applyMainBundlePatch(patched), patched); + assert.match(patched, /codexLinuxSshWrapRemoteCommand\(Gx\(e,s\)/u); + assert.match(patched, /codexLinuxSshWrapRemoteCommand\(Gx\(t,i\)/u); + assert.match(patched, /codexLinuxSshCommandWrapperArgs\(e\.codexLinuxSshCommandWrapper\)/u); + assert.ok(patched.split("codexLinuxSshCommandWrapper").length > 10); +}); + +test("main-process patch rejects a stale injected helper implementation", () => { + const patched = applyMainBundlePatch(mainFixture); + const stale = patched.replace("e.length>64", "e.length>63"); + assert.notEqual(stale, patched); + + const { value, warnings } = withCapturedWarnings(() => applyMainBundlePatch(stale)); + assert.equal(value, stale); + assert.match(warnings.join("\n"), /helperSource=0/u); +}); + +test("main-process patch fails soft and byte-identical on drift", () => { + const warnings = []; + const originalWarn = console.warn; + console.warn = (message) => warnings.push(String(message)); + try { + assert.equal(applyMainBundlePatch("function Gx(){}"), "function Gx(){}"); + } finally { + console.warn = originalWarn; + } + assert.ok(warnings.length > 0); +}); + +test("main-process patch rejects duplicate owned SSH targets", () => { + const duplicateTarget = `${mainFixture};function duplicate(){return ${managementCall}}`; + const { value, warnings } = withCapturedWarnings(() => applyMainBundlePatch(duplicateTarget)); + assert.equal(value, duplicateTarget); + assert.match(warnings.join("\n"), /partial, ambiguous, or drifted/u); +}); + +test("main-process helper-only partial state is reported as feature drift", () => { + const partial = mainFixture.replace( + "function Gx(", + "function codexLinuxSshCommandWrapperArgs(e){}function Gx(", + ); + withFeatureConfig(["ssh-command-wrapper"], (featuresRoot) => { + const descriptor = loadLinuxFeaturePatchDescriptors({ featuresRoot }) + .find((item) => item.id === "feature:ssh-command-wrapper:main-bundle-ssh-command-wrapper"); + const report = createPatchReport(); + report.enabledFeatures = ["ssh-command-wrapper"]; + const { value, warnings } = withCapturedWarnings(() => + applyMainBundlePatchDescriptors(partial, [descriptor], {}, report), + ); + assert.equal(value.patchedSource, partial); + assert.match(warnings.join("\n"), /partial, ambiguous, or drifted/u); + assert.equal(report.patches[0].status, "skipped-optional"); + }); +}); + +test("patches the SSH connection editor for manual hosts and aliases", () => { + const patched = applyWebviewPatch(webviewFixture); + assert.notEqual(patched, webviewFixture); + assert.equal(applyWebviewPatch(patched), patched); + assert.match(patched, /Remote command wrapper/u); + assert.match(patched, /ssh -T target-host --/u); + assert.match(patched, /invalidSshCommandWrapper/u); + assert.match(patched, /codexLinuxSshCommandWrapper:codexLinuxParseSshCommandWrapper/u); +}); + +test("webview patch rejects a damaged injected helper implementation", () => { + const patched = applyWebviewPatch(webviewFixture); + const damaged = patched.replace("t.length>64", "t.length>63"); + assert.notEqual(damaged, patched); + + const { value, warnings } = withCapturedWarnings(() => applyWebviewPatch(damaged)); + assert.equal(value, damaged); + assert.match(warnings.join("\n"), /helperSource=0/u); +}); + +test("webview patch rejects duplicate owned editor targets", () => { + const duplicateTarget = `${webviewFixture}function duplicate(){return{authMode:\`none\`,identity:\`\`}}`; + const { value, warnings } = withCapturedWarnings(() => applyWebviewPatch(duplicateTarget)); + assert.equal(value, duplicateTarget); + assert.match(warnings.join("\n"), /partial, ambiguous, or drifted/u); +}); + +test("webview helper-only partial state is reported as feature drift", () => { + const partial = webviewFixture.replace( + "function Pi(){", + "function codexLinuxParseSshCommandWrapper(e){}function Pi(){", + ); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ssh-command-wrapper-webview-")); + const assetsDir = path.join(tempDir, "webview", "assets"); + const assetPath = path.join(assetsDir, "remote-connections-settings-current.js"); + fs.mkdirSync(assetsDir, { recursive: true }); + fs.writeFileSync(assetPath, partial); + try { + withFeatureConfig(["ssh-command-wrapper"], (featuresRoot) => { + const descriptor = loadLinuxFeaturePatchDescriptors({ featuresRoot }) + .find((item) => item.id === "feature:ssh-command-wrapper:webview-ssh-command-wrapper-settings"); + const report = createPatchReport(); + report.enabledFeatures = ["ssh-command-wrapper"]; + const { warnings } = withCapturedWarnings(() => + applyWebviewAssetPatchDescriptors(tempDir, [descriptor], {}, report), + ); + assert.equal(fs.readFileSync(assetPath, "utf8"), partial); + assert.match(warnings.join("\n"), /partial, ambiguous, or drifted/u); + assert.equal(report.patches[0].status, "skipped-optional"); + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("exports opt-in main and settings descriptors", () => { + assert.deepEqual( + descriptors.map(({ phase, ciPolicy }) => [phase, ciPolicy]), + [ + ["main-bundle", "opt-in"], + ["webview-asset", "opt-in"], + ], + ); + assert.equal( + descriptors[1].pattern.test("remote-connections-settings-current.js"), + true, + ); +}); + +test("feature stays disabled until explicitly enabled", () => { + withFeatureConfig([], (featuresRoot) => { + assert.deepEqual(loadLinuxFeaturePatchDescriptors({ featuresRoot }), []); + }); + withFeatureConfig(["ssh-command-wrapper"], (featuresRoot) => { + assert.deepEqual( + loadLinuxFeaturePatchDescriptors({ featuresRoot }).map(({ id }) => id), + [ + "feature:ssh-command-wrapper:main-bundle-ssh-command-wrapper", + "feature:ssh-command-wrapper:webview-ssh-command-wrapper-settings", + ], + ); + }); +}); diff --git a/linux-features/ui-tweaks/README.md b/linux-features/ui-tweaks/README.md index 710af44b9..b98e2c546 100644 --- a/linux-features/ui-tweaks/README.md +++ b/linux-features/ui-tweaks/README.md @@ -147,7 +147,7 @@ Config keys: ### `reasoning.keepEffortLabelsEnglish` -Leaves the reasoning effort values as `None`, `Minimal`, `Low`, `Medium`, +Leaves the current reasoning effort values as `None`, `Minimal`, `Medium`, `High`, `XHigh`, `Max`, and `Ultra` in the Simplified Chinese locale. The surrounding picker title and usage warning remain translated. This avoids collapsing distinct upstream values such as `XHigh` and `Ultra` into the same diff --git a/linux-features/ui-tweaks/dock-icon.test.js b/linux-features/ui-tweaks/dock-icon.test.js index bd300ebad..57ac79d78 100644 --- a/linux-features/ui-tweaks/dock-icon.test.js +++ b/linux-features/ui-tweaks/dock-icon.test.js @@ -28,29 +28,29 @@ const { const currentAppInfoSource = [ "function F_(e,t){return`icon-chatgpt`}", "function I_(e){return{dark:`icon-codex-dark-color.png`,light:`icon-codex-light.png`}}", - "function R_(e,t){if(process.platform!==`darwin`||t==null)return null;let n=I_(e),r=wb(`${F_(e,t)}.png`),i=wb(n.dark),a=wb(n.light);return r==null||i==null||a==null?null:{appDefault:r,codexDark:i,codexLight:a}}", - "function wb(e){if(e==null)return null;let t=l.app.isPackaged?(0,p.join)(process.resourcesPath,e):null,n=t!=null&&(0,_.existsSync)(t)?t:(0,p.join)(l.app.getAppPath(),`src`,`icons`,e),r=l.nativeImage.createFromPath(n);return r.isEmpty()?null:r.resize({width:128,height:128,quality:`best`}).toDataURL()}", + "function R_(e,t){if(process.platform!==`darwin`||t==null)return null;let n=I_(e),r=MS(`${F_(e,t)}.png`),i=MS(n.dark),a=MS(n.light);return r==null||i==null||a==null?null:{appDefault:r,codexDark:i,codexLight:a}}", + "function MS(e){if(e==null)return null;let t=l.app.isPackaged?(0,p.join)(process.resourcesPath,e):null,n=t!=null&&(0,_.existsSync)(t)?t:(0,p.join)(l.app.getAppPath(),`src`,`icons`,e),r=l.nativeImage.createFromPath(n);return r.isEmpty()?null:r.resize({width:128,height:128,quality:`best`}).toDataURL()}", ].join(""); const currentRuntimeSource = [ - "function Xie({appBrand:e,buildFlavor:r,settingsStore:p,repoRoot:_,isMacOS:v,onWindowRegistered:C,disposables:w}){", - "let T=(0,p.join)(_,`electron`,`src`,`icons`),E=e=>{if(!l.app.isPackaged)return null;let t=(0,p.join)(process.resourcesPath,e);return(0,_.existsSync)(t)?t:null},", - "D=e=>null,O=e=>E(e)??D(e),k=()=>p.get(n.Fc.DOCK_ICON_PREFERENCE)??`app-default`,", - "A=()=>O(`${F_(r,e)}.png`),j=process.platform===`linux`?K5(r,e,T):null,M=I_(r),N=()=>l.nativeTheme.shouldUseDarkColorsForSystemIntegratedUI?M.dark:M.light,", - "P=t=>{if(t===`app-default`&&r!==i.a.Dev&&(l.app.isPackaged||e===n.Ml.ChatGPT)){let e=l.app.dock;e!=null&&Reflect.apply(e.setIcon.bind(e),e,[null]);return}let a=t===`codex-system`?N():null,o=(a==null?null:O(a))??A(),s=o==null?l.nativeImage.createEmpty():l.nativeImage.createFromPath(o);s.isEmpty()||l.app.dock?.setIcon(s)},", - "F=()=>{if(!v)return;let e=k();P(e),koe({preference:e,resourceName:e===`codex-system`?M.light:null}).then(e=>{e&&P(k())})};", + "function Nwe({appBrand:e,buildFlavor:i,settingsStore:f,repoRoot:g,isMacOS:v,onWindowRegistered:C,disposables:w}){", + "let T=(0,p.join)(g,`electron`,`src`,`icons`),E=e=>{if(!l.app.isPackaged)return null;let t=(0,p.join)(process.resourcesPath,e);return(0,_.existsSync)(t)?t:null},", + "D=e=>null,O=e=>E(e)??D(e),k=()=>f.get(n.js.DOCK_ICON_PREFERENCE)??`app-default`,", + "A=()=>O(`${hS(i,e)}.png`),j=process.platform===`linux`?W5(i,e,T):null,M=gS(i),N=()=>l.nativeTheme.shouldUseDarkColorsForSystemIntegratedUI?M.dark:M.light,", + "P=t=>{if(t===`app-default`&&i!==a.a.Dev&&(l.app.isPackaged||e===n.gc.ChatGPT)){let e=l.app.dock;e!=null&&Reflect.apply(e.setIcon.bind(e),e,[null]);return}let r=t===`codex-system`?N():null,o=(r==null?null:O(r))??A(),s=o==null?l.nativeImage.createEmpty():l.nativeImage.createFromPath(o);s.isEmpty()||l.app.dock?.setIcon(s)},", + "F=()=>{if(!v)return;let e=k();P(e),Yce({preference:e,resourceName:e===`codex-system`?M.light:null}).then(e=>{e&&P(k())})};", "if(v){F();let e=()=>{let e=k();e===`codex-system`&&P(e)};l.nativeTheme.on(`updated`,e),w.add(()=>{l.nativeTheme.off(`updated`,e)})}", - "let ee=null,I=new Rie({onWindowRegistered:e=>{ee?.registerWindow(e),C?.(e)}});", - "return{updateDockIcon:F,windowManager:I}}", + "let I=null,L=new xwe({onWindowRegistered:e=>{I?.registerWindow(e),C?.(e)}});", + "return{updateDockIcon:F,windowManager:L}}", ].join(""); const currentTraySource = - "let codexLinuxTray=null,codexLinuxRegisterTray=e=>(codexLinuxTray=e,e);async function dae(e){let t=await fae(e.buildFlavor,e.appBrand,e.repoRoot),n=codexLinuxRegisterTray(new l.Tray(t.defaultIcon));if(!G9)return n.destroy(),null;return n}"; + "let codexLinuxTray=null,codexLinuxRegisterTray=e=>(codexLinuxTray=e,e);async function Ywe(e){let t=await Xwe(e.buildFlavor,e.appBrand,e.repoRoot),n=codexLinuxRegisterTray(new l.Tray(...(process.platform===`linux`?[t.defaultIcon]:[t.defaultIcon,process.platform===`win32`&&l.app.isPackaged?dEe(e.buildFlavor):void 0])));if(!W9)return n.destroy(),null;return n}"; const currentMainSource = currentAppInfoSource + currentRuntimeSource + currentTraySource; const currentSettingsSource = - "function oa(){let e=(0,Q.c)(27),t=B(C),n=z(),{platform:r}=_t(),{data:i}=H(Kn),a=V(K.dockIconPreference),o;if(e[0]===t)o=e[1];else{o=function(e){c(t,K.dockIconPreference,e)},e[0]=t,e[1]=o}let s=o;if(r!==`macOS`||ke.ChatGPT!==`chatgpt`||oe.Agent===`prod`)return null;let c=i?.dockIconPreviews;if(c==null)return null;return W(c,s)}"; + "function oa(){let e=(0,Q.c)(27),t=B(C),n=z(),{platform:r}=_t(),{data:i}=H(Kn),a=V(K.dockIconPreference),o;if(e[0]===t)o=e[1];else{o=function(e){c(t,K.dockIconPreference,e)},e[0]=t,e[1]=o}let s=o;if(r!==`macOS`||un.ChatGPT!==`chatgpt`||yt.Agent===`prod`)return null;let c=i?.dockIconPreviews;if(c==null)return null;return W(c,s)}"; const currentSearchSource = applyLinuxSettingsSearchVisibilityPatch([ "function qn(e){let t=(0,Zn.c)(17),n=re(),r=Bn(e),{data:i}=_(e),a=i?.isSystemBackdropSupported!==!1,o=i?.platform===`darwin`,{data:s}=T(k,e.selectedHostId),c,l=c;if(a){let e;e=e=>e.sectionSlug===`appearance`&&!a?{...e,messages:e.messages.filter(Jn)}:e.sectionSlug===`agent`?{...e,terms:[]}:e,m=r.map(e)}else m=r;return m}", @@ -165,6 +165,9 @@ test("main patch enables official previews and synchronizes Linux window and tra assert.deepEqual(secondPass.warnings, []); assert.match(patched, /codexLinuxDockIconResourcePath/); assert.match(patched, /codexLinuxApplyDockIcon/); + assert.match(patched, /i!==a\.a\.Dev/); + assert.match(patched, /e===n\.gc\.ChatGPT/); + assert.doesNotMatch(patched, /n\.Ec\.ChatGPT/); assert.match(patched, /process\.platform!==`darwin`&&process\.platform!==`linux`/); assert.match( patched, @@ -183,11 +186,11 @@ test("main patch enables official previews and synchronizes Linux window and tra assert.match(patched, /codexLinuxDockIconImage\.isEmpty\(\)/); assert.match( patched, - /codexLinuxRegisterTray\(new l\.Tray\(process\.platform===`linux`&&globalThis\.codexLinuxDockIconImage/, + /codexLinuxRegisterTray\(new l\.Tray\(\.\.\.\(process\.platform===`linux`\?\[globalThis\.codexLinuxDockIconImage/, ); assert.match( patched, - /onWindowRegistered:e=>\{ee\?\.registerWindow\(e\),C\?\.\(e\),process\.platform===`linux`&&setImmediate\(F\)\}/, + /onWindowRegistered:e=>\{I\?\.registerWindow\(e\),C\?\.\(e\),process\.platform===`linux`&&setImmediate\(F\)\}/, ); assert.ok( patched.indexOf("setImmediate(F)") > 0, @@ -197,13 +200,13 @@ test("main patch enables official previews and synchronizes Linux window and tra test("main patch rejects drift at every current-DMG insertion point byte-identically", () => { const insertionPoints = [ "if(process.platform!==`darwin`||t==null)return null", - "function wb(e){if(e==null)return null", + "function MS(e){if(e==null)return null", "E=e=>{if(!l.app.isPackaged)return null", "P=t=>{if(t===`app-default`", "F=()=>{if(!v)return", "if(v){F();let e=()=>", - "onWindowRegistered:e=>{ee?.registerWindow(e),C?.(e)}", - "codexLinuxRegisterTray(new l.Tray(t.defaultIcon))", + "onWindowRegistered:e=>{I?.registerWindow(e),C?.(e)}", + "codexLinuxRegisterTray(new l.Tray(...(process.platform===`linux`?[t.defaultIcon]:[t.defaultIcon,process.platform===`win32`&&l.app.isPackaged?dEe(e.buildFlavor):void 0])))", ]; for (const insertionPoint of insertionPoints) { @@ -221,16 +224,47 @@ test("main patch rejects drift at every current-DMG insertion point byte-identic } }); -test("main patch rejects mixed patched and clean contracts byte-identically", () => { - const mixed = applyDockIconMainPatch(currentMainSource).replace( +test("main patch rejects drift at every patched insertion point byte-identically", () => { + const patched = applyDockIconMainPatch(currentMainSource); + const insertionPoints = [ + "if(process.platform!==`darwin`&&process.platform!==`linux`||t==null)return null", + "function codexLinuxDockIconResourcePath(e){return process.platform===`linux`", + "E=e=>{if(!l.app.isPackaged&&process.platform!==`linux`)return null", + "P=function codexLinuxApplyDockIcon(t){", "F=()=>{if(!v&&process.platform!==`linux`)return", - "F=()=>{if(!v)return", - ); - const { value, warnings } = captureWarns(() => applyDockIconMainPatch(mixed)); + "if(v||process.platform===`linux`){F();let e=()=>", + "onWindowRegistered:e=>{I?.registerWindow(e),C?.(e),process.platform===`linux`&&setImmediate(F)}", + "n=codexLinuxRegisterTray(new l.Tray(...(process.platform===`linux`?[globalThis.codexLinuxDockIconImage&&!globalThis.codexLinuxDockIconImage.isEmpty()?globalThis.codexLinuxDockIconImage:t.defaultIcon]:[t.defaultIcon,process.platform===`win32`&&l.app.isPackaged?dEe(e.buildFlavor):void 0])));if(!W9)return", + ]; - assert.equal(value, mixed); - assert.equal(warnings.length, 1); - assert.match(warnings[0], /current Dock icon main-process contract/); + for (const insertionPoint of insertionPoints) { + assert.equal(patched.includes(insertionPoint), true, insertionPoint); + const splitAt = Math.floor(insertionPoint.length / 2); + const drifted = patched.replace( + insertionPoint, + `${insertionPoint.slice(0, splitAt)}drift${insertionPoint.slice(splitAt)}`, + ); + const { value, warnings } = captureWarns(() => applyDockIconMainPatch(drifted)); + + assert.equal(value, drifted, insertionPoint); + assert.equal(warnings.length, 1, insertionPoint); + assert.match(warnings[0], /current Dock icon main-process contract/); + } +}); + +test("main patch rejects duplicate clean, patched, and mixed contracts byte-identically", () => { + const patched = applyDockIconMainPatch(currentMainSource); + for (const source of [ + currentMainSource + currentMainSource, + patched + patched, + currentMainSource + patched, + ]) { + const { value, warnings } = captureWarns(() => applyDockIconMainPatch(source)); + + assert.equal(value, source); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /current Dock icon main-process contract/); + } }); test("settings patch exposes the native row on Linux", () => { @@ -242,8 +276,26 @@ test("settings patch exposes the native row on Linux", () => { assert.deepEqual(secondPass.warnings, []); }); +test("settings patch preserves current minified aliases across renderer churn", () => { + const nextAliases = currentSettingsSource.replace( + "if(r!==`macOS`||un.ChatGPT!==`chatgpt`||yt.Agent===`prod`)return null", + "if(platformAlias!==`macOS`||brandAlias.ChatGPT!==`chatgpt`||buildAlias.Agent===`prod`)return null", + ); + const patched = applyDockIconSettingsPatch(nextAliases); + const secondPass = captureWarns(() => applyDockIconSettingsPatch(patched)); + + assert.match( + patched, + /if\(platformAlias!==`macOS`&&platformAlias!==`linux`\|\|brandAlias\.ChatGPT!==`chatgpt`\|\|buildAlias\.Agent===`prod`\)return null/, + ); + assert.equal(secondPass.value, patched); + assert.deepEqual(secondPass.warnings, []); + assert.equal(descriptors[1].assetMatch(nextAliases), true); + assert.equal(descriptors[1].assetMatch(patched), true); +}); + test("settings drift remains byte-identical", () => { - const drifted = currentSettingsSource.replace("oe.Agent===`prod`", "oe.Agent!==`prod`"); + const drifted = currentSettingsSource.replace("yt.Agent===`prod`", "yt.Agent!==`prod`"); const { value, warnings } = captureWarns(() => applyDockIconSettingsPatch(drifted)); assert.equal(value, drifted); @@ -251,6 +303,24 @@ test("settings drift remains byte-identical", () => { assert.match(warnings[0], /current Dock icon settings contract/); }); +test("settings duplicate and mixed contracts remain byte-identical", () => { + const patched = applyDockIconSettingsPatch(currentSettingsSource); + const drifted = currentSettingsSource.replace("yt.Agent===`prod`", "yt.Agent!==`prod`"); + for (const source of [ + currentSettingsSource + currentSettingsSource, + currentSettingsSource + patched, + currentSettingsSource + drifted, + patched + drifted, + patched + patched, + ]) { + const { value, warnings } = captureWarns(() => applyDockIconSettingsPatch(source)); + assert.equal(value, source); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /current Dock icon settings contract/); + assert.equal(descriptors[1].assetMatch(source), false); + } +}); + test("search patch restores Dock icon results after the Linux core patch", () => { const patched = applyDockIconSearchPatch(currentSearchSource); const secondPass = captureWarns(() => applyDockIconSearchPatch(patched)); diff --git a/linux-features/ui-tweaks/patches/dock-icon.js b/linux-features/ui-tweaks/patches/dock-icon.js index 2bddf6588..87bb850a8 100644 --- a/linux-features/ui-tweaks/patches/dock-icon.js +++ b/linux-features/ui-tweaks/patches/dock-icon.js @@ -4,33 +4,33 @@ const currentPreviewGate = "if(process.platform!==`darwin`||t==null)return null" const patchedPreviewGate = "if(process.platform!==`darwin`&&process.platform!==`linux`||t==null)return null"; const currentAppInfoResource = - "function wb(e){if(e==null)return null;let t=l.app.isPackaged?(0,p.join)(process.resourcesPath,e):null"; + "function MS(e){if(e==null)return null;let t=l.app.isPackaged?(0,p.join)(process.resourcesPath,e):null"; const patchedAppInfoResource = - "function codexLinuxDockIconResourcePath(e){return process.platform===`linux`?(0,p.join)(process.resourcesPath,`dock-icon`,e):(0,p.join)(process.resourcesPath,e)}function wb(e){if(e==null)return null;let t=l.app.isPackaged||process.platform===`linux`?codexLinuxDockIconResourcePath(e):null"; + "function codexLinuxDockIconResourcePath(e){return process.platform===`linux`?(0,p.join)(process.resourcesPath,`dock-icon`,e):(0,p.join)(process.resourcesPath,e)}function MS(e){if(e==null)return null;let t=l.app.isPackaged||process.platform===`linux`?codexLinuxDockIconResourcePath(e):null"; const currentWindowResource = "E=e=>{if(!l.app.isPackaged)return null;let t=(0,p.join)(process.resourcesPath,e);return(0,_.existsSync)(t)?t:null}"; const patchedWindowResource = "E=e=>{if(!l.app.isPackaged&&process.platform!==`linux`)return null;let t=codexLinuxDockIconResourcePath(e);return(0,_.existsSync)(t)?t:null}"; const currentApplyIcon = - "P=t=>{if(t===`app-default`&&r!==i.a.Dev&&(l.app.isPackaged||e===n.Ml.ChatGPT)){let e=l.app.dock;e!=null&&Reflect.apply(e.setIcon.bind(e),e,[null]);return}let a=t===`codex-system`?N():null,o=(a==null?null:O(a))??A(),s=o==null?l.nativeImage.createEmpty():l.nativeImage.createFromPath(o);s.isEmpty()||l.app.dock?.setIcon(s)}"; + "P=t=>{if(t===`app-default`&&i!==a.a.Dev&&(l.app.isPackaged||e===n.gc.ChatGPT)){let e=l.app.dock;e!=null&&Reflect.apply(e.setIcon.bind(e),e,[null]);return}let r=t===`codex-system`?N():null,o=(r==null?null:O(r))??A(),s=o==null?l.nativeImage.createEmpty():l.nativeImage.createFromPath(o);s.isEmpty()||l.app.dock?.setIcon(s)}"; const patchedApplyIcon = - "P=function codexLinuxApplyDockIcon(t){if(t===`app-default`&&process.platform!==`linux`&&r!==i.a.Dev&&(l.app.isPackaged||e===n.Ml.ChatGPT)){let e=l.app.dock;e!=null&&Reflect.apply(e.setIcon.bind(e),e,[null]);return}let a=t===`codex-system`?N():null,o=(a==null?null:O(a))??A(),s=o==null?l.nativeImage.createEmpty():l.nativeImage.createFromPath(o);if(s.isEmpty())return;if(process.platform===`linux`){let codexLinuxIconSelection=t===`codex-system`?(l.nativeTheme.shouldUseDarkColorsForSystemIntegratedUI?`codex-dark`:`codex-light`):`chatgpt`;codexLinuxIconSelection===`codex-dark`?s=s.crop({x:34,y:34,width:956,height:956}):codexLinuxIconSelection===`codex-light`&&(s=s.crop({x:13,y:23,width:998,height:998}));globalThis.codexLinuxDockIconImage=s;for(let e of l.BrowserWindow.getAllWindows())e.isDestroyed()||e.setIcon(s);codexLinuxTray!=null&&!codexLinuxTray.isDestroyed()&&codexLinuxTray.setImage(s);let codexLinuxSyncScript=codexLinuxDockIconResourcePath(`sync-desktop-icon.sh`);if(_.existsSync(codexLinuxSyncScript))try{let e=require(`node:child_process`).spawn(codexLinuxSyncScript,[codexLinuxIconSelection],{detached:!0,stdio:[`pipe`,`ignore`,`ignore`]});e.on(`error`,()=>{}),e.stdin.on(`error`,()=>{}),e.stdin.end(s.toPNG()),e.unref()}catch(e){}return}l.app.dock?.setIcon(s)}"; + "P=function codexLinuxApplyDockIcon(t){if(t===`app-default`&&process.platform!==`linux`&&i!==a.a.Dev&&(l.app.isPackaged||e===n.gc.ChatGPT)){let e=l.app.dock;e!=null&&Reflect.apply(e.setIcon.bind(e),e,[null]);return}let r=t===`codex-system`?N():null,o=(r==null?null:O(r))??A(),s=o==null?l.nativeImage.createEmpty():l.nativeImage.createFromPath(o);if(s.isEmpty())return;if(process.platform===`linux`){let codexLinuxIconSelection=t===`codex-system`?(l.nativeTheme.shouldUseDarkColorsForSystemIntegratedUI?`codex-dark`:`codex-light`):`chatgpt`;codexLinuxIconSelection===`codex-dark`?s=s.crop({x:34,y:34,width:956,height:956}):codexLinuxIconSelection===`codex-light`&&(s=s.crop({x:13,y:23,width:998,height:998}));globalThis.codexLinuxDockIconImage=s;for(let e of l.BrowserWindow.getAllWindows())e.isDestroyed()||e.setIcon(s);codexLinuxTray!=null&&!codexLinuxTray.isDestroyed()&&codexLinuxTray.setImage(s);let codexLinuxSyncScript=codexLinuxDockIconResourcePath(`sync-desktop-icon.sh`);if(_.existsSync(codexLinuxSyncScript))try{let e=require(`node:child_process`).spawn(codexLinuxSyncScript,[codexLinuxIconSelection],{detached:!0,stdio:[`pipe`,`ignore`,`ignore`]});e.on(`error`,()=>{}),e.stdin.on(`error`,()=>{}),e.stdin.end(s.toPNG()),e.unref()}catch(e){}return}l.app.dock?.setIcon(s)}"; const currentUpdateGate = - "F=()=>{if(!v)return;let e=k();P(e),koe({preference:e,resourceName:e===`codex-system`?M.light:null}).then(e=>{e&&P(k())})}"; + "F=()=>{if(!v)return;let e=k();P(e),Yce({preference:e,resourceName:e===`codex-system`?M.light:null}).then(e=>{e&&P(k())})}"; const patchedUpdateGate = - "F=()=>{if(!v&&process.platform!==`linux`)return;let e=k();P(e),koe({preference:e,resourceName:e===`codex-system`?M.light:null}).then(e=>{e&&P(k())})}"; + "F=()=>{if(!v&&process.platform!==`linux`)return;let e=k();P(e),Yce({preference:e,resourceName:e===`codex-system`?M.light:null}).then(e=>{e&&P(k())})}"; const currentThemeGate = "if(v){F();let e=()=>{let e=k();e===`codex-system`&&P(e)};l.nativeTheme.on(`updated`,e),w.add(()=>{l.nativeTheme.off(`updated`,e)})}"; const patchedThemeGate = "if(v||process.platform===`linux`){F();let e=()=>{let e=k();e===`codex-system`&&P(e)};l.nativeTheme.on(`updated`,e),w.add(()=>{l.nativeTheme.off(`updated`,e)})}"; const currentWindowRegistration = - "onWindowRegistered:e=>{ee?.registerWindow(e),C?.(e)}"; + "onWindowRegistered:e=>{I?.registerWindow(e),C?.(e)}"; const patchedWindowRegistration = - "onWindowRegistered:e=>{ee?.registerWindow(e),C?.(e),process.platform===`linux`&&setImmediate(F)}"; + "onWindowRegistered:e=>{I?.registerWindow(e),C?.(e),process.platform===`linux`&&setImmediate(F)}"; const currentTrayRegistration = - "n=codexLinuxRegisterTray(new l.Tray(t.defaultIcon));if(!G9)return"; + "n=codexLinuxRegisterTray(new l.Tray(...(process.platform===`linux`?[t.defaultIcon]:[t.defaultIcon,process.platform===`win32`&&l.app.isPackaged?dEe(e.buildFlavor):void 0])));if(!W9)return"; const patchedTrayRegistration = - "n=codexLinuxRegisterTray(new l.Tray(process.platform===`linux`&&globalThis.codexLinuxDockIconImage&&!globalThis.codexLinuxDockIconImage.isEmpty()?globalThis.codexLinuxDockIconImage:t.defaultIcon));if(!G9)return"; + "n=codexLinuxRegisterTray(new l.Tray(...(process.platform===`linux`?[globalThis.codexLinuxDockIconImage&&!globalThis.codexLinuxDockIconImage.isEmpty()?globalThis.codexLinuxDockIconImage:t.defaultIcon]:[t.defaultIcon,process.platform===`win32`&&l.app.isPackaged?dEe(e.buildFlavor):void 0])));if(!W9)return"; const currentMainContracts = [ currentPreviewGate, @@ -101,24 +101,61 @@ function applyDockIconMainPatch(source) { ); } -const currentSettingsGate = - "if(r!==`macOS`||ke.ChatGPT!==`chatgpt`||oe.Agent===`prod`)return null"; -const patchedSettingsGate = - "if(r!==`macOS`&&r!==`linux`||ke.ChatGPT!==`chatgpt`||oe.Agent===`prod`)return null"; +const currentSettingsGatePattern = + /if\(([A-Za-z_$][\w$]*)!==`macOS`\|\|([A-Za-z_$][\w$]*)\.ChatGPT!==`chatgpt`\|\|([A-Za-z_$][\w$]*)\.Agent===`prod`\)return null/g; +const patchedSettingsGatePattern = + /if\(([A-Za-z_$][\w$]*)!==`macOS`&&\1!==`linux`\|\|([A-Za-z_$][\w$]*)\.ChatGPT!==`chatgpt`\|\|([A-Za-z_$][\w$]*)\.Agent===`prod`\)return null/g; +const settingsRowAnchorPattern = /\.dockIconPreviews\b/g; + +function settingsGateMatches(source, pattern) { + pattern.lastIndex = 0; + return [...source.matchAll(pattern)]; +} + +function dockIconSettingsContract(source) { + if (typeof source !== "string") { + return "drifted"; + } + const currentMatches = settingsGateMatches(source, currentSettingsGatePattern); + const patchedMatches = settingsGateMatches(source, patchedSettingsGatePattern); + const rowAnchors = settingsGateMatches(source, settingsRowAnchorPattern); + if ( + rowAnchors.length === 1 && + currentMatches.length === 1 && + patchedMatches.length === 0 + ) { + return "current"; + } + if ( + rowAnchors.length === 1 && + currentMatches.length === 0 && + patchedMatches.length === 1 + ) { + return "patched"; + } + return "drifted"; +} function applyDockIconSettingsPatch(source) { - const currentCount = countOccurrences(source, currentSettingsGate); - const patchedCount = countOccurrences(source, patchedSettingsGate); - if (currentCount === 0 && patchedCount === 1) { + const contract = dockIconSettingsContract(source); + if (contract === "patched") { return source; } - if (currentCount !== 1 || patchedCount !== 0) { + if (contract !== "current") { console.warn( "WARN: Could not find the current Dock icon settings contract - skipping Dock icon settings patch", ); return source; } - return source.replace(currentSettingsGate, patchedSettingsGate); + return source.replace( + currentSettingsGatePattern, + ( + _match, + platformAlias, + brandAlias, + buildFlavorAlias, + ) => `if(${platformAlias}!==\`macOS\`&&${platformAlias}!==\`linux\`||${brandAlias}.ChatGPT!==\`chatgpt\`||${buildFlavorAlias}.Agent===\`prod\`)return null`, + ); } const currentSearchFilter = @@ -156,8 +193,7 @@ const descriptors = [ order: 20_950, ciPolicy: "optional", pattern: /^general-settings-[A-Za-z0-9_-]+\.js$/, - assetMatch: (source) => - hasCompleteSinglePointContract(source, currentSettingsGate, patchedSettingsGate), + assetMatch: (source) => dockIconSettingsContract(source) !== "drifted", missingDescription: "General settings Dock icon bundle", skipDescription: "Dock icon settings row patch", enabled: dockIconEnabled, diff --git a/linux-features/ui-tweaks/patches/reasoning-effort-labels.js b/linux-features/ui-tweaks/patches/reasoning-effort-labels.js index 3c0f0659b..32efabb4b 100644 --- a/linux-features/ui-tweaks/patches/reasoning-effort-labels.js +++ b/linux-features/ui-tweaks/patches/reasoning-effort-labels.js @@ -4,7 +4,6 @@ const ZH_CN_LOCALE_ASSET_PATTERN = /^zh-CN-[^.]+\.js$/; const ENGLISH_REASONING_LABELS = Object.freeze({ "composer.mode.local.reasoning.none.label": "None", "composer.mode.local.reasoning.minimal.label": "Minimal", - "composer.mode.local.reasoning.low.label": "Low", "composer.mode.local.reasoning.medium.label": "Medium", "composer.mode.local.reasoning.high.label": "High", "composer.mode.local.reasoning.xhigh.label": "XHigh", diff --git a/linux-features/ui-tweaks/patches/sidebar-project-name.js b/linux-features/ui-tweaks/patches/sidebar-project-name.js index cdfc9bd2f..93bd65191 100644 --- a/linux-features/ui-tweaks/patches/sidebar-project-name.js +++ b/linux-features/ui-tweaks/patches/sidebar-project-name.js @@ -2,14 +2,14 @@ const DEFAULT_PROJECT_NAME_STYLE = "font-weight: 700 !important;"; const PROJECTS_SIDEBAR_ASSET_PATTERN = /^app-initial-[^.]+\.js$/; -const PROJECT_NAME_SELECTOR = ".group\\/folder-row .text-fade-truncate.pr-1"; +const PROJECT_NAME_SELECTOR = ".group\\/folder-row .text-fade-truncate.pe-1"; const STYLE_ID = "codex-linux-ui-tweaks-sidebar-project-name-style"; const RUNTIME_MARKER = "codexLinuxUiTweaksSidebarProjectNameStyleRuntime"; const UNSAFE_PROJECT_NAME_STYLE_PATTERN = /[{}@<>]|\r|\n|\/\*|\*\/|\burl\s*\(/i; const SIDEBAR_PROJECT_NAME_MARKERS = [ "group/folder-row", - "className:`text-fade-truncate pr-1`", + "className:`text-fade-truncate pe-1`", ]; function warn(message) { diff --git a/linux-features/ui-tweaks/test.js b/linux-features/ui-tweaks/test.js index 6b1bcd743..a34c1fcf6 100644 --- a/linux-features/ui-tweaks/test.js +++ b/linux-features/ui-tweaks/test.js @@ -45,7 +45,7 @@ const { function projectBundleFixture() { return [ "function row(){let j=Pn(`group/folder-row group relative flex h-[var(--height-token-row)] text-sm text-token-foreground`);", - "let V=(0,Iy.jsx)(`span`,{className:`text-fade-truncate pr-1`,children:p});return [j,V]}", + "let V=(0,Iy.jsx)(`span`,{className:`text-fade-truncate pe-1`,children:p});return [j,V]}", ].join(""); } @@ -104,7 +104,6 @@ function simplifiedChineseLocaleFixture() { const labels = { "composer.mode.local.reasoning.none.label": "无", "composer.mode.local.reasoning.minimal.label": "极低", - "composer.mode.local.reasoning.low.label": "轻度", "composer.mode.local.reasoning.medium.label": "中", "composer.mode.local.reasoning.high.label": "高", "composer.mode.local.reasoning.xhigh.label": "极高", @@ -310,6 +309,10 @@ test("reasoning effort labels stay in English in the Simplified Chinese locale", const source = simplifiedChineseLocaleFixture(); const patched = applyEnglishReasoningLabels(source); + assert.equal( + Object.hasOwn(ENGLISH_REASONING_LABELS, "composer.mode.local.reasoning.low.label"), + false, + ); for (const [key, label] of Object.entries(ENGLISH_REASONING_LABELS)) { assert.match(patched, new RegExp(`"${key.replaceAll(".", "\\.")}":\\\`${label}\\\``)); } diff --git a/linux-features/x11-ewmh-computer-use/README.md b/linux-features/x11-ewmh-computer-use/README.md index 3ce488748..03af5cc2d 100644 --- a/linux-features/x11-ewmh-computer-use/README.md +++ b/linux-features/x11-ewmh-computer-use/README.md @@ -1,6 +1,6 @@ # X11/EWMH Computer Use Linux Feature -This optional Linux Feature stages the standalone `codex-computer-use-x11` MCP plugin into ChatGPT Desktop for Linux. It stays disabled by default and is enabled only when listed in `linux-features/features.json`. +This optional Linux Feature stages the standalone `codex-computer-use-x11` MCP plugin into ChatGPT Desktop for Linux. Core Computer Use now supports generic X11/EWMH directly; this feature remains an alternative, separately namespaced tool surface. It stays disabled by default and is enabled only when listed in `linux-features/features.json`. ## Enable @@ -65,9 +65,7 @@ CODEX_X11_COMPUTER_USE_BINARY=/path/to/codex-computer-use-x11 ## Upstream alignment -This feature wires the separate `codex-computer-use-x11` plugin as an opt-in Linux Feature. It does not move X11/EWMH behavior into the core Computer Use backend and does not replace the bundled `computer-use` plugin. - -`agent-sh/computer-use-linux` selectable backend/flavor integration is a separate future investigation. If that route proves a better fit, handle it in a separate change or pull request; no backend/flavor experiment may require enabling this feature by default or modifying core Computer Use behavior in this feature. +This feature wires the separate `codex-computer-use-x11` plugin as an opt-in Linux Feature. It does not replace the bundled `computer-use` plugin or its built-in generic X11/EWMH backend; enable it only when the alternative `x11_*` tools are specifically desired. ## Non-goals diff --git a/nix/linux-features-test.nix b/nix/linux-features-test.nix index 4f30276cb..23822ad02 100644 --- a/nix/linux-features-test.nix +++ b/nix/linux-features-test.nix @@ -13,6 +13,7 @@ let testFeatureIds = [ "persistent-status-panel" "appshots" + "codex-micro" "codex-wrapper-updater" "directory-only-working-tree-watch" "frameless-titlebar" @@ -27,6 +28,7 @@ let ]; normalizedTestFeatureIds = [ "appshots" + "codex-micro" "codex-wrapper-updater" "directory-only-working-tree-watch" "frameless-titlebar" @@ -42,6 +44,7 @@ let watchdogFeatureIds = (builtins.fromJSON (builtins.readFile ../scripts/ci/watchdog-linux-features.json)).enabled; normalizedWatchdogFeatureIds = [ "appshots" + "codex-micro" "codex-wrapper-updater" "directory-only-working-tree-watch" "frameless-titlebar" @@ -113,6 +116,10 @@ let type = lib.types.attrsOf lib.types.anything; default = { }; }; + services.udev.packages = lib.mkOption { + type = lib.types.listOf lib.types.package; + default = [ ]; + }; systemd.user.services = lib.mkOption { type = lib.types.attrsOf lib.types.anything; default = { }; @@ -129,6 +136,14 @@ let builtins.head (evalNixOS moduleConfig).config.environment.systemPackages; defaultConfig = { enable = true; }; + codexMicroConfig = { + enable = true; + linuxFeatures = [ "codex-micro" ]; + }; + disabledCodexMicroConfig = { + enable = false; + linuxFeatures = [ "codex-micro" ]; + }; legacyRemoteConfig = { enable = true; remoteMobileControl.enable = true; @@ -144,11 +159,15 @@ let enableComputerUseUi = true; linuxFeatureIds = normalizedTestFeatureIds; }; + expectedCodexMicro = packages.codex-desktop.override { + linuxFeatureIds = [ "codex-micro" ]; + }; reorderedCombined = packages.codex-desktop.override { enableComputerUseUi = true; linuxFeatureIds = [ "remote-mobile-control" "frameless-titlebar" + "codex-micro" "codex-wrapper-updater" "directory-only-working-tree-watch" "global-dictation" @@ -160,13 +179,19 @@ let "ui-tweaks" "appshots" "appshots" + "codex-micro" ]; }; + nixosDefault = evalNixOS defaultConfig; + nixosCodexMicro = evalNixOS codexMicroConfig; + nixosDisabledCodexMicro = evalNixOS disabledCodexMicroConfig; + customPackage = pkgs.runCommand "codex-desktop-custom-test-package" { } '' mkdir -p "$out" ''; customConfig = combinedConfig // { package = customPackage; }; + nixosCustom = evalNixOS customConfig; remoteControlConfig = { enable = true; package = customPackage; @@ -289,6 +314,15 @@ let )).config.assertions ) contextEnvironmentFiles; in +assert lib.assertMsg + (lib.elem "codex-micro" (linuxFeatures.normalize linuxFeatures.supportedFeatureIds)) + "codex-micro is missing from the normalized Nix-supported feature list"; +assert lib.assertMsg + (linuxFeatures.normalize [ "codex-micro" "appshots" "codex-micro" ] == [ + "appshots" + "codex-micro" + ]) + "codex-micro was not accepted, sorted, and deduplicated"; assert lib.assertMsg (linuxFeatures.normalize testFeatureIds == normalizedTestFeatureIds) "Nix Linux feature IDs must be sorted and deduplicated"; @@ -301,6 +335,28 @@ assert lib.assertMsg assert lib.assertMsg ((nixosPackage defaultConfig).drvPath == packages.codex-desktop.drvPath) "the NixOS default package changed"; +assert lib.assertMsg + ((homePackage codexMicroConfig).drvPath == expectedCodexMicro.drvPath) + "Home Manager did not select the codex-micro package"; +assert lib.assertMsg + ((nixosPackage codexMicroConfig).drvPath == expectedCodexMicro.drvPath) + "NixOS did not select the codex-micro package"; +assert lib.assertMsg + (expectedCodexMicro.drvPath != packages.codex-desktop.drvPath) + "enabling codex-micro did not change the selected package"; +assert lib.assertMsg + ( + builtins.length nixosCodexMicro.config.services.udev.packages == 1 + && (builtins.head nixosCodexMicro.config.services.udev.packages).drvPath + == expectedCodexMicro.drvPath + ) + "NixOS did not register the codex-micro package as a udev rules source"; +assert lib.assertMsg + (nixosDefault.config.services.udev.packages == [ ]) + "the NixOS default unexpectedly installs codex-micro udev rules"; +assert lib.assertMsg + (nixosDisabledCodexMicro.config.services.udev.packages == [ ]) + "disabled NixOS unexpectedly installs codex-micro udev rules"; assert lib.assertMsg ((homePackage legacyRemoteConfig).drvPath == packages.codex-desktop-remote-mobile-control.drvPath) "the Home Manager remoteMobileControl shorthand changed"; @@ -322,6 +378,9 @@ assert lib.assertMsg assert lib.assertMsg ((nixosPackage customConfig).drvPath == customPackage.drvPath) "the NixOS custom package override lost precedence"; +assert lib.assertMsg + (nixosCustom.config.services.udev.packages == [ ]) + "the NixOS custom package override unexpectedly inherited codex-micro udev policy"; assert lib.assertMsg (!invalidBuilder.success) "the package builder accepted an unsupported feature"; assert lib.assertMsg shallowRepositoryWatchBuilder.success diff --git a/nix/linux-features.nix b/nix/linux-features.nix index 41dc55d20..12991609e 100644 --- a/nix/linux-features.nix +++ b/nix/linux-features.nix @@ -2,6 +2,7 @@ let supportedFeatureIds = [ "appshots" + "codex-micro" "codex-wrapper-updater" "directory-only-working-tree-watch" "frameless-titlebar" @@ -14,6 +15,7 @@ let "remote-control-ui" "remote-mobile-control" "shallow-repository-watches" + "ssh-command-wrapper" "ui-tweaks" ]; diff --git a/nix/native-modules/package-lock.json b/nix/native-modules/package-lock.json index d5056c688..0aba8fc54 100644 --- a/nix/native-modules/package-lock.json +++ b/nix/native-modules/package-lock.json @@ -584,9 +584,9 @@ } }, "node_modules/node-gyp/node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -905,9 +905,9 @@ } }, "node_modules/tar": { - "version": "7.5.20", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", - "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -983,9 +983,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "license": "MIT", "optional": true, "engines": { diff --git a/nix/native-modules/package.json b/nix/native-modules/package.json index 41b5b4899..03e53bfef 100644 --- a/nix/native-modules/package.json +++ b/nix/native-modules/package.json @@ -8,5 +8,10 @@ "electron": "42.3.0", "node-abi": "^4.31.0", "node-pty": "1.1.0" + }, + "overrides": { + "@electron/get": { + "undici": "7.29.0" + } } } diff --git a/nix/nixos-module.nix b/nix/nixos-module.nix index 8a94bdf22..5d5c0eb8e 100644 --- a/nix/nixos-module.nix +++ b/nix/nixos-module.nix @@ -23,6 +23,9 @@ let inherit cfg flakePackages lib; }; basePackage = packageSelection.package; + codexMicroEnabled = + cfg.package == null + && lib.elem "codex-micro" packageSelection.normalizedFeatureIds; codexCliPackage = if cfg.cliPackage != null then cfg.cliPackage @@ -274,6 +277,10 @@ in desktopPackage ]; + services.udev.packages = lib.optionals codexMicroEnabled [ + basePackage + ]; + environment.sessionVariables = lib.mkIf (remoteCfg.enable && remoteCfg.disableLauncherAutostart) { CODEX_REMOTE_CONTROL_DAEMON_AUTOSTART_DISABLED = "1"; }; diff --git a/notification-actions-linux/src/main.rs b/notification-actions-linux/src/main.rs index bb5c15055..ee852f43d 100644 --- a/notification-actions-linux/src/main.rs +++ b/notification-actions-linux/src/main.rs @@ -4,7 +4,10 @@ use anyhow::{bail, Context, Result}; use futures_util::StreamExt; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncBufReadExt, BufReader}; -use zbus::{zvariant::OwnedValue, Connection, Proxy}; +use zbus::{ + zvariant::{OwnedValue, Str}, + Connection, Proxy, +}; const NOTIFICATIONS_SERVICE: &str = "org.freedesktop.Notifications"; const NOTIFICATIONS_PATH: &str = "/org/freedesktop/Notifications"; @@ -106,7 +109,13 @@ async fn run() -> Result<()> { .await .context("failed to subscribe to notification closure")?; let action_pairs = notification_actions(&request.actions); - let hints: HashMap = HashMap::new(); + let mut hints: HashMap = HashMap::new(); + if let Some(desktop_entry) = launched_desktop_entry_id() { + hints.insert( + "desktop-entry".to_owned(), + OwnedValue::from(Str::from(desktop_entry)), + ); + } let notification_id: u32 = proxy .call( "Notify", @@ -197,6 +206,12 @@ fn validate_request(request: &ShowRequest) -> Result<()> { Ok(()) } +fn launched_desktop_entry_id() -> Option { + std::env::var("CODEX_LINUX_LAUNCHED_DESKTOP_ENTRY") + .ok() + .filter(|desktop_entry| !desktop_entry.is_empty()) +} + fn notification_actions(actions: &[String]) -> Vec { let mut pairs = Vec::with_capacity(2 + actions.len() * 2); pairs.push("default".to_owned()); diff --git a/packaging/appimage/codex-desktop.desktop b/packaging/appimage/codex-desktop.desktop index 13d25c45e..e8feaf29c 100644 --- a/packaging/appimage/codex-desktop.desktop +++ b/packaging/appimage/codex-desktop.desktop @@ -1,7 +1,7 @@ [Desktop Entry] Name=__PACKAGE_DISPLAY_NAME__ Comment=__PACKAGE_COMMENT__ -Exec=AppRun %u +Exec=AppRun --show %u Icon=__PACKAGE_NAME__ Terminal=false Type=Application diff --git a/packaging/linux/PKGBUILD.template b/packaging/linux/PKGBUILD.template index e6d978d2d..e0a7c856b 100644 --- a/packaging/linux/PKGBUILD.template +++ b/packaging/linux/PKGBUILD.template @@ -51,5 +51,5 @@ source=() sha256sums=() package() { - cp -a "__STAGING_DIR__/." "${pkgdir}/" + cp -a --no-preserve=ownership "__STAGING_DIR__/." "${pkgdir}/" } diff --git a/scripts/ci/container-entrypoint.sh b/scripts/ci/container-entrypoint.sh index ef577be73..c4582d6f6 100755 --- a/scripts/ci/container-entrypoint.sh +++ b/scripts/ci/container-entrypoint.sh @@ -336,12 +336,39 @@ run_deb_job() { [ "$deb_feature_mode" = '-rw-r-----' ] \ || error "Expected Debian fixture mode 0640, got: ${deb_feature_mode:-missing}" + 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 + CARGO_TARGET_DIR="$target_dir" \ + UPDATER_BINARY_SOURCE="$target_dir/release/codex-update-manager" \ + CODEX_LINUX_FEATURES_CONFIG=/tmp/codex-micro-features.json \ + PACKAGE_VERSION="$CI_PACKAGE_VERSION" \ + ./scripts/build-deb.sh + + local deb_micro_file + local deb_micro_mode + deb_micro_file="$(package_file_or_fail 'codex-desktop_*.deb')" + dpkg-deb -c "$deb_micro_file" | tee /tmp/deb-micro-contents.txt >/dev/null + dpkg-deb -f "$deb_micro_file" Depends | tee /tmp/deb-micro-depends.txt >/dev/null + assert_contains_file /tmp/deb-micro-contents.txt './usr/lib/udev/rules.d/70-codex-micro.rules' + assert_contains_file /tmp/deb-micro-depends.txt 'libudev1' + assert_contains_file /tmp/deb-micro-depends.txt 'libusb-1.0-0' + deb_micro_mode="$( + awk '$NF == "./usr/lib/udev/rules.d/70-codex-micro.rules" { print $1 }' \ + /tmp/deb-micro-contents.txt + )" + [ "$deb_micro_mode" = '-rw-r--r--' ] \ + || error "Expected Debian Codex Micro rule mode 0644, got: ${deb_micro_mode:-missing}" + append_summary "Debian Package Validation" \ "Built: \`$(basename "$deb_file")\`" \ "Verified updater binary, user service, update-builder bundle, and packaged runtime helper." \ "Verified PACKAGE_WITH_UPDATER=0 omits updater artifacts." \ "Feature-enabled build: \`$(basename "$deb_feature_file")\`." \ - "Verified generic feature resource, 0640 mode, and runtime dependency." + "Verified generic feature resource, 0640 mode, and runtime dependency." \ + "Codex Micro build: \`$(basename "$deb_micro_file")\`." \ + "Verified udev rule, 0644 mode, and libudev/libusb dependencies." } run_rpm_job() { @@ -420,12 +447,40 @@ run_rpm_job() { [ "$rpm_feature_mode" = '-rw-r-----' ] \ || error "Expected RPM fixture mode 0640, got: ${rpm_feature_mode:-missing}" + 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 + CARGO_TARGET_DIR="$target_dir" \ + UPDATER_BINARY_SOURCE="$target_dir/release/codex-update-manager" \ + CODEX_LINUX_FEATURES_CONFIG=/tmp/codex-micro-features.json \ + PACKAGE_VERSION="$CI_PACKAGE_VERSION" \ + ./scripts/build-rpm.sh + + local rpm_micro_file + local rpm_micro_mode + rpm_micro_file="$(package_file_or_fail 'codex-desktop-*.rpm')" + rpm -qlp "$rpm_micro_file" | tee /tmp/rpm-micro-contents.txt >/dev/null + rpm -qlvp "$rpm_micro_file" | tee /tmp/rpm-micro-long-contents.txt >/dev/null + rpm -qp --requires "$rpm_micro_file" | tee /tmp/rpm-micro-requires.txt >/dev/null + assert_contains_file /tmp/rpm-micro-contents.txt '/usr/lib/udev/rules.d/70-codex-micro.rules' + assert_contains_file /tmp/rpm-micro-requires.txt '^libudev\.so\.1' + assert_contains_file /tmp/rpm-micro-requires.txt '^libusb-1\.0\.so\.0' + rpm_micro_mode="$( + awk '$NF == "/usr/lib/udev/rules.d/70-codex-micro.rules" { print $1 }' \ + /tmp/rpm-micro-long-contents.txt + )" + [ "$rpm_micro_mode" = '-rw-r--r--' ] \ + || error "Expected RPM Codex Micro rule mode 0644, got: ${rpm_micro_mode:-missing}" + append_summary "RPM Package Validation" \ "Built: \`$(basename "$rpm_file")\`" \ "Verified updater binary, user service, update-builder bundle, and packaged runtime helper." \ "Verified PACKAGE_WITH_UPDATER=0 omits updater artifacts." \ "Feature-enabled build: \`$(basename "$rpm_feature_file")\`." \ - "Verified generic feature resource, 0640 mode, and runtime dependency." + "Verified generic feature resource, 0640 mode, and runtime dependency." \ + "Codex Micro build: \`$(basename "$rpm_micro_file")\`." \ + "Verified udev rule, 0644 mode, and libudev/libusb dependencies." } run_pacman_job() { @@ -508,12 +563,39 @@ run_pacman_job() { [ "$pkg_feature_mode" = '-rw-r-----' ] \ || error "Expected pacman fixture mode 0640, got: ${pkg_feature_mode:-missing}" + 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 + CARGO_TARGET_DIR="$target_dir" \ + UPDATER_BINARY_SOURCE="$target_dir/release/codex-update-manager" \ + CODEX_LINUX_FEATURES_CONFIG=/tmp/codex-micro-features.json \ + PACKAGE_VERSION="$CI_PACKAGE_VERSION" \ + ./scripts/build-pacman.sh + + local pkg_micro_file + local pkg_micro_mode + pkg_micro_file="$(package_file_or_fail 'codex-desktop-*.pkg.tar.*')" + pacman -Qlp "$pkg_micro_file" | tee /tmp/pacman-micro-contents.txt >/dev/null + tar -xOf "$pkg_micro_file" .PKGINFO | tee /tmp/pacman-micro-pkginfo.txt >/dev/null + tar -tvf "$pkg_micro_file" \ + usr/lib/udev/rules.d/70-codex-micro.rules \ + | tee /tmp/pacman-micro-long-contents.txt >/dev/null + assert_contains_file /tmp/pacman-micro-contents.txt 'usr/lib/udev/rules.d/70-codex-micro.rules' + assert_contains_file /tmp/pacman-micro-pkginfo.txt '^depend = libusb$' + assert_contains_file /tmp/pacman-micro-pkginfo.txt '^depend = systemd-libs$' + pkg_micro_mode="$(awk 'NR == 1 { print $1 }' /tmp/pacman-micro-long-contents.txt)" + [ "$pkg_micro_mode" = '-rw-r--r--' ] \ + || error "Expected pacman Codex Micro rule mode 0644, got: ${pkg_micro_mode:-missing}" + append_summary "Pacman Package Validation" \ "Built: \`$(basename "$pkg_file")\`" \ "Verified updater binary, user service, update-builder bundle, and packaged runtime helper." \ "Verified PACKAGE_WITH_UPDATER=0 omits updater artifacts." \ "Feature-enabled build: \`$(basename "$pkg_feature_file")\`." \ - "Verified generic feature resource, 0640 mode, and runtime dependency." + "Verified generic feature resource, 0640 mode, and runtime dependency." \ + "Codex Micro build: \`$(basename "$pkg_micro_file")\`." \ + "Verified udev rule, 0644 mode, and systemd-libs/libusb dependencies." } run_install_deps_job_as_root() { @@ -609,7 +691,7 @@ run_upstream_job() { if [ ! -s "$dmg_path" ]; then info "Downloading upstream DMG" - curl -fL --retry 3 -o "$dmg_path" "$UPSTREAM_DMG_URL" + scripts/ci/download-upstream-dmg.sh "$UPSTREAM_DMG_URL" "$dmg_path" else info "Using cached upstream DMG: $dmg_path" fi diff --git a/scripts/ci/download-upstream-dmg.sh b/scripts/ci/download-upstream-dmg.sh new file mode 100755 index 000000000..73aff6736 --- /dev/null +++ b/scripts/ci/download-upstream-dmg.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "usage: $0 [--reuse-existing]" >&2 + exit 2 +} + +[ "$#" -ge 2 ] && [ "$#" -le 3 ] || usage + +UPSTREAM_DMG_URL="$1" +UPSTREAM_DMG_PATH="$2" +REUSE_EXISTING="${3:-}" +DOWNLOAD_ATTEMPTS="${CODEX_DMG_DOWNLOAD_ATTEMPTS:-3}" +RETRY_DELAY_SECONDS="${CODEX_DMG_RETRY_DELAY_SECONDS:-1}" + +case "$REUSE_EXISTING" in + ""|--reuse-existing) ;; + *) usage ;; +esac +case "$DOWNLOAD_ATTEMPTS" in + *[!0-9]*|0) + echo "CODEX_DMG_DOWNLOAD_ATTEMPTS must be a positive integer" >&2 + exit 2 + ;; +esac +case "$RETRY_DELAY_SECONDS" in + *[!0-9]*) + echo "CODEX_DMG_RETRY_DELAY_SECONDS must be a non-negative integer" >&2 + exit 2 + ;; +esac +case "$UPSTREAM_DMG_URL" in + https://*) ;; + *) + echo "Upstream DMG URL must use HTTPS" >&2 + exit 2 + ;; +esac + +if [ "$REUSE_EXISTING" = "--reuse-existing" ] && [ -s "$UPSTREAM_DMG_PATH" ]; then + echo "Using cached upstream DMG: $UPSTREAM_DMG_PATH" + exit 0 +fi + +mkdir -p "$(dirname "$UPSTREAM_DMG_PATH")" +PART_PATH="$UPSTREAM_DMG_PATH.part" + +cleanup() { + rm -f -- "$PART_PATH" +} +trap cleanup EXIT HUP INT TERM + +attempt=1 +while [ "$attempt" -le "$DOWNLOAD_ATTEMPTS" ]; do + rm -f -- "$PART_PATH" + if curl \ + -fL \ + --retry 2 \ + --retry-all-errors \ + --connect-timeout 30 \ + --max-time 900 \ + -o "$PART_PATH" \ + -- "$UPSTREAM_DMG_URL"; then + if [ -s "$PART_PATH" ]; then + mv -f -- "$PART_PATH" "$UPSTREAM_DMG_PATH" + trap - EXIT HUP INT TERM + echo "Downloaded upstream DMG: $UPSTREAM_DMG_PATH" + exit 0 + fi + echo "Upstream DMG download attempt $attempt produced an empty file" >&2 + else + echo "Upstream DMG download attempt $attempt failed" >&2 + fi + + if [ "$attempt" -lt "$DOWNLOAD_ATTEMPTS" ] && [ "$RETRY_DELAY_SECONDS" -gt 0 ]; then + sleep "$RETRY_DELAY_SECONDS" + fi + attempt=$((attempt + 1)) +done + +echo "Could not download a non-empty upstream DMG after $DOWNLOAD_ATTEMPTS attempts" >&2 +exit 1 diff --git a/scripts/ci/update-nix-hashes.sh b/scripts/ci/update-nix-hashes.sh index 7b0f3be61..494f34a56 100755 --- a/scripts/ci/update-nix-hashes.sh +++ b/scripts/ci/update-nix-hashes.sh @@ -167,8 +167,7 @@ nix_pin_files_changed() { } main() { - mkdir -p "$(dirname "$UPSTREAM_DMG_PATH")" - curl -fL --retry 3 -o "$UPSTREAM_DMG_PATH" "$UPSTREAM_DMG_URL" + "$REPO_DIR/scripts/ci/download-upstream-dmg.sh" "$UPSTREAM_DMG_URL" "$UPSTREAM_DMG_PATH" new_dmg_hash="$(nix hash file --sri --type sha256 "$UPSTREAM_DMG_PATH")" if ! validate_sri_hash "$new_dmg_hash"; then diff --git a/scripts/ci/upstream-dmg-acceptance.test.js b/scripts/ci/upstream-dmg-acceptance.test.js index 8a7dcc46b..db4f73d55 100644 --- a/scripts/ci/upstream-dmg-acceptance.test.js +++ b/scripts/ci/upstream-dmg-acceptance.test.js @@ -72,6 +72,24 @@ test("rejects required patch and post-patch integrity failures", () => withFixtu assert.ok(decision.blockers.some((item) => item.code === "post-patch-integrity")); })); +test("rejects a fatal descriptor integrity failure regardless of optional policy", () => withFixture(({ root, dmg }) => { + const core = requiredCoreReport(); + core.patches.push(patch("optional-transaction", { + status: "failed-integrity", + ciPolicy: "optional", + reason: "rollback could not restore original bytes", + })); + const decision = evaluate(root, dmg, { core }); + assert.equal(decision.verdict, "rejected"); + assert.ok( + decision.blockers.some( + (item) => + item.name === "optional-transaction" && + item.reason.includes("failed-integrity"), + ), + ); +})); + test("rejects drift from a user-enabled feature", () => withFixture(({ root, dmg }) => { const core = requiredCoreReport(); core.enabledFeatures = ["ui-tweaks"]; @@ -149,6 +167,31 @@ test("a structured rejection wins over incomplete checks", () => withFixture(({ assert.equal(decision.verdict, "rejected"); })); +test("preserves packaged builder source metadata when a build fails before build info", () => withFixture(({ root, dmg }) => { + const commit = "a".repeat(40); + writeJson(root, ".codex-linux/source-info.json", { + commit, + shortCommit: commit.slice(0, 12), + version: "0.10.1", + branch: "main", + remote: "https://github.com/ilysenko/codex-desktop-linux.git", + provenance: "packaged-update-builder", + }); + const core = requiredCoreReport(); + core.patches[0].status = "failed-required"; + core.patches[0].reason = "current upstream contract did not match"; + + const decision = evaluate(root, dmg, { + core, + buildStatus: "failure", + }); + + assert.equal(decision.verdict, "rejected"); + assert.equal(decision.source?.commit, commit); + assert.equal(decision.source?.version, "0.10.1"); + assert.equal(decision.source?.provenance, "packaged-update-builder"); +})); + test("HTTP identity requires an ETag or Last-Modified plus Content-Length", () => { assert.equal(httpIdentity({ contentLength: 42 }), null); assert.equal(httpIdentity({ lastModified: "today" }), null); @@ -168,10 +211,112 @@ test("upstream workflow concurrency is isolated per PR or ref", () => { ); assert.doesNotMatch(workflow, /group: upstream-dmg-acceptance-\$\{\{ github\.event_name \}\}\s*$/m); assert.equal((workflow.match(/- linux-features\/\*\*/g) ?? []).length, 2); + assert.equal((workflow.match(/- scripts\/ci\/download-upstream-dmg\.sh/g) ?? []).length, 2); assert.equal((workflow.match(/- scripts\/lib\/linux-features\.js/g) ?? []).length, 2); assert.doesNotMatch(workflow, /uses:\s+[^\s]+@v\d/); assert.match(workflow, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); assert.match(workflow, /persist-credentials: false/); + assert.match( + workflow, + /scripts\/ci\/download-upstream-dmg\.sh[\s\S]*--reuse-existing/, + ); +}); + +test("CI DMG downloader retries empty responses and promotes only non-empty files", () => withFixture(({ root }) => { + const bin = path.join(root, "bin"); + const destination = path.join(root, "Codex.dmg"); + const attempts = path.join(root, "attempts"); + const curl = path.join(bin, "curl"); + fs.mkdirSync(bin); + fs.writeFileSync(curl, `#!/usr/bin/env bash +set -euo pipefail +output="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) output="$2"; shift 2 ;; + *) shift ;; + esac +done +count=0 +[ ! -f "$TEST_ATTEMPTS" ] || count="$(cat "$TEST_ATTEMPTS")" +count=$((count + 1)) +printf '%s\\n' "$count" > "$TEST_ATTEMPTS" +if [ "$count" -eq 1 ]; then + : > "$output" +else + printf '%s' 'complete dmg' > "$output" +fi +`); + fs.chmodSync(curl, 0o755); + + const result = spawnSync("bash", [ + path.resolve(__dirname, "download-upstream-dmg.sh"), + "https://example.test/Codex.dmg", + destination, + ], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + TEST_ATTEMPTS: attempts, + CODEX_DMG_RETRY_DELAY_SECONDS: "0", + }, + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(fs.readFileSync(attempts, "utf8").trim(), "2"); + assert.equal(fs.readFileSync(destination, "utf8"), "complete dmg"); + assert.equal(fs.existsSync(`${destination}.part`), false); +})); + +test("CI DMG downloader preserves the previous file when every response is empty", () => withFixture(({ root }) => { + const bin = path.join(root, "bin"); + const destination = path.join(root, "Codex.dmg"); + const curl = path.join(bin, "curl"); + fs.mkdirSync(bin); + fs.writeFileSync(destination, "previous dmg"); + fs.writeFileSync(curl, `#!/usr/bin/env bash +set -euo pipefail +output="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) output="$2"; shift 2 ;; + *) shift ;; + esac +done +: > "$output" +`); + fs.chmodSync(curl, 0o755); + + const result = spawnSync("bash", [ + path.resolve(__dirname, "download-upstream-dmg.sh"), + "https://example.test/Codex.dmg", + destination, + ], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + CODEX_DMG_DOWNLOAD_ATTEMPTS: "2", + CODEX_DMG_RETRY_DELAY_SECONDS: "0", + }, + }); + + assert.equal(result.status, 1); + assert.equal(fs.readFileSync(destination, "utf8"), "previous dmg"); + assert.equal(fs.existsSync(`${destination}.part`), false); +})); + +test("all CI upstream DMG consumers use the non-empty atomic downloader", () => { + for (const relativePath of [ + "container-entrypoint.sh", + "update-nix-hashes.sh", + "validate-nix-pins.sh", + ]) { + const source = fs.readFileSync(path.resolve(__dirname, relativePath), "utf8"); + assert.match(source, /scripts\/ci\/download-upstream-dmg\.sh/); + assert.doesNotMatch(source, /curl -fL --retry 3 -o [^\n]*UPSTREAM_DMG/); + } }); test("Nix refresh serializes campaigns and deduplicates refresh and exact-head CI", () => { @@ -217,6 +362,7 @@ test("Nix hash refresh accepts a validated focused output override", () => { assert.deepEqual(watchdogProfile.enabled, [ "appshots", + "codex-micro", "codex-wrapper-updater", "directory-only-working-tree-watch", "frameless-titlebar", diff --git a/scripts/ci/upstream-dmg-issue.js b/scripts/ci/upstream-dmg-issue.js index 262e2af61..1c5351750 100644 --- a/scripts/ci/upstream-dmg-issue.js +++ b/scripts/ci/upstream-dmg-issue.js @@ -9,6 +9,8 @@ const LEGACY_LABELS = labelPolicy.migrations .filter(({ to }) => to === LABEL) .map(({ from }) => from); const FINGERPRINT_PATTERN = //i; +const TEST_REHEARSAL_PATTERN = //i; +const TRUSTED_AUTHOR_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); function labelDefinition(name) { const definition = labelPolicy.labels.find((candidate) => candidate.name === name); @@ -30,6 +32,18 @@ function fingerprintMarker(fingerprint) { return ``; } +function testRehearsalMarker(testId) { + return ``; +} + +function decisionTestId(decision) { + return decision.testRehearsal?.id ?? null; +} + +function issueTestId(issue) { + return issue.body?.match(TEST_REHEARSAL_PATTERN)?.[1] ?? null; +} + function runMarker(runId) { return ``; } @@ -38,16 +52,38 @@ function issueFingerprint(issue) { return issue.body?.match(FINGERPRINT_PATTERN)?.[1]?.toLowerCase() ?? null; } +function hasTrustedAutomationAuthor(issue) { + return issue.user?.login === "github-actions[bot]" || + TRUSTED_AUTHOR_ASSOCIATIONS.has(issue.author_association); +} + function issueTitle(decision) { const version = decision.dmg.appVersion ?? "unknown version"; - return `Upstream DMG drift: ${version} (${decision.dmg.sha256.slice(0, 12)})`; + const prefix = decisionTestId(decision) ? `[TEST ${decisionTestId(decision)}] ` : ""; + return `${prefix}Upstream DMG drift: ${version} (${decision.dmg.sha256.slice(0, 12)})`; +} + +function blockerLine(item) { + const check = item.check ?? "unknown check"; + const name = item.name ? ` / \`${item.name}\`` : ""; + const status = item.status ? ` (\`${item.status}\`)` : ""; + return `- **${check}**${name}${status}: ${item.reason}`; } function issueBody(decision) { const runUrl = decision.run.url; const lines = [ fingerprintMarker(decision.dmg.sha256), + ...(decisionTestId(decision) ? [testRehearsalMarker(decisionTestId(decision))] : []), runMarker(decision.run.id), + ...(decisionTestId(decision) ? [ + "> [!CAUTION]", + "> TEST REHEARSAL ONLY. This issue does not report a real upstream regression and must not be used for a production repair.", + "", + ] : []), + "> [!IMPORTANT]", + "> Automated repair is already in progress. Please do not open a pull request for this DMG unless the maintainer asks.", + "", "The latest upstream DMG was rejected by the shared local/CI acceptance profile.", "", "## Candidate", @@ -58,7 +94,7 @@ function issueBody(decision) { "", "## Blocking checks", "", - ...decision.blockers.map((item) => `- **${item.check}**: ${item.reason}`), + ...decision.blockers.map(blockerLine), "", "## Maintainer checklist", "", @@ -69,10 +105,12 @@ function issueBody(decision) { return `${lines.join("\n")}\n`; } -async function listTrackingIssues(github, repo) { +async function listTrackingIssues(github, repo, { scanAll = false, testId = null } = {}) { const issuesByNumber = new Map(); - for (const label of [LABEL, ...LEGACY_LABELS]) { - const params = { ...repo, state: "all", labels: label, per_page: 100 }; + const queries = scanAll + ? [{ ...repo, state: "all", per_page: 100 }] + : [LABEL, ...LEGACY_LABELS].map((label) => ({ ...repo, state: "all", labels: label, per_page: 100 })); + for (const params of queries) { let issues; try { issues = github.paginate @@ -84,12 +122,30 @@ async function listTrackingIssues(github, repo) { } for (const issue of issues) issuesByNumber.set(issue.number, issue); } - // The label is public repository metadata and may also be useful on a - // maintainer-created issue. Only the hidden fingerprint marker proves that - // this automation owns an issue and may mutate its lifecycle. - return [...issuesByNumber.values()].filter( - (issue) => issue.pull_request == null && issueFingerprint(issue) !== null, - ); + // Fingerprint markers are public and can be copied into an arbitrary issue. + // Only maintainers and this workflow's bot may create lifecycle-managed + // trackers; labels and markers alone are not sufficient ownership proof. + return [...issuesByNumber.values()].filter((issue) => ( + issue.pull_request == null && + hasTrustedAutomationAuthor(issue) && + issueFingerprint(issue) !== null && + (testId ? issueTestId(issue) === testId : issueTestId(issue) === null) + )); +} + +function issueUrl(repo, issue) { + return issue.html_url ?? `https://github.com/${repo.owner}/${repo.repo}/issues/${issue.number}`; +} + +async function ensureAssignee(github, repo, issue, assignee) { + if (!assignee) return; + const alreadyAssigned = (issue.assignees || []).some((candidate) => candidate?.login === assignee); + if (alreadyAssigned) return; + await github.rest.issues.addAssignees({ + ...repo, + issue_number: issue.number, + assignees: [assignee], + }); } async function ensureLabels(github, repo) { @@ -118,7 +174,79 @@ async function closeIssue(github, repo, issue, message, stateReason) { }); } -async function reconcileUpstreamDmgIssue({ github, repo, decision, currentHttpIdentityKey }) { +async function consolidateMatchingIssues(github, repo, issues, fingerprint) { + const matching = issues + .filter((issue) => issueFingerprint(issue) === fingerprint) + .sort((left, right) => left.number - right.number); + if (matching.length === 0) return { primary: null, duplicateIssueNumbers: [] }; + + const primary = matching.find((issue) => hasLabel(issue, MANUAL_ONLY_LABEL)) ?? matching[0]; + const duplicates = matching.filter((issue) => ( + issue.number !== primary.number && + issue.state === "open" && + !hasLabel(issue, MANUAL_ONLY_LABEL) + )); + for (const issue of duplicates) { + await closeIssue( + github, + repo, + issue, + `Duplicate upstream DMG report; tracking continues in #${primary.number}.`, + "not_planned", + ); + } + return { + primary, + duplicateIssueNumbers: duplicates.map((issue) => issue.number), + }; +} + +async function normalizeMatchingIssue({ + github, + repo, + issue, + title, + body, + decision, + manageLabels, + assignee, +}) { + const wasOpen = issue.state === "open"; + const alreadyReported = issue.body?.includes(runMarker(decision.run.id)); + if (manageLabels) { + await github.rest.issues.addLabels({ + ...repo, + issue_number: issue.number, + labels: ISSUE_LABELS, + }); + } + await github.rest.issues.update({ + ...repo, + issue_number: issue.number, + title, + body, + state: "open", + }); + await ensureAssignee(github, repo, issue, assignee); + if (!alreadyReported) { + await github.rest.issues.createComment({ + ...repo, + issue_number: issue.number, + body: `Acceptance failed again. ${decision.run.url ?? "See the latest workflow artifacts."}`, + }); + } + return wasOpen ? "updated" : "reopened"; +} + +async function reconcileUpstreamDmgIssue({ + github, + repo, + decision, + currentHttpIdentityKey, + assignee = null, + manageLabels = true, + scanAll = false, +}) { if (decision.verdict === "inconclusive") { return { action: "ignored-inconclusive" }; } @@ -133,12 +261,16 @@ async function reconcileUpstreamDmgIssue({ github, repo, decision, currentHttpId return { action: "ignored-stale-candidate" }; } - const issues = await listTrackingIssues(github, repo); + const testId = decisionTestId(decision); + const issues = await listTrackingIssues(github, repo, { scanAll, testId }); if (decision.verdict === "accepted" || decision.verdict === "accepted_with_warnings") { const openIssues = issues.filter( (issue) => issue.state === "open" && !hasLabel(issue, MANUAL_ONLY_LABEL), ); + const manualOnlyIssueNumbers = issues + .filter((issue) => issue.state === "open" && hasLabel(issue, MANUAL_ONLY_LABEL)) + .map((issue) => issue.number); for (const issue of openIssues) { await closeIssue( github, @@ -148,12 +280,20 @@ async function reconcileUpstreamDmgIssue({ github, repo, decision, currentHttpId "completed", ); } - return { action: "closed-resolved", count: openIssues.length }; + return { + action: "closed-resolved", + count: openIssues.length, + closedIssueNumbers: openIssues.map((issue) => issue.number), + manualOnlyIssueNumbers, + }; } - await ensureLabels(github, repo); + if (manageLabels) await ensureLabels(github, repo); const fingerprint = decision.dmg.sha256.toLowerCase(); - const matching = issues.find((issue) => issueFingerprint(issue) === fingerprint); + const { + primary: matching, + duplicateIssueNumbers, + } = await consolidateMatchingIssues(github, repo, issues, fingerprint); const obsolete = issues.filter((issue) => ( issue.state === "open" && issueFingerprint(issue) !== fingerprint && @@ -173,41 +313,77 @@ async function reconcileUpstreamDmgIssue({ github, repo, decision, currentHttpId const body = issueBody(decision); if (matching) { if (hasLabel(matching, MANUAL_ONLY_LABEL)) { - return { action: "manual-only", issueNumber: matching.number }; + return { + action: "manual-only", + issueNumber: matching.number, + issueUrl: issueUrl(repo, matching), + duplicateIssueNumbers, + }; } - const wasOpen = matching.state === "open"; - const alreadyReported = matching.body?.includes(runMarker(decision.run.id)); - await github.rest.issues.addLabels({ - ...repo, - issue_number: matching.number, - labels: ISSUE_LABELS, - }); - await github.rest.issues.update({ - ...repo, - issue_number: matching.number, + const action = await normalizeMatchingIssue({ + github, + repo, + issue: matching, title, body, - state: "open", + decision, + manageLabels, + assignee, }); - if (!alreadyReported) { - await github.rest.issues.createComment({ - ...repo, - issue_number: matching.number, - body: `Acceptance failed again. ${decision.run.url ?? "See the latest workflow artifacts."}`, + return { + action, + issueNumber: matching.number, + issueUrl: issueUrl(repo, matching), + duplicateIssueNumbers, + }; + } + + const createArgs = { ...repo, title, body }; + if (manageLabels) createArgs.labels = ISSUE_LABELS; + if (assignee) createArgs.assignees = [assignee]; + const created = await github.rest.issues.create(createArgs); + const refreshedIssues = await listTrackingIssues(github, repo, { scanAll, testId }); + if (!refreshedIssues.some((issue) => issue.number === created.data.number)) { + refreshedIssues.push(created.data); + } + const consolidated = await consolidateMatchingIssues(github, repo, refreshedIssues, fingerprint); + const primary = consolidated.primary ?? created.data; + if (primary.number !== created.data.number) { + if (!hasLabel(primary, MANUAL_ONLY_LABEL)) { + await normalizeMatchingIssue({ + github, + repo, + issue: primary, + title, + body, + decision, + manageLabels, + assignee, }); } - return { action: wasOpen ? "updated" : "reopened", issueNumber: matching.number }; + return { + action: hasLabel(primary, MANUAL_ONLY_LABEL) ? "manual-only" : "reused-after-create-race", + issueNumber: primary.number, + issueUrl: issueUrl(repo, primary), + duplicateIssueNumbers: consolidated.duplicateIssueNumbers, + }; } - - const created = await github.rest.issues.create({ ...repo, title, body, labels: ISSUE_LABELS }); - return { action: "created", issueNumber: created.data.number }; + return { + action: "created", + issueNumber: created.data.number, + issueUrl: issueUrl(repo, created.data), + duplicateIssueNumbers: consolidated.duplicateIssueNumbers, + }; } module.exports = { LABEL, + blockerLine, fingerprintMarker, issueBody, issueFingerprint, + issueTestId, reconcileUpstreamDmgIssue, runMarker, + testRehearsalMarker, }; diff --git a/scripts/ci/upstream-dmg-issue.test.js b/scripts/ci/upstream-dmg-issue.test.js index 611e6aa05..9ba8505f5 100644 --- a/scripts/ci/upstream-dmg-issue.test.js +++ b/scripts/ci/upstream-dmg-issue.test.js @@ -1,9 +1,16 @@ "use strict"; const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); const test = require("node:test"); -const { fingerprintMarker, reconcileUpstreamDmgIssue } = require("./upstream-dmg-issue.js"); +const { + fingerprintMarker, + issueBody, + reconcileUpstreamDmgIssue, + testRehearsalMarker, +} = require("./upstream-dmg-issue.js"); function decision(verdict, sha, runId = "100") { return { @@ -15,15 +22,20 @@ function decision(verdict, sha, runId = "100") { }; } -function fakeGithub(initialIssues = []) { +function fakeGithub(initialIssues = [], { createCollisionIssue = null } = {}) { const calls = []; let nextNumber = 50; - const issues = initialIssues.map((issue) => ({ ...issue })); + const issues = initialIssues.map((issue) => ({ + author_association: "OWNER", + user: { login: "maintainer" }, + ...issue, + })); const rest = { issues: {} }; rest.issues.listForRepo = async () => ({ data: issues }); rest.issues.getLabel = async () => ({ data: {} }); rest.issues.createLabel = async (args) => { calls.push(["createLabel", args]); return { data: {} }; }; rest.issues.addLabels = async (args) => { calls.push(["addLabels", args]); return { data: {} }; }; + rest.issues.addAssignees = async (args) => { calls.push(["addAssignees", args]); return { data: {} }; }; rest.issues.createComment = async (args) => { calls.push(["comment", args]); return { data: {} }; }; rest.issues.update = async (args) => { calls.push(["update", args]); @@ -33,13 +45,59 @@ function fakeGithub(initialIssues = []) { }; rest.issues.create = async (args) => { calls.push(["create", args]); - const issue = { ...args, number: nextNumber++, state: "open" }; + const issue = { + ...args, + number: nextNumber++, + state: "open", + author_association: "NONE", + user: { login: "github-actions[bot]" }, + }; issues.push(issue); + if (createCollisionIssue != null) { + issues.push({ + author_association: "OWNER", + user: { login: "maintainer" }, + ...createCollisionIssue, + }); + createCollisionIssue = null; + } return { data: issue }; }; return { github: { rest }, calls, issues }; } +test("scheduled reconciliation scans unlabeled watchdog issues", () => { + const workflow = fs.readFileSync( + path.resolve(__dirname, "../../.github/workflows/upstream-build-app.yml"), + "utf8", + ); + assert.match(workflow, /currentHttpIdentityKey:[\s\S]*?scanAll: true,/); +}); + +test("scan-all ignores a copied fingerprint marker from an untrusted issue author", async () => { + const sha = "a".repeat(64); + const outsider = { + number: 33, + state: "open", + body: fingerprintMarker(sha), + author_association: "CONTRIBUTOR", + user: { login: "external-contributor" }, + }; + const fixture = fakeGithub([outsider]); + + const result = await reconcileUpstreamDmgIssue({ + github: fixture.github, + repo: { owner: "o", repo: "r" }, + decision: decision("rejected", sha), + currentHttpIdentityKey: "current", + scanAll: true, + }); + + assert.equal(result.action, "created"); + assert.equal(fixture.calls.filter(([name]) => name === "create").length, 1); + assert.equal(fixture.calls.some(([, args]) => args.issue_number === 33), false); +}); + test("creates one issue for a rejected current fingerprint", async () => { const fixture = fakeGithub(); const sha = "a".repeat(64); @@ -55,6 +113,90 @@ test("creates one issue for a rejected current fingerprint", async () => { ]); }); +test("watchdog issue mode assigns the current user without mutating labels", async () => { + const fixture = fakeGithub(); + const sha = "0".repeat(64); + const result = await reconcileUpstreamDmgIssue({ + github: fixture.github, + repo: { owner: "o", repo: "r" }, + decision: decision("rejected", sha), + currentHttpIdentityKey: "current", + assignee: "maintainer", + manageLabels: false, + scanAll: true, + }); + + assert.equal(result.action, "created"); + const create = fixture.calls.find(([name]) => name === "create")[1]; + assert.deepEqual(create.assignees, ["maintainer"]); + assert.equal("labels" in create, false); + assert.match(create.body, /Automated repair is already in progress/); + assert.match(create.body, /Please do not open a pull request/); +}); + +test("issue body includes the precise blocker name and status", () => { + const current = decision("rejected", "a".repeat(64)); + current.blockers = [{ + check: "feature:ui-tweaks", + name: "model-picker-model-list", + status: "skipped-optional", + reason: "current bundle was not found", + }]; + + const body = issueBody(current); + + assert.match(body, /feature:ui-tweaks/); + assert.match(body, /model-picker-model-list/); + assert.match(body, /skipped-optional/); + assert.match(body, /current bundle was not found/); +}); + +test("test rehearsal issue is unmistakably marked and isolated from production issues", async () => { + const sha = "a".repeat(64); + const production = { number: 31, state: "open", body: fingerprintMarker("b".repeat(64)) }; + const fixture = fakeGithub([production]); + const current = decision("rejected", sha); + current.testRehearsal = { id: "issue-drill-1", merge_policy: "skip" }; + + const result = await reconcileUpstreamDmgIssue({ + github: fixture.github, + repo: { owner: "o", repo: "r" }, + decision: current, + currentHttpIdentityKey: "current", + manageLabels: false, + scanAll: true, + }); + + assert.equal(result.action, "created"); + const create = fixture.calls.find(([name]) => name === "create")[1]; + assert.match(create.title, /^\[TEST issue-drill-1\]/); + assert.match(create.body, /TEST REHEARSAL ONLY/); + assert.match(create.body, /upstream-dmg-test-rehearsal:issue-drill-1/); + assert.equal(fixture.calls.some(([, args]) => args.issue_number === 31), false); +}); + +test("production reconciliation ignores test rehearsal issues", async () => { + const testIssue = { + number: 32, + state: "open", + body: `${fingerprintMarker("c".repeat(64))}\n${testRehearsalMarker("issue-drill-2")}`, + }; + const fixture = fakeGithub([testIssue]); + + const result = await reconcileUpstreamDmgIssue({ + github: fixture.github, + repo: { owner: "o", repo: "r" }, + decision: decision("accepted", "d".repeat(64)), + currentHttpIdentityKey: "current", + manageLabels: false, + scanAll: true, + }); + + assert.equal(result.action, "closed-resolved"); + assert.equal(result.count, 0); + assert.equal(fixture.calls.length, 0); +}); + test("closes old fingerprints before creating the new issue", async () => { const oldSha = "b".repeat(64); const fixture = fakeGithub([{ number: 4, state: "open", body: fingerprintMarker(oldSha) }]); @@ -75,6 +217,111 @@ test("reopens the matching closed issue instead of duplicating it", async () => assert.equal(fixture.calls.filter(([name]) => name === "create").length, 0); }); +test("closes duplicate matching fingerprint issues and keeps the oldest canonical issue", async () => { + const sha = "d".repeat(64); + const fixture = fakeGithub([ + { number: 7, state: "open", body: fingerprintMarker(sha) }, + { number: 8, state: "open", body: fingerprintMarker(sha) }, + ]); + const result = await reconcileUpstreamDmgIssue({ + github: fixture.github, + repo: { owner: "o", repo: "r" }, + decision: decision("rejected", sha), + currentHttpIdentityKey: "current", + scanAll: true, + }); + + assert.equal(result.action, "updated"); + assert.equal(result.issueNumber, 7); + assert.deepEqual(result.duplicateIssueNumbers, [8]); + assert.ok(fixture.calls.some(([name, args]) => ( + name === "update" && args.issue_number === 8 && args.state === "closed" + ))); + assert.equal(fixture.calls.filter(([name]) => name === "create").length, 0); +}); + +test("rechecks after create and closes a colliding watchdog issue", async () => { + const sha = "c".repeat(64); + const fixture = fakeGithub([], { + createCollisionIssue: { number: 51, state: "open", body: fingerprintMarker(sha) }, + }); + const result = await reconcileUpstreamDmgIssue({ + github: fixture.github, + repo: { owner: "o", repo: "r" }, + decision: decision("rejected", sha), + currentHttpIdentityKey: "current", + scanAll: true, + }); + + assert.equal(result.action, "created"); + assert.equal(result.issueNumber, 50); + assert.deepEqual(result.duplicateIssueNumbers, [51]); + assert.ok(fixture.calls.some(([name, args]) => ( + name === "update" && args.issue_number === 51 && args.state === "closed" + ))); +}); + +test("normalizes an older canonical issue after losing a create race", async () => { + const sha = "b".repeat(64); + const fixture = fakeGithub([], { + createCollisionIssue: { + number: 49, + state: "open", + body: fingerprintMarker(sha), + labels: [], + assignees: [], + }, + }); + const result = await reconcileUpstreamDmgIssue({ + github: fixture.github, + repo: { owner: "o", repo: "r" }, + decision: decision("rejected", sha, "race-run"), + currentHttpIdentityKey: "current", + assignee: "maintainer", + scanAll: true, + }); + + assert.equal(result.action, "reused-after-create-race"); + assert.equal(result.issueNumber, 49); + assert.deepEqual(result.duplicateIssueNumbers, [50]); + assert.ok(fixture.calls.some(([name, args]) => ( + name === "update" && args.issue_number === 50 && args.state === "closed" + ))); + assert.ok(fixture.calls.some(([name, args]) => ( + name === "addLabels" && args.issue_number === 49 + ))); + assert.ok(fixture.calls.some(([name, args]) => ( + name === "addAssignees" && args.issue_number === 49 + ))); + const canonicalUpdate = fixture.calls.find(([name, args]) => ( + name === "update" && args.issue_number === 49 + )); + assert.equal(canonicalUpdate[1].state, "open"); + assert.match(canonicalUpdate[1].body, //); + assert.ok(fixture.calls.some(([name, args]) => ( + name === "comment" && args.issue_number === 49 + ))); +}); + +test("watchdog mode finds an unlabelled closed marker and reuses it", async () => { + const sha = "d".repeat(64); + const fixture = fakeGithub([{ number: 8, state: "closed", body: fingerprintMarker(sha) }]); + const result = await reconcileUpstreamDmgIssue({ + github: fixture.github, + repo: { owner: "o", repo: "r" }, + decision: decision("rejected", sha), + currentHttpIdentityKey: "current", + assignee: "maintainer", + manageLabels: false, + scanAll: true, + }); + + assert.equal(result.action, "reopened"); + assert.equal(fixture.calls.filter(([name]) => name === "create").length, 0); + assert.ok(fixture.calls.some(([name]) => name === "addAssignees")); + assert.equal(fixture.calls.some(([name]) => name === "addLabels"), false); +}); + test("accepted candidates close old drift issues without creating a new one", async () => { const fixture = fakeGithub([{ number: 9, state: "open", body: fingerprintMarker("e".repeat(64)) }]); const result = await reconcileUpstreamDmgIssue({ @@ -84,6 +331,27 @@ test("accepted candidates close old drift issues without creating a new one", as assert.equal(fixture.calls.filter(([name]) => name === "create").length, 0); }); +test("accepted candidates report manual-only issues without mutating them", async () => { + const sha = "7".repeat(64); + const fixture = fakeGithub([{ + number: 15, + state: "open", + body: fingerprintMarker(sha), + labels: [{ name: "workflow: manual only" }], + }]); + + const result = await reconcileUpstreamDmgIssue({ + github: fixture.github, + repo: { owner: "o", repo: "r" }, + decision: decision("accepted", sha), + currentHttpIdentityKey: "current", + scanAll: true, + }); + + assert.deepEqual(result.manualOnlyIssueNumbers, [15]); + assert.equal(fixture.calls.some(([, args]) => args.issue_number === 15), false); +}); + test("does not add a duplicate comment for the same workflow run", async () => { const sha = "2".repeat(64); const current = decision("rejected", sha, "123"); diff --git a/scripts/ci/validate-nix-pins.sh b/scripts/ci/validate-nix-pins.sh index b2111f99a..fd901455f 100755 --- a/scripts/ci/validate-nix-pins.sh +++ b/scripts/ci/validate-nix-pins.sh @@ -193,8 +193,7 @@ assert_equal() { } if [ ! -s "$UPSTREAM_DMG_PATH" ]; then - mkdir -p "$(dirname "$UPSTREAM_DMG_PATH")" - curl -fL --retry 3 -o "$UPSTREAM_DMG_PATH" "$UPSTREAM_DMG_URL" + "$REPO_DIR/scripts/ci/download-upstream-dmg.sh" "$UPSTREAM_DMG_URL" "$UPSTREAM_DMG_PATH" fi SEVEN_ZIP_CMD="$(find_seven_zip)" || fail "7z/7zz/7za not found" diff --git a/scripts/ci/watchdog-linux-features.json b/scripts/ci/watchdog-linux-features.json index 357a21e09..9103a7a6d 100644 --- a/scripts/ci/watchdog-linux-features.json +++ b/scripts/ci/watchdog-linux-features.json @@ -1,6 +1,7 @@ { "enabled": [ "appshots", + "codex-micro", "codex-wrapper-updater", "directory-only-working-tree-watch", "frameless-titlebar", diff --git a/scripts/dev/upstream-dmg-intel.test.js b/scripts/dev/upstream-dmg-intel.test.js index d0f189396..814883e52 100644 --- a/scripts/dev/upstream-dmg-intel.test.js +++ b/scripts/dev/upstream-dmg-intel.test.js @@ -591,9 +591,74 @@ test("classifies required patch-report failures as acceptance blockers", () => }); assert.ok(findClassification(driftReport, "record_and_replay_event_stream", "PATCH_BROKEN")); + assert.ok( + !findClassification( + driftReport, + "record_and_replay_event_stream", + "PATCH_INTEGRITY_BROKEN", + ), + ); assert.ok(!findClassification(driftReport, "record_and_replay_event_stream", "PATCH_REVIEW")); })); +test("classifies patch integrity failures as acceptance blockers", () => + withTempDir((workspace) => { + const candidateApp = createFixtureApp(workspace, "candidate"); + const candidate = extractProtectedSurfaces({ + inventory: createInventory({ registry, sourcePath: candidateApp }), + registry, + repoRoot: process.cwd(), + }); + + const driftReport = compareProtectedSurfaces({ + candidate, + patchReport: { + patches: [ + { + name: "record-and-replay bridge patch", + status: "failed-integrity", + reason: "rollback could not restore original bytes", + surfaceId: "record_and_replay_event_stream", + }, + ], + }, + }); + + assert.ok( + findClassification( + driftReport, + "record_and_replay_event_stream", + "PATCH_INTEGRITY_BROKEN", + ), + ); + assert.ok(!findClassification(driftReport, "record_and_replay_event_stream", "PATCH_BROKEN")); + assert.ok(!findClassification(driftReport, "record_and_replay_event_stream", "PATCH_REVIEW")); + })); + +test("renders a remediation for patch integrity blockers", () => { + const actionPlan = renderActionPlanMarkdown( + { + surfaceDrift: [ + { + surfaceId: "record_and_replay_event_stream", + classification: "PATCH_INTEGRITY_BROKEN", + patches: [ + { + name: "record-and-replay bridge patch", + status: "failed-integrity", + }, + ], + }, + ], + }, + { source: { path: "candidate.app" } }, + ); + + assert.match(actionPlan, /stop candidate acceptance/); + assert.match(actionPlan, /rebuild from the fresh current DMG/); + assert.match(actionPlan, /do not promote bytes whose original state cannot be proven/); +}); + test("classifies unresolved Linux settings patch symbols as acceptance blockers", () => withTempDir((workspace) => { const candidateApp = createFixtureApp(workspace, "candidate"); diff --git a/scripts/install-latest.sh b/scripts/install-latest.sh index 7c182d4a5..f165a92a9 100755 --- a/scripts/install-latest.sh +++ b/scripts/install-latest.sh @@ -3,6 +3,8 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/rust-toolchain.sh" PACKAGE_NAME="codex-desktop" SYSTEM_APP_ASAR="/opt/$PACKAGE_NAME/resources/app.asar" LOCAL_APP_ASAR="$REPO_DIR/codex-app/resources/app.asar" @@ -127,8 +129,8 @@ have_modern_7zip() { ! 7z 2>&1 | grep -m 1 "7-Zip" | grep -q "16.02" } -ensure_cargo_on_path() { - if command -v cargo >/dev/null 2>&1; then +ensure_rust_toolchain_on_path() { + if rust_toolchain_compatible; then return 0 fi @@ -137,7 +139,7 @@ ensure_cargo_on_path() { . "$HOME/.cargo/env" fi - command -v cargo >/dev/null 2>&1 + rust_toolchain_compatible } dependencies_ready() { @@ -151,7 +153,7 @@ dependencies_ready() { major="$(node_major 2>/dev/null || true)" [ -n "$major" ] && [ "$major" -ge 20 ] || return 1 have_modern_7zip || return 1 - ensure_cargo_on_path || return 1 + ensure_rust_toolchain_on_path || return 1 distro="$(detect_package_distro)" if [ "$distro" != "unknown" ]; then system_nodejs_ready "$distro" || return 1 @@ -331,7 +333,8 @@ main() { else info "Installing or verifying host dependencies" bash "$REPO_DIR/scripts/install-deps.sh" - ensure_cargo_on_path || error "cargo is still unavailable after scripts/install-deps.sh" + ensure_rust_toolchain_on_path \ + || error "Rust ${MIN_RUST_VERSION}+ is still unavailable after scripts/install-deps.sh (found $(rustc_version 2>/dev/null || printf 'none'))" fi local current_package_version current_app_version diff --git a/scripts/lib/asar-patch.sh b/scripts/lib/asar-patch.sh index 7d3c98c52..c0406c44b 100644 --- a/scripts/lib/asar-patch.sh +++ b/scripts/lib/asar-patch.sh @@ -19,6 +19,7 @@ const summary = summarizePatchReport(report); const fmt = (counts) => Object.entries(counts).map(([k, v]) => `${k}=${v}`).join(", ") || "none"; console.error("[INFO] patch summary:"); +console.error(` integrity failures: ${fmt(summary.groups.integrityFailures.statusCounts)}`); console.error(` required core: ${fmt(summary.groups.requiredCore.statusCounts)}`); console.error(` optional core: ${fmt(summary.groups.optionalCore.statusCounts)}`); diff --git a/scripts/lib/browser-client-node-repl-runtime.test.js b/scripts/lib/browser-client-node-repl-runtime.test.js index c562e3aea..4ecdfd5a1 100644 --- a/scripts/lib/browser-client-node-repl-runtime.test.js +++ b/scripts/lib/browser-client-node-repl-runtime.test.js @@ -2,8 +2,9 @@ "use strict"; const assert = require("node:assert/strict"); -const { spawn } = require("node:child_process"); +const { spawn, spawnSync } = require("node:child_process"); const fs = require("node:fs"); +const os = require("node:os"); const path = require("node:path"); const readline = require("node:readline"); const test = require("node:test"); @@ -109,6 +110,121 @@ function runNodeReplImport(runtime, clients) { }); } +test("guards every Browser client nodeRepl env read", () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "browser-client-env-guard-")); + const clientPath = path.join(fixtureRoot, "browser-client.mjs"); + const patcher = path.join(__dirname, "bundled-plugins.sh"); + const client = [ + 'var Es="BROWSER_USE_SECURITY_MODE",Bl="BROWSER_USE_AUTOMATED_SAFETY_PRECHECKS_ENABLED",Ai;', + 'function ye(){return globalThis.nodeRepl}', + 'function sT(e){if(Ai!=null)return()=>{};let t=Object.freeze({nodeRepl:e,createElicitation:e.createElicitation,env:e.env,securityMode:e.env[Es],enabled:e.env[Bl]==="1"});return Ai=t,()=>{Ai===t&&(Ai=void 0)}}', + 'function Bm(){let e=Ai;if(e==null)return!0;let t=ye();return t===e.nodeRepl&&t.env===e.env&&t.createElicitation===e.createElicitation&&t.env[Es]===e.securityMode&&t.env[Bl]==="1"===e.enabled}', + 'function Ou(e){let t=globalThis.nodeRepl?.env[e];return typeof t=="string"?t:void 0}', + ].join(""); + + try { + fs.writeFileSync(clientPath, client, "utf8"); + const applyGuard = () => + spawnSync( + "bash", + [ + "-c", + 'source "$1"; patch_browser_use_node_repl_env_guard "$2"', + "browser-client-env-guard", + patcher, + clientPath, + ], + { encoding: "utf8" }, + ); + + const first = applyGuard(); + assert.equal(first.status, 0, first.stderr); + const patched = fs.readFileSync(clientPath, "utf8"); + assert.match(patched, /codexLinuxBrowserUseNodeReplEnvGuard/); + assert.match(patched, /globalThis\.nodeRepl\?\.env\?\.\[e\]/); + assert.doesNotMatch(patched, /\b[A-Za-z_$][\w$]*\.env\[[^\]]+\]/); + assert.equal((patched.match(/\.env\?\.\[/g) ?? []).length, 5); + + const second = applyGuard(); + assert.equal(second.status, 0, second.stderr); + assert.equal(fs.readFileSync(clientPath, "utf8"), patched); + + const previousNodeRepl = globalThis.nodeRepl; + try { + globalThis.nodeRepl = {}; + const initializeSecurityState = new Function( + `${patched};return {dispose:sT(globalThis.nodeRepl),valid:Bm()}`, + ); + const securityState = initializeSecurityState(); + assert.equal(securityState.valid, true); + assert.equal(typeof securityState.dispose, "function"); + securityState.dispose(); + } finally { + globalThis.nodeRepl = previousNodeRepl; + } + } finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } +}); + +test("keeps Browser notification hooks on the cloned nodeRepl runtime", async () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "browser-client-runtime-clone-")); + const clientPath = path.join(fixtureRoot, "browser-client.mjs"); + const patcher = path.join(__dirname, "bundled-plugins.sh"); + const client = [ + "function bb(){let e=globalThis.nodeRepl;return e?.config==null?void 0:e}", + "async function cM(e){let t=e.createElicitation.bind(e),r={...e,platform:`linux`,setResponseMeta:e.setResponseMeta,get requestMeta(){return e.requestMeta},async createElicitation(o){return await t(o)}},n=await $K(e,r);return n!=null&&(r.gaas=n),r}", + "async function $K(){return null}", + ].join(""); + + try { + fs.writeFileSync(clientPath, client, "utf8"); + const applyShim = () => + spawnSync( + "bash", + [ + "-c", + 'source "$1"; patch_browser_use_node_repl_config_shim "$2"; patch_browser_use_node_repl_runtime_clone_shim "$2"', + "browser-client-runtime-clone", + patcher, + clientPath, + ], + { encoding: "utf8" }, + ); + + const first = applyShim(); + assert.equal(first.status, 0, first.stderr); + const patched = fs.readFileSync(clientPath, "utf8"); + const previousNodeRepl = globalThis.nodeRepl; + const prototype = {}; + const nodeRepl = Object.preventExtensions( + Object.assign(Object.create(prototype), { + createElicitation: async () => ({ action: "decline" }), + requestMeta: {}, + setResponseMeta() {}, + }), + ); + + try { + globalThis.nodeRepl = nodeRepl; + const initialize = new Function(`${patched};return {clone:cM,resolve:bb}`); + const runtime = initialize(); + const resolved = runtime.resolve(); + assert.equal(typeof resolved.addAfterSubmittedCodeHook, "function"); + const cloned = await runtime.clone(resolved); + assert.equal(typeof cloned.addAfterSubmittedCodeHook, "function"); + } finally { + globalThis.nodeRepl = previousNodeRepl; + } + + const second = applyShim(); + assert.equal(second.status, 0, second.stderr); + assert.equal(fs.readFileSync(clientPath, "utf8"), patched); + } finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } +}); + test( "staged Browser and Chrome clients import through the real node_repl runtime", { skip: !runtimePath || !pluginsRoot }, diff --git a/scripts/lib/build-info.js b/scripts/lib/build-info.js index b311729de..6d1730574 100644 --- a/scripts/lib/build-info.js +++ b/scripts/lib/build-info.js @@ -269,6 +269,28 @@ function appBundleVersion(appDir) { return version.length > 0 ? version : null; } +function recordAppVersionMetadata(metadataPath, appDir) { + const appVersion = appBundleVersion(appDir); + if (appVersion == null) { + return null; + } + const metadata = fs.existsSync(metadataPath) + ? JSON.parse(fs.readFileSync(metadataPath, "utf8")) + : {}; + metadata.appVersion = appVersion; + const metadataDir = path.dirname(metadataPath); + fs.mkdirSync(metadataDir, { recursive: true }); + const stagingDir = fs.mkdtempSync(path.join(metadataDir, ".upstream-dmg-metadata-")); + const stagingPath = path.join(stagingDir, path.basename(metadataPath)); + try { + fs.writeFileSync(stagingPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8"); + fs.renameSync(stagingPath, metadataPath); + } finally { + fs.rmSync(stagingDir, { recursive: true, force: true }); + } + return appVersion; +} + function linuxTargetInfo(target) { return { summary: linuxTargetSummary(target), @@ -356,6 +378,7 @@ if (require.main === module) { } module.exports = { + appBundleVersion, buildInfo, githubCommitUrl, isoTimestamp, @@ -363,5 +386,6 @@ module.exports = { sanitizeGitRemoteUrl, sourceInfo, sourceInfoFromGit, + recordAppVersionMetadata, writeBuildInfo, }; diff --git a/scripts/lib/build-info.sh b/scripts/lib/build-info.sh index ea968fbb6..7414255d8 100644 --- a/scripts/lib/build-info.sh +++ b/scripts/lib/build-info.sh @@ -4,6 +4,18 @@ # Sourced by install.sh. Do not run directly. # shellcheck shell=bash +record_upstream_app_version() { + local app_dir="$1" + local metadata_path="${CODEX_UPSTREAM_DMG_METADATA_JSON:-}" + [ -n "$metadata_path" ] || return 0 + + "${CODEX_ACCEPTANCE_NODE:-node}" -e \ + 'require(process.argv[1]).recordAppVersionMetadata(process.argv[2], process.argv[3])' \ + "$SCRIPT_DIR/scripts/lib/build-info.js" \ + "$metadata_path" \ + "$app_dir" +} + write_build_info() { local dmg_path="$1" local app_dir="$2" diff --git a/scripts/lib/build-info.test.js b/scripts/lib/build-info.test.js new file mode 100644 index 000000000..e423619e8 --- /dev/null +++ b/scripts/lib/build-info.test.js @@ -0,0 +1,86 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const { + appBundleVersion, + recordAppVersionMetadata, +} = require("./build-info.js"); + +function writeInfoPlist(appDir, version) { + const contentsDir = path.join(appDir, "Contents"); + fs.mkdirSync(contentsDir, { recursive: true }); + fs.writeFileSync( + path.join(contentsDir, "Info.plist"), + [ + '', + '', + '', + "CFBundleShortVersionString", + `${version}`, + "", + ].join("\n"), + ); +} + +test("records the extracted app version without discarding downloaded DMG metadata", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-build-info-")); + try { + const appDir = path.join(tempDir, "Codex.app"); + const metadataPath = path.join(tempDir, "reports", "upstream-dmg-metadata.json"); + writeInfoPlist(appDir, "26.803.41515"); + fs.mkdirSync(path.dirname(metadataPath), { recursive: true }); + fs.writeFileSync(metadataPath, `${JSON.stringify({ etag: "current", path: "/tmp/Codex.dmg" })}\n`); + + assert.equal(appBundleVersion(appDir), "26.803.41515"); + assert.equal(recordAppVersionMetadata(metadataPath, appDir), "26.803.41515"); + assert.deepEqual(JSON.parse(fs.readFileSync(metadataPath, "utf8")), { + etag: "current", + path: "/tmp/Codex.dmg", + appVersion: "26.803.41515", + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("leaves metadata untouched when the extracted app has no readable version", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-build-info-missing-")); + try { + const appDir = path.join(tempDir, "Codex.app"); + const metadataPath = path.join(tempDir, "upstream-dmg-metadata.json"); + const original = '{"etag":"current"}\n'; + fs.mkdirSync(appDir, { recursive: true }); + fs.writeFileSync(metadataPath, original); + + assert.equal(appBundleVersion(appDir), null); + assert.equal(recordAppVersionMetadata(metadataPath, appDir), null); + assert.equal(fs.readFileSync(metadataPath, "utf8"), original); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("leaves malformed metadata untouched instead of publishing a partial replacement", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-build-info-malformed-")); + try { + const appDir = path.join(tempDir, "Codex.app"); + const metadataPath = path.join(tempDir, "upstream-dmg-metadata.json"); + const original = '{"etag":'; + writeInfoPlist(appDir, "26.803.41515"); + fs.writeFileSync(metadataPath, original); + + assert.throws(() => recordAppVersionMetadata(metadataPath, appDir), SyntaxError); + assert.equal(fs.readFileSync(metadataPath, "utf8"), original); + assert.deepEqual( + fs.readdirSync(tempDir).filter((entry) => entry.startsWith(".upstream-dmg-metadata-")), + [], + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/scripts/lib/bundled-plugins.sh b/scripts/lib/bundled-plugins.sh index 60922513b..ed725c7d5 100644 --- a/scripts/lib/bundled-plugins.sh +++ b/scripts/lib/bundled-plugins.sh @@ -1114,6 +1114,7 @@ stage_chrome_plugin_from_upstream() { patch_browser_use_node_repl_process_env_import "$target_plugin/scripts/browser-client.mjs" patch_browser_use_node_repl_env_guard "$target_plugin/scripts/browser-client.mjs" patch_browser_use_node_repl_config_shim "$target_plugin/scripts/browser-client.mjs" + patch_browser_use_node_repl_runtime_clone_shim "$target_plugin/scripts/browser-client.mjs" patch_browser_use_native_pipe_import_meta_bridge "$target_plugin/scripts/browser-client.mjs" patch_browser_use_site_status_allowlist_fallback "$target_plugin/scripts/browser-client.mjs" patch_browser_client_linux_socket_dir "$target_plugin/scripts/browser-client.mjs" @@ -1253,7 +1254,7 @@ PY patch_browser_use_node_repl_env_guard() { local client="$1" - if grep -Eq 'globalThis\.nodeRepl\?\.env\?\.\[[^]]+\]' "$client"; then + if grep -q "codexLinuxBrowserUseNodeReplEnvGuard" "$client"; then return 0 fi @@ -1264,28 +1265,53 @@ import sys path = Path(sys.argv[1]) source = path.read_text(encoding="utf-8") -pattern = re.compile( +helper_pattern = re.compile( r'function (?P[A-Za-z_$][\w$]*)\((?P[A-Za-z_$][\w$]*)\)\{' r'let (?P[A-Za-z_$][\w$]*)=globalThis\.nodeRepl\?\.env\[(?P=key)\];' r'return typeof (?P=value)=="string"\?(?P=value):void 0\}' ) -match = pattern.search(source) -if match is None: +helper_match = helper_pattern.search(source) +if helper_match is not None: + helper = helper_match.group("helper") + key = helper_match.group("key") + value = helper_match.group("value") + replacement = ( + f'function {helper}({key}){{' + f'let {value}=globalThis.nodeRepl?.env?.[{key}];' + f'return typeof {value}=="string"?{value}:void 0}}' + ) + source = source[:helper_match.start()] + replacement + source[helper_match.end():] + +# Newer Browser clients snapshot privileged node_repl state before creating the +# browser agent. Older Linux node_repl runtimes do not expose `env`, so every +# direct property read must preserve the upstream default behavior when it is +# absent. Keep the object identity comparison itself unchanged. +direct_env_pattern = re.compile( + r'(?P\b[A-Za-z_$][\w$]*)\.env\[(?P[^\]]+)\]' +) +source, direct_env_count = direct_env_pattern.subn( + r'\g.env?.[\g]', + source, +) + +if helper_match is None and direct_env_count == 0: print( "WARN: Could not find Browser Use nodeRepl env guard insertion point — leaving browser-client.mjs unchanged", file=sys.stderr, ) raise SystemExit(0) -helper = match.group("helper") -key = match.group("key") -value = match.group("value") -replacement = ( - f'function {helper}({key}){{' - f'let {value}=globalThis.nodeRepl?.env?.[{key}];' - f'return typeof {value}=="string"?{value}:void 0}}' +marker_target = ( + "globalThis.nodeRepl?.env?.[" + if "globalThis.nodeRepl?.env?.[" in source + else ".env?.[" ) -path.write_text(source[:match.start()] + replacement + source[match.end():], encoding="utf-8") +source = source.replace( + marker_target, + f"/*codexLinuxBrowserUseNodeReplEnvGuard*/{marker_target}", + 1, +) +path.write_text(source, encoding="utf-8") PY } @@ -1481,6 +1507,57 @@ path.write_text(source[:match.start()] + replacement + source[match.end():], enc PY } +patch_browser_use_node_repl_runtime_clone_shim() { + local client="$1" + + if grep -q "codexLinuxBrowserUseRuntimeCloneShim" "$client"; then + return 0 + fi + + python3 - "$client" <<'PY' +from pathlib import Path +import re +import sys + +path = Path(sys.argv[1]) +source = path.read_text(encoding="utf-8") +if "codexLinuxBrowserUseNodeReplMethodShim" not in source: + print( + "WARN: Browser Use nodeRepl method shim is unavailable — leaving the runtime clone unchanged", + file=sys.stderr, + ) + raise SystemExit(0) + +pattern = re.compile( + r'(?P' + r'let (?P[A-Za-z_$][\w$]*)=' + r'(?P[A-Za-z_$][\w$]*)\.createElicitation\.bind\((?P=source)\),' + r'(?P[A-Za-z_$][\w$]*)=\{\.\.\.(?P=source),.{1,2048}?\}' + r')' + r',(?P' + r'[A-Za-z_$][\w$]*=await [A-Za-z_$][\w$]*\((?P=source),(?P=runtime)\);return' + r')', + re.DOTALL, +) +match = pattern.search(source) +if match is None: + print( + "WARN: Could not find Browser Use nodeRepl runtime clone — leaving browser-client.mjs unchanged", + file=sys.stderr, + ) + raise SystemExit(0) + +runtime = match.group("runtime") +replacement = ( + match.group("declaration") + + ";/*codexLinuxBrowserUseRuntimeCloneShim*/" + + f"codexLinuxBrowserUseNodeReplMethodShim({runtime});let " + + match.group("next") +) +path.write_text(source[:match.start()] + replacement + source[match.end():], encoding="utf-8") +PY +} + patch_browser_use_native_pipe_import_meta_bridge() { local client="$1" @@ -1601,6 +1678,7 @@ stage_browser_plugin_from_upstream() { patch_browser_use_node_repl_process_env_import "$target_client" patch_browser_use_node_repl_env_guard "$target_client" patch_browser_use_node_repl_config_shim "$target_client" + patch_browser_use_node_repl_runtime_clone_shim "$target_client" patch_browser_use_native_pipe_import_meta_bridge "$target_client" patch_browser_use_site_status_allowlist_fallback "$target_client" patch_browser_use_file_url_policy "$target_client" diff --git a/scripts/lib/linux-update-bridge-patch.js b/scripts/lib/linux-update-bridge-patch.js index 2d80b1dea..6d903b7a4 100644 --- a/scripts/lib/linux-update-bridge-patch.js +++ b/scripts/lib/linux-update-bridge-patch.js @@ -1,11 +1,6 @@ const fs = require("fs"); const path = require("path"); -function requireName(source, moduleName) { - const escaped = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return source.match(new RegExp(`([A-Za-z_$][\\w$]*)=require\\([\\\`'"]${escaped}[\\\`'"]\\)`))?.[1] ?? null; -} - function buildUpdateManagerEnvSource() { return "function codexLinuxUpdateManagerEnv(){let e={...process.env},t=process.env.CODEX_LINUX_ORIGINAL_LD_LIBRARY_PATH_STATE,n=t==null?void 0:process.env.CODEX_LINUX_HOST_LD_LIBRARY_PATH_STATE??t,r=process.env.CODEX_LINUX_HOST_LD_LIBRARY_PATH_STATE==null?process.env.CODEX_LINUX_ORIGINAL_LD_LIBRARY_PATH_VALUE:process.env.CODEX_LINUX_HOST_LD_LIBRARY_PATH_VALUE;n===`unset`?delete e.LD_LIBRARY_PATH:n===`empty`?e.LD_LIBRARY_PATH=``:n===`value`&&typeof r==`string`&&(e.LD_LIBRARY_PATH=r);for(let t of[`CODEX_LINUX_ORIGINAL_LD_LIBRARY_PATH_STATE`,`CODEX_LINUX_ORIGINAL_LD_LIBRARY_PATH_VALUE`,`CODEX_LINUX_HOST_LD_LIBRARY_PATH_STATE`,`CODEX_LINUX_HOST_LD_LIBRARY_PATH_VALUE`])delete e[t];return e}"; } @@ -28,11 +23,11 @@ function buildBridgeSource({ childProcessVar, fsVar, pathVar }) { `async function codexLinuxShowUpdateMessage(codexLinuxMessage,codexLinuxDetail){try{let e=codexLinuxGetElectronModule();if(!e)return;await e.dialog?.showMessageBox({type:\`info\`,buttons:[\`OK\`],defaultId:0,noLink:!0,message:codexLinuxMessage,detail:codexLinuxDetail})}catch{}}`; const installAfterQuit = buildInstallAfterQuitSource(childProcessVar); const quitForUpdate = buildQuitForUpdateSource(true); - return `${buildElectronResolverSource()}${buildUpdateManagerEnvSource()}function codexLinuxUpdateStatePath(){let e=process.env.XDG_STATE_HOME||process.env.HOME&&(0,${pathVar}.join)(process.env.HOME,\`.local\`,\`state\`);return e?(0,${pathVar}.join)(e,\`codex-update-manager\`,\`state.json\`):null}function codexLinuxReadUpdateState(){let e=codexLinuxUpdateStatePath();if(!e||!${fsVar}.existsSync(e))return null;try{let t=JSON.parse(${fsVar}.readFileSync(e,\`utf8\`));return t&&typeof t===\`object\`&&!Array.isArray(t)?t:null}catch{return null}}function codexLinuxUpdateLifecycleState(e){switch(e){case\`ready_to_install\`:case\`waiting_for_app_exit\`:return\`ready\`;case\`installing\`:return\`installing\`;case\`checking_upstream\`:case\`update_detected\`:case\`downloading_dmg\`:case\`preparing_workspace\`:case\`patching_app\`:case\`building_package\`:return\`checking\`;default:return\`idle\`}}function codexLinuxUpdateManagerPath(){let e=process.env.CODEX_UPDATE_MANAGER_PATH;return typeof e===\`string\`&&e.trim().length>0?e:\`codex-update-manager\`}${showUpdateMessage}${installAfterQuit}${quitForUpdate}function codexLinuxRunUpdateManager(e){return new Promise((t,n)=>{${childProcessVar}.execFile(codexLinuxUpdateManagerPath(),e,{encoding:\`utf8\`,windowsHide:!0,env:codexLinuxUpdateManagerEnv()},(e,r,i)=>{if(e){e.stdout=r,e.stderr=i,n(e);return}t({stdout:r??\`\`,stderr:i??\`\`})})})}async function codexLinuxProbeUpdateManager(){await codexLinuxRunUpdateManager([\`--help\`])}async function codexLinuxRefreshUpdateState(){return codexLinuxReadUpdateState()}`; + return `${buildElectronResolverSource()}${buildUpdateManagerEnvSource()}function codexLinuxUpdateStatePath(){let e=process.env.XDG_STATE_HOME||process.env.HOME&&(0,${pathVar}.join)(process.env.HOME,\`.local\`,\`state\`);return e?(0,${pathVar}.join)(e,\`codex-update-manager\`,\`state.json\`):null}function codexLinuxReadUpdateState(){let e=codexLinuxUpdateStatePath();if(!e||!${fsVar}.existsSync(e))return null;try{let t=JSON.parse(${fsVar}.readFileSync(e,\`utf8\`));return t&&typeof t===\`object\`&&!Array.isArray(t)?t:null}catch{return null}}function codexLinuxUpdateLifecycleState(e,t){if(e===\`update_detected\`&&t?.deferred_build===!0)return\`idle\`;switch(e){case\`ready_to_install\`:case\`waiting_for_app_exit\`:return\`ready\`;case\`installing\`:return\`installing\`;case\`checking_upstream\`:case\`update_detected\`:case\`downloading_dmg\`:case\`preparing_workspace\`:case\`patching_app\`:case\`building_package\`:return\`checking\`;default:return\`idle\`}}function codexLinuxUpdateManagerPath(){let e=process.env.CODEX_UPDATE_MANAGER_PATH;return typeof e===\`string\`&&e.trim().length>0?e:\`codex-update-manager\`}${showUpdateMessage}${installAfterQuit}${quitForUpdate}function codexLinuxRunUpdateManager(e){return new Promise((t,n)=>{${childProcessVar}.execFile(codexLinuxUpdateManagerPath(),e,{encoding:\`utf8\`,windowsHide:!0,env:codexLinuxUpdateManagerEnv()},(e,r,i)=>{if(e){e.stdout=r,e.stderr=i,n(e);return}t({stdout:r??\`\`,stderr:i??\`\`})})})}async function codexLinuxProbeUpdateManager(){await codexLinuxRunUpdateManager([\`--help\`])}async function codexLinuxRefreshUpdateState(){return codexLinuxReadUpdateState()}`; } function buildBootstrapBridgeSource({ childProcessVar, fsVar, pathVar }) { - return `${buildBridgeSource({ childProcessVar, fsVar, pathVar })};function codexLinuxCreatePackageUpdateManager(e){let t=!1,n=\`idle\`,r=null,i=()=>{try{let e=codexLinuxReadUpdateState(),r=e?.status;t=r===\`ready_to_install\`||r===\`waiting_for_app_exit\`,n=codexLinuxUpdateLifecycleState(r);return e}catch{return null}},a=()=>{try{e.send({type:\`app-update-ready-changed\`,isUpdateReady:t}),e.send({type:\`app-update-lifecycle-state-changed\`,lifecycleState:n}),e.send({type:\`app-update-install-progress-changed\`,installProgressPercent:r})}catch{}},s=!1,c=codexLinuxProbeUpdateManager().then(()=>{s=!0,i(),a();return!0}).catch(()=>{s=!1,t=!1,n=\`idle\`,a();return!1});let o=()=>{e.allowQuit?.();codexLinuxQuitForUpdate()};return{manager:{setAutomaticBackgroundDownloadsEnabled:()=>{},getIsUpdateReady:()=>s&&t,getUpdateLifecycleState:()=>s?n:\`idle\`,getInstallProgressPercent:()=>r,checkForUpdates:async()=>{if(!await c)return;n=\`checking\`,a();try{await codexLinuxRunUpdateManager([\`check-now\`]),i(),a()}catch(e){n=t?\`ready\`:\`idle\`,a();throw e}},installUpdatesIfAvailable:async()=>{if(!await c){a();return}i();if(!t){a();return}r=0,n=\`installing\`,a();try{let e=await codexLinuxRunUpdateManager([\`install-ready\`]),s=i();if(s?.status===\`waiting_for_app_exit\`){r=null,n=\`ready\`,a(),o();return}r=null,a(),e.stdout?.includes(\`Manual install required:\`)?await codexLinuxShowUpdateMessage(\`ChatGPT Desktop update\`,e.stdout.trim()):e.stdout?.includes(\`already installed\`)?await codexLinuxShowUpdateMessage(\`ChatGPT Desktop update\`,\`The ready update is already installed.\`):e.stdout?.includes(\`No update is ready to install\`)&&await codexLinuxShowUpdateMessage(\`ChatGPT Desktop update\`,\`There is no rebuilt update waiting to install.\`)}catch(e){r=null,n=t?\`ready\`:\`idle\`,a();throw e}}},quitForUpdate:o,refresh:async()=>{if(await c){try{await codexLinuxRefreshUpdateState()}catch{}i()}else t=!1,n=\`idle\`;a()}}}`; + return `${buildBridgeSource({ childProcessVar, fsVar, pathVar })};function codexLinuxCreatePackageUpdateManager(e){let t=!1,n=\`idle\`,r=null,i=()=>{try{let e=codexLinuxReadUpdateState(),r=e?.status;t=r===\`ready_to_install\`||r===\`waiting_for_app_exit\`,n=codexLinuxUpdateLifecycleState(r,e);return e}catch{return null}},a=()=>{try{e.send({type:\`app-update-ready-changed\`,isUpdateReady:t}),e.send({type:\`app-update-lifecycle-state-changed\`,lifecycleState:n}),e.send({type:\`app-update-install-progress-changed\`,installProgressPercent:r})}catch{}},s=!1,c=codexLinuxProbeUpdateManager().then(()=>{s=!0,i(),a();return!0}).catch(()=>{s=!1,t=!1,n=\`idle\`,a();return!1});let o=()=>{e.allowQuit?.();codexLinuxQuitForUpdate()};return{manager:{latchInAppUpdatesEnabledForLaunch:async()=>{},setAutomaticBackgroundDownloadsEnabled:()=>{},setSparkleQueryParams:()=>{},getDownloadProgressPercent:()=>null,getDownloadedUpdateAppBrand:()=>null,getIsUpdateReady:()=>s&&t,getUpdateLifecycleState:()=>s?n:\`idle\`,getInstallProgressPercent:()=>r,getRelaunchNotice:()=>null,hasUpdater:()=>s,getUnavailableReason:()=>s?null:\`Linux package update manager unavailable\`,checkForUpdates:async()=>{if(!await c)return;n=\`checking\`,a();try{await codexLinuxRunUpdateManager([\`check-now\`]),i(),a()}catch(e){n=t?\`ready\`:\`idle\`,a();throw e}},installUpdatesIfAvailable:async()=>{if(!await c){a();return}i();if(!t){a();return}r=0,n=\`installing\`,a();try{let e=await codexLinuxRunUpdateManager([\`install-ready\`]),s=i();if(s?.status===\`waiting_for_app_exit\`){r=null,n=\`ready\`,a(),o();return}r=null,a(),e.stdout?.includes(\`Manual install required:\`)?await codexLinuxShowUpdateMessage(\`ChatGPT Desktop update\`,e.stdout.trim()):e.stdout?.includes(\`already installed\`)?await codexLinuxShowUpdateMessage(\`ChatGPT Desktop update\`,\`The ready update is already installed.\`):e.stdout?.includes(\`No update is ready to install\`)&&await codexLinuxShowUpdateMessage(\`ChatGPT Desktop update\`,\`There is no rebuilt update waiting to install.\`)}catch(e){r=null,n=t?\`ready\`:\`idle\`,a();throw e}}},quitForUpdate:o,refresh:async()=>{if(await c){try{await codexLinuxRefreshUpdateState()}catch{}i()}else t=!1,n=\`idle\`;a()}}}`; } function applyCurrentBootstrapUpdaterBridgePatch(currentSource) { @@ -44,13 +39,9 @@ function applyCurrentBootstrapUpdaterBridgePatch(currentSource) { return currentSource; } - const childProcessVar = requireName(currentSource, "node:child_process"); - const fsVar = requireName(currentSource, "node:fs"); - const pathVar = requireName(currentSource, "node:path"); - if (childProcessVar == null || fsVar == null || pathVar == null) { - console.warn("WARN: Could not find updater bridge module bindings - skipping Linux updater bridge patch"); - return currentSource; - } + const childProcessVar = "require(`node:child_process`)"; + const fsVar = "require(`node:fs`)"; + const pathVar = "require(`node:path`)"; let patchedSource = currentSource; if (!patchedSource.includes("function codexLinuxCreatePackageUpdateManager(")) { @@ -69,7 +60,7 @@ function applyCurrentBootstrapUpdaterBridgePatch(currentSource) { } const destructureRegex = - /let\{startedAtMs:([A-Za-z_$][\w$]*),buildFlavor:([A-Za-z_$][\w$]*),desktopSentry:([A-Za-z_$][\w$]*),sparkleManager:([A-Za-z_$][\w$]*),productionAppcastStateStore:[A-Za-z_$][\w$]*,setSparkleBridgeHandlers:([A-Za-z_$][\w$]*),setSecondInstanceArgsHandler:([A-Za-z_$][\w$]*)\}=([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)\(\),/; + /let\{startedAtMs:([A-Za-z_$][\w$]*),buildFlavor:([A-Za-z_$][\w$]*),desktopSentry:([A-Za-z_$][\w$]*),sparkleManager:([A-Za-z_$][\w$]*),startupPhases:[A-Za-z_$][\w$]*,productionAppcastStateStore:[A-Za-z_$][\w$]*,setSparkleBridgeHandlers:([A-Za-z_$][\w$]*),setSecondInstanceArgsHandler:([A-Za-z_$][\w$]*)\}=([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)\(\),/; const destructureMatch = patchedSource.match(destructureRegex); const sparkleVar = destructureMatch?.[4] ?? null; const setSparkleBridgeHandlersVar = destructureMatch?.[5] ?? null; @@ -96,7 +87,7 @@ function applyCurrentBootstrapUpdaterBridgePatch(currentSource) { if (!patchedSource.includes("codexLinuxPackageUpdateBridge=process.platform===`linux`")) { const currentBridgeRegex = - /let ([A-Za-z_$][\w$]*)=new [A-Za-z_$][\w$]*,(?:[A-Za-z_$][\w$]*=null,){2}([A-Za-z_$][\w$]*)=[A-Za-z_$][\w$]*=>\{[^]*?\},(?=[A-Za-z_$][\w$]*=)/; + /let ([A-Za-z_$][\w$]*)=new [A-Za-z_$][\w$]*,[A-Za-z_$][\w$]*=null,([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)=>\{if\(\3\?\.quitImmediately===!1\)\{\1\.allowQuitTemporarilyForUpdateInstall\(\);return\}\1\.allowQuitTemporarilyForUpdateInstall\(\),[A-Za-z_$][\w$]*\.app\.quit\(\)\},(?=[A-Za-z_$][\w$]*=)/; const currentBridgeMatch = patchedSource.match(currentBridgeRegex); if (currentBridgeMatch == null) { console.warn("WARN: Could not find current updater callback bridge - skipping Linux updater bridge patch"); diff --git a/scripts/lib/patch-browser-client-iab-socket-scope.js b/scripts/lib/patch-browser-client-iab-socket-scope.js index c8c0421aa..511e1e112 100644 --- a/scripts/lib/patch-browser-client-iab-socket-scope.js +++ b/scripts/lib/patch-browser-client-iab-socket-scope.js @@ -43,8 +43,11 @@ if (socketDirOnly || source.includes(iabMarker)) { process.exit(0); } +// The Unix socket listing resolves its directory from the options argument. +// The Windows listing has the same overall shape but declares its own pipe +// prefix and already filters, so it never matches this pattern. const socketListingPattern = - /([A-Za-z_$][\w$]*)=\(\)=>\s*([A-Za-z_$][\w$]*)\(\)==="win32"\?([A-Za-z_$][\w$]*)\(\):([A-Za-z_$][\w$]*)\(\),\4=async\(\)=>\(await ([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\)\.map\(([A-Za-z_$][\w$]*)=>([A-Za-z_$][\w$]*)\.resolve\(\6,\7\)\),\3=async\(\)=>/g; + /(?[A-Za-z_$][\w$]*)=async (?[A-Za-z_$][\w$]*)=>\{let (?[A-Za-z_$][\w$]*)=(?[A-Za-z_$][\w$]*)\(\k\.platform\);return\(await (?[A-Za-z_$][\w$]*)\(\k\)\)\.map\((?[A-Za-z_$][\w$]*)=>(?[A-Za-z_$][\w$]*)\.resolve\(\k,\k\)\)\}/g; const matches = [...source.matchAll(socketListingPattern)]; if (matches.length !== 1) { if (source.includes("codex-browser-use")) { @@ -56,21 +59,19 @@ if (matches.length !== 1) { process.exit(0); } -const [ - target, - dispatcher, - platform, - windowsListing, +const target = matches[0][0]; +const { unixListing, - readDirectory, + options, socketDirectory, + resolver, + readDirectory, entry, pathModule, -] = matches[0]; +} = matches[0].groups; const replacement = - `${dispatcher}=()=>${platform}()==="win32"?${windowsListing}():${unixListing}(),` + - `${unixListing}=async()=>(await ${readDirectory}(${socketDirectory}))` + + `${unixListing}=async ${options}=>{let ${socketDirectory}=${resolver}(${options}.platform);` + + `return(await ${readDirectory}(${socketDirectory}))` + `.filter(${entry}=>!${entry}.startsWith("extension-")${iabMarker})` + - `.map(${entry}=>${pathModule}.resolve(${socketDirectory},${entry})),` + - `${windowsListing}=async()=>`; + `.map(${entry}=>${pathModule}.resolve(${socketDirectory},${entry}))}`; fs.writeFileSync(clientPath, source.replace(target, replacement), "utf8"); diff --git a/scripts/lib/patch-browser-client-iab-socket-scope.test.js b/scripts/lib/patch-browser-client-iab-socket-scope.test.js index 796c7f8bf..1c36c48d3 100644 --- a/scripts/lib/patch-browser-client-iab-socket-scope.test.js +++ b/scripts/lib/patch-browser-client-iab-socket-scope.test.js @@ -139,12 +139,11 @@ test("IAB discovery excludes extension sockets before connecting", async () => { const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "codex-iab-socket-scope-")); const clientPath = path.join(workspace, "browser-client.mjs"); const fixture = ` -const Cb="/tmp/codex-browser-use"; const entries=["extension-123.sock","iab-session.sock","extension-stale.sock"]; -const yP=async()=>entries; -const wP={resolve:(root,entry)=>root+"/"+entry}; -const _P=()=>"linux"; -export const EV=()=>_P()==="win32"?TV():CV(),CV=async()=>(await yP(Cb)).map(e=>wP.resolve(Cb,e)),TV=async()=>[]; +const ys=(platform)=>platform==="win32"?"\\\\.\\pipe\\codex-browser-use":"/tmp/codex-browser-use"; +const BE=async()=>entries; +const NE={resolve:(root,entry)=>root+"/"+entry}; +export const Q6=e=>e.platform==="win32"?t4(e):e4(e),e4=async e=>{let t=ys(e.platform);return(await BE(t)).map(n=>NE.resolve(t,n))},t4=async e=>[]; `; try { @@ -159,7 +158,10 @@ export const EV=()=>_P()==="win32"?TV():CV(),CV=async()=>(await yP(Cb)).map(e=>w assert.equal(fs.readFileSync(clientPath, "utf8"), patched); const module = await import(`${pathToFileURL(clientPath).href}?patched=1`); - assert.deepEqual(await module.CV(), ["/tmp/codex-browser-use/iab-session.sock"]); + assert.deepEqual( + await module.e4({ platform: "linux" }), + ["/tmp/codex-browser-use/iab-session.sock"], + ); } finally { fs.rmSync(workspace, { recursive: true, force: true }); } @@ -169,7 +171,7 @@ test("leaves an unrelated socket-directory map unchanged", () => { const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "codex-iab-unrelated-")); const clientPath = path.join(workspace, "browser-client.mjs"); const fixture = - 'const Cb="/tmp/codex-browser-use";const CV=async()=>(await yP(Cb)).map(e=>wP.resolve(Cb,e));'; + 'const Cb="/tmp/codex-browser-use";const CV=async e=>(await yP(Cb)).map(n=>wP.resolve(Cb,n));'; try { fs.writeFileSync(clientPath, fixture, "utf8"); @@ -187,9 +189,10 @@ test("leaves ambiguous IAB discovery chains unchanged", () => { const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "codex-iab-ambiguous-")); const clientPath = path.join(workspace, "browser-client.mjs"); const chain = (suffix) => - `EV${suffix}=()=>P${suffix}()==="win32"?TV${suffix}():CV${suffix}(),` + - `CV${suffix}=async()=>(await Y${suffix}(C${suffix})).map(e=>W${suffix}.resolve(C${suffix},e)),` + - `TV${suffix}=async()=>[]`; + `Q6${suffix}=e=>e.platform==="win32"?t4${suffix}(e):e4${suffix}(e),` + + `e4${suffix}=async e=>{let t=ys${suffix}(e.platform);` + + `return(await BE${suffix}(t)).map(n=>NE${suffix}.resolve(t,n))},` + + `t4${suffix}=async e=>[]`; const fixture = `const root="/tmp/codex-browser-use";${chain("A")};${chain("B")};`; try { diff --git a/scripts/lib/patch-report.js b/scripts/lib/patch-report.js index a1484761a..48cbe16d3 100644 --- a/scripts/lib/patch-report.js +++ b/scripts/lib/patch-report.js @@ -7,6 +7,7 @@ const CRITICAL_CI_POLICY = "required-upstream"; const PATCH_STATUS_APPLIED = "applied"; const PATCH_STATUS_ALREADY_APPLIED = "already-applied"; const PATCH_STATUS_APPLIED_WITH_WARNINGS = "applied-with-warnings"; +const PATCH_STATUS_FAILED_INTEGRITY = "failed-integrity"; const PATCH_STATUS_FAILED_REQUIRED = "failed-required"; const PATCH_STATUS_SKIPPED_DISABLED = "skipped-disabled"; const PATCH_STATUS_SKIPPED_OPTIONAL = "skipped-optional"; @@ -21,6 +22,11 @@ function isCriticalPolicy(ciPolicy) { return ciPolicy === CRITICAL_CI_POLICY; } +function isCriticalPatchStatus(status) { + return status === PATCH_STATUS_FAILED_INTEGRITY || + status === PATCH_STATUS_FAILED_REQUIRED; +} + function reportEntryFailure(patch) { return { name: patch.name, @@ -31,7 +37,11 @@ function reportEntryFailure(patch) { function criticalFailuresFromReport(report) { return (report?.patches ?? []) - .filter((patch) => isCriticalPolicy(patch.ciPolicy)) + .filter( + (patch) => + isCriticalPatchStatus(patch.status) || + isCriticalPolicy(patch.ciPolicy), + ) .filter((patch) => !SUCCESS_STATUSES.has(patch.status) && !NOT_APPLICABLE_STATUSES.has(patch.status)) .map(reportEntryFailure); } @@ -39,6 +49,7 @@ function criticalFailuresFromReport(report) { function optionalDriftFromReport(report) { return (report?.patches ?? []) .filter((patch) => !isCriticalPolicy(patch.ciPolicy)) + .filter((patch) => !isCriticalPatchStatus(patch.status)) .filter((patch) => !SUCCESS_STATUSES.has(patch.status) && !NOT_APPLICABLE_STATUSES.has(patch.status)) .map(reportEntryFailure); } @@ -117,6 +128,9 @@ function patchStatusFromChange(changed, warnings, ciPolicy = "optional") { } function patchGroupForEntry(entry) { + if (entry.status === PATCH_STATUS_FAILED_INTEGRITY) { + return "integrityFailures"; + } if (isCriticalPolicy(entry.ciPolicy)) { return "requiredCore"; } @@ -125,6 +139,7 @@ function patchGroupForEntry(entry) { function summarizePatchReport(report) { const groups = { + integrityFailures: { count: 0, statusCounts: {} }, requiredCore: { count: 0, statusCounts: {} }, optionalCore: { count: 0, statusCounts: {} }, optionalFeatures: { count: 0, statusCounts: {}, byFeature: {} }, @@ -156,6 +171,7 @@ module.exports = { PATCH_STATUS_ALREADY_APPLIED, PATCH_STATUS_APPLIED, PATCH_STATUS_APPLIED_WITH_WARNINGS, + PATCH_STATUS_FAILED_INTEGRITY, PATCH_STATUS_FAILED_REQUIRED, PATCH_STATUS_SKIPPED_DISABLED, PATCH_STATUS_SKIPPED_OPTIONAL, @@ -166,6 +182,7 @@ module.exports = { criticalFailuresFromReport, enabledFeatureFailuresFromReport, isCriticalPolicy, + isCriticalPatchStatus, optionalDriftFromReport, patchStatusFromChange, recordPatch, diff --git a/scripts/lib/rust-toolchain.sh b/scripts/lib/rust-toolchain.sh new file mode 100644 index 000000000..845157b3e --- /dev/null +++ b/scripts/lib/rust-toolchain.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +MIN_RUST_VERSION="${MIN_RUST_VERSION:-1.89.0}" + +rustc_version() { + local version + + command -v rustc >/dev/null 2>&1 || return 1 + version="$(rustc --version 2>/dev/null || true)" + version="${version#rustc }" + version="${version%% *}" + version="${version%%-*}" + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1 + printf '%s\n' "$version" +} + +rust_version_at_least() { + local actual="$1" + local required="$2" + local actual_major actual_minor actual_patch + local required_major required_minor required_patch + + IFS=. read -r actual_major actual_minor actual_patch <<< "$actual" + IFS=. read -r required_major required_minor required_patch <<< "$required" + + (( actual_major > required_major )) && return 0 + (( actual_major < required_major )) && return 1 + (( actual_minor > required_minor )) && return 0 + (( actual_minor < required_minor )) && return 1 + (( actual_patch >= required_patch )) +} + +rust_toolchain_compatible() { + local version + + command -v cargo >/dev/null 2>&1 || return 1 + version="$(rustc_version 2>/dev/null || true)" + [ -n "$version" ] && rust_version_at_least "$version" "$MIN_RUST_VERSION" +} diff --git a/scripts/lib/upstream-dmg-acceptance.js b/scripts/lib/upstream-dmg-acceptance.js index 949779b3f..6832c4fb2 100644 --- a/scripts/lib/upstream-dmg-acceptance.js +++ b/scripts/lib/upstream-dmg-acceptance.js @@ -4,7 +4,7 @@ const crypto = require("node:crypto"); const fs = require("node:fs"); const path = require("node:path"); -const { sourceInfoFromGit } = require("./build-info.js"); +const { sourceInfo } = require("./build-info.js"); const { enabledFeatureFailuresFromReport, optionalDriftFromReport } = require("./patch-report.js"); const { readPatchReport, validatePatchReport } = require("./patch-validation.js"); const { UPSTREAM_DMG_RELEASE_PROFILE } = require("./upstream-dmg-release-profile.js"); @@ -172,7 +172,8 @@ function evaluateUpstreamDmg(options) { : warnings.length > 0 ? "accepted_with_warnings" : "accepted"; - const source = buildInfo?.source ?? sourceInfoFromGit(options.repoRoot ?? process.cwd()) ?? null; + const discoveredSource = sourceInfo(options.repoRoot ?? process.cwd()); + const source = buildInfo?.source ?? (discoveredSource.commit == null ? null : discoveredSource); return { schemaVersion: 1, diff --git a/scripts/lib/upstream-dmg-intel.js b/scripts/lib/upstream-dmg-intel.js index 1f103e46a..769df4370 100644 --- a/scripts/lib/upstream-dmg-intel.js +++ b/scripts/lib/upstream-dmg-intel.js @@ -1060,7 +1060,11 @@ function patchFindingsBySurface(patchReport, surfacesById = {}) { if (SUCCESSFUL_PATCH_STATUSES.has(patch.status)) { continue; } - const classification = BLOCKING_PATCH_STATUSES.has(patch.status) ? "PATCH_BROKEN" : "PATCH_REVIEW"; + const classification = patch.status === "failed-integrity" + ? "PATCH_INTEGRITY_BROKEN" + : BLOCKING_PATCH_STATUSES.has(patch.status) + ? "PATCH_BROKEN" + : "PATCH_REVIEW"; const explicitSurfaceId = patch.surfaceId ?? patch.protectedSurfaceId ?? null; const matchedSurfaceIds = new Set(); if (explicitSurfaceId != null) { @@ -1627,6 +1631,8 @@ function renderActionPlanMarkdown(driftReport, candidateProtected, mapDrift = nu lines.push("Action: decide whether Linux needs a port, shim, explicit unsupported gate, or new optional feature."); } else if (item.classification === "PATCH_BROKEN") { lines.push("Action: repair the patch descriptor or feature patch before accepting the DMG."); + } else if (item.classification === "PATCH_INTEGRITY_BROKEN") { + lines.push("Action: stop candidate acceptance, diagnose the transactional patch or rollback failure, and rebuild from the fresh current DMG; do not promote bytes whose original state cannot be proven."); } else if (item.classification === "PATCH_REVIEW") { lines.push("Action: review optional patch warning/skip details; do not block DMG acceptance unless a protected surface is also missing or broken."); } else if (item.classification === "LINUX_SUBSTRATE_GAP") { diff --git a/scripts/patch-linux-window-ui.js b/scripts/patch-linux-window-ui.js index 372d7b239..fe69c0d74 100644 --- a/scripts/patch-linux-window-ui.js +++ b/scripts/patch-linux-window-ui.js @@ -9,6 +9,9 @@ const { const { patchExtractedApp, } = require("./patches/runner.js"); +const { + isPatchIntegrityError, +} = require("./patches/integrity-error.js"); const { createInventory, findPostPatchIntegrityFindings, @@ -50,7 +53,15 @@ function main() { // Enforcement needs the report data even when no --report-json was requested. const report = reportJson == null && !enforceCritical ? null : createPatchReport(); - patchExtractedApp(extractedDir, { report }); + let integrityError = null; + try { + patchExtractedApp(extractedDir, { report }); + } catch (error) { + if (!isPatchIntegrityError(error)) { + throw error; + } + integrityError = error; + } if (report != null) { const inventory = createInventory({ sourcePath: extractedDir }); const findings = findPostPatchIntegrityFindings(inventory); @@ -63,6 +74,11 @@ function main() { // Write the report before gating so CI artifact upload sees it even on failure. writePatchReport(reportJson, report); + if (integrityError != null) { + console.error(`Patch integrity failure: ${integrityError.message}`); + process.exit(1); + } + if (enforceCritical) { const failures = criticalFailuresFromReport(report); if (failures.length > 0) { diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index 84f114120..3187ffcca 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -14,6 +14,10 @@ const vm = require("node:vm"); const { applyPetOverlayPatch, } = require("../linux-features/pet-overlay/patch.js"); +const { + electron42BrowserUseRuntimeResolverBundleFixture, + settingsSharedBundleFixture, +} = require("./patches/test-fixtures/current-dmg.js"); // Pin the feature config so a developer's local gitignored features.json // cannot change which patch descriptors these core tests exercise. @@ -46,6 +50,7 @@ const { linuxDesktopSettingsAsset, applyLinuxDesktopSettingsIconPatch, applyLinuxDesktopSettingsIndexPatch, + applyLinuxDesktopSettingsNavigationGroupPatch, applyLinuxShortcutPhysicalKeyFallbackPatch, applyLinuxDesktopSettingsSectionsPatch, applyLinuxDesktopSettingsSharedPatch, @@ -70,6 +75,7 @@ const { const { applyLinuxAppReloadShortcutsPatch, applyLinuxApplicationMenuPatch, + applyLinuxManagedWindowSystemContextMenuPatch, applyLinuxMenuPatch, applyLinuxNativeTitlebarPatch, applyLinuxOpaqueBackgroundPatch, @@ -132,6 +138,7 @@ const { const { applyExtractedAppPatchDescriptors, applyMainBundlePatchDescriptors, + applyWebviewAssetPatchDescriptors, discoverCorePatchDescriptors, normalizePatchDescriptors, } = require("./patches/engine.js"); @@ -176,7 +183,7 @@ const { applyLinuxAppSunsetPatch, applyLinuxBrowserUseAvailabilityPatch, applyLinuxBrowserUseExternalAvailabilityPatch, - applyLinuxBrowserUseHiddenHostOwnershipPatch, + patchLinuxBrowserUseExternalAvailabilityAssets, applyLinuxBrowserUseWebviewHostRecoveryPatch, applyLinuxBrowserUseWebviewRemountStorePatch, applyLinuxBrowserUseNonLocalNavigationPatch, @@ -982,6 +989,7 @@ test("default core patch descriptors are grouped and unique", () => { "linux-explicit-ipc-quit", "linux-window-options", "linux-native-titlebar", + "linux-managed-window-system-context-menu", "linux-menu", "linux-multi-instance-bootstrap-lock", "linux-bootstrap-failure-exit", @@ -995,7 +1003,6 @@ test("default core patch descriptors are grouped and unique", () => { "linux-browser-use-external-availability", "linux-browser-use-webview-attach-recovery-store", "linux-browser-use-webview-attach-recovery-host", - "linux-browser-use-hidden-host-ownership", "linux-chat-search-hydration", "linux-file-manager", "linux-host-child-process-environment", @@ -1098,28 +1105,42 @@ test("default core patch descriptors are grouped and unique", () => { descriptors.find((descriptor) => descriptor.id === "linux-x11-project-picker")?.ciPolicy, "optional", ); + assert.equal( + descriptors.find( + (descriptor) => + descriptor.id === "linux-managed-window-system-context-menu", + )?.ciPolicy, + "optional", + "GNOME/X11 titlebar mitigation drift should warn without blocking install or updater candidates", + ); assert.equal( descriptors.find((descriptor) => descriptor.id === "linux-computer-use-native-desktop-apps")?.ciPolicy, "opt-in", ); const computerUseInstallFlow = descriptors.find((descriptor) => descriptor.id === "linux-computer-use-install-flow"); assert.equal( - computerUseInstallFlow.pattern.test( - "app-initial~avatarOverlayCompositionSurface~artifact-tab-content.electron~notebook-preview-~iaq4jiqv-current.js", - ), + computerUseInstallFlow.pattern.test("app-initial-BHB6SClA.js"), + true, + ); + assert.equal(computerUseInstallFlow.pattern.test("computer-use-settings-BzkBOuLk.js"), false); + assert.equal( + computerUseInstallFlow.assetMatch(currentComputerUseInstallFlow26721Fixture()), true, ); - assert.equal(computerUseInstallFlow.pattern.test("app-initial~app-main-current.js"), false); + assert.equal(computerUseInstallFlow.assetMatch("function unrelatedAppInitial(){}"), false); const computerUseHostPlatform = descriptors.find( (descriptor) => descriptor.id === "linux-computer-use-host-platform", ); assert.equal( - computerUseHostPlatform.pattern.test( - "app-initial~artifact-tab-content.electron~notebook-preview-panel~app-main~settings-command-~ekwfx4j1-current.js", - ), + computerUseHostPlatform.pattern.test("app-initial-BHB6SClA.js"), + true, + ); + assert.equal(computerUseHostPlatform.pattern.test("computer-use-settings-BzkBOuLk.js"), false); + assert.equal( + computerUseHostPlatform.assetMatch(currentComputerUseHostPlatform26721Fixture()), true, ); - assert.equal(computerUseHostPlatform.pattern.test("app-initial~app-main-current.js"), false); + assert.equal(computerUseHostPlatform.assetMatch("function unrelatedAppInitial(){}"), false); assert.equal( descriptors.find((descriptor) => descriptor.id === "linux-terminal-user-path")?.ciPolicy, "optional", @@ -1254,7 +1275,7 @@ test("optional webview descriptors follow the current monolithic app chunk", () ); assert.equal( automationUpdate.assetMatch( - ".map(e=>({type:`function`,...e,...Tc.has(e.name)?{}:{deferLoading:!0}}))", + ".map(e=>({type:`function`,...e,...x&&!Htl.has(e.name)?{deferLoading:!0}:{}}))", ), true, ); @@ -1362,12 +1383,19 @@ function trayBundleFixture() { ].join(""); } +function exactDmgNestedTernaryTrayBundleFixture() { + return trayBundleFixture().replace( + "r=new c.Tray(t.defaultIcon)", + "r=new c.Tray(t.defaultIcon,process.platform===`win32`&&c.app.isPackaged?dEe(e.buildFlavor):void 0)", + ).replace("}}v&&k.on", "}};v&&k.on"); +} + function currentTrayLifecycleBundleFixture() { return [ "let codexLinuxQuitInProgress=!1,codexLinuxExplicitQuitApproved=!1,codexLinuxMarkQuitInProgress=()=>{codexLinuxQuitInProgress=!0},codexLinuxPrepareForExplicitQuit=()=>{codexLinuxExplicitQuitApproved=!0,codexLinuxMarkQuitInProgress()},codexLinuxShouldBypassQuitPrompt=()=>codexLinuxExplicitQuitApproved===!0,codexLinuxIsQuitInProgress=()=>codexLinuxQuitInProgress===!0;", "v&&k.on(`close`,e=>{let t=this.getPrimaryWindows().some(e=>e!==k);if((process.platform===`win32`||process.platform===`linux`)&&!this.isAppQuitting&&this.options.canHideLastWindowToTray?.()===!0&&!t){e.preventDefault(),k.hide();return}});", "async function gj(e){let t=e;if(typeof t.whenReady!=`function`)return!0;try{return await t.whenReady(),!0}catch{return!1}}function _j(e){let t=e;return typeof t.isReady==`function`?t.isReady():!0}", - "var H9=null,U9=null,G9=!1;async function fae(e){return G9=!0,U9??H9??(U9=(async()=>{let t={defaultIcon:e},r=typeof codexLinuxRegisterTray===`function`?codexLinuxRegisterTray(new c.Tray(t.defaultIcon)):new c.Tray(t.defaultIcon);if(!G9)return r.destroy(),null;r.setToolTip(c.app.getName());let i=new pb(r);return H9=i,!await i.waitForReady()||H9!==i?(H9===i&&(H9=null,i.destroy()),null):i})().finally(()=>{U9=null}),U9)}", + "var H9=null,U9=null,G9=!1;async function fae(e){return G9=!0,U9??H9??(U9=(async()=>{let t={defaultIcon:e},r=new c.Tray(t.defaultIcon,process.platform===`win32`&&c.app.isPackaged?dEe(e.buildFlavor):void 0);if(!G9)return r.destroy(),null;r.setToolTip(c.app.getName());let i=new pb(r);return H9=i,!await i.waitForReady()||H9!==i?(H9===i&&(H9=null,i.destroy()),null):i})().finally(()=>{U9=null}),U9)}", "var pb=class{constructor(e){this.tray=e;if(process.platform===`linux`){this.tray.on(`click`,()=>{}),this.updatePersistentTrayMenu();return}}destroy(){this.tray.destroy()}isReady(){return _j(this.tray)}waitForReady(){return gj(this.tray)}getNativeTrayMenuItems(){return[]}updatePersistentTrayMenu(){process.platform===`linux`&&this.tray.setContextMenu(c.Menu.buildFromTemplate(this.getNativeTrayMenuItems()))}}", ].join(""); } @@ -1400,10 +1428,108 @@ function beforeQuitConfirmationBundleFixture() { function willQuitDrainBundleFixture() { return [ - "n.app.on(`will-quit`,e=>{if(g=!0,!h){if(i.shouldSkipDrainBeforeQuit()){mB({hotkeyWindowLifecycleManager:c,globalDictationLifecycleManager:l,flushAndDisposeContexts:d,disposables:f});return}e.preventDefault(),h=!0,c.dispose(),l.dispose(),Promise.all([u.flush(),p.flush()]).finally(()=>{d(),f.dispose(),n.app.quit()})}});", + "l.app.on(`will-quit`,e=>{if(y=!0,v)return;let t=()=>{U5(h,N5).then(()=>{g.dispose(),l.app.quit()})};if(r.shouldSkipDrainBeforeQuit()){e.preventDefault(),v=!0,c.dispose(),u.dispose(),Promise.allSettled([d.flush(),p(),m()]).then(t);return}e.preventDefault(),v=!0,c.dispose(),u.dispose(),Promise.allSettled([d.flush(),f.flush(),p(),m()]).then(t)});", ].join(""); } +async function runPatchedLinuxWillQuit(options = {}) { + const patched = applyLinuxWillQuitDrainTimeoutPatch(willQuitDrainBundleFixture()); + const state = { + exitCalls: 0, + exitCodes: [], + handler: null, + handlerError: null, + lifecycleDisposeCalls: 0, + preventDefaultCalls: 0, + quitCalls: 0, + warnings: [], + }; + let resolveQuit; + const quitPromise = new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Linux will-quit handler did not terminate the app")), + 250, + ); + resolveQuit = () => { + clearTimeout(timeout); + resolve(); + }; + }); + const lifecycleManager = { + dispose() { + state.lifecycleDisposeCalls += 1; + if (state.lifecycleDisposeCalls === options.lifecycleDisposeThrowsAt) { + throw new Error("lifecycle disposer failed"); + } + }, + }; + const context = { + N5: 5, + U5: options.contextDispose ?? (() => Promise.resolve()), + c: lifecycleManager, + codexLinuxExplicitQuitDrainTimeoutMs: options.timeoutMs ?? 5, + codexLinuxIsQuitInProgress: () => true, + console: { + warn(...args) { + state.warnings.push(args.map(String).join(" ")); + }, + }, + d: { flush: options.globalStateFlush ?? (() => Promise.resolve()) }, + f: { flush: options.settingsFlush ?? (() => Promise.resolve()) }, + g: { dispose: options.disposablesDispose ?? (() => {}) }, + h: {}, + l: { + app: { + exit(exitCode) { + state.exitCalls += 1; + state.exitCodes.push(exitCode); + resolveQuit(); + }, + on(eventName, handler) { + assert.equal(eventName, "will-quit"); + state.handler = handler; + }, + quit() { + state.quitCalls += 1; + resolveQuit(); + }, + }, + }, + m: options.flushTracing ?? (() => Promise.resolve()), + p: options.stopCodexMicro ?? (() => Promise.resolve()), + process: { platform: options.platform ?? "linux" }, + Promise, + r: { + shouldSkipDrainBeforeQuit() { + return options.shouldSkipDrain === true; + }, + }, + setTimeout, + u: lifecycleManager, + v: false, + y: false, + }; + if (options.omitQuitStateHelper === true) { + delete context.codexLinuxIsQuitInProgress; + } + + vm.runInNewContext(patched, context); + assert.equal(typeof state.handler, "function"); + try { + state.handler({ + preventDefault() { + state.preventDefaultCalls += 1; + }, + }); + } catch (error) { + state.handlerError = error; + } + await quitPromise; + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(state.handlerError, null); + return state; +} + function computerUseGateBundleFixture() { return [ "var Qt=`openai-bundled`,$t=`browser-use`,en=`chrome-internal`,tn=`computer-use`,nn=`latex-tectonic`;", @@ -1437,31 +1563,6 @@ function currentBundledPluginCopyBundleFixture() { ); } -function chromeNativeHostRuntimeBundleFixture() { - return [ - "let r=require(`node:path`),o=require(`node:fs`);", - "function Mc({resourcesPath:e,executableName:t}){if(!e)return null;let n=(0,r.join)(e,t);try{return(0,o.statSync)(n).isFile()?n:null}catch{return null}}", - "function Pc(e){return Mc({resourcesPath:e,executableName:process.platform===`win32`?`node_repl.exe`:`node_repl`})}", - "function Fc(e){return Mc({resourcesPath:e,executableName:process.platform===`win32`?`node.exe`:`node`})}", - "function Ic(e){return Mc({resourcesPath:e,executableName:process.platform===`win32`?`codex.exe`:`codex`})}", - "function Qp(e){let t=Ic(e.resourcesPath)??$p(e.devRuntimeRepoRoot,[`extension`,`bin`,process.platform===`win32`?`codex.exe`:`codex`]),n=Fc(e.resourcesPath)??$p(e.devRuntimeRepoRoot,[`electron`,`bin`,process.platform===`win32`?`node.exe`:`node`]),r=Pc(e.resourcesPath)??$p(e.devRuntimeRepoRoot,[`electron`,`bin`,process.platform===`win32`?`node_repl.exe`:`node_repl`]),i=[t==null?`codex`:null,n==null?`node`:null,r==null?`node_repl`:null].filter(e=>e!=null);if(i.length>0)throw Error(`Missing bundled Electron runtime required to sync Chrome native host resources for ${e.nativeHostName}: ${i.join(`, `)} (resourcesPath: ${e.resourcesPath}).`);if(t==null||n==null||r==null)throw Error(`Missing bundled Electron runtime required to sync Chrome native host resources for ${e.nativeHostName}.`);return{codexCliPath:t,nodePath:n,nodeReplPath:r}}", - "function $p(e,t){if(e==null)return null;let n=(0,r.join)(e,...t);try{return(0,o.statSync)(n).isFile()?n:null}catch{return null}}", - ].join(""); -} - -function currentChromeNativeHostRuntimeBundleFixture() { - return [ - "let r=require(`node:path`),o=require(`node:fs`);", - "function Mc({resourcesPath:e,executableName:t}){if(!e)return null;let n=(0,r.join)(e,t);try{return(0,o.statSync)(n).isFile()?n:null}catch{return null}}", - "function Oj(e){return Mc({resourcesPath:e,executableName:process.platform===`win32`?`node_repl.exe`:`node_repl`})}", - "function kj(e){return Mc({resourcesPath:e,executableName:process.platform===`win32`?`node.exe`:`node`})}", - "function Nj(e){return Mc({resourcesPath:e,executableName:process.platform===`win32`?`codex.exe`:`codex`})}", - "function QL(e){let t=Nj(e.resourcesPath)??$L(e.devRuntimeRepoRoot,[`extension`,`bin`,process.platform===`win32`?`codex.exe`:`codex`]),n=kj(e.resourcesPath),r=Oj(e.resourcesPath),i=[t==null?`codex`:null,n==null?`node`:null,r==null?`node_repl`:null].filter(e=>e!=null);if(i.length>0)throw Error(`Missing bundled Electron runtime required to sync Chrome native host resources for ${e.nativeHostName}: ${i.join(`, `)} (resourcesPath: ${e.resourcesPath}).`);if(t==null||n==null||r==null)throw Error(`Missing bundled Electron runtime required to sync Chrome native host resources for ${e.nativeHostName}.`);return{codexCliPath:t,nodePath:n,nodeModuleDirs:Aj(e.resourcesPath),nodeReplPath:r}}", - "function $L(e,t){if(e==null)return null;let n=(0,r.join)(e,...t);try{return(0,o.statSync)(n).isFile()?n:null}catch{return null}}", - "function Aj(e){return []}", - ].join(""); -} - function currentBrowserUseTrustedHashesRuntimeBuilderFixture() { return "\"use strict\";let l=require(`node:fs`),s=require(`node:path`),u=require(`node:crypto`);function build({codexHome:t,nodePath:i,nodeReplPath:a,trustedBrowserClientSha256s:h=[],shouldUseWslPaths:f}){return h}"; } @@ -1469,44 +1570,6 @@ function currentBrowserUseTrustedHashesRuntimeBuilderFixture() { const currentBrowserUseTrustedHashesInsertionRegex = /trustedBrowserClientSha256s:h=\[\],shouldUseWslPaths:f\}\)\{h=codexLinuxTrustedBrowserClientSha256s\(h\);return h/; -function electron42BrowserUseRuntimeResolverBundleFixture() { - return [ - "let s=require(`node:path`),l=require(`node:fs`);", - "function tt({resourcesPath:e}){return e}", - "function Kn(e){return e===`linux`?`/primary/node`:null}", - "function Hn({env:e=process.env,isPackaged:n=!0,platform:r=process.platform,repoRoot:i=process.cwd(),resolveCodexPath:a=t.Wn,resolveNodePath:o=t.Gn,resolveNodeReplPath:s=t.Kn,resolvePrimaryRuntimeNodePath:c=Kn,resourcesPath:l}){let u=l??tt({env:e,resourcesPath:process.resourcesPath}),d=c(r),f=Gn({platform:r,rawValue:e.CODEX_CLI_PATH,resolveWindowsAppsPath:a})??Wn({devRelativePathSegments:[`extension`,`bin`,`codex`],isPackaged:n,platform:r,repoRoot:i,resolveBundledPath:a,resourcesPath:u}),p=Wn({devRelativePathSegments:null,isPackaged:n,platform:r,repoRoot:i,resolveBundledPath:o,resourcesPath:u}),m=Gn({platform:r,rawValue:e.CODEX_BROWSER_USE_NODE_PATH,resolveWindowsAppsPath:o})??(p.path==null&&d!=null?{path:d,source:`primary-runtime`}:p),h=Gn({platform:r,rawValue:e.CODEX_NODE_REPL_PATH,resolveWindowsAppsPath:s})??Wn({devRelativePathSegments:null,isPackaged:n,platform:r,repoRoot:i,resolveBundledPath:s,resourcesPath:u});return{codexCliPath:f.path,codexCliPathSource:f.source,nodeModuleDirs:t.Vn(u),nodePath:m.path,nodePathSource:m.source,nodeReplPath:h.path,nodeReplPathSource:h.source,platform:r}}", - "function Wn(e){return{path:null,source:`missing`}}function Gn({rawValue:e}){return e==null?null:{path:e,source:`env-override`}}", - ].join(""); -} - -function currentChromePluginAppServerRuntimeBundleFixture() { - return [ - "let r=require(`node:path`),o=require(`node:fs`);", - "async function XB(e){let t=ZB(e),n=NM(e.resourcesPath),r=MM(e.resourcesPath),i=[t==null?`codex`:null,n==null?`node`:null,r==null?`node_repl`:null].filter(e=>e!=null);if(i.length>0)throw Error(`Missing bundled Electron runtime required to sync Chrome native host resources for ${e.nativeHostName}: ${i.join(`, `)} (resourcesPath: ${e.resourcesPath}).`);if(t==null||n==null||r==null)throw Error(`Missing bundled Electron runtime required to sync Chrome native host resources for ${e.nativeHostName}.`);return{codexCliPath:await fz({codexCliPath:t,codexHome:e.codexHome,nativeHostName:e.nativeHostName}),nodePath:n,nodeModuleDirs:PM(e.resourcesPath),nodeReplPath:r}}", - "function ZB(e){return LM(e.resourcesPath)??QB(e.devRuntimeRepoRoot,[`extension`,`bin`,process.platform===`win32`?`codex.exe`:`codex`])}function NM(e){return null}function MM(e){return null}function PM(e){return []}function QB(e,t){return null}function LM(e){return null}async function fz({codexCliPath:e}){return e}", - ].join(""); -} - -function currentChromePluginCodexAppServerRuntimeBundleFixture() { - return [ - "let r=require(`node:path`),o=require(`node:fs`);", - "async function VH(e){let t=_U(e);if(t==null)throw Error(`Missing bundled Electron Codex runtime required to sync Chrome plugin app server for ${e.nativeHostName} (resourcesPath: ${e.resourcesPath??``}).`);return AV({codexCliPath:t,codexHome:e.codexHome,nativeHostName:e.nativeHostName})}", - "function _U(e){return tM(e.resourcesPath)??vU(e.devRuntimeRepoRoot,[`extension`,`bin`,process.platform===`win32`?`codex.exe`:`codex`])}function vU(e,t){return null}function tM(e){return null}async function AV({codexCliPath:e}){return{codexCliPath:e}}", - ].join(""); -} - -function currentChromePluginIsolatedAppServerRuntimeBundleFixture() { - const runtime = currentChromePluginCodexAppServerRuntimeBundleFixture().replace( - "async function AV({codexCliPath:e}){return{codexCliPath:e}}", - "async function AV(e){let t=e.nativeHostName===nU,n=e.codexCliPath,r=process.env.ISSUE805_ISOLATED_CLI;o.copyFileSync(n,r);o.chmodSync(r,448);return r}", - ); - return [ - "async function decoy(e){let t=e.nativeHostName===nU;return `decoy`}", - "var tU=`.plugin-appserver`,nU=`com.openai.codexextension`;", - runtime, - ].join(""); -} - function computerUseFeatureBundleFixture() { return "function me(e,{env:t=process.env,platform:n=process.platform}={}){return n!==`win32`||t.CODEX_ELECTRON_ENABLE_WINDOWS_COMPUTER_USE!==`1`?e:{...e,computerUse:!0,computerUseNodeRepl:!0}}"; } @@ -1532,24 +1595,25 @@ function currentLaunchActionBundleWithWindowApiDriftFixture() { function settingsPersistenceBundleFixture() { return [ "let i=require(`node:path`),o=require(`node:fs`);", - "var s=`.codex-global-state.json`;", - "const h={\"set-global-state\":async({key:a,value:b,origin:c})=>(this.globalState.set(a,b),Promise.resolve())};", + "const h={\"set-global-state\":async({key:a,value:b})=>(this.setGlobalStateValue(a,b),{success:!0})};", ].join(""); } function currentSettingsPersistenceBundleFixture() { return [ "let i=require(`node:path`),o=require(`node:fs`);", - "var s=`.codex-global-state.json`,c=`config.toml`;", - "const h={\"set-global-state\":async({key:a,value:b,origin:c})=>(this.setGlobalStateValue(a,b,c),{success:!0})};", + "const h={\"set-global-state\":async({key:a,value:b})=>(this.setGlobalStateValue(a,b),{success:!0})};", ].join(""); } -function legacySettingsPersistenceBundleFixture() { +function exactDmgSettingsPersistenceBundleFixture() { + // fb93a239c811: an earlier core patch introduces a function-local fs alias + // before upstream's top-level window-all-closed, path, and fs bindings. return [ - "let i=require(`node:path`),o=require(`node:fs`);", - "var s=`.codex-global-state.json`;function codexLinuxSettingsPath(){let e=process.env.XDG_CONFIG_HOME||process.env.HOME&&i.join(process.env.HOME,`.config`);return e?i.join(e,`codex-desktop`,`settings.json`):null}function codexLinuxReadSettingsFile(){let e=codexLinuxSettingsPath();if(!e||!o.existsSync(e))return{};try{let t=o.readFileSync(e,`utf8`),n=JSON.parse(t);return n&&typeof n===`object`&&!Array.isArray(n)?n:{}}catch(e){return{}}}function codexLinuxPersistSettingsState(e,t){if(process.platform!==`linux`||![`codex-linux-prompt-window-enabled`,`codex-linux-system-tray-enabled`,`codex-linux-warm-start-enabled`].includes(e))return;try{let n=codexLinuxSettingsPath();if(!n)return;let r=codexLinuxReadSettingsFile();t===void 0?delete r[e]:r[e]=t,o.mkdirSync(i.dirname(n),{recursive:!0,mode:448}),o.writeFileSync(n,JSON.stringify(r,null,2)+`\\n`,`utf8`)}catch(e){}}", - "const h={\"set-global-state\":async({key:a,value:b,origin:c})=>(this.globalState.set(a,b),codexLinuxPersistSettingsState(a,b),Promise.resolve())};", + "function codexLinuxBrowserUseSocketDir(){let r=require(`node:fs`);return r}", + "const r=require(\"./window-all-closed-Coc41Tfs.js\");", + "let p=require(\"node:path\"),_=require(\"node:fs\");", + "const h={\"set-global-state\":async({key:e,value:t})=>(this.setGlobalStateValue(e,t),{success:!0})};", ].join(""); } @@ -1560,7 +1624,7 @@ function runSettingsPersistence(patchedSource, env, key, value) { console, JSON, Promise, - require, + require: (moduleName) => moduleName === "./window-all-closed-Coc41Tfs.js" ? {} : require(moduleName), process: { env, platform: "linux" }, }, ); @@ -1582,13 +1646,6 @@ function keybindsIndexBundleFixture() { ].join(""); } -function settingsSharedBundleFixture() { - return [ - '"general-settings":{id:`settings.nav.general-settings`,defaultMessage:`General`,description:`Title for general settings section`},appearance:{id:`settings.nav.appearance`,defaultMessage:`Appearance`,description:`Title for appearance settings section`},', - "function titleForSection(e){switch(e){case`general-settings`:{let e;return t[2]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,d.jsx)(n,{id:`settings.section.general-settings`,defaultMessage:`General`,description:`Title for general settings section`}),t[2]=e):e=t[2],e}case`appearance`:return (0,d.jsx)(n,{id:`settings.section.appearance`,defaultMessage:`Appearance`,description:`Title for appearance settings section`})}}", - ].join(""); -} - // Same bundle as settingsSharedBundleFixture() but with the minified JSX message // component bound to `r` instead of `n` (and the memo cache as `o[5]`), mirroring // the identifiers shipped in Codex 26.601.21317 (settings-shared-BibDzP9i.js). @@ -1680,6 +1737,14 @@ function createModernNativeKeyboardShortcutsSettingsFixture() { "export{SettingsRouteWrapper};", ].join(""), ); + writeAsset( + "settings-page-A.js", + [ + "var nn=`general-settings.import.profile.appearance.voice.agent.personalization.pets.keyboard-shortcuts.usage.debug`.split(`.`),", + "rn=[{key:`personal`,heading:d({id:`settings.nav.heading.personal`,defaultMessage:`Personal`,description:`Heading for personal settings in the settings navigation`}),", + "slugs:[`general-settings`,`import`,`profile`,`appearance`,`voice`,`agent`,`personalization`,`pets`,`keyboard-shortcuts`,`usage`,`debug`]}];", + ].join(""), + ); writeAsset( "use-visible-settings-sections-A.js", [ @@ -1785,7 +1850,7 @@ function createSplitRouteNativeKeyboardShortcutsSettingsFixture({ "settings-page-A.js", [ "var Wn=[`general-settings`,`import`,`profile`,`keyboard-shortcuts`];", - "var Qn=[{key:`app`,slugs:[`general-settings`,`import`,`profile`,`keyboard-shortcuts`]}];", + "var Qn=[{key:`personal`,heading:d({id:`settings.nav.heading.personal`,defaultMessage:`Personal`,description:`Heading for personal settings in the settings navigation`}),slugs:[`general-settings`,`import`,`profile`,`keyboard-shortcuts`]}];", "function loading(H){let W=!1;if(H)bb0:switch(H.slug){case`appearance`:case`general-settings`:case`agent`:case`git-settings`:case`data-controls`:case`personalization`:W=!1;break bb0;case`keyboard-shortcuts`:W=!1;break bb0}return W}", ].join(""), ); @@ -1849,27 +1914,33 @@ function currentBootstrapUpdaterBundleFixture() { "let r=require(`electron`),i=require(`node:path`),o=require(`node:fs`),u=require(`node:child_process`);", "var g6={enabled:!1,running:!1,state:`disabled`};", "async function v6(){", - "let{startedAtMs:e,buildFlavor:i,desktopSentry:o,sparkleManager:s,productionAppcastStateStore:Q,setSparkleBridgeHandlers:c,setSecondInstanceArgsHandler:l}=n.k(),d=n.P.shouldIncludeSparkle(i,process.platform,process.env)||process.platform===`linux`;", - "let ee=new G5,P=null,W=null,te=e=>{if(e?.quitImmediately===!1){ee.allowQuitTemporarilyForUpdateInstall();return}ee.allowQuitTemporarilyForUpdateInstall(),r.app.quit()},F=F3({}),oe=iZ({}),se=oe.getWindowContext();", - "c({onDownloadProgressChanged:()=>{se.broadcastAppUpdateState()},onInstallProgressChanged:()=>{T&&se.broadcastAppUpdateState()},onUpdateReadyChanged:()=>{se.broadcastAppUpdateState()},onUpdateLifecycleStateChanged:()=>{se.broadcastAppUpdateState()},onRelaunchNoticeChanged:()=>{se.broadcastAppUpdateState()},onInstallUpdatesRequested:e=>{te(e)},isTrustedIpcEvent:M});", + "let{startedAtMs:e,buildFlavor:i,desktopSentry:o,sparkleManager:s,startupPhases:d,productionAppcastStateStore:Q,setSparkleBridgeHandlers:c,setSecondInstanceArgsHandler:l}=n.k(),v=n.P.shouldIncludeSparkle(i,process.platform,process.env)||process.platform===`linux`;", + "let ee=new G5,P=null,te=e=>{if(e?.quitImmediately===!1){ee.allowQuitTemporarilyForUpdateInstall();return}ee.allowQuitTemporarilyForUpdateInstall(),r.app.quit()},F=F3({}),oe=iZ({}),se=oe.getWindowContext();", + "c({onDownloadProgressChanged:()=>{se.broadcastAppUpdateState()},onDownloadedUpdateAppBrandChanged:()=>{se.broadcastAppUpdateState()},onInstallProgressChanged:()=>{T&&se.broadcastAppUpdateState()},onUpdateReadyChanged:()=>{se.broadcastAppUpdateState()},onUpdateLifecycleStateChanged:()=>{se.broadcastAppUpdateState()},onRelaunchNoticeChanged:()=>{se.broadcastAppUpdateState()},onInstallUpdatesRequested:e=>{te(e)},isTrustedIpcEvent:M});", "}exports.runMainAppStartup=v6;", ].join(""); } +function currentSparkleUpdateMenuContractFixture() { + return "if(!u.hasUpdater()){let e=u.getUnavailableReason()??`unknown`;return e}u.checkForUpdates()"; +} + function latestAvatarOverlayBundleFixture() { return [ "let c=require(`electron`),h=require(`node:child_process`);", + "function Bs(e,t){return typeof e.setInputShape==`function`&&e.setInputShape(t)===!0}", "function eo(e,{addon:t,electronAppPath:n,platform:r=process.platform,resourcesPath:i=process.resourcesPath}={}){if(r!==`darwin`)return!1;try{return(t??Sa({electronAppPath:n??c.app.getAppPath(),resourcesPath:i})).setRemoteHostedPIPContentComputerUseCursorLocationHandler(e)}catch{return!1}}", "var d5=`/avatar-overlay`,of={width:356,height:320},m5={width:112,height:121},y5={width:0,height:0},v5={width:276,height:131};", - "var fV=class{window=null;layout=null;mascotSize=m5;traySize=null;pointerInteractive=!1;mousePassthroughEnabled=!1;layoutMode=`native`;compositionHost={setOverlayWindow(){},isNativeMaterialAttached(){return!1},getCursorPosition(){return null},performWindowDrag(){return!1},updateMascotRect(){},publishRemoteHostedPIPContentHost(){}};nativePositionController={clear(){}};", + "var fV=class{supportsInputShape=!0;window=null;layout=null;mascotSize=m5;traySize=null;pointerInteractive=!1;mousePassthroughEnabled=!1;inputShape=null;layoutMode=`native`;compositionHost={setOverlayWindow(){},isNativeMaterialAttached(){return!1},getCursorPosition(){return null},performWindowDrag(){return!1},updateMascotRect(){},publishRemoteHostedPIPContentHost(){}};nativePositionController={clear(){}};", "startDrag(e,t,n=!1){let r=this.window;if(r==null||r.isDestroyed()||r.webContents.id!==e)return;this.cancelMomentum();let i=this.getLayout(r),a=this.compositionHost.getCursorPosition(),o=t.pointerScreenX!=null?{x:t.pointerScreenX,y:t.pointerScreenY}:c.screen.getCursorScreenPoint();this.dragState=new a5(a==null?`renderer`:`native`,t.pointerWindowX-i.mascot.left,t.pointerWindowY-i.mascot.top,c.screen.getDisplayNearestPoint(o).bounds,n),this.windowServerDragActive=this.layoutMode===`native`&&!n&&this.compositionHost.performWindowDrag(),this.windowServerDragActive||(this.windowServerDragWindowX=null)}", "endDrag(e,t){let n=this.window;if(n==null||n.isDestroyed()||n.webContents.id!==e)return;let r=this.dragState,i=this.windowServerDragActive,a=null;this.dragState=null,this.windowServerDragActive=!1,this.windowServerDragWindowX=null,i?this.persistWindowBounds(n,a??this.getCurrentDisplay()):this.reclampWindowToVisibleDisplay({shouldPersist:!0});let o=this.dockTarget;o!=null&&this.dockPresentation(o.anchor,o.onDock)}", "setElementSize(e,{elementSizeRevision:t,isTrayVisible:n,mascot:r,nativeCompositionEnabled:i,tray:a}){let o=this.window;if(o==null||o.isDestroyed()||o.webContents.id!==e)return;this.mascotSize=r,this.traySize=a,this.applyLatestElementSizes(o),this.stageWindowForNativePresentation(o),this.showWindowIfReady(o)}", - "async createWindow(){let e=await this.windowManager.createWindow({title:c.app.getName(),width:of.width,height:of.height,appearance:`avatarOverlay`,focusable:!1,show:!1,initialRoute:d5});return this.window=e,this.compositionHost.setOverlayWindow(e),this.dragState=null,this.layout=null,this.mousePassthroughEnabled=!1,this.traySize=null,e.on(`closed`,()=>{if(this.window!==e)return;let t=this.presentationVisibility!=null;this.cancelMomentum(),this.clearMovedWindowPersist(),this.window=null,this.dragState=null,this.pointerInteractive=!1,this.mousePassthroughEnabled=!1,this.compositionHost.setOverlayWindow(null),this.broadcastOpenState()}),e}", + "async createWindow(){let e=await this.windowManager.createWindow({title:c.app.getName(),width:of.width,height:of.height,appearance:`avatarOverlay`,supportsWindowTiling:!1,focusable:!1,show:!1,initialRoute:d5});return this.window=e,this.compositionHost.setOverlayWindow(e),this.dragState=null,this.layout=null,this.mousePassthroughEnabled=!1,this.inputShape=null,this.traySize=null,e.on(`closed`,()=>{if(this.window!==e)return;let t=this.presentationVisibility!=null||this.startupPresentationVisibility!=null;this.cancelMomentum(),this.clearMovedWindowPersist(),this.window=null,this.dragState=null,this.pointerInteractive=!1,this.mousePassthroughEnabled=!1,this.compositionHost.setOverlayWindow(null),this.broadcastOpenState()}),e}", "getLayoutForDisplay(e){return pf({anchor:this.anchor,displayBounds:this.layoutMode===`native`?e.workArea:e.bounds,mode:this.layoutMode,mascotSize:this.mascotSize,nativeMaterialAttached:this.compositionHost.isNativeMaterialAttached(),previousPlacement:this.placement,traySize:this.traySize??(this.layoutMode===`native`?y5:v5)})}", "applyLayout(e,t=this.getCurrentDisplay(),n=!1,r=!0,i=null){if(e.isDestroyed())return;let a=this.getLayoutForDisplay(t);this.layout=a,this.setWindowBounds(e,a.windowBounds,n,r),this.compositionHost.updateMascotRect(a.mascot),this.sendLayoutToRenderer(e,i),this.computerUseCursorLocation!=null&&this.dragState==null&&this.sendComputerUseCursorLocationToRenderer(e)}", "showWindow(e){if(e.isDestroyed())return;let t=this.isOpen();e.moveTop(),e.showInactive(),this.compositionHost.publishRemoteHostedPIPContentHost(),!t&&this.isOpen()&&this.broadcastOpenState()}", - "applyPointerInteractivityPolicy(){let e=this.window;if(e==null||e.isDestroyed()){this.mousePassthroughEnabled=!1;return}let t=!this.pointerInteractive;if(this.mousePassthroughEnabled!==t){if(this.mousePassthroughEnabled=t,t){e.setIgnoreMouseEvents(!0,{forward:!0});return}e.setIgnoreMouseEvents(!1),this.refreshCursorAtCurrentMousePosition(e)}}", + "applyPointerInteractivityPolicy(){let e=this.window;if(e==null||e.isDestroyed()){this.mousePassthroughEnabled=!1;return}if(this.applyInputShape(e))return;let t=!this.pointerInteractive;if(this.mousePassthroughEnabled!==t){if(this.mousePassthroughEnabled=t,t){e.setIgnoreMouseEvents(!0,{forward:!0});return}e.setIgnoreMouseEvents(!1),this.refreshCursorAtCurrentMousePosition(e)}}", + "applyInputShape(e){if(!this.supportsInputShape||this.inputShape==null)return!1;this.mousePassthroughEnabled&&=(e.setIgnoreMouseEvents(!1),!1);let t=Bs(e,this.inputShape.map(({height:e,left:t,top:n,width:r})=>({height:e,width:r,x:t,y:n})));return t&&(this.mousePassthroughEnabled=!1),t}", "setComputerUseCursorLocation(e){this.computerUseCursorLocation=e,this.computerUseCursorPoint=e.isActive?{x:e.x,y:e.y}:null}", "sendComputerUseCursorLocationToRenderer(e){this.windowManager.sendMessageToWebContents(e.webContents,{type:`avatar-overlay-computer-use-cursor-changed`})}", "refreshCursorAtCurrentMousePosition(e){let t=c.screen.getCursorScreenPoint();return this.sendCursorPointToAvatarOverlay(e,t,!1)}", @@ -2750,10 +2821,48 @@ test("retains the current native Linux tray when quit-state helpers already exis assert.equal((patched.match(/codexLinuxRegisterTray=e=>/g) ?? []).length, 1); assert.match(patched, /let codexLinuxTray=null,codexLinuxRegisterTray=e=>/); - assert.match(patched, /r=codexLinuxRegisterTray\(new c\.Tray\(t\.defaultIcon\)\)/); + assert.match( + patched, + /r=codexLinuxRegisterTray\(new c\.Tray\(\.\.\.\(process\.platform===`linux`\?\[t\.defaultIcon\]:\[t\.defaultIcon,process\.platform===`win32`&&c\.app\.isPackaged\?dEe\(e\.buildFlavor\):void 0\]\)\)\)/, + ); assert.doesNotMatch(patched, /typeof codexLinuxRegisterTray===`function`/); }); +test("wraps the complete exact-DMG nested-ternary Tray constructor in a parseable bundle", () => { + const source = `${currentMainBundlePrefix}${exactDmgNestedTernaryTrayBundleFixture()}`; + const patched = patchMainBundleSource(source, null); + const retainedConstructor = + "r=codexLinuxRegisterTray(new c.Tray(...(process.platform===`linux`?[t.defaultIcon]:[t.defaultIcon,process.platform===`win32`&&c.app.isPackaged?dEe(e.buildFlavor):void 0])))"; + + assert.ok(patched.includes(retainedConstructor)); + assert.doesNotThrow(() => new Function(patched)); + assert.equal(patchMainBundleSource(patched, null), patched); + + const trayArguments = (platform) => { + const context = { + c: { + app: { isPackaged: true }, + Tray: class { + constructor(...args) { + this.args = args; + } + }, + }, + codexLinuxRegisterTray: (tray) => tray, + dEe: () => "windows-guid", + e: { buildFlavor: "prod" }, + process: { platform }, + result: null, + t: { defaultIcon: "icon" }, + }; + vm.runInNewContext(`${retainedConstructor};result=r.args`, context); + return [...context.result]; + }; + + assert.deepEqual(trayArguments("linux"), ["icon"]); + assert.deepEqual(trayArguments("win32"), ["icon", "windows-guid"]); +}); + test("bypasses the upstream before-quit confirmation after a Linux explicit quit", () => { const source = `${currentMainBundlePrefix}${beforeQuitConfirmationBundleFixture()}`; const patched = applyPatchTwice( @@ -2771,7 +2880,7 @@ test("bypasses the upstream before-quit confirmation after a Linux explicit quit ); }); -test("adds a bounded will-quit drain fallback for Linux explicit quit", () => { +test("adds a bounded will-quit drain fallback on Linux", () => { const source = `${currentMainBundlePrefix}${willQuitDrainBundleFixture()}`; const patched = applyPatchTwice( applyLinuxWillQuitDrainTimeoutPatch, @@ -2779,198 +2888,610 @@ test("adds a bounded will-quit drain fallback for Linux explicit quit", () => { ); assert.match(patched, /codexLinuxExplicitQuitDrainTimeoutMs=3e3/); - assert.match(patched, /\(\(\)=>\{let codexLinuxFinalizeQuit=\(\)=>\{d\(\),f\.dispose\(\),n\.app\.quit\(\)\},codexLinuxDrainPromise=Promise\.all\(\[u\.flush\(\),p\.flush\(\)\]\);/); - assert.match(patched, /if\(process\.platform===`linux`&&\(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress\(\)\)\)\{Promise\.race\(\[codexLinuxDrainPromise,new Promise\(e=>setTimeout\(e,typeof codexLinuxExplicitQuitDrainTimeoutMs===`number`\?codexLinuxExplicitQuitDrainTimeoutMs:3e3\)\)\]\)\.finally\(codexLinuxFinalizeQuit\);return\}/); - assert.doesNotMatch(patched, /\\`number\\`/); - assert.match(patched, /codexLinuxDrainPromise\.finally\(codexLinuxFinalizeQuit\)\}\)\(\)/); - assert.doesNotThrow(() => new Function(patched)); -}); - -test("marks Linux quit-in-progress for the tray quit path", () => { - const source = `${currentMainBundlePrefix}${explicitQuitBundleFixture()}`; - const patched = applyPatchTwice( - applyLinuxExplicitTrayQuitPatch, - applyLinuxQuitGuardPatch(source), + assert.match( + patched, + /codexLinuxLogQuitDrainResults=e=>\{for\(let t of e\)if\(t\.status===`rejected`\)try\{console\.warn\(`WARN: Linux quit drain cleanup failed`,t\.reason\)\}catch\{\};return e\}/, ); - + assert.doesNotMatch(patched, /codexLinuxQuitFinalized/); assert.match( patched, - /\{label:this\.systemQuitMenuItemLabel,click:\(\)=>\{typeof codexLinuxPrepareForExplicitQuit===`function`\?codexLinuxPrepareForExplicitQuit\(\):typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress\(\),n\.app\.quit\(\)\}\}/, + /\.then\(\(\)=>Promise\.resolve\(\)\.then\(\(\)=>U5\(h,N5\)\)\.catch\(e=>\{try\{console\.warn\(`WARN: Linux quit context cleanup failed`,e\)\}catch\{\}\}\)\)/, ); -}); - -test("marks Linux quit-in-progress for the quit-app IPC path", () => { - const source = `${currentMainBundlePrefix}${explicitQuitBundleFixture()}`; - const patched = applyPatchTwice( - applyLinuxExplicitIpcQuitPatch, - applyLinuxQuitGuardPatch(source), + assert.match( + patched, + /try\{g\.dispose\(\)\}catch\(e\)\{try\{console\.warn\(`WARN: Linux quit disposables cleanup failed`,e\)\}catch\{\}\}finally\{l\.app\.exit\(0\)\}/, ); - assert.match( patched, - /if\(o\.type===`quit-app`\)\{typeof codexLinuxPrepareForExplicitQuit===`function`\?codexLinuxPrepareForExplicitQuit\(\):typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress\(\),n\.app\.quit\(\);return\}/, + /Promise\.race\(\[Promise\.resolve\(\)\.then\(e\)[^;]+new Promise\(\(_,e\)=>setTimeout\(\(\)=>e\(Error\(`Linux quit cleanup timed out`\)\),typeof codexLinuxExplicitQuitDrainTimeoutMs===`number`\?codexLinuxExplicitQuitDrainTimeoutMs:3e3\)\)\]\)\.catch\(e=>\{try\{console\.warn\(`WARN: Linux quit cleanup failed`,e\)\}catch\{\}\}\)\.then\(codexLinuxFinalizeQuit\)/, ); -}); - -test("supports explicit IPC quit patching when minified aliases drift", () => { - const source = - "let x=require(`electron`);if(m.type===`quit-app`){x.app.quit();return}"; - const patched = applyPatchTwice(applyLinuxExplicitIpcQuitPatch, source); - assert.match( patched, - /if\(m\.type===`quit-app`\)\{typeof codexLinuxPrepareForExplicitQuit===`function`\?codexLinuxPrepareForExplicitQuit\(\):typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress\(\),x\.app\.quit\(\);return\}/, + /codexLinuxRunQuitCleanup=e=>\{if\(process\.platform===`linux`\)\{/, ); + assert.equal( + (patched.match(/codexLinuxRunQuitCleanup\(\(\)=>\{/g) ?? []).length, + 2, + ); + assert.equal( + (patched.match(/\.then\(codexLinuxLogQuitDrainResults\)/g) ?? []).length, + 1, + ); + assert.doesNotMatch(patched, /\\`number\\`/); + assert.match(patched, /e\(\)\.then\(t\)\}/); + assert.doesNotMatch(patched, /Promise\.allSettled\([^;]+\.then\(t\)/); + assert.doesNotThrow(() => new Function(patched)); }); -test("patches remaining explicit quit handlers when another copy is already patched", () => { - const quitMarkerExpression = - "typeof codexLinuxPrepareForExplicitQuit===`function`?codexLinuxPrepareForExplicitQuit():typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress(),"; - const patchedTrayQuit = `{label:this.systemQuitMenuItemLabel,click:()=>{${quitMarkerExpression}n.app.quit()}}`; - const unpatchedTrayQuit = "{label:this.systemQuitMenuItemLabel,click:()=>{n.app.quit()}}"; - const patchedIpcQuit = `if(o.type===\`quit-app\`){${quitMarkerExpression}n.app.quit();return}`; - const unpatchedIpcQuit = "if(o.type===`quit-app`){n.app.quit();return}"; +test("Linux will-quit reaches app.exit exactly once when a disposer throws", async () => { + const state = await runPatchedLinuxWillQuit({ + disposablesDispose() { + throw new Error("disposer failed"); + }, + }); - const patchedTray = applyPatchTwice( - applyLinuxExplicitTrayQuitPatch, - `${patchedTrayQuit}function createSecondTray(){return ${unpatchedTrayQuit}}`, - ); - const patchedIpc = applyPatchTwice( - applyLinuxExplicitIpcQuitPatch, - `${patchedIpcQuit}function createSecondIpc(){${unpatchedIpcQuit}}`, - ); + assert.equal(state.preventDefaultCalls, 1); + assert.equal(state.lifecycleDisposeCalls, 2); + assert.equal(state.exitCalls, 1); + assert.deepEqual(state.exitCodes, [0]); + assert.equal(state.quitCalls, 0); + assert.deepEqual(state.warnings, [ + "WARN: Linux quit disposables cleanup failed Error: disposer failed", + ]); +}); - assert.equal((patchedTray.match(/codexLinuxPrepareForExplicitQuit\(\)/g) ?? []).length, 2); - assert.match( - patchedTray, - /function createSecondTray\(\)\{return \{label:this\.systemQuitMenuItemLabel,click:\(\)=>\{typeof codexLinuxPrepareForExplicitQuit===`function`\?codexLinuxPrepareForExplicitQuit\(\):typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress\(\),n\.app\.quit\(\)\}\}\}/, - ); - assert.equal((patchedIpc.match(/codexLinuxPrepareForExplicitQuit\(\)/g) ?? []).length, 2); - assert.match( - patchedIpc, - /function createSecondIpc\(\)\{if\(o\.type===`quit-app`\)\{typeof codexLinuxPrepareForExplicitQuit===`function`\?codexLinuxPrepareForExplicitQuit\(\):typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress\(\),n\.app\.quit\(\);return\}\}/, - ); +test("Linux will-quit reaches app.exit after the drain deadline", async () => { + let resolveGlobalState; + const stalledGlobalState = new Promise((resolve) => { + resolveGlobalState = resolve; + }); + const state = await runPatchedLinuxWillQuit({ + globalStateFlush: () => stalledGlobalState, + timeoutMs: 5, + }); + + assert.equal(state.exitCalls, 1); + assert.deepEqual(state.exitCodes, [0]); + assert.equal(state.quitCalls, 0); + assert.deepEqual(state.warnings, [ + "WARN: Linux quit cleanup failed Error: Linux quit cleanup timed out", + ]); + resolveGlobalState(); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(state.exitCalls, 1); + assert.equal(state.quitCalls, 0); +}); + +test("Linux will-quit bounds context disposal inside the quit deadline", async () => { + const stalledContextDispose = new Promise(() => {}); + let contextDisposeCalls = 0; + let disposablesCalls = 0; + const state = await runPatchedLinuxWillQuit({ + contextDispose() { + contextDisposeCalls += 1; + return stalledContextDispose; + }, + disposablesDispose() { + disposablesCalls += 1; + }, + timeoutMs: 5, + }); + + assert.equal(contextDisposeCalls, 1); + assert.equal(disposablesCalls, 1); + assert.equal(state.exitCalls, 1); + assert.deepEqual(state.exitCodes, [0]); + assert.equal(state.quitCalls, 0); + assert.deepEqual(state.warnings, [ + "WARN: Linux quit cleanup failed Error: Linux quit cleanup timed out", + ]); }); -test("uses the frameless native Codex titlebar for primary Linux windows", () => { - const source = [ - "function A2(e){return e===`avatarOverlay`}", - "function I2({platform:e,appearance:t,opaqueWindowsEnabled:n,prefersDarkColors:r}){return n&&!A2(t)&&(e===`darwin`||e===`win32`)?{backgroundColor:r?a2:o2,backgroundMaterial:e===`win32`?`none`:null}:e===`linux`&&!A2(t)?{backgroundColor:r?a2:o2,backgroundMaterial:null}:{backgroundColor:i2,backgroundMaterial:null}}", - "function b2(e=1){return{color:i2,symbolColor:a.nativeTheme.shouldUseDarkColors?v2:_2,height:Math.round(g2*e)}}", - "case`quickChat`:case`primary`:return n===`darwin`?{titleBarStyle:`hiddenInset`,trafficLightPosition:y2(r),...e===`quickChat`?{hasShadow:!0,resizable:!0,transparent:!0}:{},...t?{}:{vibrancy:`menu`}}:n===`win32`||n===`linux`?{titleBarStyle:`hidden`,titleBarOverlay:b2(r),...e===`quickChat`?{resizable:!0}:{}}:{titleBarStyle:`default`,...e===`quickChat`?{resizable:!0}:{}};", - ].join(""); - const patched = applyPatchTwice(applyLinuxNativeTitlebarPatch, source); +test("Linux reduced will-quit branch shares the complete cleanup deadline", async () => { + const stalledContextDispose = new Promise(() => {}); + let contextDisposeCalls = 0; + let disposablesCalls = 0; + let globalStateFlushCalls = 0; + let settingsFlushCalls = 0; + let stopCodexMicroCalls = 0; + let flushTracingCalls = 0; + const state = await runPatchedLinuxWillQuit({ + contextDispose() { + contextDisposeCalls += 1; + return stalledContextDispose; + }, + disposablesDispose() { + disposablesCalls += 1; + }, + flushTracing() { + flushTracingCalls += 1; + return Promise.resolve(); + }, + globalStateFlush() { + globalStateFlushCalls += 1; + return Promise.resolve(); + }, + settingsFlush() { + settingsFlushCalls += 1; + return Promise.resolve(); + }, + shouldSkipDrain: true, + stopCodexMicro() { + stopCodexMicroCalls += 1; + return Promise.resolve(); + }, + timeoutMs: 5, + }); - assert.match( - patched, - /function codexLinuxTitleBarOverlay\(e=1\)\{return\{color:a\.nativeTheme\.shouldUseDarkColors\?`#111111`:o2,symbolColor:a\.nativeTheme\.shouldUseDarkColors\?v2:_2,height:Math\.round\(30\*e\)\}\}/, - ); - assert.match( - patched, - /titleBarOverlay:n===`linux`\?codexLinuxTitleBarOverlay\(r\):b2\(r\),\.\.\.e===`quickChat`/, - ); - assert.match(patched, /\.\.\.t\?\{\}:\{vibrancy:`menu`\}/); - assert.doesNotMatch(patched, /titleBarOverlay:b2\(r\),\.\.\.e===`quickChat`/); + assert.equal(contextDisposeCalls, 1); + assert.equal(disposablesCalls, 1); + assert.equal(globalStateFlushCalls, 1); + assert.equal(settingsFlushCalls, 0); + assert.equal(stopCodexMicroCalls, 1); + assert.equal(flushTracingCalls, 1); + assert.equal(state.exitCalls, 1); + assert.deepEqual(state.exitCodes, [0]); + assert.equal(state.quitCalls, 0); + assert.deepEqual(state.warnings, [ + "WARN: Linux quit cleanup failed Error: Linux quit cleanup timed out", + ]); }); -test("uses a module-scoped Linux native titlebar helper when aliases shadow Electron", () => { - const source = [ - "function A3(e){return e===`avatarOverlay`}", - "function I3({platform:e,appearance:t,opaqueWindowsEnabled:n,prefersDarkColors:r}){return n&&!A3(t)&&(e===`darwin`||e===`win32`)?{backgroundColor:r?L4:K4,backgroundMaterial:e===`win32`?`none`:null}:e===`linux`&&!A3(t)?{backgroundColor:r?L4:K4,backgroundMaterial:null}:{backgroundColor:W4,backgroundMaterial:null}}", - "function o3(e=1){return{color:W4,symbolColor:r.nativeTheme.shouldUseDarkColors?i3:r3,height:Math.round(g3*e)}}", - "function T3({appearance:e,opaqueWindowSurfaceEnabled:t,platform:n,windowZoom:r=1}){switch(e){case`quickChat`:case`primary`:return n===`darwin`?{titleBarStyle:`hiddenInset`,trafficLightPosition:a3(r),...e===`quickChat`?{hasShadow:!0,resizable:!0,transparent:!0}:{},...t?{}:{vibrancy:`menu`}}:n===`win32`||n===`linux`?{titleBarStyle:`hidden`,titleBarOverlay:o3(r),...e===`quickChat`?{resizable:!0}:{}}:{titleBarStyle:`default`,...e===`quickChat`?{resizable:!0}:{}};}}", - ].join(""); - const { value, warnings } = captureWarns(() => - applyPatchTwice(applyLinuxNativeTitlebarPatch, source), - ); +test("Linux will-quit logs rejected asynchronous drain work before exit", async () => { + const state = await runPatchedLinuxWillQuit({ + globalStateFlush: () => Promise.reject(new Error("global-state flush rejected")), + }); - assert.match( - value, - /function codexLinuxTitleBarOverlay\(e=1\)\{return\{color:r\.nativeTheme\.shouldUseDarkColors\?`#111111`:K4,symbolColor:r\.nativeTheme\.shouldUseDarkColors\?i3:r3,height:Math\.round\(30\*e\)\}\}/, - ); - assert.match( - value, - /titleBarOverlay:n===`linux`\?codexLinuxTitleBarOverlay\(r\):o3\(r\),\.\.\.e===`quickChat`/, - ); - assert.doesNotMatch(value, /titleBarOverlay:\{color:r\.nativeTheme\.shouldUseDarkColors/); - assert.deepEqual(warnings, []); + assert.equal(state.exitCalls, 1); + assert.deepEqual(state.exitCodes, [0]); + assert.equal(state.quitCalls, 0); + assert.deepEqual(state.warnings, [ + "WARN: Linux quit drain cleanup failed Error: global-state flush rejected", + ]); }); -test("updates the Linux native titlebar overlay when nativeTheme changes", () => { - const source = [ - "function A2(e){return e===`avatarOverlay`}", - "function I2({platform:e,appearance:t,opaqueWindowsEnabled:n,prefersDarkColors:r}){return n&&!A2(t)&&(e===`darwin`||e===`win32`)?{backgroundColor:r?a2:o2,backgroundMaterial:e===`win32`?`none`:null}:e===`linux`&&!A2(t)?{backgroundColor:r?a2:o2,backgroundMaterial:null}:{backgroundColor:i2,backgroundMaterial:null}}", - "function b2(e=1){return{color:i2,symbolColor:a.nativeTheme.shouldUseDarkColors?v2:_2,height:Math.round(g2*e)}}", - "case`quickChat`:case`primary`:return n===`darwin`?{titleBarStyle:`hiddenInset`,trafficLightPosition:y2(r),...e===`quickChat`?{hasShadow:!0,resizable:!0,transparent:!0}:{},...t?{}:{vibrancy:`menu`}}:n===`win32`||n===`linux`?{titleBarStyle:`hidden`,titleBarOverlay:b2(r),...e===`quickChat`?{resizable:!0}:{}}:{titleBarStyle:`default`,...e===`quickChat`?{resizable:!0}:{}};", - "installApplicationMenuTitleBarOverlaySync(e,t){if(process.platform!==`win32`&&process.platform!==`linux`||t!==`primary`&&t!==`quickChat`)return;let n=()=>{e.isDestroyed()||e.setTitleBarOverlay(b2(this.windowZooms.get(e.id)))};return a.nativeTheme.on(`updated`,n),n(),()=>{a.nativeTheme.off(`updated`,n)}}", - ].join(""); - const patched = applyPatchTwice(applyLinuxNativeTitlebarPatch, source); +test("Linux will-quit reaches app.exit when pre-drain disposal throws synchronously", async () => { + const state = await runPatchedLinuxWillQuit({ + lifecycleDisposeThrowsAt: 1, + }); - assert.match( - patched, - /if\(process\.platform!==`win32`&&process\.platform!==`linux`\|\|t!==`primary`&&t!==`quickChat`\)return/, - ); - assert.match( - patched, - /e\.setTitleBarOverlay\(process\.platform===`linux`\?codexLinuxTitleBarOverlay\(this\.windowZooms\.get\(e\.id\)\):b2\(this\.windowZooms\.get\(e\.id\)\)\)/, - ); - assert.doesNotMatch(patched, /webContents\.executeJavaScript\(/); - assert.doesNotMatch(patched, /data-codex-window-type/); + assert.equal(state.preventDefaultCalls, 1); + assert.equal(state.lifecycleDisposeCalls, 1); + assert.equal(state.exitCalls, 1); + assert.deepEqual(state.exitCodes, [0]); + assert.equal(state.quitCalls, 0); + assert.deepEqual(state.warnings, [ + "WARN: Linux quit drain cleanup failed Error: lifecycle disposer failed", + ]); }); -test("redirects the renamed Linux-aware titlebar overlay sync away from the transparent win32 helper", () => { - const source = [ - "function A2(e){return e===`avatarOverlay`}", - "function I2({platform:e,appearance:t,opaqueWindowsEnabled:n,prefersDarkColors:r}){return n&&!A2(t)&&(e===`darwin`||e===`win32`)?{backgroundColor:r?a2:o2,backgroundMaterial:e===`win32`?`none`:null}:e===`linux`&&!A2(t)?{backgroundColor:r?a2:o2,backgroundMaterial:null}:{backgroundColor:i2,backgroundMaterial:null}}", - "function b2(e=1){return{color:i2,symbolColor:a.nativeTheme.shouldUseDarkColors?v2:_2,height:Math.round(g2*e)}}", - "case`quickChat`:case`primary`:return n===`darwin`?{titleBarStyle:`hiddenInset`,trafficLightPosition:y2(r),...e===`quickChat`?{hasShadow:!0,resizable:!0,transparent:!0}:{},...t?{}:{vibrancy:`menu`}}:n===`win32`||n===`linux`?{titleBarStyle:`hidden`,titleBarOverlay:b2(r),...e===`quickChat`?{resizable:!0}:{}}:{titleBarStyle:`default`,...e===`quickChat`?{resizable:!0}:{}};", - "installApplicationMenuTitleBarOverlaySync(e,t){if(process.platform!==`win32`&&process.platform!==`linux`||t!==`primary`&&t!==`quickChat`)return;let n=()=>{e.isDestroyed()||e.setTitleBarOverlay(b2(this.windowZooms.get(e.id)))};return a.nativeTheme.on(`updated`,n),n(),()=>{a.nativeTheme.off(`updated`,n)}}", - "process.platform===`darwin`?n.setWindowButtonPosition(y2(t)):(process.platform===`win32`||process.platform===`linux`)&&(this.windowZooms.set(n.id,t),n.setTitleBarOverlay(b2(t)))", - ].join(""); - const { value: patched, warnings } = captureWarns(() => - applyPatchTwice(applyLinuxNativeTitlebarPatch, source), +test("Linux will-quit reaches app.exit when drain setup throws synchronously", async () => { + const state = await runPatchedLinuxWillQuit({ + globalStateFlush() { + throw new Error("global-state flush failed"); + }, + }); + + assert.equal(state.preventDefaultCalls, 1); + assert.equal(state.lifecycleDisposeCalls, 2); + assert.equal(state.exitCalls, 1); + assert.deepEqual(state.exitCodes, [0]); + assert.equal(state.quitCalls, 0); + assert.deepEqual(state.warnings, [ + "WARN: Linux quit drain cleanup failed Error: global-state flush failed", + ]); +}); + +test("Linux will-quit reaches app.exit when the quit-state helper is outside its scope", async () => { + let resolveGlobalState; + const stalledGlobalState = new Promise((resolve) => { + resolveGlobalState = resolve; + }); + const state = await runPatchedLinuxWillQuit({ + globalStateFlush: () => stalledGlobalState, + omitQuitStateHelper: true, + timeoutMs: 5, + }); + + assert.equal(state.exitCalls, 1); + assert.deepEqual(state.exitCodes, [0]); + assert.equal(state.quitCalls, 0); + resolveGlobalState(); +}); + +test("Linux will-quit continues cleanup when bounded context disposal rejects", async () => { + let disposablesCalls = 0; + const state = await runPatchedLinuxWillQuit({ + contextDispose: () => Promise.reject(new Error("context failed")), + disposablesDispose() { + disposablesCalls += 1; + }, + }); + + assert.equal(disposablesCalls, 1); + assert.equal(state.exitCalls, 1); + assert.deepEqual(state.exitCodes, [0]); + assert.equal(state.quitCalls, 0); + assert.deepEqual(state.warnings, [ + "WARN: Linux quit context cleanup failed Error: context failed", + ]); +}); + +test("non-Linux will-quit preserves the upstream drain path", async () => { + let resolveGlobalState; + let quitReached = false; + const stalledGlobalState = new Promise((resolve) => { + resolveGlobalState = resolve; + }); + const run = runPatchedLinuxWillQuit({ + globalStateFlush: () => stalledGlobalState, + platform: "darwin", + settingsFlush: () => Promise.reject(new Error("non-Linux rejected member")), + timeoutMs: 5, + }).then((state) => { + quitReached = true; + return state; + }); + + await new Promise((resolve) => setTimeout(resolve, 15)); + assert.equal(quitReached, false); + resolveGlobalState(); + + const state = await run; + assert.equal(state.exitCalls, 0); + assert.equal(state.quitCalls, 1); + assert.deepEqual(state.warnings, []); +}); + +test("current will-quit drift fails the required lifecycle patch", () => { + const source = willQuitDrainBundleFixture().replace( + "U5(h,N5)", + "U5(h,N5,unexpected)", + ); + const descriptor = corePatchDescriptors().find( + (candidate) => candidate.id === "linux-explicit-quit-drain-timeout", + ); + const report = createPatchReport(); + const { value: result, warnings } = captureWarns(() => + applyMainBundlePatchDescriptors(source, [descriptor], {}, report), + ); + + assert.equal(result.patchedSource, source); + assert.deepEqual(warnings, [ + "WARN: Could not uniquely match current will-quit drain sequence — skipping Linux explicit quit drain timeout patch", + ]); + assert.equal(report.patches[0]?.status, "failed-required"); + assert.equal(report.patches[0]?.reason, warnings[0]); +}); + +test("missing, renamed, or ambiguous will-quit targets fail the required lifecycle patch", () => { + const sources = [ + willQuitDrainBundleFixture().replace( + "Promise.allSettled([d.flush(),p(),m()])", + "Promise.allSettled([p(),m()])", + ), + willQuitDrainBundleFixture().replace( + "shouldSkipDrainBeforeQuit()", + "skipDrainBeforeQuit()", + ), + "l.app.on(`ready`,()=>{})", + `${willQuitDrainBundleFixture()}${willQuitDrainBundleFixture()}`, + ]; + const descriptor = corePatchDescriptors().find( + (candidate) => candidate.id === "linux-explicit-quit-drain-timeout", + ); + + for (const source of sources) { + const report = createPatchReport(); + const { value: result, warnings } = captureWarns(() => + applyMainBundlePatchDescriptors(source, [descriptor], {}, report), + ); + + assert.equal(result.patchedSource, source); + assert.deepEqual(warnings, [ + "WARN: Could not uniquely match current will-quit drain sequence — skipping Linux explicit quit drain timeout patch", + ]); + assert.equal(report.patches[0]?.status, "failed-required"); + assert.equal(report.patches[0]?.reason, warnings[0]); + } +}); + +test("recognizes the bounded Linux quit cleanup as already applied", () => { + const source = applyLinuxWillQuitDrainTimeoutPatch( + willQuitDrainBundleFixture(), + ); + const descriptor = corePatchDescriptors().find( + (candidate) => candidate.id === "linux-explicit-quit-drain-timeout", + ); + const report = createPatchReport(); + const { value: result, warnings } = captureWarns(() => + applyMainBundlePatchDescriptors(source, [descriptor], {}, report), + ); + + assert.equal(result.patchedSource, source); + assert.deepEqual(warnings, []); + assert.equal(report.patches[0]?.status, "already-applied"); +}); + +test("does not accept a partial Linux quit cleanup call-site patch", () => { + const source = applyLinuxWillQuitDrainTimeoutPatch( + willQuitDrainBundleFixture(), + ).replace( + "codexLinuxRunQuitCleanup(()=>{c.dispose(),u.dispose();", + "codexLinuxRunQuitDrain(()=>{c.dispose(),u.dispose();", + ); + const descriptor = corePatchDescriptors().find( + (candidate) => candidate.id === "linux-explicit-quit-drain-timeout", + ); + const report = createPatchReport(); + const { value: result, warnings } = captureWarns(() => + applyMainBundlePatchDescriptors(source, [descriptor], {}, report), + ); + + assert.equal(result.patchedSource, source); + assert.deepEqual(warnings, [ + "WARN: Could not uniquely match current will-quit drain sequence — skipping Linux explicit quit drain timeout patch", + ]); + assert.equal(report.patches[0]?.status, "failed-required"); + assert.equal(report.patches[0]?.reason, warnings[0]); +}); + +test("does not accept damaged Linux quit cleanup factory bodies", () => { + const patched = applyLinuxWillQuitDrainTimeoutPatch( + willQuitDrainBundleFixture(), + ); + const sources = [ + patched.replace( + "codexLinuxRunQuitCleanup(()=>{c.dispose(),u.dispose();return Promise.allSettled([d.flush(),p(),m()])})", + "codexLinuxRunQuitCleanup(()=>{return Promise.resolve()})", + ), + patched.replace( + "codexLinuxRunQuitCleanup(()=>{c.dispose(),u.dispose();return Promise.allSettled([d.flush(),f.flush(),p(),m()])})", + "codexLinuxRunQuitCleanup(()=>{return Promise.resolve()})", + ), + ]; + const descriptor = corePatchDescriptors().find( + (candidate) => candidate.id === "linux-explicit-quit-drain-timeout", + ); + + for (const source of sources) { + const report = createPatchReport(); + const { value: result, warnings } = captureWarns(() => + applyMainBundlePatchDescriptors(source, [descriptor], {}, report), + ); + + assert.equal(result.patchedSource, source); + assert.deepEqual(warnings, [ + "WARN: Could not uniquely match current will-quit drain sequence — skipping Linux explicit quit drain timeout patch", + ]); + assert.equal(report.patches[0]?.status, "failed-required"); + assert.equal(report.patches[0]?.reason, warnings[0]); + } +}); + +test("a non-exiting Linux finalizer is not accepted as already applied", () => { + const previousBrokenPatch = applyLinuxWillQuitDrainTimeoutPatch( + willQuitDrainBundleFixture(), + ).replace("l.app.exit(0)", "l.app.quit()") + "finally{z.app.exit(0)}"; + const descriptor = corePatchDescriptors().find( + (candidate) => candidate.id === "linux-explicit-quit-drain-timeout", + ); + const report = createPatchReport(); + const { value: result, warnings } = captureWarns(() => + applyMainBundlePatchDescriptors(previousBrokenPatch, [descriptor], {}, report), + ); + + assert.equal(result.patchedSource, previousBrokenPatch); + assert.deepEqual(warnings, [ + "WARN: Could not uniquely match current will-quit drain sequence — skipping Linux explicit quit drain timeout patch", + ]); + assert.equal(report.patches[0]?.status, "failed-required"); + assert.equal(report.patches[0]?.reason, warnings[0]); +}); + +test("marks Linux quit-in-progress for the tray quit path", () => { + const source = `${currentMainBundlePrefix}${explicitQuitBundleFixture()}`; + const patched = applyPatchTwice( + applyLinuxExplicitTrayQuitPatch, + applyLinuxQuitGuardPatch(source), ); assert.match( patched, - /titleBarOverlay:n===`linux`\?codexLinuxTitleBarOverlay\(r\):b2\(r\),\.\.\.e===`quickChat`/, + /\{label:this\.systemQuitMenuItemLabel,click:\(\)=>\{typeof codexLinuxPrepareForExplicitQuit===`function`\?codexLinuxPrepareForExplicitQuit\(\):typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress\(\),n\.app\.quit\(\)\}\}/, + ); +}); + +test("marks Linux quit-in-progress for the quit-app IPC path", () => { + const source = `${currentMainBundlePrefix}${explicitQuitBundleFixture()}`; + const patched = applyPatchTwice( + applyLinuxExplicitIpcQuitPatch, + applyLinuxQuitGuardPatch(source), ); + assert.match( patched, - /installApplicationMenuTitleBarOverlaySync\(e,t\)\{if\(process\.platform!==`win32`&&process\.platform!==`linux`\|\|t!==`primary`&&t!==`quickChat`\)return/, + /if\(o\.type===`quit-app`\)\{typeof codexLinuxPrepareForExplicitQuit===`function`\?codexLinuxPrepareForExplicitQuit\(\):typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress\(\),n\.app\.quit\(\);return\}/, ); +}); + +test("supports explicit IPC quit patching when minified aliases drift", () => { + const source = + "let x=require(`electron`);if(m.type===`quit-app`){x.app.quit();return}"; + const patched = applyPatchTwice(applyLinuxExplicitIpcQuitPatch, source); + assert.match( patched, - /e\.setTitleBarOverlay\(process\.platform===`linux`\?codexLinuxTitleBarOverlay\(this\.windowZooms\.get\(e\.id\)\):b2\(this\.windowZooms\.get\(e\.id\)\)\)/, + /if\(m\.type===`quit-app`\)\{typeof codexLinuxPrepareForExplicitQuit===`function`\?codexLinuxPrepareForExplicitQuit\(\):typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress\(\),x\.app\.quit\(\);return\}/, + ); +}); + +test("patches remaining explicit quit handlers when another copy is already patched", () => { + const quitMarkerExpression = + "typeof codexLinuxPrepareForExplicitQuit===`function`?codexLinuxPrepareForExplicitQuit():typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress(),"; + const patchedTrayQuit = `{label:this.systemQuitMenuItemLabel,click:()=>{${quitMarkerExpression}n.app.quit()}}`; + const unpatchedTrayQuit = "{label:this.systemQuitMenuItemLabel,click:()=>{n.app.quit()}}"; + const patchedIpcQuit = `if(o.type===\`quit-app\`){${quitMarkerExpression}n.app.quit();return}`; + const unpatchedIpcQuit = "if(o.type===`quit-app`){n.app.quit();return}"; + + const patchedTray = applyPatchTwice( + applyLinuxExplicitTrayQuitPatch, + `${patchedTrayQuit}function createSecondTray(){return ${unpatchedTrayQuit}}`, + ); + const patchedIpc = applyPatchTwice( + applyLinuxExplicitIpcQuitPatch, + `${patchedIpcQuit}function createSecondIpc(){${unpatchedIpcQuit}}`, + ); + + assert.equal((patchedTray.match(/codexLinuxPrepareForExplicitQuit\(\)/g) ?? []).length, 2); + assert.match( + patchedTray, + /function createSecondTray\(\)\{return \{label:this\.systemQuitMenuItemLabel,click:\(\)=>\{typeof codexLinuxPrepareForExplicitQuit===`function`\?codexLinuxPrepareForExplicitQuit\(\):typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress\(\),n\.app\.quit\(\)\}\}\}/, + ); + assert.equal((patchedIpc.match(/codexLinuxPrepareForExplicitQuit\(\)/g) ?? []).length, 2); + assert.match( + patchedIpc, + /function createSecondIpc\(\)\{if\(o\.type===`quit-app`\)\{typeof codexLinuxPrepareForExplicitQuit===`function`\?codexLinuxPrepareForExplicitQuit\(\):typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress\(\),n\.app\.quit\(\);return\}\}/, + ); +}); + +function nativeTitlebarZoomFixture( + electronAlias = "a", + overlayHelperAlias = "b2", + buttonHelperAlias = "y2", +) { + return `setWindowZoom(e,t){let n=${electronAlias}.BrowserWindow.fromWebContents(e),r=n&&this.windowAppearances.get(n.id);n==null||r!==\`primary\`&&r!==\`quickChat\`||(process.platform===\`darwin\`?n.setWindowButtonPosition(${buttonHelperAlias}(t)):(process.platform===\`win32\`||process.platform===\`linux\`)&&(this.windowZooms.set(n.id,t),n.setTitleBarOverlay(${overlayHelperAlias}(t))))}`; +} + +function nativeTitlebarSyncFixture( + electronAlias = "a", + overlayHelperAlias = "b2", +) { + return `installApplicationMenuTitleBarOverlaySync(e,t){if(process.platform!==\`win32\`&&process.platform!==\`linux\`||t!==\`primary\`&&t!==\`quickChat\`)return;let n=()=>{e.isDestroyed()||e.setTitleBarOverlay(${overlayHelperAlias}(this.windowZooms.get(e.id)))};return ${electronAlias}.nativeTheme.on(\`updated\`,n),n(),()=>{${electronAlias}.nativeTheme.off(\`updated\`,n)}}`; +} + +test("uses the frameless native Codex titlebar for primary Linux windows", () => { + const source = [ + "function A2(e){return e===`avatarOverlay`}", + "function I2({platform:e,appearance:t,opaqueWindowsEnabled:n,prefersDarkColors:r}){return n&&!A2(t)&&(e===`darwin`||e===`win32`)?{backgroundColor:r?a2:o2,backgroundMaterial:e===`win32`?`none`:null}:e===`linux`&&!A2(t)?{backgroundColor:r?a2:o2,backgroundMaterial:null}:{backgroundColor:i2,backgroundMaterial:null}}", + "function b2(e=1){return{color:i2,symbolColor:a.nativeTheme.shouldUseDarkColors?v2:_2,height:Math.round(g2*e)}}", + "case`quickChat`:case`primary`:return n===`darwin`?{titleBarStyle:`hiddenInset`,trafficLightPosition:y2(r),...e===`quickChat`?{hasShadow:!0,resizable:!0,transparent:!0}:{},...t?{}:{vibrancy:`menu`}}:n===`win32`||n===`linux`?{titleBarStyle:`hidden`,titleBarOverlay:b2(r),...e===`quickChat`?{resizable:!0}:{}}:{titleBarStyle:`default`,...e===`quickChat`?{resizable:!0}:{}};", + nativeTitlebarZoomFixture(), + nativeTitlebarSyncFixture(), + ].join(""); + const patched = applyPatchTwice(applyLinuxNativeTitlebarPatch, source); + + assert.match( + patched, + /function codexLinuxTitleBarOverlay\(e=1\)\{return\{color:a\.nativeTheme\.shouldUseDarkColors\?`#111111`:o2,symbolColor:a\.nativeTheme\.shouldUseDarkColors\?v2:_2,height:Math\.round\(30\*e\)\}\}/, ); assert.match( patched, - /n\.setTitleBarOverlay\(process\.platform===`linux`\?codexLinuxTitleBarOverlay\(t\):b2\(t\)\)/, + /titleBarOverlay:n===`linux`\?codexLinuxTitleBarOverlay\(r\):b2\(r\),\.\.\.e===`quickChat`/, ); - assert.doesNotMatch(patched, /setTitleBarOverlay\(b2\(/); + assert.match(patched, /\.\.\.t\?\{\}:\{vibrancy:`menu`\}/); + assert.doesNotMatch(patched, /titleBarOverlay:b2\(r\),\.\.\.e===`quickChat`/); +}); + +test("uses a module-scoped Linux native titlebar helper when aliases shadow Electron", () => { + const source = [ + "function A3(e){return e===`avatarOverlay`}", + "function I3({platform:e,appearance:t,opaqueWindowsEnabled:n,prefersDarkColors:r}){return n&&!A3(t)&&(e===`darwin`||e===`win32`)?{backgroundColor:r?L4:K4,backgroundMaterial:e===`win32`?`none`:null}:e===`linux`&&!A3(t)?{backgroundColor:r?L4:K4,backgroundMaterial:null}:{backgroundColor:W4,backgroundMaterial:null}}", + "function o3(e=1){return{color:W4,symbolColor:r.nativeTheme.shouldUseDarkColors?i3:r3,height:Math.round(g3*e)}}", + "function T3({appearance:e,opaqueWindowSurfaceEnabled:t,platform:n,windowZoom:r=1}){switch(e){case`quickChat`:case`primary`:return n===`darwin`?{titleBarStyle:`hiddenInset`,trafficLightPosition:a3(r),...e===`quickChat`?{hasShadow:!0,resizable:!0,transparent:!0}:{},...t?{}:{vibrancy:`menu`}}:n===`win32`||n===`linux`?{titleBarStyle:`hidden`,titleBarOverlay:o3(r),...e===`quickChat`?{resizable:!0}:{}}:{titleBarStyle:`default`,...e===`quickChat`?{resizable:!0}:{}};}}", + nativeTitlebarZoomFixture("r", "o3", "a3"), + nativeTitlebarSyncFixture("r", "o3"), + ].join(""); + const { value, warnings } = captureWarns(() => + applyPatchTwice(applyLinuxNativeTitlebarPatch, source), + ); + + assert.match( + value, + /function codexLinuxTitleBarOverlay\(e=1\)\{return\{color:r\.nativeTheme\.shouldUseDarkColors\?`#111111`:K4,symbolColor:r\.nativeTheme\.shouldUseDarkColors\?i3:r3,height:Math\.round\(30\*e\)\}\}/, + ); + assert.match( + value, + /titleBarOverlay:n===`linux`\?codexLinuxTitleBarOverlay\(r\):o3\(r\),\.\.\.e===`quickChat`/, + ); + assert.doesNotMatch(value, /titleBarOverlay:\{color:r\.nativeTheme\.shouldUseDarkColors/); assert.deepEqual(warnings, []); }); -test("updates every Linux zoom titlebar overlay refresh call site", () => { +test("updates the Linux native titlebar overlay when nativeTheme changes", () => { const source = [ "function A2(e){return e===`avatarOverlay`}", "function I2({platform:e,appearance:t,opaqueWindowsEnabled:n,prefersDarkColors:r}){return n&&!A2(t)&&(e===`darwin`||e===`win32`)?{backgroundColor:r?a2:o2,backgroundMaterial:e===`win32`?`none`:null}:e===`linux`&&!A2(t)?{backgroundColor:r?a2:o2,backgroundMaterial:null}:{backgroundColor:i2,backgroundMaterial:null}}", "function b2(e=1){return{color:i2,symbolColor:a.nativeTheme.shouldUseDarkColors?v2:_2,height:Math.round(g2*e)}}", "case`quickChat`:case`primary`:return n===`darwin`?{titleBarStyle:`hiddenInset`,trafficLightPosition:y2(r),...e===`quickChat`?{hasShadow:!0,resizable:!0,transparent:!0}:{},...t?{}:{vibrancy:`menu`}}:n===`win32`||n===`linux`?{titleBarStyle:`hidden`,titleBarOverlay:b2(r),...e===`quickChat`?{resizable:!0}:{}}:{titleBarStyle:`default`,...e===`quickChat`?{resizable:!0}:{}};", + nativeTitlebarZoomFixture(), "installApplicationMenuTitleBarOverlaySync(e,t){if(process.platform!==`win32`&&process.platform!==`linux`||t!==`primary`&&t!==`quickChat`)return;let n=()=>{e.isDestroyed()||e.setTitleBarOverlay(b2(this.windowZooms.get(e.id)))};return a.nativeTheme.on(`updated`,n),n(),()=>{a.nativeTheme.off(`updated`,n)}}", - "process.platform===`darwin`?n.setWindowButtonPosition(y2(t)):(process.platform===`win32`||process.platform===`linux`)&&(this.windowZooms.set(n.id,t),n.setTitleBarOverlay(b2(t)))", - "process.platform===`darwin`?o.setWindowButtonPosition(y2(i)):(process.platform===`win32`||process.platform===`linux`)&&(this.windowZooms.set(o.id,i),o.setTitleBarOverlay(b2(i)))", ].join(""); const patched = applyPatchTwice(applyLinuxNativeTitlebarPatch, source); - assert.equal( - (patched.match(/setTitleBarOverlay\(process\.platform===`linux`\?codexLinuxTitleBarOverlay/g) ?? []).length, - 3, + assert.match( + patched, + /if\(process\.platform!==`win32`&&process\.platform!==`linux`\|\|t!==`primary`&&t!==`quickChat`\)return/, ); - assert.doesNotMatch( + assert.match( patched, - /\(process\.platform===`win32`\|\|process\.platform===`linux`\)&&\(this\.windowZooms\.set\([^)]+\),[A-Za-z_$][\w$]*\.setTitleBarOverlay\(b2\([^)]+\)\)\)/, + /e\.setTitleBarOverlay\(process\.platform===`linux`\?codexLinuxTitleBarOverlay\(this\.windowZooms\.get\(e\.id\)\):b2\(this\.windowZooms\.get\(e\.id\)\)\)/, ); + assert.doesNotMatch(patched, /webContents\.executeJavaScript\(/); + assert.doesNotMatch(patched, /data-codex-window-type/); +}); + +test("leaves the native titlebar bundle byte-identical when overlay sync drifts", () => { + const source = [ + "function A2(e){return e===`avatarOverlay`}", + "function I2({platform:e,appearance:t,opaqueWindowsEnabled:n,prefersDarkColors:r}){return n&&!A2(t)&&(e===`darwin`||e===`win32`)?{backgroundColor:r?a2:o2,backgroundMaterial:e===`win32`?`none`:null}:e===`linux`&&!A2(t)?{backgroundColor:r?a2:o2,backgroundMaterial:null}:{backgroundColor:i2,backgroundMaterial:null}}", + "function b2(e=1){return{color:i2,symbolColor:a.nativeTheme.shouldUseDarkColors?v2:_2,height:Math.round(g2*e)}}", + "case`quickChat`:case`primary`:return n===`darwin`?{titleBarStyle:`hiddenInset`,trafficLightPosition:y2(r),...e===`quickChat`?{hasShadow:!0,resizable:!0,transparent:!0}:{},...t?{}:{vibrancy:`menu`}}:n===`win32`||n===`linux`?{titleBarStyle:`hidden`,titleBarOverlay:b2(r),...e===`quickChat`?{resizable:!0}:{}}:{titleBarStyle:`default`,...e===`quickChat`?{resizable:!0}:{}};", + nativeTitlebarZoomFixture(), + "installApplicationMenuTitleBarOverlaySync(e,t){if(process.platform!==`win32`&&process.platform!==`linux`||t!==`primary`&&t!==`quickChat`)return;let n=()=>{e.isDestroyed()||e.setTitleBarOverlay(b2(this.windowZooms.get(e.id),unexpected))};return a.nativeTheme.on(`updated`,n),n(),()=>{a.nativeTheme.off(`updated`,n)}}", + ].join(""); + + const { value, warnings } = captureWarns(() => + applyLinuxNativeTitlebarPatch(source), + ); + + assert.equal(value, source); + assert.deepEqual(warnings, [ + "WARN: Could not patch titleBarOverlay nativeTheme sync for Linux", + ]); +}); + +test("redirects the renamed Linux-aware titlebar overlay sync away from the transparent win32 helper", () => { + const source = [ + "function A2(e){return e===`avatarOverlay`}", + "function I2({platform:e,appearance:t,opaqueWindowsEnabled:n,prefersDarkColors:r}){return n&&!A2(t)&&(e===`darwin`||e===`win32`)?{backgroundColor:r?a2:o2,backgroundMaterial:e===`win32`?`none`:null}:e===`linux`&&!A2(t)?{backgroundColor:r?a2:o2,backgroundMaterial:null}:{backgroundColor:i2,backgroundMaterial:null}}", + "function b2(e=1){return{color:i2,symbolColor:a.nativeTheme.shouldUseDarkColors?v2:_2,height:Math.round(g2*e)}}", + "case`quickChat`:case`primary`:return n===`darwin`?{titleBarStyle:`hiddenInset`,trafficLightPosition:y2(r),...e===`quickChat`?{hasShadow:!0,resizable:!0,transparent:!0}:{},...t?{}:{vibrancy:`menu`}}:n===`win32`||n===`linux`?{titleBarStyle:`hidden`,titleBarOverlay:b2(r),...e===`quickChat`?{resizable:!0}:{}}:{titleBarStyle:`default`,...e===`quickChat`?{resizable:!0}:{}};", + "installApplicationMenuTitleBarOverlaySync(e,t){if(process.platform!==`win32`&&process.platform!==`linux`||t!==`primary`&&t!==`quickChat`)return;let n=()=>{e.isDestroyed()||e.setTitleBarOverlay(b2(this.windowZooms.get(e.id)))};return a.nativeTheme.on(`updated`,n),n(),()=>{a.nativeTheme.off(`updated`,n)}}", + nativeTitlebarZoomFixture(), + ].join(""); + const { value: patched, warnings } = captureWarns(() => + applyPatchTwice(applyLinuxNativeTitlebarPatch, source), + ); + + assert.match( + patched, + /titleBarOverlay:n===`linux`\?codexLinuxTitleBarOverlay\(r\):b2\(r\),\.\.\.e===`quickChat`/, + ); + assert.match( + patched, + /installApplicationMenuTitleBarOverlaySync\(e,t\)\{if\(process\.platform!==`win32`&&process\.platform!==`linux`\|\|t!==`primary`&&t!==`quickChat`\)return/, + ); + assert.match( + patched, + /e\.setTitleBarOverlay\(process\.platform===`linux`\?codexLinuxTitleBarOverlay\(this\.windowZooms\.get\(e\.id\)\):b2\(this\.windowZooms\.get\(e\.id\)\)\)/, + ); + assert.match( + patched, + /n\.setTitleBarOverlay\(process\.platform===`linux`\?codexLinuxTitleBarOverlay\(t\):b2\(t\)\)/, + ); + assert.doesNotMatch(patched, /setTitleBarOverlay\(b2\(/); + assert.deepEqual(warnings, []); }); function windowControlsSafeAreaFixture(firstInset = 0, secondInset = 0) { @@ -3049,97 +3570,622 @@ test("patches remaining Linux window controls safe areas when another copy is al test("patches remaining Linux header safe-area padding when the menu inset is already patched", () => { const source = windowControlsSafeAreaFixture(138, 138); - const patched = applyPatchTwice(applyLinuxWindowControlsSafeAreaPatch, source); + const patched = applyPatchTwice(applyLinuxWindowControlsSafeAreaPatch, source); + + assert.match( + patched, + /"pe-2":n===`start`&&i\|\|n===`end`&&!codexLinuxUseWindowControlsSafeArea,"pe-\(--spacing-token-safe-header-right\)":n===`end`&&codexLinuxUseWindowControlsSafeArea/, + ); + assert.doesNotMatch(patched, /"pe-2":n===`start`&&i\|\|n===`end`(?=[,}])/); +}); + +test("rejects a non-numeric application menu inset hidden beside a valid owner", () => { + const patched = applyLinuxWindowControlsSafeAreaPatch( + windowControlsSafeAreaFixture(), + ); + const damaged = patched.replace( + "applicationMenu:Object.freeze({left:0,right:138})", + "applicationMenu:Object.freeze({left:0,right:dynamicInset})", + ); + + const { value, warnings } = captureWarns(() => + applyLinuxWindowControlsSafeAreaPatch(damaged), + ); + + assert.equal(value, damaged); + assert.deepEqual(warnings, [ + "WARN: Found incomplete Linux window-controls safe-area patch marker — skipping", + ]); +}); + +test("warns when the Linux window-controls safe area cannot follow the current header layout", () => { + const source = [ + "var l=Object.freeze({applicationMenu:Object.freeze({left:0,right:0})});", + "function ol({isHeaderEdgeScroll:e,isApplicationMenuBarEnabled:t}){return jsx(`drifted-header`)}", + ].join(""); + + const { value, warnings } = captureWarns(() => + applyLinuxWindowControlsSafeAreaPatch(source), + ); + + assert.equal(value, source); + assert.deepEqual(warnings, [ + "WARN: Could not connect the Linux window controls safe area to the current app header layout", + "WARN: Could not complete Linux window-controls safe-area consumers — skipping", + ]); +}); + +test("keeps tooltips out of the Linux window controls titlebar area", () => { + const middleware = + "middleware:[a({mainAxis:C,crossAxis:t}),c({padding:8}),l({padding:8}),u({padding:8,apply({availableWidth:e,availableHeight:t,elements:n,rects:r}){n.floating.style.setProperty(`--radix-tooltip-trigger-width`,`1px`)}})]"; + const source = `${middleware};${middleware}`; + + const patched = applyPatchTwice(applyLinuxTooltipWindowControlsCollisionPatch, source); + + assert.equal( + (patched.match(/padding:\{top:44,right:8,bottom:8,left:8\}/g) ?? []).length, + 6, + ); + assert.doesNotMatch(patched, /[,(]\{padding:8\}/); +}); + +test("patches remaining tooltip collision middleware when another copy is already patched", () => { + const patchedMiddleware = + "middleware:[a({mainAxis:C,crossAxis:t}),c({padding:{top:44,right:8,bottom:8,left:8}}),l({padding:{top:44,right:8,bottom:8,left:8}}),u({padding:{top:44,right:8,bottom:8,left:8},apply({availableWidth:e,availableHeight:t,elements:n,rects:r}){n.floating.style.setProperty(`--radix-tooltip-trigger-width`,`1px`)}})]"; + const defaultMiddleware = + "middleware:[a({mainAxis:C,crossAxis:t}),c({padding:8}),l({padding:8}),u({padding:8,apply({availableWidth:e,availableHeight:t,elements:n,rects:r}){n.floating.style.setProperty(`--radix-tooltip-trigger-width`,`1px`)}})]"; + const source = `${patchedMiddleware};${defaultMiddleware}`; + + const patched = applyPatchTwice(applyLinuxTooltipWindowControlsCollisionPatch, source); + + assert.equal( + (patched.match(/padding:\{top:44,right:8,bottom:8,left:8\}/g) ?? []).length, + 6, + ); + assert.doesNotMatch(patched, /[,(]\{padding:8\}/); +}); + +test("keeps tooltip collision padding after middleware alias drift", () => { + const source = + "middleware:[o({mainAxis:ne,crossAxis:t}),l({padding:8}),u({padding:8}),d({padding:8,apply({availableWidth:e,availableHeight:t,elements:n,rects:r}){n.floating.style.setProperty(`--radix-tooltip-trigger-width`,`1px`)}})]"; + + const patched = applyPatchTwice(applyLinuxTooltipWindowControlsCollisionPatch, source); + + assert.match(patched, /o\(\{mainAxis:ne,crossAxis:t\}\),l\(\{padding:\{top:44,right:8,bottom:8,left:8\}\}\),u\(\{padding:\{top:44,right:8,bottom:8,left:8\}\}\),d\(\{padding:\{top:44,right:8,bottom:8,left:8\},apply/); + assert.doesNotMatch(patched, /[,(]\{padding:8\}/); +}); + +test("removes native title tooltip from the thread side panel toolbar action", () => { + const toolbar = + "function dt(e){let t=(0,X.c)(11),{children:n,disabled:r,label:i,onClick:a,color:o,pressed:s,shortcut:c}=e,l=r===void 0?!1:r,u=o===`outline`?s?`outlineActive`:`outline`:s?`secondary`:`ghost`,d;t[0]!==n||t[1]!==l||t[2]!==i||t[3]!==a||t[4]!==s||t[5]!==u?(d=(0,q.jsx)(R,{size:`toolbar`,color:u,\"aria-label\":i,\"aria-pressed\":s,disabled:l,title:i,onClick:a,uniform:!0,children:n}),t[0]=n,t[1]=l,t[2]=i,t[3]=a,t[4]=s,t[5]=u,t[6]=d):d=t[6];let f;return t[7]!==i||t[8]!==c||t[9]!==d?(f=(0,q.jsx)(L,{tooltipContent:i,shortcut:c,delayOpen:!0,children:d}),t[7]=i,t[8]=c,t[9]=d,t[10]=f):f=t[10],f}var Rt=j({toggleSidePanel:{id:`thread.sidePanel.toggle`,defaultMessage:`Toggle side panel`,description:`Toggles the thread side panel in a local or new thread`}});"; + const source = `${toolbar}${toolbar}`; + + const patched = applyPatchTwice(applyLinuxThreadSidePanelNativeTooltipPatch, source); + + assert.match(patched, /"aria-label":i/); + assert.match(patched, /tooltipContent:i/); + assert.doesNotMatch(patched, /title:i/); +}); + +function managedWindowMenuFixture( + menuSnippet, + { + appearanceAlias = "o", + className = "WindowManager", + parameterAlias = "e", + popupSnippet = "", + windowAlias = "N", + } = {}, +) { + return [ + `class ${className}{registerWindow(){}async createWindow(${parameterAlias}={}){`, + `let t=process.platform===\`win32\`&&(${parameterAlias}.appearance??\`primary\`)===\`primary\`?electron.screen.getPrimaryDisplay().workArea:null,`, + `{appearance:${appearanceAlias}=\`primary\`}=${parameterAlias},`, + `${windowAlias}=new electron.BrowserWindow({});`, + menuSnippet, + `this.registerWindow(${windowAlias},0,!0,${appearanceAlias},\`register\`);`, + popupSnippet, + `return ${windowAlias}}}`, + ].join(""); +} + +function windowsAndLinuxMenuSnippet(windowAlias) { + return ( + `(process.platform===\`win32\`||process.platform===\`linux\`)&&` + + `${windowAlias}.removeMenu(),` + ); +} + +function gnomeX11SystemContextMenuListenerSnippet( + windowAlias, + eventAlias = "e", +) { + return ( + "process.platform===`linux`&&" + + "(process.env.XDG_SESSION_TYPE??``).trim().toLowerCase()===`x11`&&" + + "/(^|:)gnome(:|$)/i.test((process.env.XDG_CURRENT_DESKTOP??``).trim())&&" + + `${windowAlias}.on(\`system-context-menu\`,${eventAlias}=>` + + `${eventAlias}.preventDefault()),` + ); +} + +function canonicalLinuxMenuSnippet(windowAlias, eventAlias = "e") { + return ( + gnomeX11SystemContextMenuListenerSnippet(windowAlias, eventAlias) + + `process.platform===\`linux\`&&${windowAlias}.removeMenu(),` + + `process.platform===\`win32\`&&${windowAlias}.removeMenu(),` + ); +} + +function scopedManagedLinuxMenuSnippet(windowAlias, eventAlias = "e") { + return ( + gnomeX11SystemContextMenuListenerSnippet(windowAlias, eventAlias) + + `(process.platform===\`win32\`||process.platform===\`linux\`)&&` + + `${windowAlias}.removeMenu(),` + ); +} + +function browserCommentPopupMenuSnippet(menuSnippet) { + return ( + "host.on(`did-create-window`,()=>{let e=new electron.BrowserWindow({});" + + `${menuSnippet}e.show()});` + ); +} + +test("patches the managed WindowManager window before the browser-comment popup", () => { + const popupStartMarker = "host.on(`did-create-window`"; + const source = managedWindowMenuFixture( + windowsAndLinuxMenuSnippet("N"), + { + popupSnippet: browserCommentPopupMenuSnippet( + "process.platform===`win32`&&e.removeMenu(),", + ), + }, + ); + + const managedPatched = applyLinuxManagedWindowSystemContextMenuPatch(source); + const popupStart = managedPatched.indexOf(popupStartMarker); + assert.notEqual(popupStart, -1); + assert.equal( + (managedPatched.slice(0, popupStart).match(/system-context-menu/g) ?? []).length, + 1, + ); + assert.equal( + (managedPatched.slice(popupStart).match(/system-context-menu/g) ?? []).length, + 0, + "the managed-window patch must not claim success by patching only the popup", + ); + assert.match( + managedPatched, + new RegExp(escapeRegExp(scopedManagedLinuxMenuSnippet("N"))), + ); + + const fullyPatched = applyLinuxMenuPatch(managedPatched); + assert.equal((fullyPatched.match(/system-context-menu/g) ?? []).length, 2); + assert.match( + fullyPatched, + new RegExp(escapeRegExp(scopedManagedLinuxMenuSnippet("N"))), + ); + assert.match( + fullyPatched, + new RegExp(escapeRegExp(canonicalLinuxMenuSnippet("e"))), + ); + assert.equal( + applyLinuxMenuPatch( + applyLinuxManagedWindowSystemContextMenuPatch(fullyPatched), + ), + fullyPatched, + ); + assert.doesNotThrow(() => new Function(fullyPatched)); +}); + +test("patches the current WindowManager contract across minified aliases", () => { + const source = managedWindowMenuFixture( + windowsAndLinuxMenuSnippet("M"), + { windowAlias: "M" }, + ); + const patched = applyPatchTwice( + applyLinuxManagedWindowSystemContextMenuPatch, + source, + ); + + assert.match( + patched, + new RegExp(escapeRegExp(scopedManagedLinuxMenuSnippet("M"))), + ); +}); + +test("recognizes an equivalent managed-window preventDefault listener", () => { + const source = managedWindowMenuFixture( + scopedManagedLinuxMenuSnippet("N", "event"), + ); + + assert.equal( + applyLinuxManagedWindowSystemContextMenuPatch(source), + source, + ); +}); + +test("rejects malformed or duplicate managed-window system context menu listeners", () => { + const malformed = managedWindowMenuFixture( + "process.platform===`linux`&&(N.on(`system-context-menu`,event=>handle(event)),N.removeMenu())," + + "process.platform===`win32`&&N.removeMenu(),", + ); + assert.throws( + () => applyLinuxManagedWindowSystemContextMenuPatch(malformed), + /non-canonical or duplicate system-context-menu listener/, + ); + + const duplicate = managedWindowMenuFixture( + scopedManagedLinuxMenuSnippet("N") + + "N.on(`system-context-menu`,event=>event.preventDefault()),", + ); + assert.throws( + () => applyLinuxManagedWindowSystemContextMenuPatch(duplicate), + /non-canonical or duplicate system-context-menu listener/, + ); + + const duplicateMenuTarget = managedWindowMenuFixture( + scopedManagedLinuxMenuSnippet("N") + + "process.platform===`win32`&&N.removeMenu(),", + ); + assert.throws( + () => + applyLinuxManagedWindowSystemContextMenuPatch( + duplicateMenuTarget, + ), + /multiple menu targets/, + ); + + const mixedUnpatchedTargets = managedWindowMenuFixture( + windowsAndLinuxMenuSnippet("N") + + "process.platform===`win32`&&N.removeMenu(),", + ); + assert.throws( + () => + applyLinuxManagedWindowSystemContextMenuPatch( + mixedUnpatchedTargets, + ), + /Found 2 removeMenu calls/, + ); + + for (const existingListener of [ + 'N.on("system-context-menu",event=>event.preventDefault()),', + "N.addListener('system-context-menu',event=>event.preventDefault()),", + ]) { + const alternateApiOrQuote = managedWindowMenuFixture( + existingListener + windowsAndLinuxMenuSnippet("N"), + ); + assert.throws( + () => + applyLinuxManagedWindowSystemContextMenuPatch( + alternateApiOrQuote, + ), + /non-canonical or duplicate system-context-menu listener/, + ); + } +}); + +test("fails loudly when the managed window is missing or ambiguous", () => { + const alreadyPatchedPopup = browserCommentPopupMenuSnippet( + canonicalLinuxMenuSnippet("e"), + ); + assert.throws( + () => applyLinuxManagedWindowSystemContextMenuPatch(alreadyPatchedPopup), + /Could not identify the managed BrowserWindow/, + "a patched popup must not hide a missing primary WindowManager target", + ); + + const missingMenuTarget = managedWindowMenuFixture("N.show(),"); + assert.throws( + () => applyLinuxManagedWindowSystemContextMenuPatch(missingMenuTarget), + /Could not find the menu-removal target/, + ); - assert.match( - patched, - /"pe-2":n===`start`&&i\|\|n===`end`&&!codexLinuxUseWindowControlsSafeArea,"pe-\(--spacing-token-safe-header-right\)":n===`end`&&codexLinuxUseWindowControlsSafeArea/, + const ambiguous = + managedWindowMenuFixture(windowsAndLinuxMenuSnippet("N"), { + className: "FirstWindowManager", + }) + + managedWindowMenuFixture(windowsAndLinuxMenuSnippet("M"), { + className: "SecondWindowManager", + windowAlias: "M", + }); + assert.throws( + () => applyLinuxManagedWindowSystemContextMenuPatch(ambiguous), + /Found 2 managed BrowserWindow candidates/, ); - assert.doesNotMatch(patched, /"pe-2":n===`start`&&i\|\|n===`end`(?=[,}])/); }); -test("warns when the Linux window-controls safe area cannot follow the current header layout", () => { - const source = [ - "var l=Object.freeze({applicationMenu:Object.freeze({left:0,right:0})});", - "function ol({isHeaderEdgeScroll:e,isApplicationMenuBarEnabled:t}){return jsx(`drifted-header`)}", - ].join(""); +test("records managed-window menu drift as optional without blocking candidates", () => { + const descriptor = corePatchDescriptors().find( + (candidate) => + candidate.id === "linux-managed-window-system-context-menu", + ); + assert.ok(descriptor); + const source = browserCommentPopupMenuSnippet( + canonicalLinuxMenuSnippet("e"), + ); + const report = createPatchReport(); - const { value, warnings } = captureWarns(() => - applyLinuxWindowControlsSafeAreaPatch(source), + const result = applyMainBundlePatchDescriptors( + source, + [descriptor], + {}, + report, ); - assert.match(value, /applicationMenu:Object\.freeze\(\{left:0,right:138\}\)/); - assert.deepEqual(warnings, [ - "WARN: Could not connect the Linux window controls safe area to the current app header layout", + assert.equal(result.patchedSource, source); + assert.deepEqual(result.requiredCoreWarnings, []); + const entry = report.patches.find( + (patch) => patch.name === descriptor.id, + ); + assert.equal(entry?.ciPolicy, "optional"); + assert.equal(entry?.status, "skipped-optional"); + assert.match(entry?.reason ?? "", /Could not identify the managed BrowserWindow/); + assert.deepEqual(entry?.strategies, [ + { group: "linux-managed-window-menu", strategy: "none" }, ]); + assert.deepEqual(criticalFailuresFromReport(report), []); + assert.deepEqual( + optionalDriftFromReport(report).map(({ name, status }) => ({ + name, + status, + })), + [ + { + name: "linux-managed-window-system-context-menu", + status: "skipped-optional", + }, + ], + ); }); -test("keeps tooltips out of the Linux window controls titlebar area", () => { - const middleware = - "middleware:[a({mainAxis:C,crossAxis:t}),c({padding:8}),l({padding:8}),u({padding:8,apply({availableWidth:e,availableHeight:t,elements:n,rects:r}){n.floating.style.setProperty(`--radix-tooltip-trigger-width`,`1px`)}})]"; - const source = `${middleware};${middleware}`; +test("does not let the generic popup patch mask managed-window drift", () => { + const descriptors = corePatchDescriptors().filter( + (candidate) => + candidate.id === "linux-managed-window-system-context-menu" || + candidate.id === "linux-menu", + ); + const source = [ + "class DriftedWindowManager{async createWindowDrift(e={}){", + "let{appearance:o=`primary`}=e,N=new electron.BrowserWindow({});", + windowsAndLinuxMenuSnippet("N"), + browserCommentPopupMenuSnippet( + "process.platform===`win32`&&e.removeMenu(),", + ), + "return N}}", + ].join(""); + const report = createPatchReport(); - const patched = applyPatchTwice(applyLinuxTooltipWindowControlsCollisionPatch, source); + const result = applyMainBundlePatchDescriptors( + source, + descriptors, + {}, + report, + ); + assert.deepEqual(result.requiredCoreWarnings, []); + assert.match( + result.patchedSource, + new RegExp(escapeRegExp(windowsAndLinuxMenuSnippet("N"))), + ); + assert.match( + result.patchedSource, + new RegExp(escapeRegExp(canonicalLinuxMenuSnippet("e"))), + ); assert.equal( - (patched.match(/padding:\{top:44,right:8,bottom:8,left:8\}/g) ?? []).length, - 6, + report.patches.find( + (patch) => + patch.name === "linux-managed-window-system-context-menu", + )?.status, + "skipped-optional", ); - assert.doesNotMatch(patched, /[,(]\{padding:8\}/); + assert.equal( + report.patches.find((patch) => patch.name === "linux-menu")?.status, + "applied", + ); + assert.deepEqual(criticalFailuresFromReport(report), []); }); -test("patches remaining tooltip collision middleware when another copy is already patched", () => { - const patchedMiddleware = - "middleware:[a({mainAxis:C,crossAxis:t}),c({padding:{top:44,right:8,bottom:8,left:8}}),l({padding:{top:44,right:8,bottom:8,left:8}}),u({padding:{top:44,right:8,bottom:8,left:8},apply({availableWidth:e,availableHeight:t,elements:n,rects:r}){n.floating.style.setProperty(`--radix-tooltip-trigger-width`,`1px`)}})]"; - const defaultMiddleware = - "middleware:[a({mainAxis:C,crossAxis:t}),c({padding:8}),l({padding:8}),u({padding:8,apply({availableWidth:e,availableHeight:t,elements:n,rects:r}){n.floating.style.setProperty(`--radix-tooltip-trigger-width`,`1px`)}})]"; - const source = `${patchedMiddleware};${defaultMiddleware}`; - - const patched = applyPatchTwice(applyLinuxTooltipWindowControlsCollisionPatch, source); +test("reports managed-window patch strategy and idempotence", () => { + const descriptor = corePatchDescriptors().find( + (candidate) => + candidate.id === "linux-managed-window-system-context-menu", + ); + assert.ok(descriptor); + const source = managedWindowMenuFixture( + windowsAndLinuxMenuSnippet("N"), + ); + const firstReport = createPatchReport(); + const first = applyMainBundlePatchDescriptors( + source, + [descriptor], + {}, + firstReport, + ); + const firstEntry = firstReport.patches.find( + (patch) => patch.name === descriptor.id, + ); + assert.equal(firstEntry?.status, "applied"); + assert.deepEqual(firstEntry?.strategies, [ + { + group: "linux-managed-window-menu", + strategy: "upstream-combined", + }, + ]); - assert.equal( - (patched.match(/padding:\{top:44,right:8,bottom:8,left:8\}/g) ?? []).length, - 6, + const secondReport = createPatchReport(); + const second = applyMainBundlePatchDescriptors( + first.patchedSource, + [descriptor], + {}, + secondReport, ); - assert.doesNotMatch(patched, /[,(]\{padding:8\}/); + assert.equal(second.patchedSource, first.patchedSource); + const secondEntry = secondReport.patches.find( + (patch) => patch.name === descriptor.id, + ); + assert.equal(secondEntry?.status, "already-applied"); + assert.deepEqual(secondEntry?.strategies, [ + { + group: "linux-managed-window-menu", + strategy: "already-applied", + }, + ]); }); -test("keeps tooltip collision padding after middleware alias drift", () => { - const source = - "middleware:[o({mainAxis:ne,crossAxis:t}),l({padding:8}),u({padding:8}),d({padding:8,apply({availableWidth:e,availableHeight:t,elements:n,rects:r}){n.floating.style.setProperty(`--radix-tooltip-trigger-width`,`1px`)}})]"; +test("suppresses the managed-window system menu only on GNOME/X11", async () => { + const source = applyLinuxManagedWindowSystemContextMenuPatch( + managedWindowMenuFixture(windowsAndLinuxMenuSnippet("N")), + ); - const patched = applyPatchTwice(applyLinuxTooltipWindowControlsCollisionPatch, source); + for (const expected of [ + { + name: "GNOME X11", + platform: "linux", + env: { + XDG_CURRENT_DESKTOP: "GNOME", + XDG_SESSION_TYPE: "x11", + }, + listenerCount: 1, + removeMenuCalls: 1, + preventDefaultCalls: 1, + }, + { + name: "Ubuntu GNOME X11 with normalized session casing", + platform: "linux", + env: { + XDG_CURRENT_DESKTOP: "ubuntu:GNOME", + XDG_SESSION_TYPE: " X11 ", + }, + listenerCount: 1, + removeMenuCalls: 1, + preventDefaultCalls: 1, + }, + { + name: "GNOME Wayland", + platform: "linux", + env: { + XDG_CURRENT_DESKTOP: "GNOME", + XDG_SESSION_TYPE: "wayland", + }, + listenerCount: 0, + removeMenuCalls: 1, + preventDefaultCalls: 0, + }, + { + name: "KDE X11", + platform: "linux", + env: { + XDG_CURRENT_DESKTOP: "KDE", + XDG_SESSION_TYPE: "x11", + }, + listenerCount: 0, + removeMenuCalls: 1, + preventDefaultCalls: 0, + }, + { + name: "unknown Linux session", + platform: "linux", + env: {}, + listenerCount: 0, + removeMenuCalls: 1, + preventDefaultCalls: 0, + }, + { + name: "Windows with copied Linux environment", + platform: "win32", + env: { + XDG_CURRENT_DESKTOP: "GNOME", + XDG_SESSION_TYPE: "x11", + }, + listenerCount: 0, + removeMenuCalls: 1, + preventDefaultCalls: 0, + }, + { + name: "macOS", + platform: "darwin", + env: {}, + listenerCount: 0, + removeMenuCalls: 0, + preventDefaultCalls: 0, + }, + ]) { + class BrowserWindow extends EventEmitter { + constructor() { + super(); + this.removeMenuCalls = 0; + } - assert.match(patched, /o\(\{mainAxis:ne,crossAxis:t\}\),l\(\{padding:\{top:44,right:8,bottom:8,left:8\}\}\),u\(\{padding:\{top:44,right:8,bottom:8,left:8\}\}\),d\(\{padding:\{top:44,right:8,bottom:8,left:8\},apply/); - assert.doesNotMatch(patched, /[,(]\{padding:8\}/); -}); + removeMenu() { + this.removeMenuCalls += 1; + } + } -test("removes native title tooltip from the thread side panel toolbar action", () => { - const toolbar = - "function dt(e){let t=(0,X.c)(11),{children:n,disabled:r,label:i,onClick:a,color:o,pressed:s,shortcut:c}=e,l=r===void 0?!1:r,u=o===`outline`?s?`outlineActive`:`outline`:s?`secondary`:`ghost`,d;t[0]!==n||t[1]!==l||t[2]!==i||t[3]!==a||t[4]!==s||t[5]!==u?(d=(0,q.jsx)(R,{size:`toolbar`,color:u,\"aria-label\":i,\"aria-pressed\":s,disabled:l,title:i,onClick:a,uniform:!0,children:n}),t[0]=n,t[1]=l,t[2]=i,t[3]=a,t[4]=s,t[5]=u,t[6]=d):d=t[6];let f;return t[7]!==i||t[8]!==c||t[9]!==d?(f=(0,q.jsx)(L,{tooltipContent:i,shortcut:c,delayOpen:!0,children:d}),t[7]=i,t[8]=c,t[9]=d,t[10]=f):f=t[10],f}var Rt=j({toggleSidePanel:{id:`thread.sidePanel.toggle`,defaultMessage:`Toggle side panel`,description:`Toggles the thread side panel in a local or new thread`}});"; - const source = `${toolbar}${toolbar}`; + const context = vm.createContext({ + electron: { + BrowserWindow, + screen: { + getPrimaryDisplay() { + return { workArea: {} }; + }, + }, + }, + process: { + env: expected.env, + platform: expected.platform, + }, + }); + vm.runInContext( + `${source};globalThis.ManagedWindowManager=WindowManager;`, + context, + ); + const window = await new context.ManagedWindowManager().createWindow(); + assert.equal( + window.listenerCount("system-context-menu"), + expected.listenerCount, + expected.name, + ); + let preventDefaultCalls = 0; + window.emit("system-context-menu", { + preventDefault() { + preventDefaultCalls += 1; + }, + }); + assert.equal( + window.removeMenuCalls, + expected.removeMenuCalls, + expected.name, + ); + assert.equal( + preventDefaultCalls, + expected.preventDefaultCalls, + expected.name, + ); + } +}); - const patched = applyPatchTwice(applyLinuxThreadSidePanelNativeTooltipPatch, source); +test("leaves the managed combined removeMenu shape to its semantic descriptor", () => { + const source = + "(process.platform===`win32`||process.platform===`linux`)&&k.removeMenu(),"; + const patched = applyPatchTwice(applyLinuxMenuPatch, source); - assert.match(patched, /"aria-label":i/); - assert.match(patched, /tooltipContent:i/); - assert.doesNotMatch(patched, /title:i/); + assert.equal(patched, source); }); test("removes the Linux menu next to Windows removeMenu calls", () => { const source = "process.platform===`win32`&&k.removeMenu(),"; const patched = applyPatchTwice(applyLinuxMenuPatch, source); - assert.equal( - patched, - "process.platform===`linux`&&(k.on(`system-context-menu`,e=>e.preventDefault()),k.removeMenu()),process.platform===`win32`&&k.removeMenu(),", - ); + assert.equal(patched, canonicalLinuxMenuSnippet("k")); }); test("patches remaining Windows menu snippets when another copy is already Linux-patched", () => { const windowsMenuSnippet = "process.platform===`win32`&&k.removeMenu(),"; const linuxMenuPatch = - "process.platform===`linux`&&(k.on(`system-context-menu`,e=>e.preventDefault()),k.removeMenu()),"; + gnomeX11SystemContextMenuListenerSnippet("k") + + "process.platform===`linux`&&k.removeMenu(),"; const source = `${linuxMenuPatch}${windowsMenuSnippet}function createSecondWindow(){${windowsMenuSnippet}}`; const patched = applyPatchTwice(applyLinuxMenuPatch, source); @@ -3148,44 +4194,35 @@ test("patches remaining Windows menu snippets when another copy is already Linux assert.equal((patched.match(/system-context-menu/g) ?? []).length, 2); assert.match( patched, - /function createSecondWindow\(\)\{process\.platform===`linux`&&\(k\.on\(`system-context-menu`,e=>e\.preventDefault\(\)\),k\.removeMenu\(\)\),process\.platform===`win32`&&k\.removeMenu\(\),\}/, - ); -}); - -test("upgrades legacy Linux menu snippets to remove the menu", () => { - const source = - "process.platform===`linux`&&(k.setMenuBarVisibility(!1),k.removeMenu?.()),process.platform===`win32`&&k.removeMenu(),"; - - const patched = applyPatchTwice(applyLinuxMenuPatch, source); - - assert.equal( - patched, - "process.platform===`linux`&&(k.on(`system-context-menu`,e=>e.preventDefault()),k.removeMenu()),process.platform===`win32`&&k.removeMenu(),", + new RegExp( + escapeRegExp( + `function createSecondWindow(){${canonicalLinuxMenuSnippet("k")}}`, + ), + ), ); - assert.doesNotMatch(patched, /setMenuBarVisibility/); }); -test("upgrades old Linux removeMenu snippets to suppress system context menus", () => { - const source = - "process.platform===`linux`&&k.removeMenu(),process.platform===`win32`&&k.removeMenu(),"; +test("recognizes the Linux system context menu suppression snippet as already applied", () => { + const source = canonicalLinuxMenuSnippet("k"); const patched = applyPatchTwice(applyLinuxMenuPatch, source); - assert.equal( - patched, - "process.platform===`linux`&&(k.on(`system-context-menu`,e=>e.preventDefault()),k.removeMenu()),process.platform===`win32`&&k.removeMenu(),", - ); + assert.equal(patched, source); assert.equal((patched.match(/system-context-menu/g) ?? []).length, 1); }); -test("recognizes the Linux system context menu suppression snippet as already applied", () => { +test("leaves unrelated non-Darwin modal menu removal untouched", () => { + const unrelated = + "process.platform!==`darwin`&&S.removeMenu(),S.show(),"; const source = - "process.platform===`linux`&&(k.on(`system-context-menu`,e=>e.preventDefault()),k.removeMenu()),process.platform===`win32`&&k.removeMenu(),"; + managedWindowMenuFixture(windowsAndLinuxMenuSnippet("N")) + + unrelated; - const patched = applyPatchTwice(applyLinuxMenuPatch, source); - - assert.equal(patched, source); + const patched = applyLinuxMenuPatch( + applyLinuxManagedWindowSystemContextMenuPatch(source), + ); assert.equal((patched.match(/system-context-menu/g) ?? []).length, 1); + assert.equal((patched.match(new RegExp(escapeRegExp(unrelated), "g")) ?? []).length, 1); }); test("preserves the global application menu on Linux for accelerators", () => { @@ -3514,32 +4551,31 @@ test("patches current webview opaque window default bundle shapes", () => { ); }); -test("patches the current comment preload screenshot anchor shape", () => { +test("patches the current browser page preload screenshot anchor shape", () => { const source = [ - "let mt=Te;M?.kind===`comment`?mt=pt?[M.annotation]:Te:pt||P?mt=[]:ft!=null&&(mt=Te.filter(e=>e.id!==ft.id));", - "let ht=mt.flatMap(e=>[e]),kt=null,At=`hover-box`,jt,Mt=0,I=[];", - "if(P&&M?.annotation.anchor.kind===`element`){Mt=xt[0]??0;let e=bt==null?null:hs(bt),t=e?.rect??Ss(M.annotation.anchor);jt=e?.borderRadius,At=Vs(M.annotation.anchor,t,C.width,C.height),kt=Is(M.annotation.anchor,t,bt),I=bc(F,C,{clipToVisibleArea:!0})}", + "let Ft=Nt==null?[]:Pl(Nt),It=Pt==null?Ft:[],Lt=null,Rt=`hover-box`,zt,Bt=[];", + "if(ht&&j?.annotation.anchor.kind===`element`){let e=kt==null?null:ns(kt),t=e?.rect??ls(j.annotation.anchor);zt=e?.borderRadius,Rt=Os(j.annotation.anchor,t,w.width,w.height),Lt=Cs(j.annotation.anchor,t,kt),Bt=sc(N,w,{clipToVisibleArea:!0,selectionIndexOffset:1,viewportSize:j.annotation.viewportSize})}", ].join(""); const patched = applyPatchTwice(applyBrowserAnnotationScreenshotPatch, source); assert.match( patched, - /if\(P&&M\?\.annotation\.anchor\.kind===`element`\)\{Mt=xt\[0\]\?\?0;let t=Ss\(M\.annotation\.anchor\);jt=void 0,At=Vs/, + /if\(ht&&j\?\.annotation\.anchor\.kind===`element`\)\{let t=ls\(j\.annotation\.anchor\);zt=void 0,Rt=Os/, ); - assert.match(patched, /M\?\.kind===`comment`\?mt=pt\?\[M\.annotation\]:Te/); - assert.doesNotMatch(patched, /e\?\.rect\?\?Ss/); + assert.match(patched, /selectionIndexOffset:1/); + assert.doesNotMatch(patched, /e\?\.rect\?\?ls/); }); test("keeps the current stored annotation anchor shape unchanged", () => { const source = - "if(P&&M?.annotation.anchor.kind===`element`){Mt=xt[0]??0;let t=Ss(M.annotation.anchor);jt=void 0,At=Vs(M.annotation.anchor,t,C.width,C.height)}"; + "if(pt&&N?.annotation.anchor.kind===`element`){let t=fs(N.annotation.anchor);Lt=void 0,It=js(N.annotation.anchor,t,w.width,w.height)}"; assert.equal(applyPatchTwice(applyBrowserAnnotationScreenshotPatch, source), source); }); -test("reports current comment preload screenshot anchor drift", () => { - const source = "if(P&&M?.annotation.anchor.kind===`element`){renderDriftedAnchor()}"; +test("reports current browser page preload screenshot anchor drift", () => { + const source = "if(pt&&N?.annotation.anchor.kind===`element`){renderDriftedAnchor()}"; const { value, warnings } = captureWarns(() => applyBrowserAnnotationScreenshotPatch(source), ); @@ -3651,8 +4687,7 @@ test("adds Linux avatar overlay mouse passthrough recovery", () => { assert.match(patched, /codexLinuxStartAvatarPassthroughRecovery\(\)/); assert.match(patched, /codexLinuxStopAvatarPassthroughRecovery\(\)/); assert.match(patched, /codexLinuxSyncAvatarPointerInteractivity\(e\)/); - assert.match(patched, /codexLinuxBuildAvatarInputShape\(e\)/); - assert.match(patched, /codexLinuxApplyAvatarInputShape\(e\)/); + assert.match(patched, /codexLinuxInputShape\(e\)/); assert.match(patched, /codexLinuxShouldUseWholeWindowInput\(\)\{return this\.codexLinuxWholeWindowInput===!0\}/); assert.match(patched, /codexLinuxIsI3Session\(\)/); assert.match(patched, /process\.env\.I3SOCK/); @@ -3669,23 +4704,15 @@ test("adds Linux avatar overlay mouse passthrough recovery", () => { assert.match(patched, /Number\(__codexAvatarWidth\)!==t\.width/); assert.match(patched, /Number\(__codexAvatarHeight\)!==t\.height/); assert.doesNotMatch(patched, /let\[,l,h,d,f\]=c/); - assert.doesNotMatch(patched, /this\.codexLinuxIsI3Session\(\)\)\{this\.codexLinuxStopAvatarPassthroughRecovery\(\),this\.codexLinuxAvatarInputShapeKey=null,this\.pointerInteractive=!0,this\.mousePassthroughEnabled&&\(this\.mousePassthroughEnabled=!1\),e\.setIgnoreMouseEvents\(!1\);return\}/); - assert.match(patched, /if\(this\.codexLinuxIsAvatarShapeBackend\(\)&&typeof e\.setShape==`function`\)\{/); - assert.match(patched, /if\(this\.codexLinuxIsAvatarShapeBackend\(\)&&typeof e\.setShape==`function`\)\{this\.codexLinuxStartAvatarPassthroughRecovery\(\),/); - assert.match(patched, /codexLinuxIsAvatarShapeBackend\(\)\{/); - assert.match(patched, /getSwitchValue\(`ozone-platform`\)/); - assert.match(patched, /return e===`x11`\|\|e===``&&!process\.env\.WAYLAND_DISPLAY/); - assert.doesNotMatch(patched, /XDG_SESSION_TYPE/); - assert.doesNotMatch(patched, /if\(process\.platform===`linux`&&typeof e\.setShape==`function`\)\{this\.codexLinuxStopAvatarPassthroughRecovery\(\),/); - assert.doesNotMatch(patched, /typeof e\.setShape==`function`&&!this\.codexLinuxIsI3Session\(\)/); - assert.match(patched, /if\(t==null\)return null/); - assert.match(patched, /try\{let t=this\.codexLinuxBuildAvatarInputShape\(e\);if\(t==null\)return!1;let n=JSON\.stringify\(t\)/); - assert.match(patched, /e\.setShape\(t\),this\.codexLinuxAvatarInputShapeKey=n;return!0/); - assert.match(patched, /return\[i\(t\.mascot\),i\(t\.tray\)\]\.filter\(Boolean\)/); + assert.match(patched, /if\(this\.applyInputShape\(e\)\)\{this\.codexLinuxStopAvatarPassthroughRecovery\(\);return\}/); + assert.match(patched, /Bs\(e,this\.codexLinuxInputShape\(e\)\.map\(/); + assert.match(patched, /return n==null\|\|!Number\.isFinite\(n\.width\)\|\|!Number\.isFinite\(n\.height\)\?t:\[\{height:n\.height,left:0,top:0,width:n\.width\}\]/); + assert.doesNotMatch(patched, /codexLinuxIsAvatarShapeBackend/); + assert.doesNotMatch(patched, /codexLinuxApplyAvatarInputShape/); + assert.doesNotMatch(patched, /\.setShape\(/); assert.match(patched, /process\.platform!==`linux`/); assert.match(patched, /setInterval\(\(\)=>\{let e=this\.window/); assert.match(patched, /\},32\)/); - assert.doesNotMatch(patched, /typeof e\.setShape==`function`\)return;this\.codexLinuxAvatarPassthroughRecoveryTimer=setInterval/); assert.match(patched, /this\.dragState!=null/); assert.match(patched, /this\.codexLinuxIsCursorInAvatarInteractiveRegion\(e\)/); assert.match(patched, /__codexWindowHit=__codexX>=0&&__codexY>=0&&__codexX<=__codexBounds\.width&&__codexY<=__codexBounds\.height/); @@ -3702,7 +4729,28 @@ test("adds Linux avatar overlay mouse passthrough recovery", () => { assert.match(patched, /this\.sendComputerUseCursorLocationToRenderer\(e\),process\.platform===`linux`&&this\.applyPointerInteractivityPolicy\(\)\}showWindow/); assert.match(patched, /e\.moveTop\(\),e\.showInactive\(\),process\.platform===`linux`&&this\.codexLinuxApplyAvatarCompositorHints\(e\),process\.platform===`linux`&&this\.applyPointerInteractivityPolicy\(\)/); assert.doesNotMatch(patched, /codexLinuxRecoverAvatarPointerInteractivity/); - assert.match(patched, /if\(this\.window!==e\)return;let t=this\.presentationVisibility!=null;this\.codexLinuxStopAvatarPassthroughRecovery\(\),this\.codexLinuxAvatarInputShapeKey=null,this\.codexLinuxAvatarCompositorHintsApplied=!1,this\.codexLinuxAvatarCompositorHintsApplying=!1,this\.cancelMomentum\(\)/); + assert.match(patched, /if\(this\.window!==e\)return;let t=this\.presentationVisibility!=null\|\|this\.startupPresentationVisibility!=null;this\.codexLinuxStopAvatarPassthroughRecovery\(\),this\.codexLinuxAvatarCompositorHintsApplied=!1,this\.codexLinuxAvatarCompositorHintsApplying=!1,this\.cancelMomentum\(\)/); +}); + +test("obsolete avatar overlay interactivity policy fails the required patch", () => { + const source = latestAvatarOverlayBundleFixture().replace( + "if(this.applyInputShape(e))return;", + "", + ); + const descriptor = corePatchDescriptors().find( + (candidate) => candidate.id === "linux-avatar-overlay-mouse-passthrough", + ); + const report = createPatchReport(); + const { value: result, warnings } = captureWarns(() => + applyMainBundlePatchDescriptors(source, [descriptor], {}, report), + ); + + assert.equal(result.patchedSource, source); + assert.deepEqual(warnings, [ + "WARN: Could not find avatar overlay mouse passthrough policy — skipping Linux avatar overlay passthrough recovery patch", + ]); + assert.equal(report.patches[0]?.status, "failed-required"); + assert.equal(report.patches[0]?.reason, warnings[0]); }); test("keeps the avatar overlay core patch idempotent after pet overlay composition", () => { @@ -3729,7 +4777,6 @@ test("pet overlay opts into full-window input on X11 and Wayland", () => { ), ); const cursor = { x: 100, y: 100 }; - let ozonePlatform = "x11"; const context = { globalThis: {}, clearInterval() {}, @@ -3738,7 +4785,7 @@ test("pet overlay opts into full-window input on X11 and Wayland", () => { if (moduleName === "node:child_process") return { execFile() {} }; assert.equal(moduleName, "electron"); return { - app: { commandLine: { getSwitchValue: () => ozonePlatform }, getName: () => "Codex" }, + app: { getName: () => "Codex" }, screen: { getCursorScreenPoint: () => cursor }, }; }, @@ -3755,26 +4802,28 @@ test("pet overlay opts into full-window input on X11 and Wayland", () => { mascot: { left: 220, top: 190, width: 113, height: 122 }, tray: { left: 57, top: 55, width: 276, height: 131 }, }; + controller.inputShape = [ + { left: 220, top: 190, width: 113, height: 122 }, + { left: 57, top: 55, width: 276, height: 131 }, + ]; const window = { getContentBounds: () => ({ x: 0, y: 0, width: 356, height: 320 }), isDestroyed: () => false, isVisible: () => true, setIgnoreMouseEvents() {}, - setShape() {}, + setInputShape: () => true, }; controller.codexPetOverlaySyncWindow(window); assert.equal(controller.codexLinuxWholeWindowInput, true); - assert.deepEqual(JSON.parse(JSON.stringify(controller.codexLinuxBuildAvatarInputShape(window))), [ - { x: 0, y: 0, width: 356, height: 320 }, + assert.deepEqual(JSON.parse(JSON.stringify(controller.codexLinuxInputShape(window))), [ + { left: 0, top: 0, width: 356, height: 320 }, ]); cursor.x = 10; cursor.y = 300; assert.equal(controller.codexLinuxIsCursorInAvatarInteractiveRegion(window), true); - ozonePlatform = "wayland"; controller.pointerInteractive = false; - assert.equal(controller.codexLinuxIsAvatarShapeBackend(), false); assert.equal(controller.codexLinuxSyncAvatarPointerInteractivity(window), true); assert.equal(controller.pointerInteractive, true); }); @@ -3789,7 +4838,6 @@ test("locked pet overlay keeps only mascot and tray interactive on X11 and Wayla { feature: { manifest: { petOverlay: { lockPosition: true } }, settings: {} } }, ); const cursor = { x: 10, y: 300 }; - let ozonePlatform = "x11"; const context = { globalThis: {}, clearInterval() {}, @@ -3798,7 +4846,7 @@ test("locked pet overlay keeps only mascot and tray interactive on X11 and Wayla if (moduleName === "node:child_process") return { execFile() {} }; assert.equal(moduleName, "electron"); return { - app: { commandLine: { getSwitchValue: () => ozonePlatform }, getName: () => "Codex" }, + app: { getName: () => "Codex" }, screen: { getCursorScreenPoint: () => cursor }, }; }, @@ -3815,29 +4863,31 @@ test("locked pet overlay keeps only mascot and tray interactive on X11 and Wayla mascot: { left: 220, top: 190, width: 113, height: 122 }, tray: { left: 57, top: 55, width: 276, height: 131 }, }; + controller.inputShape = [ + { left: 220, top: 190, width: 113, height: 122 }, + { left: 57, top: 55, width: 276, height: 131 }, + ]; const ignored = []; const window = { getContentBounds: () => ({ x: 0, y: 0, width: 356, height: 320 }), isDestroyed: () => false, isVisible: () => true, setIgnoreMouseEvents: (...args) => ignored.push(args), - setShape() {}, + setInputShape: () => true, }; controller.codexPetOverlaySyncWindow(window); assert.equal(controller.codexLinuxWholeWindowInput, false); - assert.deepEqual(JSON.parse(JSON.stringify(controller.codexLinuxBuildAvatarInputShape(window))), [ - { x: 220, y: 190, width: 113, height: 122 }, - { x: 57, y: 55, width: 276, height: 131 }, + assert.deepEqual(JSON.parse(JSON.stringify(controller.codexLinuxInputShape(window))), [ + { left: 220, top: 190, width: 113, height: 122 }, + { left: 57, top: 55, width: 276, height: 131 }, ]); - ozonePlatform = "wayland"; controller.window = window; controller.pointerInteractive = true; - assert.equal(controller.codexLinuxIsAvatarShapeBackend(), false); assert.equal(controller.codexLinuxIsCursorInAvatarInteractiveRegion(window), false); controller.applyPointerInteractivityPolicy(); - assert.deepEqual(JSON.parse(JSON.stringify(ignored)), [[true, { forward: true }]]); + assert.deepEqual(JSON.parse(JSON.stringify(ignored)), []); }); test("keeps Linux avatar overlay above the app while reply inputs are focusable", () => { @@ -3848,9 +4898,9 @@ test("keeps Linux avatar overlay above the app while reply inputs are focusable" assert.match( patched, - /appearance:`avatarOverlay`,alwaysOnTop:process\.platform===`linux`,skipTaskbar:process\.platform===`linux`,focusable:process\.platform===`linux`\?!0:!1,show:!1/, + /appearance:`avatarOverlay`,supportsWindowTiling:!1,alwaysOnTop:process\.platform===`linux`,skipTaskbar:process\.platform===`linux`,focusable:process\.platform===`linux`\?!0:!1,show:!1/, ); - assert.doesNotMatch(patched, /appearance:`avatarOverlay`,focusable:!1,show:!1/); + assert.doesNotMatch(patched, /appearance:`avatarOverlay`,supportsWindowTiling:!1,focusable:!1,show:!1/); const nonAvatarSource = "async createWindow(){return this.windowManager.createWindow({appearance:`main`,focusable:!1,show:!1})}"; assert.equal( @@ -3865,7 +4915,6 @@ test("Linux avatar overlay interactivity is bounded to avatar regions", () => { latestAvatarOverlayBundleFixture(), ); const cursor = { x: 5843, y: 1036 }; - let ozonePlatform = ""; const context = { globalThis: {}, process: { @@ -3881,7 +4930,6 @@ test("Linux avatar overlay interactivity is bounded to avatar regions", () => { return { app: { getName: () => "Codex", - commandLine: { getSwitchValue: () => ozonePlatform }, }, screen: { getCursorScreenPoint: () => cursor, @@ -3922,30 +4970,38 @@ test("Linux avatar overlay interactivity is bounded to avatar regions", () => { false, ); + const appliedShapes = []; const overlayWindow = { isDestroyed: () => false, getContentBounds: () => ({ x: 5743, y: 936, width: 356, height: 320 }), - setShape() {}, + setInputShape(shape) { + appliedShapes.push(shape); + return true; + }, }; const serializeShape = (shape) => JSON.parse(JSON.stringify(shape)); - assert.deepEqual(serializeShape(controller.codexLinuxBuildAvatarInputShape(overlayWindow)), [ - { x: 220, y: 190, width: 113, height: 122 }, - { x: 57, y: 55, width: 276, height: 131 }, + controller.inputShape = [ + { left: 220, top: 190, width: 113, height: 122 }, + { left: 57, top: 55, width: 276, height: 131 }, + ]; + assert.deepEqual(serializeShape(controller.codexLinuxInputShape(overlayWindow)), [ + { left: 220, top: 190, width: 113, height: 122 }, + { left: 57, top: 55, width: 276, height: 131 }, ]); controller.pointerInteractive = true; - assert.deepEqual(serializeShape(controller.codexLinuxBuildAvatarInputShape(overlayWindow)), [ - { x: 220, y: 190, width: 113, height: 122 }, - { x: 57, y: 55, width: 276, height: 131 }, + assert.deepEqual(serializeShape(controller.codexLinuxInputShape(overlayWindow)), [ + { left: 220, top: 190, width: 113, height: 122 }, + { left: 57, top: 55, width: 276, height: 131 }, ]); controller.dragState = {}; - assert.deepEqual(serializeShape(controller.codexLinuxBuildAvatarInputShape(overlayWindow)), [ - { x: 0, y: 0, width: 356, height: 320 }, + assert.deepEqual(serializeShape(controller.codexLinuxInputShape(overlayWindow)), [ + { left: 0, top: 0, width: 356, height: 320 }, ]); controller.dragState = null; assert.equal(controller.codexLinuxShouldUseWholeWindowInput(), false); controller.codexLinuxWholeWindowInput = true; - assert.deepEqual(serializeShape(controller.codexLinuxBuildAvatarInputShape(overlayWindow)), [ - { x: 0, y: 0, width: 356, height: 320 }, + assert.deepEqual(serializeShape(controller.codexLinuxInputShape(overlayWindow)), [ + { left: 0, top: 0, width: 356, height: 320 }, ]); assert.equal( controller.codexLinuxIsCursorInAvatarInteractiveRegion({ @@ -3954,52 +5010,25 @@ test("Linux avatar overlay interactivity is bounded to avatar regions", () => { true, ); controller.codexLinuxWholeWindowInput = false; - context.process.env.WAYLAND_DISPLAY = "wayland-0"; - assert.equal(controller.codexLinuxIsAvatarShapeBackend(), false); - assert.equal(controller.codexLinuxApplyAvatarInputShape(overlayWindow), false); - let setShapeCalls = 0; - ozonePlatform = "x11"; - assert.equal(controller.codexLinuxIsAvatarShapeBackend(), true); - assert.equal( - controller.codexLinuxApplyAvatarInputShape({ - ...overlayWindow, - setShape() { - setShapeCalls += 1; - }, - }), - true, - ); - assert.equal(setShapeCalls, 1); - ozonePlatform = "wayland"; - assert.equal(controller.codexLinuxIsAvatarShapeBackend(), false); - ozonePlatform = "x11"; + assert.equal(controller.applyInputShape(overlayWindow), true); + assert.deepEqual(serializeShape(appliedShapes), [[ + { x: 220, y: 190, width: 113, height: 122 }, + { x: 57, y: 55, width: 276, height: 131 }, + ]]); + controller.codexLinuxWholeWindowInput = true; let failingBoundsCalls = 0; - assert.equal( - controller.codexLinuxApplyAvatarInputShape({ - isDestroyed: () => false, - getContentBounds: () => { - failingBoundsCalls += 1; - throw new Error("drift"); - }, - setShape() {}, - }), - false, - ); + assert.deepEqual(serializeShape(controller.codexLinuxInputShape({ + getContentBounds: () => { + failingBoundsCalls += 1; + throw new Error("drift"); + }, + })), [ + { left: 220, top: 190, width: 113, height: 122 }, + { left: 57, top: 55, width: 276, height: 131 }, + ]); assert.equal(failingBoundsCalls, 1); - controller.codexLinuxAvatarInputShapeKey = null; - let failingSetShapeCalls = 0; - assert.equal( - controller.codexLinuxApplyAvatarInputShape({ - isDestroyed: () => false, - getContentBounds: () => ({ x: 5743, y: 936, width: 356, height: 320 }), - setShape() { - failingSetShapeCalls += 1; - throw new Error("unsupported"); - }, - }), - false, - ); - assert.equal(failingSetShapeCalls, 1); + controller.supportsInputShape = false; + assert.equal(controller.applyInputShape(overlayWindow), false); }); test("patches the latest avatar overlay class without depending on adjacent methods", () => { @@ -4017,7 +5046,7 @@ test("patches the latest avatar overlay class without depending on adjacent meth assert.match(patched, /this\.windowServerDragActive=!1[\s\S]*?process\.platform===`linux`&&this\.applyPointerInteractivityPolicy\(\)\}setElementSize/); assert.match(patched, /this\.applyLatestElementSizes\(o\),process\.platform===`linux`&&this\.applyPointerInteractivityPolicy\(\)/); assert.match(patched, /this\.compositionHost\.updateMascotRect\(a\.mascot\)[\s\S]*?process\.platform===`linux`&&this\.applyPointerInteractivityPolicy\(\)\}showWindow/); - assert.match(patched, /if\(this\.window!==e\)return;let t=this\.presentationVisibility!=null;this\.codexLinuxStopAvatarPassthroughRecovery\(\)/); + assert.match(patched, /if\(this\.window!==e\)return;let t=this\.presentationVisibility!=null\|\|this\.startupPresentationVisibility!=null;this\.codexLinuxStopAvatarPassthroughRecovery\(\)/); assert.match(patched, /traySize:process\.platform===`linux`&&typeof this\.codexLinuxIsI3Session==`function`/); }); @@ -4535,7 +5564,7 @@ test("adds Linux build information to the tray menu", () => { test("adds Linux build information request handlers for renderer settings", () => { const source = - "let n=require(`electron`),o=require(`node:fs`),i=require(`node:path`),e={bn:{help:`help`}};const h={\"get-global-state\":async({key:a})=>({value:this.globalState.get(a)}),\"set-global-state\":async({key:a,value:b,origin:c})=>(this.setGlobalStateValue(a,b,c),{success:!0})};let $e=[{role:`help`,id:e.bn.help,submenu:[{label:`Codex Documentation`,click:()=>{n.shell.openExternal(`https://developers.openai.com/codex/app`)}}]}],et=n.Menu.buildFromTemplate($e);n.Menu.setApplicationMenu(et);"; + "let n=require(`electron`),o=require(`node:fs`),i=require(`node:path`),e={help:`help`};const h={\"get-global-state\":async({key:a})=>({value:this.getGlobalStateValue(a)}),\"set-global-state\":async({key:a,value:b})=>(this.setGlobalStateValue(a,b),{success:!0})};let $e=[{label:y.formatMessage({messageId:`windowsMenuBar.help`,defaultMessage:`Help`}),role:`help`,id:e.help,submenu:[{label:y.formatMessage({messageId:`loadingPage.documentationLink`,defaultMessage:`Documentation`}),click:()=>{n.shell.openExternal(`https://developers.openai.com/codex/app`)}}]}],et=n.Menu.buildFromTemplate($e);n.Menu.setApplicationMenu(et);"; const patched = applyPatchTwice(applyLinuxBuildInfoTrayPatch, source); assert.match(patched, /function codexLinuxGetBuildInfo\(\)/); @@ -4552,7 +5581,7 @@ test("adds Linux build information request handlers for renderer settings", () = test("Linux build information helper locals do not shadow minified module bindings", () => { const source = - "let a=require(`electron`),l=require(`node:fs`),s=require(`node:path`),e={bn:{help:`help`}};const h={\"get-global-state\":async({key:a})=>({value:this.globalState.get(a)}),\"set-global-state\":async({key:a,value:b,origin:c})=>(this.setGlobalStateValue(a,b,c),{success:!0})};let $e=[{role:`help`,id:e.bn.help,submenu:[{label:`Codex Documentation`,click:()=>{a.shell.openExternal(`https://developers.openai.com/codex/app`)}}]}],et=a.Menu.buildFromTemplate($e);a.Menu.setApplicationMenu(et);"; + "let a=require(`electron`),l=require(`node:fs`),s=require(`node:path`),e={help:`help`};const h={\"get-global-state\":async({key:a})=>({value:this.getGlobalStateValue(a)}),\"set-global-state\":async({key:a,value:b})=>(this.setGlobalStateValue(a,b),{success:!0})};let $e=[{label:y.formatMessage({messageId:`windowsMenuBar.help`,defaultMessage:`Help`}),role:`help`,id:e.help,submenu:[{label:y.formatMessage({messageId:`loadingPage.documentationLink`,defaultMessage:`Documentation`}),click:()=>{a.shell.openExternal(`https://developers.openai.com/codex/app`)}}]}],et=a.Menu.buildFromTemplate($e);a.Menu.setApplicationMenu(et);"; const patched = applyPatchTwice(applyLinuxBuildInfoTrayPatch, source); assert.match(patched, /await a\.dialog\?\.showMessageBox/); @@ -4564,7 +5593,7 @@ test("Linux build information helper locals do not shadow minified module bindin test("Linux build information request handlers are inserted into the handler table", () => { const source = - "let a=require(`electron`),l=require(`node:fs`),s=require(`node:path`),e={bn:{help:`help`}};const h={\"is-copilot-api-available\":async()=>({available:!1}),\"get-global-state\":async({key:e})=>({value:this.globalState.get(e)}),\"set-global-state\":async({key:e,value:t,origin:n})=>(this.setGlobalStateValue(e,t,n),{success:!0})};let $e=[{role:`help`,id:e.bn.help,submenu:[{label:`Codex Documentation`,click:()=>{a.shell.openExternal(`https://developers.openai.com/codex/app`)}}]}],et=a.Menu.buildFromTemplate($e);a.Menu.setApplicationMenu(et);"; + "let a=require(`electron`),l=require(`node:fs`),s=require(`node:path`),e={help:`help`};const h={\"is-copilot-api-available\":async()=>({available:!1}),\"get-global-state\":async({key:e})=>({value:this.getGlobalStateValue(e)}),\"set-global-state\":async({key:e,value:t})=>(this.setGlobalStateValue(e,t),{success:!0})};let $e=[{label:y.formatMessage({messageId:`windowsMenuBar.help`,defaultMessage:`Help`}),role:`help`,id:e.help,submenu:[{label:y.formatMessage({messageId:`loadingPage.documentationLink`,defaultMessage:`Documentation`}),click:()=>{a.shell.openExternal(`https://developers.openai.com/codex/app`)}}]}],et=a.Menu.buildFromTemplate($e);a.Menu.setApplicationMenu(et);"; const patched = applyPatchTwice(applyLinuxBuildInfoTrayPatch, source); assert.match( @@ -4586,14 +5615,14 @@ test("adds Linux build information to current tray menu shape", () => { test("adds Linux build information to the app Help menu", () => { const source = - "let n=require(`electron`),o=require(`node:fs`),i=require(`node:path`),e={bn:{help:`help`}};let $e=[{role:`help`,id:e.bn.help,submenu:[{label:`Codex Documentation`,click:()=>{n.shell.openExternal(`https://developers.openai.com/codex/app`)}}]}],et=n.Menu.buildFromTemplate($e);n.Menu.setApplicationMenu(et);"; + "let n=require(`electron`),o=require(`node:fs`),i=require(`node:path`),e={help:`help`};let $e=[{label:y.formatMessage({messageId:`windowsMenuBar.help`,defaultMessage:`Help`}),role:`help`,id:e.help,submenu:[{label:y.formatMessage({messageId:`loadingPage.documentationLink`,defaultMessage:`Documentation`}),click:()=>{n.shell.openExternal(`https://developers.openai.com/codex/app`)}}]}],et=n.Menu.buildFromTemplate($e);n.Menu.setApplicationMenu(et);"; const patched = applyPatchTwice(applyLinuxBuildInfoTrayPatch, source); assert.match(patched, /function codexLinuxShowBuildInfo\(\)/); assert.doesNotThrow(() => new Function(patched)); assert.match( patched, - /\{role:`help`,id:e\.bn\.help,submenu:\[\.\.\.process\.platform===`linux`\?\[\{label:`Build Information`,click:\(\)=>\{codexLinuxShowBuildInfo\(\)\}\},\{type:`separator`\}\]:\[\],\{label:`Codex Documentation`/, + /role:`help`,id:e\.help,submenu:\[\.\.\.process\.platform===`linux`\?\[\{label:`Build Information`,click:\(\)=>\{codexLinuxShowBuildInfo\(\)\}\},\{type:`separator`\}\]:\[\],\{label:y\.formatMessage/, ); }); @@ -4959,9 +5988,8 @@ test("persists Linux settings with current setGlobalStateValue handler shape", ( const settingsFile = path.join(tempRoot, "config", "codex-desktop", "settings.json"); const patched = applyPatchTwice(applyLinuxSettingsPersistencePatch, currentSettingsPersistenceBundleFixture()); - assert.match(patched, /var s=`\.codex-global-state\.json`;function codexLinuxSettingsAppId/); - assert.match(patched, /var c=`config\.toml`/); - assert.match(patched, /this\.setGlobalStateValue\(a,b,c\),codexLinuxPersistSettingsState\(a,b\)/); + assert.match(patched, /^function codexLinuxSettingsAppId/); + assert.match(patched, /this\.setGlobalStateValue\(a,b\),codexLinuxPersistSettingsState\(a,b\)/); runSettingsPersistence( patched, { @@ -4989,43 +6017,32 @@ test("persists Linux settings with current setGlobalStateValue handler shape", ( } }); -test("migrates already-patched Linux settings persistence away from codex-desktop", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-settings-migrate-")); +test("persists Linux settings with exact current DMG module alias ordering across idempotent patch passes", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-settings-exact-dmg-")); try { - const xdgConfig = path.join(tempRoot, "xdg-config"); - const patched = applyPatchTwice(applyLinuxSettingsPersistencePatch, legacySettingsPersistenceBundleFixture()); - - assert.match(patched, /process\.env\.CODEX_LINUX_SETTINGS_FILE/); - assert.doesNotMatch(patched, /join\(e,`codex-desktop`,`settings\.json`\)/); - runSettingsPersistence( - patched, - { - CODEX_LINUX_APP_ID: "codex-cua-lab", - XDG_CONFIG_HOME: xdgConfig, - }, - "codex-linux-prompt-window-enabled", - false, - ); + const settingsFile = path.join(tempRoot, "config", "codex-desktop", "settings.json"); + const source = exactDmgSettingsPersistenceBundleFixture(); + const patchedOnce = applyLinuxSettingsPersistencePatch(source); + const patchedTwice = applyLinuxSettingsPersistencePatch(patchedOnce); - assert.equal( - JSON.parse(fs.readFileSync(path.join(xdgConfig, "codex-cua-lab", "settings.json"), "utf8"))["codex-linux-prompt-window-enabled"], - false, - ); + assert.equal(patchedTwice, patchedOnce); runSettingsPersistence( - patched, - { - CODEX_LINUX_APP_ID: "codex-cua-lab", - XDG_CONFIG_HOME: xdgConfig, - }, - "codex-linux-read-aloud-enabled", - true, + patchedTwice, + { CODEX_LINUX_SETTINGS_FILE: settingsFile }, + "codex-linux-system-tray-enabled", + false, ); - - assert.equal( - JSON.parse(fs.readFileSync(path.join(xdgConfig, "codex-cua-lab", "settings.json"), "utf8"))["codex-linux-read-aloud-enabled"], + runSettingsPersistence( + patchedTwice, + { CODEX_LINUX_SETTINGS_FILE: settingsFile }, + "codex-linux-warm-start-enabled", true, ); - assert.equal(fs.existsSync(path.join(xdgConfig, "codex-desktop", "settings.json")), false); + + assert.deepEqual(JSON.parse(fs.readFileSync(settingsFile, "utf8")), { + "codex-linux-system-tray-enabled": false, + "codex-linux-warm-start-enabled": true, + }); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); } @@ -5038,10 +6055,9 @@ test("adds Linux settings persistence after current global-state handler drift", ); assert.match(patched, /function codexLinuxSettingsAppId\(\)/); - assert.match(patched, /var c=`config\.toml`;/); assert.match( patched, - /"set-global-state":async\(\{key:a,value:b,origin:c\}\)=>\(this\.setGlobalStateValue\(a,b,c\),codexLinuxPersistSettingsState\(a,b\),\{success:!0\}\)/, + /"set-global-state":async\(\{key:a,value:b\}\)=>\(this\.setGlobalStateValue\(a,b\),codexLinuxPersistSettingsState\(a,b\),\{success:!0\}\)/, ); }); @@ -5049,7 +6065,7 @@ test("adds Linux settings persistence when upstream removed the state-file marke const source = [ "\"use strict\";", "let i=require(`node:path`),o=require(`node:fs`);", - "const h={\"set-global-state\":async({key:a,value:b,origin:c})=>(this.setGlobalStateValue(a,b,c),{success:!0})};", + "const h={\"set-global-state\":async({key:a,value:b})=>(this.setGlobalStateValue(a,b),{success:!0})};", ].join(""); const patched = applyPatchTwice(applyLinuxSettingsPersistencePatch, source); @@ -5057,7 +6073,7 @@ test("adds Linux settings persistence when upstream removed the state-file marke assert.match(patched, /^"use strict";function codexLinuxSettingsAppId\(\)/); assert.match( patched, - /"set-global-state":async\(\{key:a,value:b,origin:c\}\)=>\(this\.setGlobalStateValue\(a,b,c\),codexLinuxPersistSettingsState\(a,b\),\{success:!0\}\)/, + /"set-global-state":async\(\{key:a,value:b\}\)=>\(this\.setGlobalStateValue\(a,b\),codexLinuxPersistSettingsState\(a,b\),\{success:!0\}\)/, ); }); @@ -6170,6 +7186,14 @@ test("adds Linux desktop settings in the current monolithic app bundle", () => { routeChunkSource, /"linux-desktop":Ya\(async\(\)=>\(await Pr\(async\(\)=>\{let\{LinuxDesktopSettings:e\}=await import\(`\.\/linux-desktop-settings-linux\.js\?v=[a-f0-9]{12}`\);return\{LinuxDesktopSettings:e\}\},\[\],import\.meta\.url\)\)\.LinuxDesktopSettings\),"general-settings":/, ); + const settingsPageSource = fs.readFileSync( + path.join(assetsDir, "settings-page-A.js"), + "utf8", + ); + assert.match( + settingsPageSource, + /slugs:\[`general-settings`,`linux-desktop`,`import`,`profile`,`keyboard-shortcuts`\]/, + ); const secondResult = patchKeybindsSettingsAssets(extractedDir); assert.equal(secondResult.matched, true); @@ -6263,6 +7287,62 @@ test("adds Linux desktop section to current native Keyboard Shortcuts sections b assert.match(patched, /r=\[\{slug:`general-settings`\},\{slug:`linux-desktop`\},\{slug:`profile`\}/); }); +test("adds Linux desktop to the current personal settings navigation group", () => { + const source = [ + "var nn=`general-settings.import.profile.appearance.keyboard-shortcuts`.split(`.`),", + "rn=[{key:`personal`,heading:d({id:`settings.nav.heading.personal`,defaultMessage:`Personal`,description:`Heading for personal settings in the settings navigation`}),", + "slugs:[`general-settings`,`import`,`profile`,`appearance`,`keyboard-shortcuts`]}];", + ].join(""); + + const patched = applyPatchTwice(applyLinuxDesktopSettingsNavigationGroupPatch, source); + + assert.match( + patched, + /slugs:\[`general-settings`,`linux-desktop`,`import`,`profile`,`appearance`,`keyboard-shortcuts`\]/, + ); +}); + +test("rejects ambiguous current personal settings navigation groups", () => { + const group = + "{key:`personal`,heading:d({id:`settings.nav.heading.personal`,defaultMessage:`Personal`,description:`Heading for personal settings in the settings navigation`}),slugs:[`general-settings`,`appearance`]}"; + + assert.throws( + () => applyLinuxDesktopSettingsNavigationGroupPatch(`[${group},${group}]`), + /expected exactly one current personal settings navigation group \(found 2, 0 already patched\)/, + ); +}); + +test("skips Linux desktop settings when the current navigation group asset drifts", () => { + const { extractedDir, assetsDir } = createSplitRouteNativeKeyboardShortcutsSettingsFixture(); + try { + const settingsPagePath = path.join(assetsDir, "settings-page-A.js"); + fs.writeFileSync( + settingsPagePath, + fs.readFileSync(settingsPagePath, "utf8").replace( + "id:`settings.nav.heading.personal`", + "id:`settings.nav.heading.primary`", + ), + "utf8", + ); + + const { value: result, warnings } = captureWarns(() => patchKeybindsSettingsAssets(extractedDir)); + + assert.equal(result.matched, false); + assert.equal(result.changed, 0); + assert.match(result.reason, /exactly one current settings navigation group asset \(found 0\)/); + assert.ok(warnings.some((warning) => warning.includes(result.reason))); + assert.equal(fs.existsSync(path.join(assetsDir, linuxDesktopSettingsAsset)), false); + + const report = createPatchReport(); + captureWarns(() => patchExtractedApp(extractedDir, { report })); + const reportEntry = report.patches.find((patch) => patch.name === "keybinds-settings"); + assert.equal(reportEntry.status, "skipped-optional"); + assert.match(reportEntry.reason, /exactly one current settings navigation group asset \(found 0\)/); + } finally { + fs.rmSync(extractedDir, { recursive: true, force: true }); + } +}); + test("skips Linux desktop settings when the current visibility asset drifts", () => { const { extractedDir, assetsDir } = createSplitRouteNativeKeyboardShortcutsSettingsFixture(); try { @@ -6523,7 +7603,7 @@ test("recognizes current settings language row i18n gate as already patched", () test("keeps automation_update eager in dynamic tools built during thread start", () => { const source = - "async function pUt(){return[{type:`namespace`,name:cX,description:`Tools provided by the Codex app.`,tools:[...h?[_ee()]:[],...[],...i?.open_in_codex===!0?[TBt]:[],...h&&d?[SBt]:[],lu,...h&&y?[Ra]:[],...[],...g?AHt({availableHandoffHosts:e,availableModels:b,crossHostHandoffEnabled:n,forkThreadEnabled:!0}):[],...h&&_?[PBt,FBt]:[],...m===`conversational_onboarding`?[yoe]:[],...v&&m!==`conversational_onboarding`?[...vee,bu]:[]].map(e=>({type:`function`,...e,..._Ut.has(e.name)?{}:{deferLoading:!0}}))}]}async sendRequest(e,t,n){if(e===`config/read`)return this.sendConfigReadRequest(t,n);let{request:r,promise:i}=this.createRequest(e,t,n);return i}"; + "var PZn=`automation_update`,LZn={name:PZn},RZn={name:PZn};function nZn(){return zZn?LZn:RZn}async function Rtl(){let x=!0,A=[nZn(),gmn].map(e=>({type:`function`,...e,...x&&!Htl.has(e.name)?{deferLoading:!0}:{}}));return x?[{type:`namespace`,name:R2,description:`Tools provided by the Codex app.`,tools:A}]:A}"; const patched = applyPatchTwice(applyAutomationUpdateEagerToolPatch, source); @@ -6855,10 +7935,17 @@ test("adds Linux package updater to current bootstrap updater wiring", () => { assert.match(patched, /s=codexLinuxPackageUpdateBridge\.manager/); assert.match(patched, /te=codexLinuxPackageUpdateBridge\.quitForUpdate/); assert.match(patched, /async function codexLinuxProbeUpdateManager\(\)/); + assert.match(patched, /require\(`node:child_process`\)\.execFile\(codexLinuxUpdateManagerPath\(\)/); + assert.match(patched, /require\(`node:fs`\)\.existsSync\(e\)/); + assert.match(patched, /require\(`node:path`\)\.join/); + assert.doesNotMatch(patched, /__codexChild\.execFile\(codexLinuxUpdateManagerPath\(\)/); assert.match(patched, /codexLinuxRunUpdateManager\(\[`--help`\]\)/); assert.match(patched, /async function codexLinuxRefreshUpdateState\(\)\{return codexLinuxReadUpdateState\(\)\}/); + assert.match(patched, /codexLinuxUpdateLifecycleState\(r,e\)/); + assert.match(patched, /e===`update_detected`&&t\?\.deferred_build===!0/); assert.match(patched, /codexLinuxProbeUpdateManager\(\)\.then\(\(\)=>\{s=!0,i\(\),a\(\);return!0\}\)/); - assert.match(patched, /manager:\{setAutomaticBackgroundDownloadsEnabled:\(\)=>\{\}/); + assert.match(patched, /manager:\{latchInAppUpdatesEnabledForLaunch:async\(\)=>\{\}/); + assert.match(patched, /setAutomaticBackgroundDownloadsEnabled:\(\)=>\{\}/); assert.match(patched, /getIsUpdateReady:\(\)=>s&&t/); assert.match(patched, /checkForUpdates:async\(\)=>\{if\(!await c\)return;n=`checking`/); assert.match(patched, /installUpdatesIfAvailable:async\(\)=>\{if\(!await c\)\{a\(\);return\}i\(\);if\(!t\)\{a\(\);return\}/); @@ -6867,15 +7954,233 @@ test("adds Linux package updater to current bootstrap updater wiring", () => { assert.doesNotMatch(patched, /codexLinuxRunUpdateManager\(\[`status`,`--json`\]\)/); }); +test("does not reuse function-scoped module bindings in the Linux updater bridge", () => { + const source = + "function helper(){let __codexChild=require(`node:child_process`)," + + "__codexFs=require(`node:fs`),__codexPath=require(`node:path`);" + + "return[__codexChild,__codexFs,__codexPath]}" + + currentBootstrapUpdaterBundleFixture(); + + const patched = applyLinuxAppUpdaterBridgePatch(source); + + assert.match(patched, /require\(`node:child_process`\)\.execFile\(codexLinuxUpdateManagerPath\(\)/); + assert.match(patched, /require\(`node:fs`\)\.existsSync\(e\)/); + assert.match(patched, /require\(`node:path`\)\.join/); + assert.doesNotMatch(patched, /__codexChild\.execFile\(codexLinuxUpdateManagerPath\(\)/); + assert.doesNotMatch(patched, /__codexFs\.existsSync\(e\)/); + assert.doesNotMatch(patched, /__codexPath\.join/); +}); + +test("implements the current Sparkle AppView, menu, and RPC contract on Linux", () => { + const patched = applyLinuxAppUpdaterBridgePatch(currentBootstrapUpdaterBundleFixture()); + + assert.match(patched, /getDownloadProgressPercent:\(\)=>null/); + assert.match(patched, /getDownloadedUpdateAppBrand:\(\)=>null/); + assert.match(patched, /getInstallProgressPercent:\(\)=>r/); + assert.match(patched, /getIsUpdateReady:\(\)=>s&&t/); + assert.match(patched, /getUpdateLifecycleState:\(\)=>s\?n:`idle`/); + assert.match(patched, /getRelaunchNotice:\(\)=>null/); + assert.match(patched, /hasUpdater:\(\)=>s/); + assert.match( + patched, + /getUnavailableReason:\(\)=>s\?null:`Linux package update manager unavailable`/, + ); + assert.match(patched, /setSparkleQueryParams:\(\)=>\{\}/); + assert.match(patched, /latchInAppUpdatesEnabledForLaunch:async\(\)=>\{\}/); +}); + +test("keeps every exact-DMG Sparkle manager caller callable on the Linux replacement", async () => { + const patched = applyLinuxAppUpdaterBridgePatch(currentBootstrapUpdaterBundleFixture()); + const bridgeEnd = patched.indexOf(";var g6="); + assert.notEqual(bridgeEnd, -1); + + const externallyCalledMethods = [ + "checkForUpdates", + "getDownloadProgressPercent", + "getDownloadedUpdateAppBrand", + "getInstallProgressPercent", + "getIsUpdateReady", + "getRelaunchNotice", + "getUnavailableReason", + "getUpdateLifecycleState", + "hasUpdater", + "installUpdatesIfAvailable", + "latchInAppUpdatesEnabledForLaunch", + "setAutomaticBackgroundDownloadsEnabled", + "setSparkleQueryParams", + ]; + const calls = []; + const context = { + process: { env: {} }, + require(moduleName) { + if (moduleName === "electron") { + return {}; + } + if (moduleName === "node:path") { + return path; + } + if (moduleName === "node:fs") { + return { existsSync: () => false }; + } + if (moduleName === "node:child_process") { + return { + execFile(command, args, _options, callback) { + calls.push([command, ...args]); + callback(null, "", ""); + }, + }; + } + throw new Error(`Unexpected module request: ${moduleName}`); + }, + setTimeout, + }; + vm.runInNewContext( + `${patched.slice(0, bridgeEnd)};globalThis.createManager=codexLinuxCreatePackageUpdateManager`, + context, + ); + const manager = context.createManager({ send() {} }).manager; + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual( + Object.keys(manager).filter((methodName) => externallyCalledMethods.includes(methodName)).sort(), + externallyCalledMethods, + ); + for (const methodName of externallyCalledMethods) { + assert.equal(typeof manager[methodName], "function", methodName); + } + + const startupHandlers = { + latchInAppUpdatesEnabledForLaunch: (enabled) => { + manager.latchInAppUpdatesEnabledForLaunch(enabled); + }, + setAutomaticBackgroundDownloadsEnabled: (enabled) => { + manager.setAutomaticBackgroundDownloadsEnabled(enabled); + }, + }; + const invokeExactSparkleGatesCaller = (message) => { + if (message.type === "electron-sparkle-gates-changed") { + startupHandlers.latchInAppUpdatesEnabledForLaunch(!message.disableInAppUpdates); + startupHandlers.setAutomaticBackgroundDownloadsEnabled(!message.disableSparkleAutodownload); + } + }; + assert.doesNotThrow(() => invokeExactSparkleGatesCaller({ + type: "electron-sparkle-gates-changed", + disableInAppUpdates: false, + disableSparkleAutodownload: true, + })); + + manager.setSparkleQueryParams({ channel: "stable" }); + assert.equal(manager.getDownloadProgressPercent(), null); + assert.equal(manager.getDownloadedUpdateAppBrand(), null); + assert.equal(manager.getInstallProgressPercent(), null); + assert.equal(manager.getIsUpdateReady(), false); + assert.equal(manager.getRelaunchNotice(), null); + assert.equal(manager.getUnavailableReason(), null); + assert.equal(manager.getUpdateLifecycleState(), "idle"); + assert.equal(manager.hasUpdater(), true); + await manager.latchInAppUpdatesEnabledForLaunch(true); + await manager.checkForUpdates(); + await manager.installUpdatesIfAvailable(); + assert.deepEqual(calls, [ + ["codex-update-manager", "--help"], + ["codex-update-manager", "check-now"], + ]); +}); + +test("keeps the current Sparkle menu contract callable across Linux updater probe outcomes", async () => { + const patched = applyLinuxAppUpdaterBridgePatch(currentBootstrapUpdaterBundleFixture()); + const bridgeEnd = patched.indexOf(";var g6="); + assert.notEqual(bridgeEnd, -1); + + const requiredMenuMethods = [ + ...new Set( + [...currentSparkleUpdateMenuContractFixture().matchAll(/u\.([A-Za-z_$][\w$]*)\(/g)] + .map((match) => match[1]), + ), + ]; + assert.deepEqual(requiredMenuMethods, [ + "hasUpdater", + "getUnavailableReason", + "checkForUpdates", + ]); + + const createManager = (probeError = null) => { + const calls = []; + const context = { + process: { env: {} }, + require(moduleName) { + if (moduleName === "electron") { + return {}; + } + if (moduleName === "node:path") { + return path; + } + if (moduleName === "node:fs") { + return { existsSync: () => false }; + } + if (moduleName === "node:child_process") { + return { + execFile(command, args, _options, callback) { + calls.push([command, ...args]); + if (args[0] === "--help" && probeError != null) { + callback(probeError, "", "probe failed"); + return; + } + callback(null, "", ""); + }, + }; + } + throw new Error(`Unexpected module request: ${moduleName}`); + }, + setTimeout, + }; + vm.runInNewContext( + `${patched.slice(0, bridgeEnd)};globalThis.createManager=codexLinuxCreatePackageUpdateManager`, + context, + ); + return { calls, manager: context.createManager({ send() {} }).manager }; + }; + + const available = createManager(); + for (const methodName of requiredMenuMethods) { + assert.equal(typeof available.manager[methodName], "function"); + } + assert.equal(available.manager.hasUpdater(), false); + assert.equal( + available.manager.getUnavailableReason(), + "Linux package update manager unavailable", + ); + + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(available.manager.hasUpdater(), true); + assert.equal(available.manager.getUnavailableReason(), null); + await available.manager.checkForUpdates(); + assert.deepEqual(available.calls, [ + ["codex-update-manager", "--help"], + ["codex-update-manager", "check-now"], + ]); + + const unavailable = createManager(new Error("probe failed")); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(unavailable.manager.hasUpdater(), false); + assert.equal( + unavailable.manager.getUnavailableReason(), + "Linux package update manager unavailable", + ); + await unavailable.manager.checkForUpdates(); + assert.deepEqual(unavailable.calls, [["codex-update-manager", "--help"]]); +}); + test("fails soft when the current updater callback bridge drifts", () => { for (const source of [ currentBootstrapUpdaterBundleFixture().replace( - "let ee=new G5,P=null,W=null,te=e=>", - "let ee=G5(),P=null,W=null,te=e=>", + "let ee=new G5,P=null,te=e=>", + "let ee=G5(),P=null,te=e=>", ), currentBootstrapUpdaterBundleFixture().replace( - "let ee=new G5,P=null,W=null,te=e=>", - "let ee=new G5,P=null,te=e=>", + "ee.allowQuitTemporarilyForUpdateInstall(),r.app.quit()", + "ee.allowQuitTemporarilyForUpdateInstall(),r.app.exit()", ), ]) { const { value: patched, warnings } = captureWarns(() => @@ -6889,7 +8194,7 @@ test("fails soft when the current updater callback bridge drifts", () => { test("enables the existing app update menu on Linux", () => { const source = - "let{startedAtMs:r,buildFlavor:a,desktopSentry:o,sparkleManager:s,productionAppcastStateStore:P,setSparkleBridgeHandlers:c,setSecondInstanceArgsHandler:l}=t.y(),u=t.Z(a),d=t.C.shouldIncludeSparkle(a,process.platform,process.env),f=t.C.shouldIncludeUpdater(a,process.platform,process.env);Yb({enableSparkle:d});"; + "let{startedAtMs:r,buildFlavor:a,desktopSentry:o,sparkleManager:s,startupPhases:h,productionAppcastStateStore:P,setSparkleBridgeHandlers:c,setSecondInstanceArgsHandler:l}=t.y(),u=t.Z(a),d=t.C.shouldIncludeSparkle(a,process.platform,process.env),f=t.C.shouldIncludeUpdater(a,process.platform,process.env);Yb({enableSparkle:d});"; const patched = applyPatchTwice(applyLinuxAppUpdaterMenuPatch, source); assert.match( @@ -7307,153 +8612,64 @@ test("escapes dollar-prefixed bundled plugin reconcile identifiers", async () => capturedSnapshotVar: "$n", featureStateVar: "$p", forceVar: "$force", - latestHashVar: "$h", - pendingVar: "$v", - reasonVar: "$reason", - workerArgVar: "$t", - workerVar: "$j", - }), - ); - - api.setFeatures({ externalBrowserUse: false, inAppBrowserUse: false }); - await api.waitForPreflight(); - const latestReconcile = api.setFeatures({ - externalBrowserUse: true, - inAppBrowserUse: true, - }); - api.release(); - await latestReconcile; - - assert.deepEqual(api.getCalls(), [ - { externalBrowserUse: true, inAppBrowserUse: true }, - ]); - assert.equal( - (patched.match(/codex-linux-skip-stale-bundled-plugin-reconcile/g) || []).length, - 1, - ); -}); - -test("fails closed when the bundled plugin reconcile worker is ambiguous", () => { - const source = - bundledPluginReconcileRaceFixture() + - "j=async q=>{let y;try{y=await destructive({appServerConnection:null})}finally{}};"; - const { value, warnings } = captureWarns(() => - applyLinuxBundledPluginReconcileStaleSnapshotPatch(source), - ); - assert.equal(value, source); - assert.match(warnings[0], /Expected one bundled plugin reconcile worker definition/); -}); - -test("fails closed when bundled plugin reconcile insertion order drifts", () => { - const source = bundledPluginReconcileRaceFixture() - .replace("h=c;return v=", "return v=") - .replace("let j=async", "h=c;let j=async"); - const { value, warnings } = captureWarns(() => - applyLinuxBundledPluginReconcileStaleSnapshotPatch(source), - ); - assert.equal(value, source); - assert.match(warnings[0], /insertion order drifted/); -}); - -test("uses Linux managed runtime paths for Chrome native host sync", () => { - const patched = applyPatchTwice( - applyLinuxChromeNativeHostRuntimePatch, - chromeNativeHostRuntimeBundleFixture(), - ); - const files = new Set([ - "/opt/codex/resources/node-runtime/bin/node", - "/opt/codex/resources/node_repl", - "/home/josh/.local/bin/codex", - ]); - - const result = vm.runInNewContext( - `${patched};Qp({resourcesPath:"/opt/codex/resources",devRuntimeRepoRoot:null,nativeHostName:"com.openai.codexextension"});`, - { - require(moduleName) { - if (moduleName === "node:path") { - return path; - } - if (moduleName === "node:fs") { - return { - statSync(filePath) { - if (!files.has(filePath)) { - throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); - } - return { isFile: () => true }; - }, - }; - } - return require(moduleName); - }, - process: { - platform: "linux", - env: { - CODEX_CLI_PATH: "/home/josh/.local/bin/codex", - }, - }, - }, - ); - - assert.deepEqual(JSON.parse(JSON.stringify(result)), { - codexCliPath: "/home/josh/.local/bin/codex", - nodePath: "/opt/codex/resources/node-runtime/bin/node", - nodeReplPath: "/opt/codex/resources/node_repl", - }); -}); - -test("uses Linux managed runtime paths for current Chrome native host sync shape", () => { - const patched = applyPatchTwice( - applyLinuxChromeNativeHostRuntimePatch, - currentChromeNativeHostRuntimeBundleFixture(), - ); - const files = new Set([ - "/opt/codex/resources/node-runtime/bin/node", - "/opt/codex/resources/node_repl", - "/home/josh/.local/bin/codex", - ]); - - const result = vm.runInNewContext( - `${patched};QL({resourcesPath:"/opt/codex/resources",devRuntimeRepoRoot:null,nativeHostName:"com.openai.codexextension"});`, - { - require(moduleName) { - if (moduleName === "node:path") { - return path; - } - if (moduleName === "node:fs") { - return { - statSync(filePath) { - if (!files.has(filePath)) { - throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); - } - return { isFile: () => true }; - }, - }; - } - return require(moduleName); - }, - process: { - platform: "linux", - env: { - CODEX_CLI_PATH: "/home/josh/.local/bin/codex", - }, - }, - }, + latestHashVar: "$h", + pendingVar: "$v", + reasonVar: "$reason", + workerArgVar: "$t", + workerVar: "$j", + }), ); - assert.deepEqual(JSON.parse(JSON.stringify(result)), { - codexCliPath: "/home/josh/.local/bin/codex", - nodeModuleDirs: [], - nodePath: "/opt/codex/resources/node-runtime/bin/node", - nodeReplPath: "/opt/codex/resources/node_repl", + api.setFeatures({ externalBrowserUse: false, inAppBrowserUse: false }); + await api.waitForPreflight(); + const latestReconcile = api.setFeatures({ + externalBrowserUse: true, + inAppBrowserUse: true, }); + api.release(); + await latestReconcile; + + assert.deepEqual(api.getCalls(), [ + { externalBrowserUse: true, inAppBrowserUse: true }, + ]); + assert.equal( + (patched.match(/codex-linux-skip-stale-bundled-plugin-reconcile/g) || []).length, + 1, + ); +}); + +test("fails closed when the bundled plugin reconcile worker is ambiguous", () => { + const source = + bundledPluginReconcileRaceFixture() + + "j=async q=>{let y;try{y=await destructive({appServerConnection:null})}finally{}};"; + const { value, warnings } = captureWarns(() => + applyLinuxBundledPluginReconcileStaleSnapshotPatch(source), + ); + assert.equal(value, source); + assert.match(warnings[0], /Expected one bundled plugin reconcile worker definition/); +}); + +test("fails closed when bundled plugin reconcile insertion order drifts", () => { + const source = bundledPluginReconcileRaceFixture() + .replace("h=c;return v=", "return v=") + .replace("let j=async", "h=c;let j=async"); + const { value, warnings } = captureWarns(() => + applyLinuxBundledPluginReconcileStaleSnapshotPatch(source), + ); + assert.equal(value, source); + assert.match(warnings[0], /insertion order drifted/); }); test("uses Linux managed runtime paths for Electron 42 Browser Use runtime resolver", () => { - const patched = applyPatchTwice( - applyLinuxChromeNativeHostRuntimePatch, + const first = applyLinuxChromeNativeHostRuntimePatch( electron42BrowserUseRuntimeResolverBundleFixture(), ); + const { value: patched, warnings } = captureWarns(() => + applyLinuxChromeNativeHostRuntimePatch(first), + ); + assert.equal(patched, first); + assert.deepEqual(warnings, []); assert.match( patched, /codexLinuxChromeNativeHostRuntimeEntry\(codexLinuxChromeNativeHostRuntimePath\(`codex`\),`linux-path`\)\?\?Wn/, @@ -7466,29 +8682,14 @@ test("uses Linux managed runtime paths for Electron 42 Browser Use runtime resol patched, /codexLinuxChromeNativeHostRuntimeFile\(u,\[\[r===`win32`\?`node_repl\.exe`:`node_repl`\]\]\)/, ); -}); - -test("uses Linux managed runtime paths for current Chrome plugin app-server sync", () => { - const patched = applyPatchTwice( - applyLinuxChromeNativeHostRuntimePatch, - currentChromePluginAppServerRuntimeBundleFixture(), - ); - assert.match(patched, /ZB\(e\)\?\?codexLinuxChromeNativeHostRuntimeEnv\(`CODEX_CLI_PATH`\)\?\?codexLinuxChromeNativeHostRuntimePath\(`codex`\)/); - assert.match(patched, /NM\(e\.resourcesPath\)\?\?codexLinuxChromeNativeHostRuntimeEnv\(`CODEX_BROWSER_USE_NODE_PATH`\)/); - assert.match(patched, /codexLinuxChromeNativeHostRuntimeFile\(e\.resourcesPath,\[\[`node-runtime`,`bin`,process\.platform===`win32`\?`node\.exe`:`node`\]\]\)/); - assert.match(patched, /MM\(e\.resourcesPath\)\?\?codexLinuxChromeNativeHostRuntimeEnv\(`CODEX_NODE_REPL_PATH`\)/); -}); - -test("uses Linux Codex CLI path for Chrome plugin app-server sync", async () => { - const patched = applyPatchTwice( - applyLinuxChromeNativeHostRuntimePatch, - currentChromePluginCodexAppServerRuntimeBundleFixture(), - ); - const files = new Set(["/home/josh/.local/bin/codex"]); - - const result = await vm.runInNewContext( - `${patched};VH({resourcesPath:"/opt/codex/resources",devRuntimeRepoRoot:null,nativeHostName:"com.openai.codexextension"});`, + const files = new Set([ + "/opt/codex/resources/node-runtime/bin/node", + "/opt/codex/resources/node_repl", + "/home/josh/.local/bin/codex", + ]); + const result = vm.runInNewContext( + `${patched};Hn({env:{CODEX_CLI_PATH:"/home/josh/.local/bin/codex",PATH:""},isPackaged:true,platform:"linux",repoRoot:null,resolveCodexPath:()=>null,resolveNodePath:()=>null,resolveNodeReplPath:()=>null,resolvePrimaryRuntimeNodePath:()=>null,resourcesPath:"/opt/codex/resources"});`, { require(moduleName) { if (moduleName === "node:path") { @@ -7507,149 +8708,27 @@ test("uses Linux Codex CLI path for Chrome plugin app-server sync", async () => return require(moduleName); }, process: { + cwd: () => "/tmp", + env: {}, platform: "linux", - env: { - CODEX_CLI_PATH: "/home/josh/.local/bin/codex", - PATH: "", - }, + resourcesPath: "/opt/codex/resources", }, + t: { Vn: () => [] }, }, ); assert.deepEqual(JSON.parse(JSON.stringify(result)), { codexCliPath: "/home/josh/.local/bin/codex", + codexCliPathSource: "env-override", + nodeModuleDirs: [], + nodePath: "/opt/codex/resources/node-runtime/bin/node", + nodePathSource: "linux-node-runtime", + nodeReplPath: "/opt/codex/resources/node_repl", + nodeReplPathSource: "linux-node-repl-runtime", + platform: "linux", }); }); -test("keeps the original Linux CLI path when Chrome plugin app-server sync would isolate it", async () => { - const patched = applyPatchTwice( - applyLinuxChromeNativeHostRuntimePatch, - currentChromePluginIsolatedAppServerRuntimeBundleFixture(), - ); - const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-chrome-esm-cli-")); - try { - const packageDir = path.join(root, "CLI installs"); - const cliPath = path.join(packageDir, "codex"); - const isolatedPath = path.join(root, "isolated", "codex"); - fs.mkdirSync(path.dirname(isolatedPath), { recursive: true }); - fs.mkdirSync(packageDir, { recursive: true }); - fs.writeFileSync(path.join(packageDir, "package.json"), '{"type":"module"}\n'); - fs.writeFileSync(path.join(packageDir, "dependency.js"), 'export const version = "esm-ok";\n'); - fs.writeFileSync( - cliPath, - '#!/usr/bin/env node\nimport { version } from "./dependency.js";\nconsole.log(version);\n', - ); - fs.chmodSync(cliPath, 0o700); - - const result = await vm.runInNewContext( - `${patched};VH({resourcesPath:"/opt/codex/resources",devRuntimeRepoRoot:null,nativeHostName:"com.openai.codexextension"});`, - { - require, - process: { - platform: "linux", - env: { - CODEX_CLI_PATH: cliPath, - ISSUE805_ISOLATED_CLI: isolatedPath, - PATH: "", - }, - }, - }, - ); - - assert.equal(result, cliPath); - assert.equal(fs.existsSync(isolatedPath), false); - assert.match(patched, /async function decoy\(e\)\{let t=e\.nativeHostName===nU;return `decoy`\}/); - const execution = spawnSync(result, [], { encoding: "utf8" }); - assert.equal(execution.status, 0, execution.stderr); - assert.equal(execution.stdout.trim(), "esm-ok"); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -}); - -test("preserves Chrome plugin app-server isolation outside Linux", async () => { - const patched = applyPatchTwice( - applyLinuxChromeNativeHostRuntimePatch, - currentChromePluginIsolatedAppServerRuntimeBundleFixture(), - ); - const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-chrome-non-linux-cli-")); - try { - const sourcePath = path.join(root, "source-codex"); - const isolatedPath = path.join(root, "isolated-codex"); - fs.writeFileSync(sourcePath, "source"); - - const result = await vm.runInNewContext( - `${patched};AV({codexCliPath:${JSON.stringify(sourcePath)},nativeHostName:"com.openai.codexextension"});`, - { - require, - process: { - platform: "darwin", - env: { ISSUE805_ISOLATED_CLI: isolatedPath }, - }, - }, - ); - - assert.equal(result, isolatedPath); - assert.equal(fs.readFileSync(isolatedPath, "utf8"), "source"); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -}); - -test("patches multiple Chrome runtime resolvers in one Electron 42 bundle", () => { - const patched = applyPatchTwice( - applyLinuxChromeNativeHostRuntimePatch, - [ - electron42BrowserUseRuntimeResolverBundleFixture(), - currentChromePluginCodexAppServerRuntimeBundleFixture(), - currentChromePluginAppServerRuntimeBundleFixture(), - ].join(""), - ); - - assert.match( - patched, - /codexLinuxChromeNativeHostRuntimeEntry\(codexLinuxChromeNativeHostRuntimePath\(`codex`\),`linux-path`\)\?\?Wn/, - ); - assert.match(patched, /_U\(e\)\?\?codexLinuxChromeNativeHostRuntimeEnv\(`CODEX_CLI_PATH`\)\?\?codexLinuxChromeNativeHostRuntimePath\(`codex`\)/); - assert.match(patched, /ZB\(e\)\?\?codexLinuxChromeNativeHostRuntimeEnv\(`CODEX_CLI_PATH`\)\?\?codexLinuxChromeNativeHostRuntimePath\(`codex`\)/); - assert.match(patched, /NM\(e\.resourcesPath\)\?\?codexLinuxChromeNativeHostRuntimeEnv\(`CODEX_BROWSER_USE_NODE_PATH`\)/); - assert.equal((patched.match(/function codexLinuxChromeNativeHostRuntimeFile/g) || []).length, 1); -}); - -test("reports drifted Chrome native host runtime resolver as optional drift", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-patch-report-chrome-runtime-drift-")); - try { - const buildDir = path.join(tempRoot, ".vite", "build"); - fs.mkdirSync(buildDir, { recursive: true }); - fs.writeFileSync( - path.join(buildDir, "main.js"), - [ - "let r=require(`node:path`),o=require(`node:fs`);", - "function Qp(e){throw Error(`Missing bundled Electron runtime required to sync Chrome native host resources for ${e.nativeHostName}.`)}", - ].join(""), - ); - - const report = createPatchReport(); - captureWarns(() => patchExtractedApp(tempRoot, { report })); - - const runtimePatch = report.patches.find((patch) => patch.name === "linux-chrome-native-host-runtime"); - assert.equal(runtimePatch.status, "skipped-optional"); - assert.match(runtimePatch.reason, /Could not identify Chrome native host runtime resolver shape/); - assert.ok( - !validateReport(report, "upstream-build").some((failure) => - failure.startsWith("linux-chrome-native-host-runtime:"), - ), - "browser integration drift must not fail the build", - ); - assert.ok( - optionalDriftFromReport(report).some((drift) => drift.name === "linux-chrome-native-host-runtime"), - "the drift must still be surfaced in the optional-drift summary", - ); - } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); - } -}); - test("adds Linux availability to an already auto-installed Chrome plugin gate", () => { const source = currentChromePluginGateBundleFixture().replace( "{forceReload:!0,name:o.c,syncInstallStateWithChromeExtension:!0,isAvailable:", @@ -7818,34 +8897,42 @@ test("enables Browser Use availability on Linux when only the Statsig gate is di test("enables external Browser Use availability on Linux without the upstream rollout flag", () => { const source = - "function m(e){let t=(0,l.c)(5),{hostId:n,windowType:r}=e,a=r===void 0?`electron`:r,o=i(`410065390`),s;t[0]===n?s=t[1]:(s={featureName:`browser_use_external`,hostId:n},t[0]=n,t[1]=s);let c=u(s),d=a===`chrome-extension`||o&&c.enabled&&!c.isLoading,f=a===`chrome-extension`?!1:c.isLoading,p;return t[2]!==d||t[3]!==f?(p={allowed:d,available:d,isLoading:f},t[2]=d,t[3]=f,t[4]=p):p=t[4],p}"; + "function wfi(e){let t=(0,Efi.c)(14),{enabled:n,hostId:r,windowType:i}=e,a=n===void 0||n,o=i===void 0?`electron`:i,s=sh(`410065390`),c;t[0]!==a||t[1]!==r?(c={enabled:a,featureName:`browser_use_external`,hostId:r},t[0]=a,t[1]=r,t[2]=c):c=t[2];let l=pfi(c),u=Np(Cu.runCodexInWsl),d=ey(r),f=u===!0||d.kind===`wsl`,p;t[3]!==l.enabled||t[4]!==l.isLoading||t[5]!==s||t[6]!==f||t[7]!==o?(p=Tfi({isExternalBrowserUseFeatureEnabled:l.enabled,isExternalBrowserUseFeatureLoading:l.isLoading,isExternalBrowserUseGateEnabled:s,runCodexInWsl:f,windowType:o}),t[3]=l.enabled,t[4]=l.isLoading,t[5]=s,t[6]=f,t[7]=o,t[8]=p):p=t[8];return p}function Tfi({isExternalBrowserUseFeatureEnabled:e,isExternalBrowserUseFeatureLoading:t,isExternalBrowserUseGateEnabled:n,runCodexInWsl:r,windowType:i}){return i===`chrome-extension`?`available`:t?`loading`:n?e?r?`wsl-disabled`:`available`:`config-requirement-disabled`:`statsig-disabled`}"; const patched = applyPatchTwice(applyLinuxBrowserUseExternalAvailabilityPatch, source); assert.match( patched, - /d=a===`chrome-extension`\|\|navigator\.userAgent\.includes\(`Linux`\)\|\|o&&c\.enabled&&!c\.isLoading/, + /return i===`chrome-extension`\|\|navigator\.userAgent\.includes\(`Linux`\)\?`available`:/, ); - assert.match( - patched, - /f=a===`chrome-extension`\|\|navigator\.userAgent\.includes\(`Linux`\)\?!1:c\.isLoading/, + const context = { navigator: { userAgent: "Linux x86_64" } }; + vm.runInNewContext( + `${patched};globalThis.result=Tfi({isExternalBrowserUseFeatureEnabled:!1,isExternalBrowserUseFeatureLoading:!1,isExternalBrowserUseGateEnabled:!1,runCodexInWsl:!1,windowType:\`electron\`})`, + context, ); + assert.equal(context.result, "available"); assert.match(patched, /featureName:`browser_use_external`/); - assert.match(patched, /i\(`410065390`\)/); + assert.match(patched, /sh\(`410065390`\)/); }); test("keeps already patched external Browser Use availability unchanged", () => { const source = - "function m(e){let t=(0,l.c)(5),{hostId:n,windowType:r}=e,a=r===void 0?`electron`:r,o=i(`410065390`),s;t[0]===n?s=t[1]:(s={featureName:`browser_use_external`,hostId:n},t[0]=n,t[1]=s);let c=u(s),d=a===`chrome-extension`||navigator.userAgent.includes(`Linux`)||o&&c.enabled&&!c.isLoading,f=a===`chrome-extension`||navigator.userAgent.includes(`Linux`)?!1:c.isLoading,p;return p}"; + "function wfi(){return{featureName:`browser_use_external`,gate:`410065390`}}function Tfi({isExternalBrowserUseFeatureEnabled:e,isExternalBrowserUseFeatureLoading:t,isExternalBrowserUseGateEnabled:n,runCodexInWsl:r,windowType:i}){return i===`chrome-extension`||navigator.userAgent.includes(`Linux`)?`available`:t?`loading`:n?e?r?`wsl-disabled`:`available`:`config-requirement-disabled`:`statsig-disabled`}"; assert.equal(applyPatchTwice(applyLinuxBrowserUseExternalAvailabilityPatch, source), source); }); -test("external Browser Use availability descriptor matches the current monolithic bundle", () => { +test("external Browser Use availability descriptor patches the complete current extracted-app contract", () => { const descriptor = require("./patches/core/all-linux/webview/browser-use-external-availability/patch.js"); - assert.match("app-initial-BTphDPeq.js", descriptor.pattern); - assert.doesNotMatch("use-in-app-browser-use-availability-B4Bdb14G.js", descriptor.pattern); + assert.equal(descriptor.phase, "extracted-app:post-webview"); + assert.equal(descriptor.order, 1990); + assert.equal(descriptor.ciPolicy, "optional"); + assert.equal(descriptor.apply, patchLinuxBrowserUseExternalAvailabilityAssets); + assert.deepEqual(descriptor.status({ matched: 0, changed: 0 }, []), { + status: "skipped-optional", + reason: null, + }); }); test("allows Browser Use non-local navigation on Linux without the upstream rollout flag", () => { @@ -8460,11 +9547,11 @@ test("does not poison shared Browser recovery when a stale host timer fires", () }); const browserUseRecoveryStoreSource = - "function Af(e,t){return t??e}function Ef(e,t){return`${e}\\0${t}`}var Pf=class{webviews=new Map;snapshots=new Map;tabPersistenceStates=new Map;browserUseActiveTabKeys=new Set;browserUseViewportSizes=new Map;transferredWebviewKeys=new Set;registrationAttempts=new WeakMap;nextHostGeneration=0;getSnapshot(e,t){return this.snapshots.get(Ef(e,t))??null}setBrowserUseActive(e,...t){let n=typeof t[0]==`boolean`?Af(e,void 0):t[0],r=typeof t[0]==`boolean`?t[0]:t[1],i=Ef(e,n),a=this.browserUseActiveTabKeys.has(i);if(r){let t=`${e}\\0`;for(let e of Array.from(this.browserUseActiveTabKeys)){if(e===i||!e.startsWith(t))continue;this.browserUseActiveTabKeys.delete(e);let n=null}this.browserUseActiveTabKeys.add(i)}else this.browserUseActiveTabKeys.delete(i);return a}releaseBrowserUseTab(e,t){let n=Ef(e,t),r=this.browserUseActiveTabKeys.delete(n);return r}removeTab(e,t){let n=Ef(e,t),r=this.webviews.get(n);this.webviews.delete(n)}registerWebviewHost(e,t){return true}removeConversationTabs(e){let t=`${e}\\0`;for(let e of this.snapshots.keys())e.startsWith(t)&&this.snapshots.delete(e)}reassociateTabState(e,...t){let n=t[0],r=t[1],i=t[2],o=`transfer`,s=Ef(e,n),c=Ef(r,i);if(s===c||this.transferredWebviewKeys.has(o))return;if(this.webviews.has(c))return;let m=this.browserUseViewportSizes.get(s)??null,h=this.browserUseActiveTabKeys.delete(s);h&&this.browserUseActiveTabKeys.add(c);return m}disposeAll(){this.electronPageHandoff.disposeAll(),this.webviews.clear()}disposeWebviewHost(e,t,n,r){this.webviews.delete(n)}emitChange(){for(let e of this.listeners)e()}}"; + "function Ef(e,t){return`${e}\\0${t}`}var Pf=class{webviews=new Map;snapshots=new Map;tabPersistenceStates=new Map;browserUseActiveTabKeys=new Set;browserUseTabKeys=new Set;browserUseCursorStates=new Map;browserUseCaptureSurfaceSizes=new Map;browserUseViewportSizes=new Map;deviceToolbarTabStates=new Map;transferredWebviewKeys=new Set;registrationAttempts=new WeakMap;nextHostGeneration=0;getSnapshot(e,t){return this.snapshots.get(Ef(e,t))??null}setBrowserUseActive(e,t,n){let r=Ef(e,t),i=this.browserUseActiveTabKeys.has(r),a=this.browserUseTabKeys.has(r),o=this.browserUseCursorStates.get(r)??null;n?(this.browserUseTabKeys.add(r),this.browserUseActiveTabKeys.add(r)):(this.browserUseActiveTabKeys.delete(r),o!=null&&this.browserUseCursorStates.set(r,{visible:!1,x:o.x,y:o.y}));return i!==n||a}releaseBrowserUseTab(e,t){let n=Ef(e,t),r=this.browserUseActiveTabKeys.delete(n),i=this.browserUseTabKeys.delete(n);return r||i}removeTab(e,t){let n=Ef(e,t),r=this.webviews.get(n);this.webviews.delete(n)}registerWebviewHost(e,t){return true}removeConversationTabs(e){let t=`${e}\\0`;for(let e of this.snapshots.keys())e.startsWith(t)&&this.snapshots.delete(e)}reassociateTabState(e,t,n,r,i){let a=`transfer`,o=Ef(e,t),s=Ef(n,r);if(o===s||this.transferredWebviewKeys.has(a))return;let c=this.webviews.get(o)??null,l=this.webviews.get(s)??null,u=this.tabPersistenceStates.get(o)??null,d=this.tabPersistenceStates.get(s)??null,f=this.snapshots.get(o)??null;if(l!=null)return;let p=this.browserUseViewportSizes.get(o)??null,m=this.browserUseTabKeys.has(o),h=this.browserUseActiveTabKeys.delete(o);h&&this.browserUseActiveTabKeys.add(s);return p}disposeAll(){this.electronPageHandoff.disposeAll(),this.webviews.clear()}disposeWebviewHost(e,t,n,r){this.webviews.delete(n)}emitChange(){for(let e of this.listeners)e()}}"; const browserUseRecoveryHostSource = "function K({adoptionLease:e,adoptedWebContentsId:t,bounds:n,browserTabId:r,children:i,conversationId:a,hostKind:o=`right-panel`,initialUrl:s,isVisible:c,scale:l,shouldBootstrapWhenHidden:u,shouldPaint:d,webviewRef:f,windowZoom:p}){let m=(0,q.useRef)(null),h=(0,q.useId)(),g=(0,q.useRef)(!1),_=(0,q.useRef)(!1),v=(0,q.useRef)(P.getMountGeneration(a,r)),y=(0,q.useRef)(ae(a,r)),b=(0,q.useSyncExternalStore)(P.subscribe,()=>P.getCursorOverlayHost(a,r),()=>null);y.current=ae(a,r),(0,q.useLayoutEffect)(()=>(_.current=!0,()=>{_.current=!1}),[]);let x=c&&n!=null;return(0,q.useLayoutEffect)(()=>{let e=ae(a,r);if(ie({hasManagedWebview:m.current!=null,isPresented:x,shouldBootstrapWhenHidden:u})===`skip`){g.current=!1,v.current=P.getMountGeneration(a,r);return}let t=P.claimMountGeneration(a,r,h);return v.current=t,g.current=!0,()=>{g.current=!1,queueMicrotask(()=>{if(_.current&&y.current===e&&g.current)return;let n=P.releaseMountGeneration(a,r,h,t);v.current===t&&(v.current=n)})}},[r,a,x,h,u]),(0,q.useLayoutEffect)(()=>{let e=ae(a,r);return()=>{let t=m.current,n=v.current;queueMicrotask(()=>{let i=y.current;_.current&&i===e||P.hasOtherMountGenerationClaim(a,r,h,n)||t!=null&&(P.detachElectronWebview(t,f,o,n),m.current===t&&(m.current=null))})}},[r,a,o,h,f]),(0,q.useLayoutEffect)(()=>{m.current?.disposed&&(m.current=null);let i=m.current,c=ie({hasManagedWebview:i!=null,isPresented:x,shouldBootstrapWhenHidden:u});if(c===`skip`){if(i!=null){let e=v.current;P.hasOtherMountGenerationClaim(a,r,h,e)||P.detachElectronWebview(i,f,o,e)}m.current===i&&(m.current=null);return}let g=P.getWebview(a,r,s,{adoptionLease:e,adoptedWebContentsId:t,hostKind:o});m.current=g,P.syncElectronWebview(g,{bounds:n,isVisible:x,mountGeneration:v.current,scale:l,shouldBootstrap:c===`bootstrap`,shouldPaint:d,windowZoom:p},f,o)},[r,a,o,s,e,t,n,x,h,l,d,u,f,p]),b==null||i==null?null:(0,oe.createPortal)(i,b)}"; -const browserUseHiddenHostSource = - "function f(e){return e}function A(e){let{browserUseTabIdsKey:n,conversationId:r}=e,c=e.isRouteOwner,B=e.visibleTabs;if(!c&&B.size>0)return null;let H=Symbol.for(`react.early_return_sentinel`);bb0:{let e=e=>!B.has(e);let a=n.split(`\\0`).map(f).filter(e);if(a.length===0){H=null;break bb0}return a}if(H!==Symbol.for(`react.early_return_sentinel`))return H}"; +const currentUpstreamHiddenBrowserUseHostSource = + "function f(e){return e}function A({browserUseTabIdsKey:n,visibleTabs:I}){let e=e=>!I.has(e),a=n.split(`\\0`).map(f).filter(e);if(a.length===0)return null;return a}"; test("patches the current monolithic Browser webview store and host contracts", () => { const patchedStore = applyPatchTwice( @@ -8488,7 +9575,7 @@ test("patches the current monolithic Browser webview store and host contracts", assert.match(patched, /linuxFailWebviewRecovery\(e,t,n\)/); assert.match( patched, - /r\|\|this\.linuxBrowserUseRecoveryStates\.delete\(Ef\(e,n\)\)/, + /n\|\|this\.linuxBrowserUseRecoveryStates\.delete\(r\)/, ); assert.match( patched, @@ -8504,11 +9591,7 @@ test("patches the current monolithic Browser webview store and host contracts", ); assert.match( patched, - /browserUseActiveTabKeys\.delete\(e\);this\.linuxBrowserUseRecoveryStates\.delete\(e\);let n=/, - ); - assert.match( - patched, - /linuxBrowserUseRecoveryStates\.delete\(s\),this\.linuxBrowserUseRecoveryStates\.set\(c,codexLinuxRecoveryState\)/, + /linuxBrowserUseRecoveryStates\.delete\(o\),this\.linuxBrowserUseRecoveryStates\.set\(s,codexLinuxRecoveryState\)/, ); assert.match(patched, /disposeAll\(\)\{this\.electronPageHandoff\.disposeAll\(\),this\.linuxBrowserUseRecoveryStates\.clear\(\),/); assert.match(patched, /function codexLinuxWatchBrowserWebviewAttachment/); @@ -8601,14 +9684,6 @@ test("patches the current monolithic Browser webview store and host contracts", true, ); store.webviews.set("conversation-1\0tab-1", secondHost); - store.browserUseActiveTabKeys.add("conversation-1\0tab-1"); - store.setBrowserUseActive("conversation-1", "tab-2", true); - store.webviews.set("conversation-1\0tab-1", secondHost); - assert.equal( - store.linuxRemountWebview("conversation-1", "tab-1", secondHost).started, - true, - ); - store.webviews.set("conversation-1\0tab-1", secondHost); store.releaseBrowserUseTab("conversation-1", "tab-1"); store.webviews.set("conversation-1\0tab-1", secondHost); assert.equal( @@ -8707,13 +9782,9 @@ test("Browser webview recovery descriptors target the current monolithic rendere const hostDescriptor = descriptors.find( (descriptor) => descriptor.id === "linux-browser-use-webview-attach-recovery-host", ); - const hiddenHostDescriptor = descriptors.find( - (descriptor) => descriptor.id === "linux-browser-use-hidden-host-ownership", - ); assert.ok(storeDescriptor); assert.ok(hostDescriptor); - assert.ok(hiddenHostDescriptor); assert.match( "app-initial-BTphDPeq.js", storeDescriptor.pattern, @@ -8730,14 +9801,6 @@ test("Browser webview recovery descriptors target the current monolithic rendere "app-initial~app-main~onboarding-page-legacy.js", hostDescriptor.pattern, ); - assert.match( - "browser-sidebar-hidden-browser-use-webview-host-Dv56miJM.js", - hiddenHostDescriptor.pattern, - ); - assert.doesNotMatch( - "app-initial~app-main~onboarding-page-current.js", - hiddenHostDescriptor.pattern, - ); }); test("current monolithic Browser webview asset applies all recovery descriptors without report drift", () => { @@ -8752,11 +9815,6 @@ test("current monolithic Browser webview asset applies all recovery descriptors path.join(assetsDir, "app-initial-BTphDPeq.js"), `${browserUseRecoveryStoreSource}${browserUseRecoveryHostSource}`, ); - fs.writeFileSync( - path.join(assetsDir, "browser-sidebar-hidden-browser-use-webview-host-DbLBblbO.js"), - browserUseHiddenHostSource, - ); - const report = createPatchReport(); const corePatchRoot = path.join( __dirname, @@ -8771,7 +9829,6 @@ test("current monolithic Browser webview asset applies all recovery descriptors for (const patchName of [ "linux-browser-use-webview-attach-recovery-store", "linux-browser-use-webview-attach-recovery-host", - "linux-browser-use-hidden-host-ownership", ]) { assert.equal( report.patches.find((patch) => patch.name === patchName)?.status, @@ -8794,10 +9851,6 @@ test("reports drift when current Browser recovery assets lose their primary need assetName: "app-initial-BTphDPeq.js", patchName: "linux-browser-use-webview-attach-recovery-host", }, - { - assetName: "browser-sidebar-hidden-browser-use-webview-host-DbLBblbO.js", - patchName: "linux-browser-use-hidden-host-ownership", - }, ]; for (const { assetName, patchName } of cases) { @@ -8855,24 +9908,14 @@ test("Browser webview host recovery rejects current-DMG drift byte-identically", assert.ok(warnings.some((message) => message.includes("host lifecycle seams"))); }); -test("mounts inactive Browser Use hosts when another conversation owns the visible panel", () => { - const patched = applyPatchTwice( - applyLinuxBrowserUseHiddenHostOwnershipPatch, - browserUseHiddenHostSource, - ); - - assert.match( - patched, - /if\(!c&&B\.size>0&&n\.split\(`\\0`\)\.map\(f\)\.every\(codexLinuxBrowserUseTabId=>B\.has\(codexLinuxBrowserUseTabId\)\)\)return null/, - ); - assert.doesNotThrow(() => new vm.Script(patched)); +test("current upstream hidden Browser Use host mounts every tab missing from visible panels", () => { + assert.doesNotThrow(() => new vm.Script(currentUpstreamHiddenBrowserUseHostSource)); - const mount = vm.runInNewContext(`${patched};A`); + const mount = vm.runInNewContext(`${currentUpstreamHiddenBrowserUseHostSource};A`); assert.deepEqual( Array.from( mount({ browserUseTabIdsKey: "target-tab", - isRouteOwner: false, visibleTabs: new Set(["other-conversation-tab"]), }), ), @@ -8881,7 +9924,6 @@ test("mounts inactive Browser Use hosts when another conversation owns the visib assert.equal( mount({ browserUseTabIdsKey: "target-tab", - isRouteOwner: false, visibleTabs: new Set(["target-tab"]), }), null, @@ -8890,7 +9932,6 @@ test("mounts inactive Browser Use hosts when another conversation owns the visib Array.from( mount({ browserUseTabIdsKey: "visible-tab\0hidden-tab", - isRouteOwner: false, visibleTabs: new Set(["visible-tab"]), }), ), @@ -9063,7 +10104,7 @@ test("Computer Use availability descriptor matches the current settings bundle n test("enables the current Computer Use settings contract on Linux", () => { const source = - "function Ht(){let e=cache(24),{selectedHostId:t}=host(),n=data(t),i={hostId:t};" + + "var computerUsePluginName=`computer-use`;function Ht(){let e=cache(24),{selectedHostId:t}=host(),n=data(t),i={hostId:t};" + "let a=useAvailability(i),{platform:o}=usePlatform(),s=hostKind(t)===`local`,c=flag(`188145323`);" + "let f=jsx(Settings,{computerUseAvailability:a,platform:o});" + "let h=a.available?jsx(AllowedApps,{}):null;return jsx(Page,{children:[f,h]})}" + @@ -9082,7 +10123,7 @@ test("enables the current Computer Use settings contract on Linux", () => { test("reuses current bundled-plugin metadata for the synthetic Computer Use card", () => { const source = - "function Ht(){let e=cache(24),{selectedHostId:t}=host(),n=data(t),i={hostId:t};" + + "var computerUsePluginName=`computer-use`;function Ht(){let e=cache(24),{selectedHostId:t}=host(),n=data(t),i={hostId:t};" + "let a=useAvailability(i),{platform:o}=usePlatform(),s=hostKind(t)===`local`,c=flag(`188145323`);" + "let f=jsx(Settings,{computerUseAvailability:a,platform:o});" + "let h=a.available?jsx(AllowedApps,{}):null;return jsx(Page,{children:[f,h]})}" + @@ -9103,6 +10144,7 @@ test("reuses current bundled-plugin metadata for the synthetic Computer Use card remoteMarketplaceName: null, plugin: { id: "chrome@openai-bundled", name: "chrome", installed: true, enabled: true }, }; + let lastSelectedPlugin = null; function availablePluginsFor({ availablePlugins = [chromeDonor], @@ -9127,7 +10169,9 @@ test("reuses current bundled-plugin metadata for the synthetic Computer Use card secondFlag: "second", selectPlugin: (plugins) => { selectedPlugins = plugins; - return null; + lastSelectedPlugin = + plugins.find((plugin) => plugin.plugin?.name === "computer-use") ?? null; + return lastSelectedPlugin; }, useAvailability: () => ({ available: false }), useFlag: () => false, @@ -9150,6 +10194,9 @@ test("reuses current bundled-plugin metadata for the synthetic Computer Use card assert.equal(plugins[1].plugin.name, "computer-use"); assert.equal(plugins[1].marketplacePath, bundledMarketplaceManifest); assert.notEqual(plugins[1].marketplacePath, incorrectHomeRelativeManifest); + assert.equal(lastSelectedPlugin, plugins[1]); + assert.equal(lastSelectedPlugin.marketplaceName, "openai-bundled"); + assert.equal(lastSelectedPlugin.marketplacePath, bundledMarketplaceManifest); const laterValidDonor = { marketplaceName: "openai-bundled", @@ -9282,7 +10329,7 @@ test("does not mistake legacy synthetic Computer Use card paths for the current test("does not treat an unrelated marketplace manifest suffix as the current patch", () => { const source = "const unrelated=`/.agents/plugins/marketplace.json`;" + - "function Ht(){let e=cache(24),{selectedHostId:t}=host(),n=data(t),i={hostId:t};" + + "var computerUsePluginName=`computer-use`;function Ht(){let e=cache(24),{selectedHostId:t}=host(),n=data(t),i={hostId:t};" + "let a=useAvailability(i),{platform:o}=usePlatform(),s=hostKind(t)===`local`,c=flag(`188145323`);" + "let f=jsx(Settings,{computerUseAvailability:a,platform:o});" + "let h=a.available?jsx(AllowedApps,{}):null;return jsx(Page,{children:[f,h]})}" + @@ -9323,16 +10370,41 @@ test("does not report partial current Computer Use settings patches as applied", ]); }); -test("allows the current Computer Use host platform on Linux", () => { - const source = - "function Se(e){return e===`macOS`||e===`windows`}" + - "function Ce(e){let t=cache(16),{enabled:n,hostId:r}=e,i=n===void 0?!0:n,{isLoading:a,platform:o}=usePlatform(),s=flag(`1506311413`),c;t[0]===r?c=t[1]:(c={featureName:`computer_use`,hostId:r},t[0]=r,t[1]=c);let l=useFeature(c),u=o===`windows`&&!a,d=i&&u,f;t[2]===d?f=t[3]:(f={enabled:d},t[2]=d,t[3]=f);let p=useWindowsFeature(f),m=l.isLoading||u&&p.isLoading,h=l.enabled&&(!u||p.enabled),g;t[4]!==h||t[5]!==i||t[6]!==m||t[7]!==s||t[8]!==a||t[9]!==o?(g=resolveAvailability({areRequiredFeaturesEnabled:h,enabled:i,isAnyFeatureLoading:m,isComputerUseGateEnabled:s,isHostCompatiblePlatform:Se(o),isPlatformLoading:a,windowType:`electron`}),t[4]=h,t[5]=i,t[6]=m,t[7]=s,t[8]=a,t[9]=o,t[10]=g):g=t[10];return g}"; +function currentComputerUseHostPlatform26721Fixture() { + return ( + "function K3r(e){return e===`macOS`||e===`windows`}" + + "function q3r(e){let t=cache(16),{enabled:n,hostId:r}=e,i=n===void 0?!0:n,{isLoading:a,platform:o}=usePlatform(),s=flag(`1506311413`),c;" + + "t[0]===r?c=t[1]:(c={featureName:`computer_use`,hostId:r},t[0]=r,t[1]=c);" + + "let l=useFeature(c),u=o===`windows`&&!a,d=i&&u,f;t[2]===d?f=t[3]:(f={enabled:d},t[2]=d,t[3]=f);" + + "let p=useWindowsFeature(f),m=l.isLoading||u&&p.isLoading,h=l.enabled&&(!u||p.enabled),g;" + + "t[4]!==h||t[5]!==i||t[6]!==m||t[7]!==s||t[8]!==a||t[9]!==o?" + + "(g=X3r({areRequiredFeaturesEnabled:h,enabled:i,isAnyFeatureLoading:m,isComputerUseGateEnabled:s,isHostCompatiblePlatform:K3r(o),isPlatformLoading:a,windowType:`electron`})," + + "t[4]=h,t[5]=i,t[6]=m,t[7]=s,t[8]=a,t[9]=o,t[10]=g):g=t[10];return g}" + ); +} + +function currentComputerUseInstallFlow26721Fixture() { + return ( + "function i4i(e){let t=cache(31),{hostId:n,marketplacePath:r,pluginName:i,remoteMarketplaceName:a,enabled:o}=e," + + "s=o===void 0?!0:o,c=n??`local`,l;t[0]===c?l=t[1]:(l={hostId:c},t[0]=c,t[1]=l);" + + "let u=hostReady(l),d=environment(),f;t[2]===i?f=t[3]:(f=i!=null&&isAvailabilityGated(i),t[2]=i,t[3]=f);" + + "let p=f,m;t[4]!==c||t[5]!==p?(m={enabled:p,hostId:c},t[4]=c,t[5]=p,t[6]=m):m=t[6];" + + "let h=useComputerUseAvailability(m),g=(r!=null||a!=null)&&i!=null,v=u&&s&&g&&(!p||h.available);" + + "let b=async()=>{if(i==null)throw Error(`plugin detail query requires pluginName`);" + + "return read(`read-plugin`,{hostId:c,...pluginLocation({marketplacePath:r,remoteMarketplaceName:a}),pluginName:i})};" + + "return useQuery({queryFn:b,enabled:v})}" + ); +} - const patched = applyPatchTwice(applyLinuxComputerUseHostPlatformPatch, source); +test("allows the exact 26.721 Computer Use host platform contract on Linux", () => { + const patched = applyPatchTwice( + applyLinuxComputerUseHostPlatformPatch, + currentComputerUseHostPlatform26721Fixture(), + ); assert.match( patched, - /g=resolveAvailability\(\{areRequiredFeaturesEnabled:h,enabled:i,isAnyFeatureLoading:m,isComputerUseGateEnabled:s,isHostCompatiblePlatform:o===`linux`\|\|Se\(o\),isPlatformLoading:a,windowType:`electron`\}\)/, + /g=X3r\(\{areRequiredFeaturesEnabled:h,enabled:i,isAnyFeatureLoading:m,isComputerUseGateEnabled:s,isHostCompatiblePlatform:o===`linux`\|\|K3r\(o\),isPlatformLoading:a,windowType:`electron`\}\)/, ); assert.doesNotMatch(patched, /areRequiredFeaturesEnabled:o===`linux`|isComputerUseGateEnabled:o===`linux`/); }); @@ -9350,22 +10422,59 @@ test("rejects current Computer Use host-platform drift byte-identically", () => assert.deepEqual(warnings, [ "WARN: Could not find current Computer Use host-platform gate — skipping Linux Computer Use host-platform patch", ]); + const hostDescriptor = + require("./patches/core/all-linux/webview/computer-use-ui/patch.js")[1]; + assert.equal(hostDescriptor.assetMatch(source), false); }); -test("loads current Computer Use plugin details on Linux despite the upstream availability gate", () => { - const source = - "function Ke(e){let t=cache(31),{hostId:n,marketplacePath:r,pluginName:i,remoteMarketplaceName:a,enabled:o}=e," + - "c=o===void 0?!0:o,l=n??`local`,d;t[0]===l?d=t[1]:(d={hostId:l},t[0]=l,t[1]=d);" + - "let f=hostReady(d),p=environment(),m;t[2]===i?m=t[3]:(m=i!=null&&isAvailabilityGated(i),t[2]=i,t[3]=m);" + - "let g=m,_;t[4]!==l||t[5]!==g?(_={enabled:g,hostId:l},t[4]=l,t[5]=g,t[6]=_):_=t[6];" + - "let v=useComputerUseAvailability(_),y=(r!=null||a!=null)&&i!=null,b=f&&c&&y&&g&&v.isLoading,x=f&&c&&y&&(!g||v.available);" + - "let query=async()=>{if(i==null)throw Error(`plugin detail query requires pluginName`);return read(`read-plugin`,{hostId:l,pluginName:i})};" + - "return useQuery({queryFn:query,enabled:x})}"; - - const patched = applyPatchTwice(applyLinuxComputerUseInstallFlowPatch, source); +test("loads the exact 26.721 Computer Use plugin detail contract on Linux", async () => { + const patched = applyPatchTwice( + applyLinuxComputerUseInstallFlowPatch, + currentComputerUseInstallFlow26721Fixture(), + ); + + assert.match(patched, /let p=f&&i!==`computer-use`,m;/); + assert.doesNotMatch(patched, /let p=f,m;/); + + const marketplacePath = + "/tmp/codex-test/openai-bundled/.agents/plugins/marketplace.json"; + let pluginRead = null; + const query = vm.runInNewContext( + `${patched};i4i(${JSON.stringify({ + hostId: "local", + marketplacePath, + pluginName: "computer-use", + })})`, + { + cache: (size) => new Array(size), + environment: () => "desktop", + hostReady: () => true, + isAvailabilityGated: () => true, + pluginLocation: ({ marketplacePath: selectedMarketplacePath }) => ({ + marketplacePath: selectedMarketplacePath, + }), + read: async (method, params) => { + pluginRead = { method, params }; + return { plugin: { name: "computer-use" } }; + }, + useComputerUseAvailability: () => ({ available: false }), + useQuery: (options) => options, + }, + ); - assert.match(patched, /let g=m&&i!==`computer-use`,_;/); - assert.doesNotMatch(patched, /let g=m,_;/); + assert.equal(query.enabled, true); + await query.queryFn(); + assert.deepEqual( + JSON.parse(JSON.stringify(pluginRead)), + { + method: "read-plugin", + params: { + hostId: "local", + marketplacePath, + pluginName: "computer-use", + }, + }, + ); }); test("rejects current Computer Use plugin detail drift byte-identically", () => { @@ -9383,6 +10492,52 @@ test("rejects current Computer Use plugin detail drift byte-identically", () => assert.deepEqual(warnings, [ "WARN: Could not find current Computer Use plugin detail availability gate — skipping Linux Computer Use install flow patch", ]); + const installFlowDescriptor = + require("./patches/core/all-linux/webview/computer-use-ui/patch.js")[2]; + assert.equal(installFlowDescriptor.assetMatch(source), false); +}); + +test("warns precisely and preserves drifted 26.721 app-initial near-misses", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-computer-use-near-miss-")); + try { + const assetsDir = path.join(tempRoot, "webview", "assets"); + fs.mkdirSync(assetsDir, { recursive: true }); + const assetPath = path.join(assetsDir, "app-initial-near-miss.js"); + const source = + "const feature={featureName:`computer_use`};" + + "result=helper({areRequiredFeaturesEnabled:a,enabled:b,isAnyFeatureLoading:c,isComputerUseGateEnabled:d,isHostCompatiblePlatform:drifted(platform,other),isPlatformLoading:e,windowType:`electron`});" + + "function usePluginDetail(e){let{pluginName:i}=e,f=i!=null&&isAvailabilityGated(i);" + + "let p=drifted(f),m;m={enabled:p};let h=useComputerUseAvailability(m),v=!p||h.available;" + + "let query=()=>{if(i==null)throw Error(`plugin detail query requires pluginName`);" + + "return read(`read-plugin`,{pluginName:i})};return useQuery({queryFn:query,enabled:v})}"; + fs.writeFileSync(assetPath, source); + const descriptors = + require("./patches/core/all-linux/webview/computer-use-ui/patch.js").slice(1); + const report = createPatchReport(); + const { warnings } = captureWarns(() => + applyWebviewAssetPatchDescriptors( + tempRoot, + descriptors, + { enableComputerUseUi: true }, + report, + ), + ); + + assert.equal(fs.readFileSync(assetPath, "utf8"), source); + assert.deepEqual(warnings, [ + `WARN: Could not find current Computer Use host-platform app-initial contract in ${assetsDir} — skipping Linux Computer Use host-platform patch`, + `WARN: Could not find current Computer Use install flow app-initial contract in ${assetsDir} — skipping Linux Computer Use install flow patch`, + ]); + assert.deepEqual( + report.patches.map(({ name, status }) => ({ name, status })), + [ + { name: "linux-computer-use-host-platform", status: "skipped-optional" }, + { name: "linux-computer-use-install-flow", status: "skipped-optional" }, + ], + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } }); function externalOpenChildClosingWith(code) { @@ -9417,7 +10572,7 @@ function evaluatePatchedExternalOpen({ }, }; const source = - "\"use strict\";let e=require(`electron`);async function openExternal(url,options){return e.shell.openExternal(url,options)}"; + "\"use strict\";let e=require(`electron`),t=require(`electron`);async function openExternal(url,options){return e.shell.openExternal(url,options)}"; const patched = applyPatchTwice(applyLinuxExternalOpenEnvPatch, source); const openExternal = vm.runInNewContext(`${patched};openExternal`, { require(moduleName) { @@ -9503,7 +10658,7 @@ test("falls back to Electron when sanitized xdg-open spawning fails", async () = test("keeps already-applied Linux external-open patch quiet", () => { const source = - "\"use strict\";let e=require(`electron`);async function openExternal(url,options){return e.shell.openExternal(url,options)}"; + "\"use strict\";let e=require(`electron`),t=require(`electron`);async function openExternal(url,options){return e.shell.openExternal(url,options)}"; const patched = applyLinuxExternalOpenEnvPatch(source); const { value, warnings } = captureWarns(() => applyLinuxExternalOpenEnvPatch(patched)); @@ -9518,7 +10673,7 @@ test("warns when Linux external-open helper exists without wrapped Electron requ assert.equal(value, source); assert.deepEqual(warnings, [ - "WARN: Could not find Electron require initializer — skipping Linux external open environment patch", + "WARN: Found incomplete Linux external open environment patch — skipping", ]); }); @@ -9918,7 +11073,7 @@ test("patchMainBundleSource keeps non-icon patches active without an icon asset" assert.match(patched, /n\.app\.on\(`before-quit`,codexLinuxBeforeQuitHandler\)/); assert.match( patched, - /process\.platform===`linux`&&\(k\.on\(`system-context-menu`,e=>e\.preventDefault\(\)\),k\.removeMenu\(\)\)/, + new RegExp(escapeRegExp(canonicalLinuxMenuSnippet("k"))), ); assert.match(patched, /linux:\{label:`File Manager`/); assert.match( @@ -10085,7 +11240,7 @@ test("missing icon asset skips only icon patches", () => { } }); -test("patchExtractedApp scans current Computer Use settings bundles when UI is enabled", () => { +test("patchExtractedApp selects the exact 26.721 Computer Use app-initial contract", () => { withIsolatedHome(() => { process.env[COMPUTER_USE_UI_ENV_VAR] = "1"; const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-computer-use-apps-assets-test-")); @@ -10106,67 +11261,54 @@ test("patchExtractedApp scans current Computer Use settings bundles when UI is e ].join(""), ); fs.writeFileSync( - path.join(assetsDir, "computer-use-settings-DsM_pz8i.js"), - "function Ht(){let e=cache(24),{selectedHostId:t}=host(),n=data(t),i={hostId:t};" + + path.join(assetsDir, "computer-use-settings-BzkBOuLk.js"), + "var computerUsePluginName=`computer-use`;function Ht(){let e=cache(24),{selectedHostId:t}=host(),n=data(t),i={hostId:t};" + "let a=useAvailability(i),{platform:o}=usePlatform(),s=hostKind(t)===`local`,c=flag(`188145323`);" + "let f=jsx(Settings,{computerUseAvailability:a,platform:o});let h=a.available?jsx(AllowedApps,{}):null;return jsx(Page,{children:[f,h]})}" + "function Wt(e){let t=cache(35),{computerUseAvailability:n,platform:i}=e,{selectedHostId:s}=host();" + "let g=[];let _=usePlugins(s,g),v=useMarketplacePath(s),y=useFlag(firstFlag),b=useFlag(secondFlag),x;" + "x=selectPlugin(_.availablePlugins,computerUsePluginName,v);return x}", ); + const appInitialSource = + currentComputerUseHostPlatform26721Fixture() + + currentComputerUseInstallFlow26721Fixture(); fs.writeFileSync( - path.join( - assetsDir, - "app-initial~avatarOverlayCompositionSurface~artifact-tab-content.electron~notebook-preview-~iaq4jiqv-current.js", - ), - "function Ke(e){let t=cache(31),{hostId:n,marketplacePath:r,pluginName:i,remoteMarketplaceName:a,enabled:o}=e," + - "c=o===void 0?!0:o,l=n??`local`,d;t[0]===l?d=t[1]:(d={hostId:l},t[0]=l,t[1]=d);" + - "let f=hostReady(d),p=environment(),m;t[2]===i?m=t[3]:(m=i!=null&&isAvailabilityGated(i),t[2]=i,t[3]=m);" + - "let g=m,_;t[4]!==l||t[5]!==g?(_={enabled:g,hostId:l},t[4]=l,t[5]=g,t[6]=_):_=t[6];" + - "let v=useComputerUseAvailability(_),y=(r!=null||a!=null)&&i!=null,b=f&&c&&y&&g&&v.isLoading,x=f&&c&&y&&(!g||v.available);" + - "let query=async()=>{if(i==null)throw Error(`plugin detail query requires pluginName`);return read(`read-plugin`,{hostId:l,pluginName:i})};" + - "return useQuery({queryFn:query,enabled:x})}", + path.join(assetsDir, "app-initial-BHB6SClA.js"), + appInitialSource, ); + const unrelatedAppInitialSource = + "function unrelatedAppInitial(){return `plugin detail query requires pluginName`}" + + "const unrelatedFeature={featureName:`computer_use`};"; fs.writeFileSync( - path.join( - assetsDir, - "app-initial~artifact-tab-content.electron~notebook-preview-panel~app-main~settings-command-~ekwfx4j1-current.js", - ), - "function Se(e){return e===`macOS`||e===`windows`}" + - "function Ce(e){let t=cache(16),{enabled:n,hostId:r}=e,i=n===void 0?!0:n,{isLoading:a,platform:o}=usePlatform(),s=flag(`1506311413`),c;t[0]===r?c=t[1]:(c={featureName:`computer_use`,hostId:r},t[0]=r,t[1]=c);let l=useFeature(c),u=o===`windows`&&!a,d=i&&u,f;t[2]===d?f=t[3]:(f={enabled:d},t[2]=d,t[3]=f);let p=useWindowsFeature(f),m=l.isLoading||u&&p.isLoading,h=l.enabled&&(!u||p.enabled),g;t[4]!==h||t[5]!==i||t[6]!==m||t[7]!==s||t[8]!==a||t[9]!==o?(g=resolveAvailability({areRequiredFeaturesEnabled:h,enabled:i,isAnyFeatureLoading:m,isComputerUseGateEnabled:s,isHostCompatiblePlatform:Se(o),isPlatformLoading:a,windowType:`electron`}),t[4]=h,t[5]=i,t[6]=m,t[7]=s,t[8]=a,t[9]=o,t[10]=g):g=t[10];return g}", + path.join(assetsDir, "app-initial-unrelated.js"), + unrelatedAppInitialSource, ); fs.writeFileSync(path.join(tempRoot, "package.json"), JSON.stringify({ name: "codex" })); const firstReport = createPatchReport(); patchExtractedApp(tempRoot, { report: firstReport }); - const settingsPath = path.join(assetsDir, "computer-use-settings-DsM_pz8i.js"); - const detailPath = path.join( - assetsDir, - "app-initial~avatarOverlayCompositionSurface~artifact-tab-content.electron~notebook-preview-~iaq4jiqv-current.js", - ); - const hostPlatformPath = path.join( - assetsDir, - "app-initial~artifact-tab-content.electron~notebook-preview-panel~app-main~settings-command-~ekwfx4j1-current.js", - ); + const settingsPath = path.join(assetsDir, "computer-use-settings-BzkBOuLk.js"); + const appInitialPath = path.join(assetsDir, "app-initial-BHB6SClA.js"); + const unrelatedAppInitialPath = path.join(assetsDir, "app-initial-unrelated.js"); const patchedSettings = fs.readFileSync(settingsPath, "utf8"); - const patchedDetail = fs.readFileSync(detailPath, "utf8"); - const patchedHostPlatform = fs.readFileSync(hostPlatformPath, "utf8"); + const patchedAppInitial = fs.readFileSync(appInitialPath, "utf8"); assert.match( patchedSettings, /o===`linux`&&\(a=\{\.\.\.a,available:!0,isFetching:!1,isLoading:!1\}\);/, ); assert.match(patchedSettings, /marketplaceName:`openai-bundled`/); - assert.match(patchedDetail, /let g=m&&i!==`computer-use`,_;/); + assert.match(patchedAppInitial, /let p=f&&i!==`computer-use`,m;/); + assert.match( + patchedAppInitial, + /g=X3r\(\{areRequiredFeaturesEnabled:h,enabled:i,isAnyFeatureLoading:m,isComputerUseGateEnabled:s,isHostCompatiblePlatform:o===`linux`\|\|K3r\(o\),isPlatformLoading:a,windowType:`electron`\}\)/, + ); + assert.equal(fs.readFileSync(unrelatedAppInitialPath, "utf8"), unrelatedAppInitialSource); assert.equal( firstReport.patches.find((patch) => patch.name === "linux-computer-use-ui-availability")?.status, "applied", ); - assert.match( - patchedHostPlatform, - /g=resolveAvailability\(\{areRequiredFeaturesEnabled:h,enabled:i,isAnyFeatureLoading:m,isComputerUseGateEnabled:s,isHostCompatiblePlatform:o===`linux`\|\|Se\(o\),isPlatformLoading:a,windowType:`electron`\}\)/, - ); assert.equal( firstReport.patches.find((patch) => patch.name === "linux-computer-use-host-platform")?.status, "applied", @@ -10175,13 +11317,21 @@ test("patchExtractedApp scans current Computer Use settings bundles when UI is e firstReport.patches.find((patch) => patch.name === "linux-computer-use-install-flow")?.status, "applied", ); + assert.equal( + firstReport.patches.find((patch) => patch.name === "linux-computer-use-host-platform")?.assetName, + "app-initial-BHB6SClA.js", + ); + assert.equal( + firstReport.patches.find((patch) => patch.name === "linux-computer-use-install-flow")?.assetName, + "app-initial-BHB6SClA.js", + ); const secondReport = createPatchReport(); patchExtractedApp(tempRoot, { report: secondReport }); assert.equal(fs.readFileSync(settingsPath, "utf8"), patchedSettings); - assert.equal(fs.readFileSync(detailPath, "utf8"), patchedDetail); - assert.equal(fs.readFileSync(hostPlatformPath, "utf8"), patchedHostPlatform); + assert.equal(fs.readFileSync(appInitialPath, "utf8"), patchedAppInitial); + assert.equal(fs.readFileSync(unrelatedAppInitialPath, "utf8"), unrelatedAppInitialSource); assert.equal( secondReport.patches.find((patch) => patch.name === "linux-computer-use-ui-availability")?.status, "already-applied", @@ -10359,6 +11509,7 @@ test("patch report summary separates required core, optional core, and optional patches: [ { name: "main-process-ui", status: "applied", sourceKind: "core", ciPolicy: "required-upstream" }, { name: "linux-app-updater-bridge", status: "skipped-optional", sourceKind: "core", ciPolicy: "optional" }, + { name: "linux-integrity-check", status: "failed-integrity", sourceKind: "core", ciPolicy: "optional" }, { name: "feature:remote-mobile-control:linux-remote-mobile-conversation-hydration", status: "applied-with-warnings", @@ -10370,6 +11521,9 @@ test("patch report summary separates required core, optional core, and optional }); assert.deepEqual(summary.enabledFeatures, ["remote-mobile-control"]); + assert.deepEqual(summary.groups.integrityFailures.statusCounts, { + "failed-integrity": 1, + }); assert.deepEqual(summary.groups.requiredCore.statusCounts, { applied: 1 }); assert.deepEqual(summary.groups.optionalCore.statusCounts, { "skipped-optional": 1 }); assert.deepEqual(summary.groups.optionalFeatures.statusCounts, { "applied-with-warnings": 1 }); @@ -10430,6 +11584,46 @@ test("main-process-ui aggregate ignores optional main-bundle drift warnings", () } }); +test("main-process-ui rejects a patch-created JavaScript syntax error without writing it", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-patch-report-main-syntax-")); + const coreRoot = path.join(tempRoot, "core-patches"); + try { + const buildDir = path.join(tempRoot, ".vite", "build"); + const patchDir = path.join(coreRoot, "all-linux", "main-process", "syntax-test"); + const mainPath = path.join(buildDir, "main.js"); + const originalSource = "codexRequiredPatchOff()"; + fs.mkdirSync(buildDir, { recursive: true }); + fs.mkdirSync(patchDir, { recursive: true }); + fs.writeFileSync(mainPath, originalSource); + fs.writeFileSync( + path.join(patchDir, "patch.js"), + [ + '"use strict";', + "module.exports={", + 'id:"required-main-bundle-syntax-test",', + 'phase:"main-bundle",', + 'ciPolicy:"required-upstream",', + 'apply:(source)=>source.replace("codexRequiredPatchOff()","codexRequiredPatchOn())")', + "};", + ].join("\n"), + ); + + const report = createPatchReport(); + const { warnings } = captureWarns(() => + patchExtractedApp(tempRoot, { report, corePatchRoot: coreRoot }), + ); + + const aggregate = report.patches.find((patch) => patch.name === "main-process-ui"); + assert.equal(fs.readFileSync(mainPath, "utf8"), originalSource); + assert.equal(aggregate.status, "failed-required"); + assert.match(aggregate.reason, /invalid JavaScript syntax: Unexpected token '\)'/); + assert.ok(warnings.some((warning) => warning.includes("invalid JavaScript syntax"))); + } finally { + fs.rmSync(coreRoot, { recursive: true, force: true }); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + test("patch report marks missing required webview assets as required failures", () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-patch-report-missing-webview-")); try { @@ -11022,12 +12216,14 @@ test("criticalFailuresFromReport agrees with validateReport and skips non-applic { name: "req-bad", status: "failed-required", ciPolicy: "required-upstream", reason: "anchor drifted" }, { name: "req-good", status: "applied", ciPolicy: "required-upstream" }, { name: "req-not-applicable", status: "skipped-target", ciPolicy: "required-upstream" }, + { name: "integrity-bad", status: "failed-integrity", ciPolicy: "optional", reason: "rollback failed" }, { name: "opt-bad", status: "skipped-optional", ciPolicy: "optional", reason: "optional drift" }, ], }; assert.deepEqual(criticalFailuresFromReport(report), [ { name: "req-bad", status: "failed-required", reason: "anchor drifted" }, + { name: "integrity-bad", status: "failed-integrity", reason: "rollback failed" }, ]); assert.deepEqual(optionalDriftFromReport(report), [ { name: "opt-bad", status: "skipped-optional", reason: "optional drift" }, @@ -11035,6 +12231,7 @@ test("criticalFailuresFromReport agrees with validateReport and skips non-applic const failures = validateReport(report, "upstream-build"); assert.ok(failures.some((failure) => failure.startsWith("req-bad:"))); + assert.ok(failures.some((failure) => failure.startsWith("integrity-bad:"))); assert.ok(!failures.some((failure) => failure.startsWith("req-not-applicable:"))); assert.ok(!failures.some((failure) => failure.startsWith("opt-bad:"))); }); diff --git a/scripts/patches/composition.test.js b/scripts/patches/composition.test.js new file mode 100644 index 000000000..b40c8a03b --- /dev/null +++ b/scripts/patches/composition.test.js @@ -0,0 +1,627 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const { + applyMainBundlePatchDescriptors, + applyWebviewAssetPatchDescriptors, + normalizePatchDescriptors, +} = require("./engine.js"); +const { + corePatchDescriptors, + featurePatchDescriptors, + patchCompositionDelegates, +} = require("./runner.js"); +const { + createPatchReport, + criticalFailuresFromReport, + enabledFeatureFailuresFromReport, + optionalDriftFromReport, +} = require("../lib/patch-report.js"); +const { + patchDelegationMarker, +} = require("./lib/composition-delegation.js"); + +function captureWarns(fn) { + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args.map(String).join(" ")); + try { + return { value: fn(), warnings }; + } finally { + console.warn = originalWarn; + } +} + +test("current main-process feature composition is byte-identical on a second pass", () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "codex-main-feature-composition-"), + ); + try { + const featuresConfigPath = path.join(tempRoot, "features.json"); + fs.writeFileSync( + featuresConfigPath, + JSON.stringify({ enabled: ["record-and-replay", "frameless-titlebar"] }), + ); + const descriptorIds = new Set([ + "linux-native-titlebar", + "feature:record-and-replay:linux-record-replay-main-bridge", + "linux-external-open-env", + "feature:frameless-titlebar:main-process", + ]); + const descriptors = normalizePatchDescriptors([ + ...corePatchDescriptors(), + ...featurePatchDescriptors({ featuresConfigPath }), + ].filter(({ id }) => descriptorIds.has(id))); + assert.deepEqual( + descriptors.map(({ id }) => id), + [ + "linux-native-titlebar", + "feature:record-and-replay:linux-record-replay-main-bridge", + "linux-external-open-env", + "feature:frameless-titlebar:main-process", + ], + ); + + const source = [ + "\"use strict\";let c=require(`electron`),d=require(`electron`);", + "function A9(e){return e===`avatarOverlay`}", + "function I9({platform:e,appearance:t,opaqueWindowsEnabled:n,prefersDarkColors:r}){return n&&!A9(t)&&(e===`darwin`||e===`win32`)?{backgroundColor:r?L9:K9,backgroundMaterial:e===`win32`?`none`:null}:e===`linux`&&!A9(t)?{backgroundColor:r?L9:K9,backgroundMaterial:null}:{backgroundColor:W9,backgroundMaterial:null}}", + "function j9(e=1){return{color:W9,symbolColor:c.nativeTheme.shouldUseDarkColors?i9:r9,height:Math.round(g9*e)}}", + "case`quickChat`:case`primary`:return n===`darwin`?{titleBarStyle:`hiddenInset`,trafficLightPosition:A9(r),...e===`quickChat`?{hasShadow:!0,resizable:!0,transparent:!0}:{},...t?{}:{vibrancy:`menu`}}:n===`win32`||n===`linux`?{titleBarStyle:`hidden`,titleBarOverlay:j9(r),...e===`quickChat`?{resizable:!0}:{}}:{titleBarStyle:`default`,...e===`quickChat`?{resizable:!0}:{}};", + "setWindowZoom(e,t){let n=c.BrowserWindow.fromWebContents(e),r=n&&this.windowAppearances.get(n.id);n==null||r!==`primary`&&r!==`quickChat`||(process.platform===`darwin`?n.setWindowButtonPosition(A9(t)):(process.platform===`win32`||process.platform===`linux`)&&(this.windowZooms.set(n.id,t),n.setTitleBarOverlay(j9(t))))}", + "installApplicationMenuTitleBarOverlaySync(e,t){if(process.platform!==`win32`&&process.platform!==`linux`||t!==`primary`&&t!==`quickChat`)return;let n=()=>{e.isDestroyed()||e.setTitleBarOverlay(j9(this.windowZooms.get(e.id)))};return c.nativeTheme.on(`updated`,n),n(),()=>{c.nativeTheme.off(`updated`,n)}}", + "var tray={getChronicleSidecarControlState:()=>tt().skysight?$9:Se.appServerConnectionRegistry.getMaybeConnection(`local`)?.getChronicleSidecarControlState()??$9,toggleChronicleSidecar:async()=>{if(tt().skysight)return $9;let e=Se.appServerConnectionRegistry.getMaybeConnection(V);return e==null?$9:e.getChronicleSidecarControlState().running?e.pauseChronicleSidecar():e.resumeChronicleSidecar()}};", + "var bridge={\"get-global-state\":async({key:e})=>null};", + "async function openExternal(url,options){return c.shell.openExternal(url,options)}", + ].join(""); + const context = { + enabledFeatureIds: ["frameless-titlebar", "record-and-replay"], + iconAsset: null, + patchCompositionDelegates: patchCompositionDelegates(descriptors), + }; + const firstReport = createPatchReport(); + const first = captureWarns(() => + applyMainBundlePatchDescriptors( + source, + descriptors, + context, + firstReport, + ), + ); + + assert.notEqual(first.value.patchedSource, source); + assert.deepEqual(first.warnings, []); + assert.deepEqual(first.value.warnings, []); + assert.deepEqual( + firstReport.patches.map(({ name, status }) => ({ name, status })), + descriptors.map(({ id }) => ({ name: id, status: "applied" })), + ); + + const secondReport = createPatchReport(); + const second = captureWarns(() => + applyMainBundlePatchDescriptors( + first.value.patchedSource, + descriptors, + context, + secondReport, + ), + ); + + assert.equal(second.value.patchedSource, first.value.patchedSource); + assert.deepEqual(second.warnings, []); + assert.deepEqual(second.value.warnings, []); + assert.deepEqual( + secondReport.patches.map(({ name, status }) => ({ name, status })), + descriptors.map(({ id }) => ({ + name: id, + status: "already-applied", + })), + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test("native titlebar report rejects preserved markers with damaged required consumers", () => { + const descriptor = corePatchDescriptors().find( + ({ id }) => id === "linux-native-titlebar", + ); + const source = [ + "function A9(e){return e===`avatarOverlay`}", + "function I9({platform:e,appearance:t,opaqueWindowsEnabled:n,prefersDarkColors:r}){return n&&!A9(t)&&(e===`darwin`||e===`win32`)?{backgroundColor:r?L9:K9,backgroundMaterial:e===`win32`?`none`:null}:e===`linux`&&!A9(t)?{backgroundColor:r?L9:K9,backgroundMaterial:null}:{backgroundColor:W9,backgroundMaterial:null}}", + "function j9(e=1){return{color:W9,symbolColor:c.nativeTheme.shouldUseDarkColors?i9:r9,height:Math.round(g9*e)}}", + "case`quickChat`:case`primary`:return n===`darwin`?{titleBarStyle:`hiddenInset`,trafficLightPosition:A9(r),...e===`quickChat`?{hasShadow:!0,resizable:!0,transparent:!0}:{},...t?{}:{vibrancy:`menu`}}:n===`win32`||n===`linux`?{titleBarStyle:`hidden`,titleBarOverlay:j9(r),...e===`quickChat`?{resizable:!0}:{}}:{titleBarStyle:`default`,...e===`quickChat`?{resizable:!0}:{}};", + "setWindowZoom(e,t){let n=c.BrowserWindow.fromWebContents(e),r=n&&this.windowAppearances.get(n.id);n==null||r!==`primary`&&r!==`quickChat`||(process.platform===`darwin`?n.setWindowButtonPosition(A9(t)):(process.platform===`win32`||process.platform===`linux`)&&(this.windowZooms.set(n.id,t),n.setTitleBarOverlay(j9(t))))}", + "installApplicationMenuTitleBarOverlaySync(e,t){if(process.platform!==`win32`&&process.platform!==`linux`||t!==`primary`&&t!==`quickChat`)return;let n=()=>{e.isDestroyed()||e.setTitleBarOverlay(j9(this.windowZooms.get(e.id)))};return c.nativeTheme.on(`updated`,n),n(),()=>{c.nativeTheme.off(`updated`,n)}}", + ].join(""); + const firstReport = createPatchReport(); + const first = applyMainBundlePatchDescriptors( + source, + [descriptor], + {}, + firstReport, + ).patchedSource; + const damagedVariants = [ + first.replace( + "n.setTitleBarOverlay(process.platform===`linux`?codexLinuxTitleBarOverlay(t):j9(t))", + "n.setTitleBarOverlay(j9(t))", + ), + first.replace( + "function codexLinuxTitleBarOverlay(e=1)", + "function codexLinuxTitleBarOverlay(e)", + ), + first.replace( + "if(process.platform!==`win32`&&process.platform!==`linux`||t!==`primary`", + "if(process.platform!==`win32`||t!==`primary`", + ), + first.replace( + /setWindowZoom[\s\S]*?(?=installApplicationMenuTitleBarOverlaySync)/u, + "", + ), + first.replace( + /installApplicationMenuTitleBarOverlaySync[\s\S]*$/u, + "", + ), + ]; + + for (const damaged of damagedVariants) { + assert.notEqual(damaged, first); + const secondReport = createPatchReport(); + const { warnings } = captureWarns(() => + applyMainBundlePatchDescriptors( + damaged, + [descriptor], + {}, + secondReport, + ), + ); + + assert.equal( + secondReport.patches[0]?.status, + "failed-required", + ); + assert.equal( + criticalFailuresFromReport(secondReport)[0]?.name, + "linux-native-titlebar", + ); + assert.match(warnings[0] ?? "", /incomplete Linux native titlebar patch/); + } + + const wrongDelegate = first + .replace( + "/*codexLinuxNativeTitlebarPatch*/", + patchDelegationMarker( + "linux-native-titlebar", + "record-and-replay", + ), + ) + .replace( + "n.setTitleBarOverlay(process.platform===`linux`?codexLinuxTitleBarOverlay(t):j9(t))", + "n.setTitleBarOverlay(j9(t))", + ); + const wrongDelegateReport = createPatchReport(); + const { warnings } = captureWarns(() => + applyMainBundlePatchDescriptors( + wrongDelegate, + [descriptor], + { + enabledFeatureIds: ["record-and-replay"], + patchCompositionDelegates: { + "linux-native-titlebar": ["frameless-titlebar"], + }, + }, + wrongDelegateReport, + ), + ); + assert.equal( + wrongDelegateReport.patches[0]?.status, + "failed-required", + ); + assert.match(warnings[0] ?? "", /inactive or invalid.*delegation/); +}); + +test("window controls safe-area report rejects a preserved marker with damaged consumers", () => { + const descriptor = corePatchDescriptors().find( + ({ id }) => id === "linux-window-controls-safe-area", + ); + const source = [ + "var l=Object.freeze({default:Object.freeze({left:0,right:0}),applicationMenu:Object.freeze({left:0,right:0})});", + "function ol({isHeaderEdgeScroll:e,isApplicationMenuBarEnabled:t}){return (0,gl.jsxs)(ue.header,{children:[(0,gl.jsx)(sl,{entries:m,fitWidth:n,slotWidth:c,side:`start`}),(0,gl.jsx)(sl,{entries:h,fitWidth:r,slotWidth:u,side:`end`})]})}", + "function sl({entries:e,fitWidth:t,side:n,slotWidth:r}){let i=e.some(({align:e})=>e===`end`),o=a({\"pe-2\":n===`start`&&i||n===`end`});return jsx(o)}", + ].join(""); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "codex-safe-area-composition-"), + ); + try { + const assetsDir = path.join(tempRoot, "webview", "assets"); + const assetPath = path.join(assetsDir, "app-initial-current.js"); + fs.mkdirSync(assetsDir, { recursive: true }); + fs.writeFileSync(assetPath, source); + + const firstReport = createPatchReport(); + applyWebviewAssetPatchDescriptors( + tempRoot, + [descriptor], + {}, + firstReport, + ); + const first = fs.readFileSync(assetPath, "utf8"); + const damagedVariants = [ + first.replace( + "applicationMenu:Object.freeze({left:0,right:138})", + "applicationMenu:Object.freeze({left:0,right:0})", + ), + first.replace( + "applicationMenu:Object.freeze({left:0,right:138})", + "applicationMenu:Object.freeze({left:0,right:dynamicInset})", + ), + first.replace( + ",codexLinuxUseWindowControlsSafeArea}){", + "}){", + ), + first.replace( + '"pe-2":n===`start`&&i||n===`end`&&!codexLinuxUseWindowControlsSafeArea,"pe-(--spacing-token-safe-header-right)":n===`end`&&codexLinuxUseWindowControlsSafeArea', + '"pe-2":n===`start`&&i||n===`end`', + ), + ]; + + for (const damaged of damagedVariants) { + assert.notEqual(damaged, first); + fs.writeFileSync(assetPath, damaged); + + const secondReport = createPatchReport(); + const { warnings } = captureWarns(() => + applyWebviewAssetPatchDescriptors( + tempRoot, + [descriptor], + {}, + secondReport, + ), + ); + + assert.equal( + secondReport.patches[0]?.status, + "skipped-optional", + ); + assert.equal( + optionalDriftFromReport(secondReport)[0]?.name, + "linux-window-controls-safe-area", + ); + assert.match( + warnings[0] ?? "", + /incomplete Linux window-controls safe-area patch/, + ); + } + + const wrongDelegate = first + .replace( + "/*codexLinuxWindowControlsSafeAreaPatch*/", + patchDelegationMarker( + "linux-window-controls-safe-area", + "record-and-replay", + ), + ) + .replace( + "applicationMenu:Object.freeze({left:0,right:138})", + "applicationMenu:Object.freeze({left:0,right:dynamicInset})", + ); + fs.writeFileSync(assetPath, wrongDelegate); + const wrongDelegateReport = createPatchReport(); + const { warnings } = captureWarns(() => + applyWebviewAssetPatchDescriptors( + tempRoot, + [descriptor], + { + enabledFeatureIds: ["record-and-replay"], + patchCompositionDelegates: { + "linux-window-controls-safe-area": ["frameless-titlebar"], + }, + }, + wrongDelegateReport, + ), + ); + assert.equal( + wrongDelegateReport.patches[0]?.status, + "skipped-optional", + ); + assert.match(warnings[0] ?? "", /inactive or invalid.*delegation/); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test("enabled frameless main composition drift is reported by the owning feature", () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "codex-frameless-main-report-"), + ); + try { + const featuresConfigPath = path.join(tempRoot, "features.json"); + fs.writeFileSync( + featuresConfigPath, + JSON.stringify({ enabled: ["frameless-titlebar"] }), + ); + const descriptorIds = new Set([ + "linux-native-titlebar", + "feature:frameless-titlebar:main-process", + ]); + const descriptors = normalizePatchDescriptors([ + ...corePatchDescriptors(), + ...featurePatchDescriptors({ featuresConfigPath }), + ].filter(({ id }) => descriptorIds.has(id))); + const context = { + enabledFeatureIds: ["frameless-titlebar"], + patchCompositionDelegates: patchCompositionDelegates(descriptors), + }; + const source = [ + "function A9(e){return e===`avatarOverlay`}", + "function I9({platform:e,appearance:t,opaqueWindowsEnabled:n,prefersDarkColors:r}){return n&&!A9(t)&&(e===`darwin`||e===`win32`)?{backgroundColor:r?L9:K9,backgroundMaterial:e===`win32`?`none`:null}:e===`linux`&&!A9(t)?{backgroundColor:r?L9:K9,backgroundMaterial:null}:{backgroundColor:W9,backgroundMaterial:null}}", + "function j9(e=1){return{color:W9,symbolColor:c.nativeTheme.shouldUseDarkColors?i9:r9,height:Math.round(g9*e)}}", + "case`quickChat`:case`primary`:return n===`darwin`?{titleBarStyle:`hiddenInset`,trafficLightPosition:A9(r),...e===`quickChat`?{hasShadow:!0,resizable:!0,transparent:!0}:{},...t?{}:{vibrancy:`menu`}}:n===`win32`||n===`linux`?{titleBarStyle:`hidden`,titleBarOverlay:j9(r),...e===`quickChat`?{resizable:!0}:{}}:{titleBarStyle:`default`,...e===`quickChat`?{resizable:!0}:{}};", + "setWindowZoom(e,t){let n=c.BrowserWindow.fromWebContents(e),r=n&&this.windowAppearances.get(n.id);n==null||r!==`primary`&&r!==`quickChat`||(process.platform===`darwin`?n.setWindowButtonPosition(A9(t)):(process.platform===`win32`||process.platform===`linux`)&&(this.windowZooms.set(n.id,t),n.setTitleBarOverlay(j9(t))))}", + "installApplicationMenuTitleBarOverlaySync(e,t){if(process.platform!==`win32`&&process.platform!==`linux`||t!==`primary`&&t!==`quickChat`)return;let n=()=>{e.isDestroyed()||e.setTitleBarOverlay(j9(this.windowZooms.get(e.id)))};return c.nativeTheme.on(`updated`,n),n(),()=>{c.nativeTheme.off(`updated`,n)}}", + ].join(""); + const first = applyMainBundlePatchDescriptors( + source, + descriptors, + context, + createPatchReport(), + ).patchedSource; + const delegatedMarker = patchDelegationMarker( + "linux-native-titlebar", + "frameless-titlebar", + ); + const damagedVariants = [ + { + source: first.replace( + "process.platform===`win32`&&(this.windowZooms.set", + "(process.platform===`win32`||process.platform===`linux`)&&(this.windowZooms.set", + ), + coreStatus: "already-applied", + }, + { + source: first.replace( + /setWindowZoom[\s\S]*?(?=installApplicationMenuTitleBarOverlaySync)/u, + "", + ), + coreStatus: "already-applied", + }, + { + source: first.replace( + /installApplicationMenuTitleBarOverlaySync[\s\S]*$/u, + "", + ), + coreStatus: "already-applied", + }, + { + source: first.replace( + delegatedMarker, + `/*codexLinuxNativeTitlebarPatch*/${delegatedMarker}`, + ), + coreStatus: "failed-required", + }, + ]; + for (const { source: damaged, coreStatus } of damagedVariants) { + assert.notEqual(damaged, first); + + const report = createPatchReport(); + report.enabledFeatures = ["frameless-titlebar"]; + const { value, warnings } = captureWarns(() => + applyMainBundlePatchDescriptors( + damaged, + descriptors, + context, + report, + ), + ); + + assert.equal(value.patchedSource, damaged); + assert.deepEqual( + report.patches.map(({ name, status }) => ({ name, status })), + [ + { name: "linux-native-titlebar", status: coreStatus }, + { + name: "feature:frameless-titlebar:main-process", + status: "skipped-optional", + }, + ], + ); + assert.equal( + enabledFeatureFailuresFromReport(report)[0]?.name, + "feature:frameless-titlebar:main-process", + ); + assert.ok( + warnings.some((warning) => + /delegated frameless titlebar/.test(warning) + ), + ); + if (coreStatus === "failed-required") { + assert.equal( + criticalFailuresFromReport(report)[0]?.name, + "linux-native-titlebar", + ); + assert.ok( + warnings.some((warning) => /invalid.*delegation/.test(warning)), + ); + } + } + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test("enabled frameless webview composition drift is reported by the owning feature", () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "codex-frameless-webview-report-"), + ); + try { + const featuresConfigPath = path.join(tempRoot, "features.json"); + fs.writeFileSync( + featuresConfigPath, + JSON.stringify({ enabled: ["frameless-titlebar"] }), + ); + const descriptorIds = new Set([ + "linux-window-controls-safe-area", + "feature:frameless-titlebar:webview-window-controls-layout", + ]); + const descriptors = normalizePatchDescriptors([ + ...corePatchDescriptors(), + ...featurePatchDescriptors({ featuresConfigPath }), + ].filter(({ id }) => descriptorIds.has(id))); + const context = { + enabledFeatureIds: ["frameless-titlebar"], + patchCompositionDelegates: patchCompositionDelegates(descriptors), + }; + const assetsDir = path.join(tempRoot, "webview", "assets"); + const assetPath = path.join(assetsDir, "app-initial-current.js"); + fs.mkdirSync(assetsDir, { recursive: true }); + fs.writeFileSync( + assetPath, + [ + "var l=Object.freeze({default:Object.freeze({left:0,right:0}),applicationMenu:Object.freeze({left:0,right:0})});", + "function ol({isHeaderEdgeScroll:e,isApplicationMenuBarEnabled:t}){return jsx(sl,{entries:h,fitWidth:r,slotWidth:u,side:`end`})}", + "function sl({entries:e,fitWidth:t,side:n,slotWidth:r}){let i=e.some(({align:e})=>e===`end`),o=a({\"pe-2\":n===`start`&&i||n===`end`});return jsx(o)}", + "let newer=i.includes(`win`)||r.includes(`windows`)||i.includes(`linux`)?t??l.applicationMenu:l.default;", + "function chrome(e){switch(e){case`win32`:case`linux`:return`application-menu`;default:return`native`}}", + ].join(""), + ); + applyWebviewAssetPatchDescriptors( + tempRoot, + descriptors, + context, + createPatchReport(), + ); + const first = fs.readFileSync(assetPath, "utf8"); + const delegatedMarker = patchDelegationMarker( + "linux-window-controls-safe-area", + "frameless-titlebar", + ); + const damagedVariants = [ + { + source: first.replace( + ",codexLinuxUseWindowControlsSafeArea}){", + "}){", + ), + coreStatus: "already-applied", + }, + { + source: first.replace( + "i.includes(`win`)||r.includes(`windows`)?t??l.applicationMenu:l.default", + "i.includes(`win`)||r.includes(`windows`)||i.includes(`linux`)?t??l.applicationMenu:l.default", + ), + coreStatus: "already-applied", + }, + { + source: first.replace( + "case`win32`:return`application-menu`;case`linux`:return`native`", + "case`win32`:case`linux`:return`application-menu`", + ), + coreStatus: "already-applied", + }, + { + source: first.replace( + delegatedMarker, + `/*codexLinuxWindowControlsSafeAreaPatch*/${delegatedMarker}`, + ), + coreStatus: "skipped-optional", + }, + ]; + for (const { source: damaged, coreStatus } of damagedVariants) { + assert.notEqual(damaged, first); + fs.writeFileSync(assetPath, damaged); + + const report = createPatchReport(); + report.enabledFeatures = ["frameless-titlebar"]; + const { warnings } = captureWarns(() => + applyWebviewAssetPatchDescriptors( + tempRoot, + descriptors, + context, + report, + ), + ); + + assert.equal(fs.readFileSync(assetPath, "utf8"), damaged); + assert.deepEqual( + report.patches.map(({ name, status }) => ({ name, status })), + [ + { + name: "linux-window-controls-safe-area", + status: coreStatus, + }, + { + name: + "feature:frameless-titlebar:webview-window-controls-layout", + status: "skipped-optional", + }, + ], + ); + assert.equal( + enabledFeatureFailuresFromReport(report)[0]?.name, + "feature:frameless-titlebar:webview-window-controls-layout", + ); + assert.ok( + warnings.some((warning) => /delegated frameless Linux/.test(warning)), + ); + if (coreStatus === "skipped-optional") { + assert.ok( + optionalDriftFromReport(report).some( + ({ name }) => name === "linux-window-controls-safe-area", + ), + ); + assert.ok( + warnings.some((warning) => /invalid.*delegation/.test(warning)), + ); + } + } + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test("external-open report rejects a preserved helper with one restored core target", () => { + const descriptor = corePatchDescriptors().find( + ({ id }) => id === "linux-external-open-env", + ); + const source = + '"use strict";let e=require("electron"),t=require("electron");'; + const firstReport = createPatchReport(); + const first = applyMainBundlePatchDescriptors( + source, + [descriptor], + {}, + firstReport, + ).patchedSource; + const damaged = first.replace( + "/*codexLinuxExternalOpenTarget*/codexLinuxPatchExternalOpen(require(\"electron\"))", + 'require("electron")', + ); + assert.notEqual(damaged, first); + + const secondReport = createPatchReport(); + const { warnings } = captureWarns(() => + applyMainBundlePatchDescriptors( + damaged, + [descriptor], + {}, + secondReport, + ), + ); + + assert.equal( + secondReport.patches[0]?.status, + "skipped-optional", + ); + assert.equal( + optionalDriftFromReport(secondReport)[0]?.name, + "linux-external-open-env", + ); + assert.match( + warnings[0] ?? "", + /incomplete Linux external open environment patch/, + ); +}); diff --git a/scripts/patches/core/README.md b/scripts/patches/core/README.md index fc7443129..a92c76450 100644 --- a/scripts/patches/core/README.md +++ b/scripts/patches/core/README.md @@ -83,3 +83,9 @@ package-json, avatar-overlay, and projectless-documents). Generic helpers live under `scripts/patches/lib/`. Do not recreate the deleted compatibility barrels (`scripts/patches/main-process.js`, `webview-assets.js`, or `shared.js`). + +When an optional feature composes a core patch in the same asset, core may +expose only a generic completion marker seam. The feature descriptor declares +the owner through `composesPatches`, performs the composed transform, and +validates the complete delegated state. Core descriptors must not depend on a +specific feature id. diff --git a/scripts/patches/core/all-linux/extracted-app/browser-annotation/patch.js b/scripts/patches/core/all-linux/extracted-app/browser-annotation/patch.js index fa9340c30..13bd01809 100644 --- a/scripts/patches/core/all-linux/extracted-app/browser-annotation/patch.js +++ b/scripts/patches/core/all-linux/extracted-app/browser-annotation/patch.js @@ -4,7 +4,7 @@ const { extractedAppPatch, } = require("../../../../descriptor.js"); const { patchStatusFromChange } = require("../../../../../lib/patch-report.js"); -const { patchCommentPreloadBundle } = require("../../../../impl/webview/index.js"); +const { patchBrowserPagePreloadBundle } = require("../../../../impl/webview/index.js"); module.exports = [ extractedAppPatch({ @@ -12,7 +12,7 @@ module.exports = [ phase: "extracted-app:post-webview", order: 2010, ciPolicy: "optional", - apply: (extractedDir) => patchCommentPreloadBundle(extractedDir), + apply: (extractedDir) => patchBrowserPagePreloadBundle(extractedDir), status: (result, warnings) => ({ status: patchStatusFromChange(Boolean(result?.changed), warnings), reason: warnings[0] ?? null, diff --git a/scripts/patches/core/all-linux/extracted-app/browser-integrations/patch.js b/scripts/patches/core/all-linux/extracted-app/browser-integrations/patch.js index c3ab389db..44cb52962 100644 --- a/scripts/patches/core/all-linux/extracted-app/browser-integrations/patch.js +++ b/scripts/patches/core/all-linux/extracted-app/browser-integrations/patch.js @@ -12,18 +12,20 @@ module.exports = [ order: 180, ciPolicy: "optional", apply: patchLinuxChromeNativeHostRuntimeAssets, - status: (result, warnings) => ({ - status: result?.changed - ? "applied" - : result?.matched - ? warnings.length > 0 - ? "skipped-optional" - : "already-applied" - : "skipped-optional", - reason: - result?.reason ?? - warnings[0] ?? - (result?.matched ? null : "Chrome native host runtime resolver not found"), - }), + status: (result, warnings) => { + return { + status: result?.changed + ? "applied" + : result?.matched + ? warnings.length > 0 + ? "skipped-optional" + : "already-applied" + : "skipped-optional", + reason: + result?.reason ?? + warnings[0] ?? + (result?.matched ? null : "Chrome native host runtime resolver not found"), + }; + }, }), ]; diff --git a/scripts/patches/core/all-linux/main-process/window-shell/patch.js b/scripts/patches/core/all-linux/main-process/window-shell/patch.js index 028400f84..cb3e34843 100644 --- a/scripts/patches/core/all-linux/main-process/window-shell/patch.js +++ b/scripts/patches/core/all-linux/main-process/window-shell/patch.js @@ -9,6 +9,7 @@ const { applyLinuxApplicationMenuPatch, applyLinuxWindowOptionsPatch, applyLinuxNativeTitlebarPatch, + applyLinuxManagedWindowSystemContextMenuPatch, applyLinuxMenuPatch, applyLinuxSetIconPatch, applyLinuxReadyToShowWindowStatePatch, @@ -57,6 +58,13 @@ module.exports = [ ciPolicy: "required-upstream", apply: (source, context) => applyLinuxWindowOptionsPatch(source, context.iconAsset), }), + mainBundlePatch({ + id: "linux-managed-window-system-context-menu", + phase: "main-bundle", + order: 59, + ciPolicy: "optional", + apply: applyLinuxManagedWindowSystemContextMenuPatch, + }), mainBundlePatch({ id: "linux-menu", phase: "main-bundle", diff --git a/scripts/patches/core/all-linux/webview/browser-use-attach-recovery/patch.js b/scripts/patches/core/all-linux/webview/browser-use-attach-recovery/patch.js index e08e4a6b0..d80ab4a38 100644 --- a/scripts/patches/core/all-linux/webview/browser-use-attach-recovery/patch.js +++ b/scripts/patches/core/all-linux/webview/browser-use-attach-recovery/patch.js @@ -4,7 +4,6 @@ const { webviewAssetPatch, } = require("../../../../descriptor.js"); const { - applyLinuxBrowserUseHiddenHostOwnershipPatch, applyLinuxBrowserUseWebviewHostRecoveryPatch, applyLinuxBrowserUseWebviewRemountStorePatch, } = require("../../../../impl/webview/index.js"); @@ -30,14 +29,4 @@ module.exports = [ skipDescription: "Linux Browser sidebar attachment recovery host patch", apply: applyLinuxBrowserUseWebviewHostRecoveryPatch, }), - webviewAssetPatch({ - id: "linux-browser-use-hidden-host-ownership", - phase: "webview-asset", - order: 1096, - ciPolicy: "optional", - pattern: /^browser-sidebar-hidden-browser-use-webview-host-[^.]+\.js$/, - missingDescription: "Browser Use hidden-webview host bundle", - skipDescription: "Linux inactive-route Browser Use host ownership patch", - apply: applyLinuxBrowserUseHiddenHostOwnershipPatch, - }), ]; diff --git a/scripts/patches/core/all-linux/webview/browser-use-external-availability/patch.js b/scripts/patches/core/all-linux/webview/browser-use-external-availability/patch.js index f459a6520..dc0268e88 100644 --- a/scripts/patches/core/all-linux/webview/browser-use-external-availability/patch.js +++ b/scripts/patches/core/all-linux/webview/browser-use-external-availability/patch.js @@ -1,19 +1,23 @@ "use strict"; const { - webviewAssetPatch, + extractedAppPatch, } = require("../../../../descriptor.js"); +const { patchStatusFromChange } = require("../../../../../lib/patch-report.js"); const { - applyLinuxBrowserUseExternalAvailabilityPatch, + patchLinuxBrowserUseExternalAvailabilityAssets, } = require("../../../../impl/webview/index.js"); -module.exports = webviewAssetPatch({ +module.exports = extractedAppPatch({ id: "linux-browser-use-external-availability", - phase: "webview-asset", - order: 1092, + phase: "extracted-app:post-webview", + order: 1990, ciPolicy: "optional", - pattern: /^app-initial-[^.]+\.js$/, - missingDescription: "external Browser Use availability bundle", - skipDescription: "Linux external Browser Use availability patch", - apply: applyLinuxBrowserUseExternalAvailabilityPatch, + apply: patchLinuxBrowserUseExternalAvailabilityAssets, + status: (result, warnings) => ({ + status: result?.matched === 0 + ? "skipped-optional" + : patchStatusFromChange((result?.changed ?? 0) > 0, warnings), + reason: result?.reason ?? warnings[0] ?? null, + }), }); diff --git a/scripts/patches/core/all-linux/webview/computer-use-ui/patch.js b/scripts/patches/core/all-linux/webview/computer-use-ui/patch.js index 4cc3cb96c..c0b7fe5e5 100644 --- a/scripts/patches/core/all-linux/webview/computer-use-ui/patch.js +++ b/scripts/patches/core/all-linux/webview/computer-use-ui/patch.js @@ -7,6 +7,8 @@ const { applyLinuxComputerUseHostPlatformPatch, applyLinuxComputerUseRendererAvailabilityPatch, applyLinuxComputerUseInstallFlowPatch, + matchesLinuxComputerUseHostPlatformContract, + matchesLinuxComputerUseInstallFlowContract, } = require("../../../../impl/computer-use.js"); module.exports = [ @@ -27,8 +29,9 @@ module.exports = [ order: 1105, ciPolicy: "opt-in", enabled: (context) => context.enableComputerUseUi, - pattern: /^app-initial~artifact-tab-content\.electron~notebook-preview-panel~app-main~settings-command-~ekwfx4j1-[^.]+\.js$/, - missingDescription: "current Computer Use host-platform bundle", + pattern: /^app-initial-[^.]+\.js$/, + assetMatch: matchesLinuxComputerUseHostPlatformContract, + missingDescription: "current Computer Use host-platform app-initial contract", skipDescription: "Linux Computer Use host-platform patch", apply: applyLinuxComputerUseHostPlatformPatch, }), @@ -38,8 +41,9 @@ module.exports = [ order: 1110, ciPolicy: "opt-in", enabled: (context) => context.enableComputerUseUi, - pattern: /^app-initial~avatarOverlayCompositionSurface~artifact-tab-content\.electron~notebook-preview-~iaq4jiqv-[^.]+\.js$/, - missingDescription: "current Computer Use install flow bundle", + pattern: /^app-initial-[^.]+\.js$/, + assetMatch: matchesLinuxComputerUseInstallFlowContract, + missingDescription: "current Computer Use install flow app-initial contract", skipDescription: "Linux Computer Use install flow patch", apply: applyLinuxComputerUseInstallFlowPatch, }), diff --git a/scripts/patches/descriptor.js b/scripts/patches/descriptor.js index 4377ebadd..ff363536d 100644 --- a/scripts/patches/descriptor.js +++ b/scripts/patches/descriptor.js @@ -24,11 +24,33 @@ const CI_POLICIES = new Set([ CI_POLICY_OPTIONAL, CI_POLICY_OPT_IN, ]); +const CORE_PATCH_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; function descriptorId(descriptor) { return descriptor.id ?? descriptor.name; } +function normalizeComposesPatches(value, id) { + if (value == null) { + return undefined; + } + if (!Array.isArray(value) || value.length === 0) { + throw new Error(`Patch descriptor '${id}' composesPatches must be a non-empty array`); + } + const ownerPatchIds = value.map((ownerPatchId) => { + if (typeof ownerPatchId !== "string" || !CORE_PATCH_ID_PATTERN.test(ownerPatchId)) { + throw new Error( + `Patch descriptor '${id}' composesPatches entries must match ${CORE_PATCH_ID_PATTERN}`, + ); + } + return ownerPatchId; + }); + if (new Set(ownerPatchIds).size !== ownerPatchIds.length) { + throw new Error(`Patch descriptor '${id}' composesPatches must not contain duplicates`); + } + return ownerPatchIds; +} + function assertDescriptorBase(descriptor, phase) { if (descriptor == null || typeof descriptor !== "object" || Array.isArray(descriptor)) { throw new Error(`Patch descriptor for phase '${phase}' must be an object`); @@ -47,6 +69,7 @@ function assertDescriptorBase(descriptor, phase) { if (!CI_POLICIES.has(ciPolicy)) { throw new Error(`Patch descriptor '${id}' has unsupported ciPolicy '${ciPolicy}'`); } + normalizeComposesPatches(descriptor.composesPatches, id); return id; } @@ -61,6 +84,9 @@ function patchDescriptor(phase, descriptor) { name: descriptor.name ?? id, phase, ciPolicy: descriptor.ciPolicy ?? CI_POLICY_OPTIONAL, + ...(descriptor.composesPatches == null + ? {} + : { composesPatches: normalizeComposesPatches(descriptor.composesPatches, id) }), }; } @@ -104,6 +130,7 @@ module.exports = { PHASE_WEBVIEW_ASSET, extractedAppPatch, mainBundlePatch, + normalizeComposesPatches, patchDescriptor, webviewAssetPatch, }; diff --git a/scripts/patches/descriptor.test.js b/scripts/patches/descriptor.test.js index 77ca88360..a2e620b33 100644 --- a/scripts/patches/descriptor.test.js +++ b/scripts/patches/descriptor.test.js @@ -79,4 +79,20 @@ test("descriptor factories validate the fresh descriptor contract", () => { () => mainBundlePatch({ id: "bad-policy", ciPolicy: "legacy", apply: (source) => source }), /unsupported ciPolicy 'legacy'/, ); + assert.throws( + () => mainBundlePatch({ id: "bad-composition", composesPatches: "linux-owner", apply: (source) => source }), + /composesPatches must be a non-empty array/, + ); + assert.throws( + () => mainBundlePatch({ id: "bad-owner", composesPatches: ["feature:owner"], apply: (source) => source }), + /composesPatches entries must match/, + ); + assert.throws( + () => mainBundlePatch({ + id: "duplicate-owner", + composesPatches: ["linux-owner", "linux-owner"], + apply: (source) => source, + }), + /composesPatches must not contain duplicates/, + ); }); diff --git a/scripts/patches/engine.js b/scripts/patches/engine.js index 827e7997d..1845c0cef 100644 --- a/scripts/patches/engine.js +++ b/scripts/patches/engine.js @@ -4,6 +4,7 @@ const fs = require("node:fs"); const path = require("node:path"); const { PATCH_STATUS_APPLIED, + PATCH_STATUS_FAILED_INTEGRITY, PATCH_STATUS_FAILED_REQUIRED, PATCH_STATUS_SKIPPED_DISABLED, PATCH_STATUS_SKIPPED_OPTIONAL, @@ -13,6 +14,9 @@ const { patchStatusFromChange, recordPatch, } = require("../lib/patch-report.js"); +const { + isPatchIntegrityError, +} = require("./integrity-error.js"); const { linuxTargetSummary, } = require("../lib/linux-target-context.js"); @@ -27,6 +31,7 @@ const { PHASE_MAIN_BUNDLE, PHASE_WEBVIEW_ASSET, PATCH_PHASES, + normalizeComposesPatches, } = require("./descriptor.js"); const { drainStrategies, @@ -69,12 +74,20 @@ function normalizeDescriptor(descriptor, sourcePath = null, index = 0) { sourceKind: descriptor.sourceKind ?? (descriptor.featureId != null ? "feature" : "core"), order: descriptor.order ?? 10_000 + index, sourcePath, + ...(descriptor.composesPatches == null + ? {} + : { composesPatches: normalizeComposesPatches(descriptor.composesPatches, id) }), }; if (!PATCH_PHASES.has(normalized.phase)) { throw new Error( `Patch descriptor '${id}' has unsupported phase '${normalized.phase}' in ${sourcePath ?? "inline descriptor"}`, ); } + if (normalized.composesPatches != null && normalized.sourceKind !== "feature") { + throw new Error( + `Patch descriptor '${id}' composesPatches is supported only for Linux feature descriptors`, + ); + } return normalized; } @@ -164,11 +177,15 @@ function descriptorFailureStatus(descriptor) { function describePatchError(descriptor, error) { const message = error instanceof Error ? error.message : String(error); + if (isPatchIntegrityError(error)) { + return `Patch '${descriptor.id}' integrity failure: ${message}`; + } return `Patch '${descriptor.id}' threw: ${message}`; } -// Runs a descriptor's apply function so that a throw never escapes the engine: -// the descriptor's ciPolicy — not the throw — decides whether the build fails. +// Runs a descriptor's apply function so ordinary errors can follow ciPolicy. +// PatchIntegrityError is recorded by the caller and then rethrown because the +// patch could not prove that a failed mutation restored the original bytes. // Strategy telemetry recorded during the apply is drained into the result so // it can be attributed to this descriptor's report entry. function runDescriptorApply(descriptor, fn, fallbackValue) { @@ -223,13 +240,21 @@ function recordDescriptorError(report, descriptor, error, context, strategies = recordDescriptorPatch( report, descriptor, - descriptorFailureStatus(descriptor), + isPatchIntegrityError(error) + ? PATCH_STATUS_FAILED_INTEGRITY + : descriptorFailureStatus(descriptor), describePatchError(descriptor, error), context, { error: true, ...(strategyMetadata(strategies) ?? {}) }, ); } +function rethrowPatchIntegrityError(error) { + if (isPatchIntegrityError(error)) { + throw error; + } +} + function descriptorAppliesTo(descriptor, context) { if (descriptor.appliesTo == null) { return true; @@ -275,6 +300,8 @@ function applyMainBundlePatchDescriptors(source, descriptors, context, report) { context.reportWarnings = result.warnings; if (result.error != null) { recordDescriptorError(report, descriptor, result.error, context, result.strategies); + delete context.reportWarnings; + rethrowPatchIntegrityError(result.error); } else { recordDescriptorPatch( report, @@ -368,6 +395,8 @@ function applyWebviewAssetPatchDescriptors(extractedDir, descriptors, context, r if (error != null) { warnings.push(`WARN: ${describePatchError(descriptor, error)}`); recordDescriptorError(report, descriptor, error, context, strategies); + delete context.reportWarnings; + rethrowPatchIntegrityError(error); } else { recordAssetDescriptorPatch(report, descriptor, result, warnings, context, strategies); } @@ -399,6 +428,7 @@ function applyExtractedAppPatchDescriptors(extractedDir, descriptors, context, r warnings.push(`WARN: ${describePatchError(descriptor, error)}`); recordDescriptorError(report, descriptor, error, context, strategies); delete context.reportWarnings; + rethrowPatchIntegrityError(error); continue; } const statusResult = typeof descriptor.status === "function" diff --git a/scripts/patches/impl/avatar-overlay.js b/scripts/patches/impl/avatar-overlay.js index d4b38287d..e8dc12aae 100644 --- a/scripts/patches/impl/avatar-overlay.js +++ b/scripts/patches/impl/avatar-overlay.js @@ -80,22 +80,18 @@ function avatarCursorRegionPatch(electronVar) { return `codexLinuxIsCursorInAvatarInteractiveRegion(e){let t=this.layout;if(t==null)return!1;let __codexCursor=${electronVar}.screen.getCursorScreenPoint(),__codexBounds=e.getContentBounds(),__codexX=__codexCursor.x-__codexBounds.x,__codexY=__codexCursor.y-__codexBounds.y,__codexWindowHit=__codexX>=0&&__codexY>=0&&__codexX<=__codexBounds.width&&__codexY<=__codexBounds.height;if(!__codexWindowHit)return!1;if(this.codexLinuxShouldUseWholeWindowInput())return!0;let __codexHit=e=>e!=null&&__codexX>=e.left&&__codexX<=e.left+e.width&&__codexY>=e.top&&__codexY<=e.top+e.height;return __codexHit(t.mascot)||__codexHit(t.tray)}`; } -function avatarInputShapePatch() { - return "codexLinuxShouldUseWholeWindowInput(){return this.codexLinuxWholeWindowInput===!0}codexLinuxBuildAvatarInputShape(e){let t=this.layout;if(t==null)return null;let r;try{r=e.getContentBounds()}catch{return null}if(r==null||!Number.isFinite(r.width)||!Number.isFinite(r.height))return null;if(this.dragState!=null||this.codexLinuxShouldUseWholeWindowInput())return[{x:0,y:0,width:r.width,height:r.height}];let i=e=>{if(e==null)return null;let t=Math.max(0,e.left),n=Math.max(0,e.top),i=Math.min(r.width,e.left+e.width)-t,a=Math.min(r.height,e.top+e.height)-n;return i<=0||a<=0?null:{x:t,y:n,width:i,height:a}};return[i(t.mascot),i(t.tray)].filter(Boolean)}"; -} - -function avatarApplyInputShapePatch() { - return "codexLinuxApplyAvatarInputShape(e){if(process.platform!==`linux`||e==null||e.isDestroyed()||typeof e.setShape!=`function`||typeof this.codexLinuxIsAvatarShapeBackend==`function`&&!this.codexLinuxIsAvatarShapeBackend())return!1;try{let t=this.codexLinuxBuildAvatarInputShape(e);if(t==null)return!1;let n=JSON.stringify(t);if(this.codexLinuxAvatarInputShapeKey===n)return!0;e.setShape(t),this.codexLinuxAvatarInputShapeKey=n;return!0}catch{this.codexLinuxAvatarInputShapeKey=null;return!1}}"; +function avatarInputShapeOverridePatch() { + return "codexLinuxShouldUseWholeWindowInput(){return this.codexLinuxWholeWindowInput===!0}codexLinuxInputShape(e){let t=this.inputShape;if(t==null||this.dragState==null&&!this.codexLinuxShouldUseWholeWindowInput())return t;let n;try{n=e.getContentBounds()}catch{return t}return n==null||!Number.isFinite(n.width)||!Number.isFinite(n.height)?t:[{height:n.height,left:0,top:0,width:n.width}]}"; } function patchAvatarOverlayWindowOptions(source) { const windowOptionsPatch = - "appearance:`avatarOverlay`,alwaysOnTop:process.platform===`linux`,skipTaskbar:process.platform===`linux`,focusable:process.platform===`linux`?!0:!1"; + "appearance:`avatarOverlay`,supportsWindowTiling:!1,alwaysOnTop:process.platform===`linux`,skipTaskbar:process.platform===`linux`,focusable:process.platform===`linux`?!0:!1"; if (source.includes(windowOptionsPatch)) { return source; } return source.replace( - "appearance:`avatarOverlay`,focusable:!1", + "appearance:`avatarOverlay`,supportsWindowTiling:!1,focusable:!1", windowOptionsPatch, ); } @@ -118,13 +114,14 @@ function applyLinuxAvatarOverlayMousePassthroughPatch(currentSource) { "codexLinuxIsI3Session(){let e=[process.env.XDG_CURRENT_DESKTOP,process.env.DESKTOP_SESSION,process.env.I3SOCK].filter(Boolean).join(`:`).toLowerCase();return/(^|[:;/])i3([:;/.-]|$)/.test(e)}"; const compositorHintsMethod = `codexLinuxApplyAvatarCompositorHints(e){if(process.platform!==\`linux\`||!this.codexLinuxIsI3Session()||this.codexLinuxAvatarCompositorHintsApplied||this.codexLinuxAvatarCompositorHintsApplying||e==null||e.isDestroyed()||!process.env.DISPLAY)return;let t;try{t=e.getBounds?.()??e.getContentBounds?.()}catch{}if(t==null||!Number.isFinite(t.x)||!Number.isFinite(t.y)||!Number.isFinite(t.width)||!Number.isFinite(t.height))return;let n=[];try{let r=e.getNativeWindowHandle?.();r!=null&&r.length>=4&&n.push(String(r.readUInt32LE(0)))}catch{}this.codexLinuxAvatarCompositorHintsApplying=!0;let r=e=>{let r=[...new Set(e)].filter(e=>/^[0-9]+$/.test(e)&&e!==\`0\`);if(r.length===0){this.codexLinuxAvatarCompositorHintsApplying=!1;return}let i=r.length,a=!1,o=()=>{i--,i===0&&(this.codexLinuxAvatarCompositorHintsApplying=!1,a&&(this.codexLinuxAvatarCompositorHintsApplied=!0))},s=e=>{try{${childProcessVar}.execFile(\`xwininfo\`,[\`-id\`,e],{timeout:1e3},(r,i)=>{if(r){o();return}let s=String(i??\`\`),c=s.match(/Absolute upper-left X:\\s+(-?\\d+)[\\s\\S]*Absolute upper-left Y:\\s+(-?\\d+)[\\s\\S]*Width:\\s+(\\d+)[\\s\\S]*Height:\\s+(\\d+)/);if(c==null||!/Override Redirect State:\\s+yes/.test(s)){o();return}let[,__codexAvatarX,__codexAvatarY,__codexAvatarWidth,__codexAvatarHeight]=c;if(Number(__codexAvatarX)!==t.x||Number(__codexAvatarY)!==t.y||Number(__codexAvatarWidth)!==t.width||Number(__codexAvatarHeight)!==t.height){o();return}try{${childProcessVar}.execFile(\`xprop\`,[\`-id\`,e,\`-f\`,\`_GTK_FRAME_EXTENTS\`,\`32c\`,\`-set\`,\`_GTK_FRAME_EXTENTS\`,\`0, 0, 0, 0\`],{timeout:1e3},e=>{e||(a=!0),o()})}catch{o()}})}catch{o()}};for(let t of r)s(t)};try{${childProcessVar}.execFile(\`xdotool\`,[\`search\`,\`--pid\`,String(process.pid)],{timeout:1e3},(e,t)=>{r([...n,...String(t??\`\`).trim().split(/\\s+/).filter(Boolean)])})}catch{r(n)}}`; - const shapeBackendMethod = - `codexLinuxIsAvatarShapeBackend(){if(process.platform!==\`linux\`)return!1;let e=\`\`;try{e=${electronVar}.app.commandLine.getSwitchValue(\`ozone-platform\`)}catch{}return e===\`x11\`||e===\`\`&&!process.env.WAYLAND_DISPLAY}`; - const interactivityNeedle = - "applyPointerInteractivityPolicy(){let e=this.window;if(e==null||e.isDestroyed()){this.mousePassthroughEnabled=!1;return}let t=!this.pointerInteractive;if(this.mousePassthroughEnabled!==t){if(this.mousePassthroughEnabled=t,t){e.setIgnoreMouseEvents(!0,{forward:!0});return}e.setIgnoreMouseEvents(!1),this.refreshCursorAtCurrentMousePosition(e)}}"; + "applyPointerInteractivityPolicy(){let e=this.window;if(e==null||e.isDestroyed()){this.mousePassthroughEnabled=!1;return}if(this.applyInputShape(e))return;let t=!this.pointerInteractive;if(this.mousePassthroughEnabled!==t){if(this.mousePassthroughEnabled=t,t){e.setIgnoreMouseEvents(!0,{forward:!0});return}e.setIgnoreMouseEvents(!1),this.refreshCursorAtCurrentMousePosition(e)}}"; + const inputShapeNeedle = + "applyInputShape(e){if(!this.supportsInputShape||this.inputShape==null)return!1;this.mousePassthroughEnabled&&=(e.setIgnoreMouseEvents(!1),!1);let t=Bs(e,this.inputShape.map(({height:e,left:t,top:n,width:r})=>({height:e,width:r,x:t,y:n})));return t&&(this.mousePassthroughEnabled=!1),t}"; + const inputShapeMethodPatch = + "applyInputShape(e){if(!this.supportsInputShape||this.inputShape==null)return!1;this.mousePassthroughEnabled&&=(e.setIgnoreMouseEvents(!1),!1);let t=Bs(e,this.codexLinuxInputShape(e).map(({height:e,left:t,top:n,width:r})=>({height:e,width:r,x:t,y:n})));return t&&(this.mousePassthroughEnabled=!1),t}"; const interactivityMethodPatch = - "applyPointerInteractivityPolicy(){let e=this.window;if(e==null||e.isDestroyed()){this.mousePassthroughEnabled=!1,this.codexLinuxStopAvatarPassthroughRecovery();return}if(this.codexLinuxIsAvatarShapeBackend()&&typeof e.setShape==`function`){this.codexLinuxStartAvatarPassthroughRecovery(),this.mousePassthroughEnabled&&(this.mousePassthroughEnabled=!1,e.setIgnoreMouseEvents(!1));if(this.codexLinuxApplyAvatarInputShape(e))return}process.platform===`linux`&&(this.codexLinuxStartAvatarPassthroughRecovery(),this.codexLinuxSyncAvatarPointerInteractivity(e));let t=!this.pointerInteractive;this.dragState!=null&&(t=!1);if(this.mousePassthroughEnabled!==t){if(this.mousePassthroughEnabled=t,t){e.setIgnoreMouseEvents(!0,{forward:!0});return}e.setIgnoreMouseEvents(!1),this.refreshCursorAtCurrentMousePosition(e)}}"; + "applyPointerInteractivityPolicy(){let e=this.window;if(e==null||e.isDestroyed()){this.mousePassthroughEnabled=!1,this.codexLinuxStopAvatarPassthroughRecovery();return}if(this.applyInputShape(e)){this.codexLinuxStopAvatarPassthroughRecovery();return}process.platform===`linux`&&(this.codexLinuxStartAvatarPassthroughRecovery(),this.codexLinuxSyncAvatarPointerInteractivity(e));let t=!this.pointerInteractive;this.dragState!=null&&(t=!1);if(this.mousePassthroughEnabled!==t){if(this.mousePassthroughEnabled=t,t){e.setIgnoreMouseEvents(!0,{forward:!0});return}e.setIgnoreMouseEvents(!1),this.refreshCursorAtCurrentMousePosition(e)}}"; const stopRecoveryMethod = "codexLinuxStopAvatarPassthroughRecovery(){this.codexLinuxAvatarPassthroughRecoveryTimer!=null&&(clearInterval(this.codexLinuxAvatarPassthroughRecoveryTimer),this.codexLinuxAvatarPassthroughRecoveryTimer=null)}"; const startRecoveryMethod = @@ -135,10 +132,8 @@ function applyLinuxAvatarOverlayMousePassthroughPatch(currentSource) { interactivityMethodPatch + i3SessionMethod + compositorHintsMethod + - shapeBackendMethod + stopRecoveryMethod + - avatarInputShapePatch() + - avatarApplyInputShapePatch() + + avatarInputShapeOverridePatch() + startRecoveryMethod + syncInteractivityMethod + avatarCursorRegionPatch(electronVar); @@ -148,11 +143,27 @@ function applyLinuxAvatarOverlayMousePassthroughPatch(currentSource) { patchedSource, /applyPointerInteractivityPolicy\(\)\{/, ); - if (interactivityMethod?.text === interactivityNeedle) { + const inputShapeMethod = findAvatarOverlayMethod( + patchedSource, + /applyInputShape\([A-Za-z_$][\w$]*\)\{/, + ); + if ( + interactivityMethod?.text === interactivityNeedle && + inputShapeMethod?.text === inputShapeNeedle + ) { recordStrategy("avatar-interactivity", "upstream"); patchedSource = replaceAvatarMethodText( patchedSource, - interactivityMethod, + inputShapeMethod, + inputShapeMethodPatch, + ); + const currentInteractivityMethod = findAvatarOverlayMethod( + patchedSource, + /applyPointerInteractivityPolicy\(\)\{/, + ); + patchedSource = replaceAvatarMethodText( + patchedSource, + currentInteractivityMethod, interactivityPatch, ); } else if ( @@ -377,7 +388,7 @@ function applyLinuxAvatarOverlayMousePassthroughPatch(currentSource) { ? null : createWindowMethod.text.slice(closedHandlerOpenIndex, closedHandlerCloseIndex + 1); const closeCleanup = - "this.codexLinuxStopAvatarPassthroughRecovery(),this.codexLinuxAvatarInputShapeKey=null,this.codexLinuxAvatarCompositorHintsApplied=!1,this.codexLinuxAvatarCompositorHintsApplying=!1,"; + "this.codexLinuxStopAvatarPassthroughRecovery(),this.codexLinuxAvatarCompositorHintsApplied=!1,this.codexLinuxAvatarCompositorHintsApplying=!1,"; if (closedHandler?.includes(closeCleanup)) { recordStrategy("avatar-close-cleanup", "already-applied"); } else if ( diff --git a/scripts/patches/impl/chrome-plugin.js b/scripts/patches/impl/chrome-plugin.js index 212a3cbbb..0d26a9ab2 100644 --- a/scripts/patches/impl/chrome-plugin.js +++ b/scripts/patches/impl/chrome-plugin.js @@ -2,15 +2,179 @@ const fs = require("node:fs"); const path = require("node:path"); +const { + PatchIntegrityError, + isPatchIntegrityError, +} = require("../integrity-error.js"); const { findMatchingBrace, - requireName, } = require("../lib/minified-js.js"); const { readDirectoryNames, } = require("../lib/assets.js"); +const LINUX_CHROME_NATIVE_HOST_RUNTIME_HELPER = + "function codexLinuxChromeNativeHostRuntimeFile(e,t){if(process.platform!==`linux`||e==null)return null;for(let n of t){let t=(0,require(`node:path`).join)(e,...n);try{if((0,require(`node:fs`).statSync)(t).isFile())return t}catch{}}return null}" + + "function codexLinuxChromeNativeHostRuntimeEnv(e){if(process.platform!==`linux`)return null;let t=process.env[e];if(t==null||t.length===0)return null;try{return(0,require(`node:fs`).statSync)(t).isFile()?t:null}catch{return null}}" + + "function codexLinuxChromeNativeHostRuntimePath(e){if(process.platform!==`linux`)return null;for(let t of(process.env.PATH??``).split(`:`)){if(t.length===0)continue;let n=(0,require(`node:path`).join)(t,e);try{if((0,require(`node:fs`).statSync)(n).isFile())return n}catch{}}return null}" + + "function codexLinuxChromeNativeHostRuntimeEntry(e,t){return e==null?null:{path:e,source:t}}"; + +const LINUX_CHROME_NATIVE_HOST_RUNTIME_MODERN_MARKERS = [ + "codexLinuxChromeNativeHostRuntimeEntry(codexLinuxChromeNativeHostRuntimePath(`codex`),`linux-path`)", + "`linux-node-runtime`", + "`linux-node-repl-runtime`", +]; +const LINUX_CHROME_NATIVE_HOST_RUNTIME_APP_SERVER_RUNTIME_MARKER = + "/*codexLinuxChromeNativeHostAppServerRuntime*/"; +const LINUX_CHROME_NATIVE_HOST_RUNTIME_APP_SERVER_CODEX_MARKER = + "/*codexLinuxChromeNativeHostAppServerCodexRuntime*/"; +const LINUX_CHROME_NATIVE_HOST_RUNTIME_APP_SERVER_SOURCE_PATH_MARKER = + "/*codexLinuxChromePluginAppServerSourcePath*/"; +const LINUX_CHROME_PLUGIN_RUNTIME_CONFIG_MARKER = + "/*codexLinuxChromePluginRuntimeConfig*/"; +const LINUX_CHROME_NATIVE_HOST_RUNTIME_APP_SERVER_MARKERS = [ + LINUX_CHROME_NATIVE_HOST_RUNTIME_APP_SERVER_RUNTIME_MARKER, + LINUX_CHROME_NATIVE_HOST_RUNTIME_APP_SERVER_CODEX_MARKER, + LINUX_CHROME_NATIVE_HOST_RUNTIME_APP_SERVER_SOURCE_PATH_MARKER, + LINUX_CHROME_PLUGIN_RUNTIME_CONFIG_MARKER, +]; +const LINUX_CHROME_PLUGIN_APP_SERVER_SOURCE_PATH_HELPER = + "function codexLinuxChromePluginAppServerSourcePath(e){return e.codexCliPath}"; +const LINUX_CHROME_PLUGIN_RUNTIME_CONFIG_HELPER = + "async function codexLinuxChromePluginRuntimeConfig(e){if(process.platform!==`linux`)return e;let n=require(`node:path`),r=require(`node:fs/promises`),i=process.geteuid?.();if(!Number.isInteger(i))throw Error(`Linux Chrome plugin runtime owner is unavailable`);let a=n.resolve(e.codexHome),o=await r.realpath(a),s=await r.realpath(e.pluginRoot),c=n.join(o,`plugins`,`cache`),l=n.relative(c,s);if(l===``||l===`..`||l.startsWith(`..${n.sep}`)||n.isAbsolute(l))throw Error(`Linux Chrome plugin cache path is outside CODEX_HOME`);let u=l.split(n.sep);if(u.length!==3||u[0]!==`openai-bundled`||u[1]!==`chrome`)return e;let d=n.dirname(s),f=n.join(o,`plugins`,`linux-runtime-cache`,u[0],u[1]),p=n.join(f,`latest`),m=await r.realpath(p),h=n.relative(f,m);if(h!==u[2]||n.isAbsolute(h))throw Error(`Linux Chrome plugin runtime version does not match installed plugin`);let g=await r.lstat(p);if(!g.isSymbolicLink()||g.uid!==i)throw Error(`Linux Chrome plugin runtime link is not trusted`);let _=[o,n.join(o,`plugins`),n.join(o,`plugins`,`linux-runtime-cache`),n.join(o,`plugins`,`linux-runtime-cache`,u[0]),f,m];for(let e of _){let t=await r.lstat(e);if(t.isSymbolicLink()||!t.isDirectory()||t.uid!==i||(t.mode&18)!==0)throw Error(`Linux Chrome plugin runtime parent is not trusted`)}let v=async e=>{let t=await r.lstat(e);if(t.isSymbolicLink()||!t.isDirectory()&&!t.isFile()||t.uid!==i||(t.mode&18)!==0)throw Error(`Linux Chrome plugin runtime is not trusted`);if(t.isDirectory())for(let t of await r.readdir(e))await v(n.join(e,t))};await v(m);let y=await r.lstat(n.join(m,`extension-host`,`linux`,process.arch,`extension-host`));if(!y.isFile()||(y.mode&73)===0)throw Error(`Linux Chrome plugin runtime host is not executable`);let b=JSON.parse(await r.readFile(n.join(m,`scripts`,`extension-ids.json`),`utf8`)).extensionIds;if(!Array.isArray(b)||b.length===0||b.some(e=>typeof e!==`string`||!/^[a-p]{32}$/.test(e)))throw Error(`Linux Chrome plugin runtime extension IDs are invalid`);let x=[o,n.join(o,`plugins`),c,n.join(c,u[0]),d];for(let e of x){let t=await r.lstat(e);if(t.isSymbolicLink()||!t.isDirectory()||t.uid!==i)throw Error(`Linux Chrome plugin cache parent is not trusted`);await r.chmod(e,t.mode&~18)}let w=n.dirname(n.resolve(e.pluginRoot));if(await r.realpath(w)!==d)throw Error(`Linux Chrome plugin cache path is outside CODEX_HOME`);let C=n.join(w,`.codex-linux-runtime`);try{let e=await r.lstat(C);if(!e.isSymbolicLink())throw Error(`Linux Chrome plugin runtime bridge is not trusted`)}catch(e){if(e?.code!==`ENOENT`)throw e}let S=`${C}.tmp-${require(`node:crypto`).randomUUID()}`;try{await r.symlink(m,S,`dir`),await r.rename(S,C)}finally{await r.rm(S,{force:!0})}if(await r.realpath(C)!==m)throw Error(`Linux Chrome plugin runtime bridge is not trusted`);return{...e,pluginRoot:C,extensionIds:[...new Set(b)]}}"; +const CURRENT_CHROME_NATIVE_HOST_RUNTIME_MESSAGE = + "Missing bundled Electron runtime required to sync Chrome native host resources"; +const CURRENT_CHROME_APP_SERVER_CODEX_RUNTIME_MESSAGE = + "Missing bundled Electron Codex runtime required to sync Chrome plugin app server"; +const IDENTIFIER_PATTERN = "[A-Za-z_$][\\w$]*"; + +function markerCount(source, marker) { + return source.split(marker).length - 1; +} + +function matchesExactlyOnce(source, pattern) { + const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`; + return [...source.matchAll(new RegExp(pattern.source, flags))].length === 1; +} + +function hasCompleteModernChromeNativeHostRuntimePatch(source) { + return markerCount(source, LINUX_CHROME_NATIVE_HOST_RUNTIME_HELPER) === 1 && + LINUX_CHROME_NATIVE_HOST_RUNTIME_MODERN_MARKERS.every((marker) => + markerCount(source, marker) === 1 + ) && + matchesExactlyOnce( + source, + new RegExp( + String.raw`codexLinuxChromeNativeHostRuntimeEntry\(codexLinuxChromeNativeHostRuntimePath\(\`codex\`\),\`linux-path\`\)\?\?`, + ), + ) && + matchesExactlyOnce( + source, + new RegExp( + String.raw`codexLinuxChromeNativeHostRuntimeEntry\(codexLinuxChromeNativeHostRuntimeFile\(${IDENTIFIER_PATTERN},\[\[\`node-runtime\`,\`bin\`,${IDENTIFIER_PATTERN}===\`win32\`\?\`node\.exe\`:\`node\`\]\]\),\`linux-node-runtime\`\)\?\?`, + ), + ) && + matchesExactlyOnce( + source, + new RegExp( + String.raw`codexLinuxChromeNativeHostRuntimeEntry\(codexLinuxChromeNativeHostRuntimeFile\(${IDENTIFIER_PATTERN},\[\[${IDENTIFIER_PATTERN}===\`win32\`\?\`node_repl\.exe\`:\`node_repl\`\]\]\),\`linux-node-repl-runtime\`\)\?\?`, + ), + ); +} + +function hasCompleteCurrentChromeAppServerRuntimePatch(source) { + return markerCount(source, LINUX_CHROME_NATIVE_HOST_RUNTIME_HELPER) === 1 && + markerCount(source, LINUX_CHROME_PLUGIN_APP_SERVER_SOURCE_PATH_HELPER) === 1 && + markerCount(source, LINUX_CHROME_PLUGIN_RUNTIME_CONFIG_HELPER) === 1 && + LINUX_CHROME_NATIVE_HOST_RUNTIME_APP_SERVER_MARKERS.every((marker) => + markerCount(source, marker) === 1 + ) && + matchesExactlyOnce( + source, + new RegExp( + String.raw`\/\*codexLinuxChromePluginAppServerSourcePath\*\/async function ${IDENTIFIER_PATTERN}\((?${IDENTIFIER_PATTERN})\)\{if\(process\.platform===\`linux\`\)return codexLinuxChromePluginAppServerSourcePath\(\k\);let ${IDENTIFIER_PATTERN}=\k\.nativeHostName===`, + ), + ) && + matchesExactlyOnce( + source, + new RegExp( + String.raw`\/\*codexLinuxChromeNativeHostAppServerRuntime\*\/async function ${IDENTIFIER_PATTERN}\((?${IDENTIFIER_PATTERN})\)\{let ${IDENTIFIER_PATTERN}=${IDENTIFIER_PATTERN}\(\k\)\?\?codexLinuxChromeNativeHostRuntimeEnv\(\`CODEX_CLI_PATH\`\)\?\?codexLinuxChromeNativeHostRuntimePath\(\`codex\`\),${IDENTIFIER_PATTERN}=${IDENTIFIER_PATTERN}\(\k\.resourcesPath\)\?\?codexLinuxChromeNativeHostRuntimeEnv\(\`CODEX_BROWSER_USE_NODE_PATH\`\)\?\?codexLinuxChromeNativeHostRuntimeEnv\(\`NODE_REPL_NODE_PATH\`\)\?\?codexLinuxChromeNativeHostRuntimeFile\(\k\.resourcesPath,\[\[\`node-runtime\`,\`bin\`,process\.platform===\`win32\`\?\`node\.exe\`:\`node\`\]\]\),${IDENTIFIER_PATTERN}=${IDENTIFIER_PATTERN}\(\k\.resourcesPath\)\?\?codexLinuxChromeNativeHostRuntimeEnv\(\`CODEX_NODE_REPL_PATH\`\)\?\?codexLinuxChromeNativeHostRuntimeFile\(\k\.resourcesPath,\[\[process\.platform===\`win32\`\?\`node_repl\.exe\`:\`node_repl\`\]\]\),`, + ), + ) && + matchesExactlyOnce( + source, + new RegExp( + String.raw`\/\*codexLinuxChromeNativeHostAppServerCodexRuntime\*\/async function ${IDENTIFIER_PATTERN}\((?${IDENTIFIER_PATTERN})\)\{let (?${IDENTIFIER_PATTERN})=${IDENTIFIER_PATTERN}\(\k\)\?\?codexLinuxChromeNativeHostRuntimeEnv\(\`CODEX_CLI_PATH\`\)\?\?codexLinuxChromeNativeHostRuntimePath\(\`codex\`\);if\(\k==null\)throw Error\(.+?\);return ${IDENTIFIER_PATTERN}\(\{codexCliPath:\k,codexHome:\k\.codexHome,nativeHostName:\k\.nativeHostName\}\)\}`, + ), + ) && + matchesExactlyOnce( + source, + new RegExp( + String.raw`\/\*codexLinuxChromePluginRuntimeConfig\*\/async function ${IDENTIFIER_PATTERN}\((?${IDENTIFIER_PATTERN})\)\{\k=await codexLinuxChromePluginRuntimeConfig\(\k\);let ${IDENTIFIER_PATTERN}=\[\.\.\.new Set\(\[\.\.\.\k\.extensionIds,\.\.\.${IDENTIFIER_PATTERN}\(\k\.nativeHostName\)\]\)\],`, + ), + ); +} + +function hasAnyLinuxChromeNativeHostRuntimeMarker(source) { + return source.includes("codexLinuxChromeNativeHostRuntime") || + source.includes("codexLinuxChromePluginAppServerSourcePath") || + LINUX_CHROME_NATIVE_HOST_RUNTIME_MODERN_MARKERS.some((marker) => + source.includes(marker) + ) || + LINUX_CHROME_NATIVE_HOST_RUNTIME_APP_SERVER_MARKERS.some((marker) => + source.includes(marker) + ); +} + +function hasCurrentModernChromeNativeHostRuntimeContract(source) { + return source.includes("CODEX_BROWSER_USE_NODE_PATH") && + source.includes("nodeReplPathSource") && + source.includes("resolvePrimaryRuntimeNodePath"); +} + +function hasCurrentChromeAppServerRuntimeContract(source) { + return source.includes(CURRENT_CHROME_NATIVE_HOST_RUNTIME_MESSAGE) && + source.includes(CURRENT_CHROME_APP_SERVER_CODEX_RUNTIME_MESSAGE) && + source.includes(".plugin-appserver"); +} + +function classifyChromeNativeHostRuntimeSource(source) { + const shapes = []; + if (hasCurrentModernChromeNativeHostRuntimeContract(source)) { + shapes.push("modern"); + } + if (hasCurrentChromeAppServerRuntimeContract(source)) { + shapes.push("app-server"); + } + + const hasAnyMarker = hasAnyLinuxChromeNativeHostRuntimeMarker(source); + if (shapes.length === 0) { + const hasCurrentContractFragment = + source.includes("CODEX_BROWSER_USE_NODE_PATH") || + source.includes(CURRENT_CHROME_NATIVE_HOST_RUNTIME_MESSAGE) || + source.includes(CURRENT_CHROME_APP_SERVER_CODEX_RUNTIME_MESSAGE) || + source.includes(".plugin-appserver"); + return { + shapes, + state: hasAnyMarker ? "partial" : hasCurrentContractFragment ? "drifted" : "irrelevant", + }; + } + + const modernComplete = !shapes.includes("modern") || + hasCompleteModernChromeNativeHostRuntimePatch(source); + const appServerComplete = !shapes.includes("app-server") || + hasCompleteCurrentChromeAppServerRuntimePatch(source); + const unexpectedModernMarker = !shapes.includes("modern") && + LINUX_CHROME_NATIVE_HOST_RUNTIME_MODERN_MARKERS.some((marker) => source.includes(marker)); + const unexpectedAppServerMarker = !shapes.includes("app-server") && + LINUX_CHROME_NATIVE_HOST_RUNTIME_APP_SERVER_MARKERS.some((marker) => source.includes(marker)); + if (modernComplete && appServerComplete && !unexpectedModernMarker && !unexpectedAppServerMarker) { + return { shapes, state: "complete" }; + } + return { shapes, state: hasAnyMarker ? "partial" : "clean" }; +} + function applyLinuxChromePluginAutoInstallPatch(currentSource) { const gateRegex = /\{([^{}]*?)(installWhenMissing:!0,)?name:([A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*),([^{}]*?syncInstallStateWithChromeExtension:!0,isAvailable:\(\{buildFlavor:([A-Za-z_$][\w$]*),features:([A-Za-z_$][\w$]*)\}\)=>)((?:process\.platform===`linux`\|\|\()?\6\.externalBrowserUseAllowed&&[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*\(\5\)\)?)\}/g; @@ -59,198 +223,62 @@ function applyLinuxChromePluginAutoInstallPatch(currentSource) { } function applyLinuxChromeNativeHostRuntimePatch(currentSource) { - if ( - currentSource.includes("codexLinuxChromePluginAppServerSourcePath") && - currentSource.includes("codexLinuxChromeNativeHostRuntimeFile") - ) { + const classification = classifyChromeNativeHostRuntimeSource(currentSource); + if (classification.state === "complete") { return currentSource; } - - let helper = ""; - if (!currentSource.includes("codexLinuxChromeNativeHostRuntimeFile")) { - const fsVar = requireName(currentSource, "node:fs"); - const pathVar = requireName(currentSource, "node:path"); - if (fsVar == null || pathVar == null) { - console.warn( - "WARN: Could not find fs/path aliases — skipping Linux Chrome native host runtime patch", - ); - return currentSource; - } - - helper = - `function codexLinuxChromeNativeHostRuntimeFile(e,t){if(process.platform!==\`linux\`||e==null)return null;for(let n of t){let t=(0,${pathVar}.join)(e,...n);try{if((0,${fsVar}.statSync)(t).isFile())return t}catch{}}return null}function codexLinuxChromeNativeHostRuntimeEnv(e){if(process.platform!==\`linux\`)return null;let t=process.env[e];if(t==null||t.length===0)return null;try{return(0,${fsVar}.statSync)(t).isFile()?t:null}catch{return null}}function codexLinuxChromeNativeHostRuntimePath(e){if(process.platform!==\`linux\`)return null;for(let t of(process.env.PATH??\`\`).split(\`:\`)){if(t.length===0)continue;let n=(0,${pathVar}.join)(t,e);try{if((0,${fsVar}.statSync)(n).isFile())return n}catch{}}return null}function codexLinuxChromeNativeHostRuntimeEntry(e,t){return e==null?null:{path:e,source:t}}`; - } - - let patchedSource = currentSource; - let changed = false; - const takePatch = (nextSource) => { - if (nextSource == null || nextSource === patchedSource) { - return false; - } - patchedSource = nextSource; - helper = ""; - changed = true; - return true; - }; - - const sourcePathPatched = applyLinuxChromePluginAppServerSourcePathPatch(patchedSource); - if (sourcePathPatched !== patchedSource) { - patchedSource = sourcePathPatched; - changed = true; - } - takePatch(applyModernChromeNativeHostRuntimePatch(patchedSource, helper)); - takePatch(applyChromePluginCodexAppServerRuntimePatch(patchedSource, helper)); - takePatch(applyChromePluginAppServerRuntimePatch(patchedSource, helper)); - if (changed) { - return patchedSource; - } - - const missingRuntimeMessage = - "Missing bundled Electron runtime required to sync Chrome native host resources"; - if ( - !currentSource.includes(missingRuntimeMessage) && - !currentSource.includes("Missing bundled Electron Codex runtime required to sync Chrome plugin app server") - ) { + if (classification.state === "partial") { console.warn( - "WARN: Could not find Chrome native host runtime resolver — skipping Linux runtime path patch", + "WARN: Found incomplete Chrome native host runtime patch — skipping Linux runtime path patch", ); return currentSource; } - const appServerRuntimePatch = applyChromePluginAppServerRuntimePatch( - currentSource, - helper, - ); - if (appServerRuntimePatch != null) { - return appServerRuntimePatch; - } - - const runtimeResolverRegex = - /function ([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\{let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\2\.resourcesPath\)\?\?([A-Za-z_$][\w$]*)\(\2\.devRuntimeRepoRoot,\[`extension`,`bin`,process\.platform===`win32`\?`codex\.exe`:`codex`\]\),([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\2\.resourcesPath\)\?\?\5\(\2\.devRuntimeRepoRoot,\[`electron`,`bin`,process\.platform===`win32`\?`node\.exe`:`node`\]\),([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\2\.resourcesPath\)\?\?\5\(\2\.devRuntimeRepoRoot,\[`electron`,`bin`,process\.platform===`win32`\?`node_repl\.exe`:`node_repl`\]\),/; - const match = currentSource.match(runtimeResolverRegex); - if (match != null) { - const [ - originalPrefix, - resolverName, - configVar, - codexVar, - codexResourceFn, - devRuntimeFn, - nodeVar, - nodeResourceFn, - nodeReplVar, - nodeReplResourceFn, - ] = match; - const replacement = - `${helper}function ${resolverName}(${configVar}){let ${codexVar}=${codexResourceFn}(${configVar}.resourcesPath)??codexLinuxChromeNativeHostRuntimeEnv(\`CODEX_CLI_PATH\`)??codexLinuxChromeNativeHostRuntimePath(\`codex\`)??${devRuntimeFn}(${configVar}.devRuntimeRepoRoot,[\`extension\`,\`bin\`,process.platform===\`win32\`?\`codex.exe\`:\`codex\`]),${nodeVar}=${nodeResourceFn}(${configVar}.resourcesPath)??codexLinuxChromeNativeHostRuntimeEnv(\`CODEX_BROWSER_USE_NODE_PATH\`)??codexLinuxChromeNativeHostRuntimeEnv(\`NODE_REPL_NODE_PATH\`)??codexLinuxChromeNativeHostRuntimeFile(${configVar}.resourcesPath,[[\`node-runtime\`,\`bin\`,process.platform===\`win32\`?\`node.exe\`:\`node\`]])??${devRuntimeFn}(${configVar}.devRuntimeRepoRoot,[\`electron\`,\`bin\`,process.platform===\`win32\`?\`node.exe\`:\`node\`]),${nodeReplVar}=${nodeReplResourceFn}(${configVar}.resourcesPath)??codexLinuxChromeNativeHostRuntimeEnv(\`CODEX_NODE_REPL_PATH\`)??codexLinuxChromeNativeHostRuntimeFile(${configVar}.resourcesPath,[[process.platform===\`win32\`?\`node_repl.exe\`:\`node_repl\`]])??${devRuntimeFn}(${configVar}.devRuntimeRepoRoot,[\`electron\`,\`bin\`,process.platform===\`win32\`?\`node_repl.exe\`:\`node_repl\`]),`; - - return currentSource.replace(originalPrefix, replacement); - } - - const currentRuntimeResolverRegex = - /function ([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\{let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\2\.resourcesPath\)\?\?([A-Za-z_$][\w$]*)\(\2\.devRuntimeRepoRoot,\[`extension`,`bin`,process\.platform===`win32`\?`codex\.exe`:`codex`\]\),([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\2\.resourcesPath\),([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\2\.resourcesPath\),/; - const currentMatch = currentSource.match(currentRuntimeResolverRegex); - if (currentMatch == null) { + if (classification.state === "clean") { + let patchedSource = currentSource; + let helper = LINUX_CHROME_NATIVE_HOST_RUNTIME_HELPER; + if (classification.shapes.includes("modern")) { + const patched = applyModernChromeNativeHostRuntimePatch(patchedSource, helper); + if (patched == null) { + console.warn( + "WARN: Could not identify Chrome native host runtime resolver shape — skipping Linux runtime path patch", + ); + return currentSource; + } + patchedSource = patched; + helper = ""; + } + if (classification.shapes.includes("app-server")) { + const patched = applyCurrentChromeAppServerRuntimePatches(patchedSource, helper); + if (patched == null) { + console.warn( + "WARN: Could not identify current Chrome plugin app-server runtime contract — skipping Linux runtime path patch", + ); + return currentSource; + } + patchedSource = patched; + } + + if (classifyChromeNativeHostRuntimeSource(patchedSource).state === "complete") { + return patchedSource; + } console.warn( - "WARN: Could not identify Chrome native host runtime resolver shape — skipping Linux runtime path patch", + "WARN: Could not complete Chrome native host runtime patch contract — skipping Linux runtime path patch", ); return currentSource; } - const [ - originalPrefix, - resolverName, - configVar, - codexVar, - codexResourceFn, - devRuntimeFn, - nodeVar, - nodeResourceFn, - nodeReplVar, - nodeReplResourceFn, - ] = currentMatch; - const replacement = - `${helper}function ${resolverName}(${configVar}){let ${codexVar}=${codexResourceFn}(${configVar}.resourcesPath)??codexLinuxChromeNativeHostRuntimeEnv(\`CODEX_CLI_PATH\`)??codexLinuxChromeNativeHostRuntimePath(\`codex\`)??${devRuntimeFn}(${configVar}.devRuntimeRepoRoot,[\`extension\`,\`bin\`,process.platform===\`win32\`?\`codex.exe\`:\`codex\`]),${nodeVar}=${nodeResourceFn}(${configVar}.resourcesPath)??codexLinuxChromeNativeHostRuntimeEnv(\`CODEX_BROWSER_USE_NODE_PATH\`)??codexLinuxChromeNativeHostRuntimeEnv(\`NODE_REPL_NODE_PATH\`)??codexLinuxChromeNativeHostRuntimeFile(${configVar}.resourcesPath,[[\`node-runtime\`,\`bin\`,process.platform===\`win32\`?\`node.exe\`:\`node\`]])??${devRuntimeFn}(${configVar}.devRuntimeRepoRoot,[\`electron\`,\`bin\`,process.platform===\`win32\`?\`node.exe\`:\`node\`]),${nodeReplVar}=${nodeReplResourceFn}(${configVar}.resourcesPath)??codexLinuxChromeNativeHostRuntimeEnv(\`CODEX_NODE_REPL_PATH\`)??codexLinuxChromeNativeHostRuntimeFile(${configVar}.resourcesPath,[[process.platform===\`win32\`?\`node_repl.exe\`:\`node_repl\`]])??${devRuntimeFn}(${configVar}.devRuntimeRepoRoot,[\`electron\`,\`bin\`,process.platform===\`win32\`?\`node_repl.exe\`:\`node_repl\`]),`; - - return currentSource.replace(originalPrefix, replacement); -} - -function applyLinuxChromePluginAppServerSourcePathPatch(currentSource) { - const marker = "codexLinuxChromePluginAppServerSourcePath"; - const isolationRoot = currentSource.indexOf(".plugin-appserver"); - if (currentSource.includes(marker) || isolationRoot === -1) { - return currentSource; - } - - const isolationSource = currentSource.slice(isolationRoot, isolationRoot + 12_000); - const syncFunctionRegex = - /async function ([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\{(?=let [A-Za-z_$][\w$]*=\2\.nativeHostName===)/; - const match = isolationSource.match(syncFunctionRegex); - if (match == null) { + if (classification.state === "drifted") { console.warn( - "WARN: Could not find Chrome plugin app-server isolation function — Linux CLI path may not be relocatable", + "WARN: Could not identify Chrome native host runtime resolver shape — skipping Linux runtime path patch", ); return currentSource; } - const [functionStart, , configVar] = match; - const helper = `function ${marker}(e){return e.codexCliPath}`; - const functionIndex = isolationRoot + match.index; - return currentSource.slice(0, functionIndex) + - `${helper}${functionStart}if(process.platform===\`linux\`)return ${marker}(${configVar});` + - currentSource.slice(functionIndex + functionStart.length); -} - -function applyChromePluginCodexAppServerRuntimePatch(currentSource, helper) { - if (!currentSource.includes("Missing bundled Electron Codex runtime required to sync Chrome plugin app server")) { - return null; - } - - const appServerCodexRuntimeRegex = - /async function ([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\{let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\2\);if\(\3==null\)throw Error\(`Missing bundled Electron Codex runtime required to sync Chrome plugin app server for \$\{\2\.nativeHostName\} \(resourcesPath: \$\{\2\.resourcesPath\?\?``\}\)\.`\);return ([A-Za-z_$][\w$]*)\(\{codexCliPath:\3,codexHome:\2\.codexHome,nativeHostName:\2\.nativeHostName\}\)\}/; - const match = currentSource.match(appServerCodexRuntimeRegex); - if (match == null) { - return null; - } - - const [ - original, - resolverFn, - configVar, - codexVar, - bundledCodexResolverFn, - syncFn, - ] = match; - const replacement = - `${helper}async function ${resolverFn}(${configVar}){let ${codexVar}=${bundledCodexResolverFn}(${configVar})??codexLinuxChromeNativeHostRuntimeEnv(\`CODEX_CLI_PATH\`)??codexLinuxChromeNativeHostRuntimePath(\`codex\`);if(${codexVar}==null)throw Error(\`Missing bundled Electron Codex runtime required to sync Chrome plugin app server for \${${configVar}.nativeHostName} (resourcesPath: \${${configVar}.resourcesPath??\`\`}).\`);return ${syncFn}({codexCliPath:${codexVar},codexHome:${configVar}.codexHome,nativeHostName:${configVar}.nativeHostName})}`; - return currentSource.replace(original, replacement); -} - -function applyChromePluginAppServerRuntimePatch(currentSource, helper) { - if (!currentSource.includes("nativeHostName") || !currentSource.includes("nodeModuleDirs")) { - return null; - } - - const appServerRuntimeRegex = - /async function ([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\{let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\2\),([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\2\.resourcesPath\),([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\2\.resourcesPath\),/; - const match = currentSource.match(appServerRuntimeRegex); - if (match == null) { - return null; - } - const [ - originalPrefix, - resolverFn, - configVar, - codexVar, - codexResolverFn, - nodeVar, - nodeResolverFn, - nodeReplVar, - nodeReplResolverFn, - ] = match; - const replacement = - `${helper}async function ${resolverFn}(${configVar}){let ${codexVar}=${codexResolverFn}(${configVar})??codexLinuxChromeNativeHostRuntimeEnv(\`CODEX_CLI_PATH\`)??codexLinuxChromeNativeHostRuntimePath(\`codex\`),${nodeVar}=${nodeResolverFn}(${configVar}.resourcesPath)??codexLinuxChromeNativeHostRuntimeEnv(\`CODEX_BROWSER_USE_NODE_PATH\`)??codexLinuxChromeNativeHostRuntimeEnv(\`NODE_REPL_NODE_PATH\`)??codexLinuxChromeNativeHostRuntimeFile(${configVar}.resourcesPath,[[\`node-runtime\`,\`bin\`,process.platform===\`win32\`?\`node.exe\`:\`node\`]]),${nodeReplVar}=${nodeReplResolverFn}(${configVar}.resourcesPath)??codexLinuxChromeNativeHostRuntimeEnv(\`CODEX_NODE_REPL_PATH\`)??codexLinuxChromeNativeHostRuntimeFile(${configVar}.resourcesPath,[[process.platform===\`win32\`?\`node_repl.exe\`:\`node_repl\`]]),`; - return currentSource.replace(originalPrefix, replacement); + console.warn( + "WARN: Could not find Chrome native host runtime resolver — skipping Linux runtime path patch", + ); + return currentSource; } function applyModernChromeNativeHostRuntimePatch(currentSource, helper) { @@ -301,28 +329,192 @@ function applyModernChromeNativeHostRuntimePatch(currentSource, helper) { (_match, prefix, resolverFn) => `${prefix}codexLinuxChromeNativeHostRuntimeEntry(codexLinuxChromeNativeHostRuntimePath(\`codex\`),\`linux-path\`)??${resolverFn}({devRelativePathSegments:[\`extension\`,\`bin\`,\`codex\`]`, ); + if (patchedResolver === resolverSource) { + return null; + } + const codexPatchedResolver = patchedResolver; patchedResolver = patchedResolver.replace( nodePathRegex, (_match, prefix, fallbackExpressionStart) => `${prefix}codexLinuxChromeNativeHostRuntimeEntry(codexLinuxChromeNativeHostRuntimeFile(${resourcesVar},[[\`node-runtime\`,\`bin\`,${platformVar}===\`win32\`?\`node.exe\`:\`node\`]]),\`linux-node-runtime\`)??${fallbackExpressionStart}`, ); + if (patchedResolver === codexPatchedResolver) { + return null; + } + const nodePatchedResolver = patchedResolver; patchedResolver = patchedResolver.replace( nodeReplPathRegex, (_match, prefix, resolverFn) => `${prefix}codexLinuxChromeNativeHostRuntimeEntry(codexLinuxChromeNativeHostRuntimeFile(${resourcesVar},[[${platformVar}===\`win32\`?\`node_repl.exe\`:\`node_repl\`]]),\`linux-node-repl-runtime\`)??${resolverFn}({devRelativePathSegments:null`, ); - - if (patchedResolver === resolverSource) { + if (patchedResolver === nodePatchedResolver) { return null; } - return currentSource.slice(0, functionStart) + + const patchedSource = currentSource.slice(0, functionStart) + helper + patchedResolver + currentSource.slice(functionEnd + 1); + return hasCompleteModernChromeNativeHostRuntimePatch(patchedSource) + ? patchedSource + : null; +} + +function applyCurrentChromeAppServerRuntimePatches(currentSource, helper) { + let patchedSource = applyLinuxChromePluginAppServerSourcePathPatch(currentSource); + if (patchedSource == null) { + return null; + } + + patchedSource = applyChromePluginAppServerRuntimePatch(patchedSource, helper); + if (patchedSource == null) { + return null; + } + + patchedSource = applyChromePluginCodexAppServerRuntimePatch(patchedSource, ""); + if (patchedSource == null) { + return null; + } + + patchedSource = applyLinuxChromePluginRuntimeConfigPatch(patchedSource); + if (patchedSource == null) { + return null; + } + + return hasCompleteCurrentChromeAppServerRuntimePatch(patchedSource) + ? patchedSource + : null; +} + +function applyLinuxChromePluginRuntimeConfigPatch(currentSource) { + const registrationRegex = + /async function ([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\{let ([A-Za-z_$][\w$]*)=\[\.\.\.new Set\(\[\.\.\.\2\.extensionIds,\.\.\.([A-Za-z_$][\w$]*)\(\2\.nativeHostName\)\]\)\],/; + const match = currentSource.match(registrationRegex); + if (match == null) { + return null; + } + const [originalPrefix, functionName, configVar, extensionIdsVar, bundledIdsFn] = match; + const replacement = + `${LINUX_CHROME_PLUGIN_RUNTIME_CONFIG_HELPER}${LINUX_CHROME_PLUGIN_RUNTIME_CONFIG_MARKER}async function ${functionName}(${configVar}){${configVar}=await codexLinuxChromePluginRuntimeConfig(${configVar});let ${extensionIdsVar}=[...new Set([...${configVar}.extensionIds,...${bundledIdsFn}(${configVar}.nativeHostName)])],`; + return currentSource.replace(originalPrefix, replacement); +} + +function applyLinuxChromePluginAppServerSourcePathPatch(currentSource) { + const isolationRoot = currentSource.indexOf(".plugin-appserver"); + if (isolationRoot === -1) { + return null; + } + + const isolationSource = currentSource.slice(isolationRoot, isolationRoot + 12_000); + const syncFunctionRegex = + /async function ([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\{(?=let [A-Za-z_$][\w$]*=\2\.nativeHostName===)/; + const match = isolationSource.match(syncFunctionRegex); + if (match == null) { + return null; + } + + const [functionStart, , configVar] = match; + const functionIndex = isolationRoot + match.index; + return currentSource.slice(0, functionIndex) + + `${LINUX_CHROME_PLUGIN_APP_SERVER_SOURCE_PATH_HELPER}${LINUX_CHROME_NATIVE_HOST_RUNTIME_APP_SERVER_SOURCE_PATH_MARKER}${functionStart}if(process.platform===\`linux\`)return codexLinuxChromePluginAppServerSourcePath(${configVar});` + + currentSource.slice(functionIndex + functionStart.length); } -function patchLinuxChromeNativeHostRuntimeAssets(extractedDir) { +function applyChromePluginAppServerRuntimePatch(currentSource, helper) { + const appServerRuntimeRegex = + /async function ([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\{let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\2\),([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\2\.resourcesPath\),([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\2\.resourcesPath\),/; + const match = currentSource.match(appServerRuntimeRegex); + if (match == null) { + return null; + } + const [ + originalPrefix, + resolverFn, + configVar, + codexVar, + codexResolverFn, + nodeVar, + nodeResolverFn, + nodeReplVar, + nodeReplResolverFn, + ] = match; + const replacement = + `${helper}${LINUX_CHROME_NATIVE_HOST_RUNTIME_APP_SERVER_RUNTIME_MARKER}async function ${resolverFn}(${configVar}){let ${codexVar}=${codexResolverFn}(${configVar})??codexLinuxChromeNativeHostRuntimeEnv(\`CODEX_CLI_PATH\`)??codexLinuxChromeNativeHostRuntimePath(\`codex\`),${nodeVar}=${nodeResolverFn}(${configVar}.resourcesPath)??codexLinuxChromeNativeHostRuntimeEnv(\`CODEX_BROWSER_USE_NODE_PATH\`)??codexLinuxChromeNativeHostRuntimeEnv(\`NODE_REPL_NODE_PATH\`)??codexLinuxChromeNativeHostRuntimeFile(${configVar}.resourcesPath,[[\`node-runtime\`,\`bin\`,process.platform===\`win32\`?\`node.exe\`:\`node\`]]),${nodeReplVar}=${nodeReplResolverFn}(${configVar}.resourcesPath)??codexLinuxChromeNativeHostRuntimeEnv(\`CODEX_NODE_REPL_PATH\`)??codexLinuxChromeNativeHostRuntimeFile(${configVar}.resourcesPath,[[process.platform===\`win32\`?\`node_repl.exe\`:\`node_repl\`]]),`; + return currentSource.replace(originalPrefix, replacement); +} + +function applyChromePluginCodexAppServerRuntimePatch(currentSource, helper) { + const appServerCodexRuntimeRegex = + /async function ([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\{let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\2\);if\(\3==null\)throw Error\(`Missing bundled Electron Codex runtime required to sync Chrome plugin app server for \$\{\2\.nativeHostName\} \(resourcesPath: \$\{\2\.resourcesPath\?\?``\}\)\.\`\);return ([A-Za-z_$][\w$]*)\(\{codexCliPath:\3,codexHome:\2\.codexHome,nativeHostName:\2\.nativeHostName\}\)\}/; + const match = currentSource.match(appServerCodexRuntimeRegex); + if (match == null) { + return null; + } + const [ + original, + resolverFn, + configVar, + codexVar, + bundledCodexResolverFn, + syncFn, + ] = match; + const replacement = + `${helper}${LINUX_CHROME_NATIVE_HOST_RUNTIME_APP_SERVER_CODEX_MARKER}async function ${resolverFn}(${configVar}){let ${codexVar}=${bundledCodexResolverFn}(${configVar})??codexLinuxChromeNativeHostRuntimeEnv(\`CODEX_CLI_PATH\`)??codexLinuxChromeNativeHostRuntimePath(\`codex\`);if(${codexVar}==null)throw Error(\`Missing bundled Electron Codex runtime required to sync Chrome plugin app server for \${${configVar}.nativeHostName} (resourcesPath: \${${configVar}.resourcesPath??\`\`}).\`);return ${syncFn}({codexCliPath:${codexVar},codexHome:${configVar}.codexHome,nativeHostName:${configVar}.nativeHostName})}`; + return currentSource.replace(original, replacement); +} + +function writeChromeNativeHostRuntimeAssetCandidates( + candidates, + writeFileSync, + readFileSync, +) { + const attempted = []; + try { + for (const candidate of candidates) { + attempted.push(candidate); + writeFileSync(candidate.filePath, candidate.patched, "utf8"); + } + } catch (error) { + const rollbackWriteFailures = []; + for (const candidate of attempted.reverse()) { + try { + writeFileSync(candidate.filePath, candidate.source, "utf8"); + } catch (rollbackError) { + rollbackWriteFailures.push(rollbackError); + } + } + + const rollbackVerificationFailures = []; + for (const candidate of attempted) { + try { + if (readFileSync(candidate.filePath, "utf8") !== candidate.source) { + rollbackVerificationFailures.push( + new Error(`rollback byte verification failed for ${candidate.filePath}`), + ); + } + } catch (rollbackError) { + rollbackVerificationFailures.push(rollbackError); + } + } + + if (rollbackVerificationFailures.length > 0) { + const writeFailureContext = rollbackWriteFailures[0] == null + ? "" + : `; rollback write also failed: ${rollbackWriteFailures[0].message}`; + const integrityError = new PatchIntegrityError( + `Chrome native host runtime rollback could not restore original bytes: ${rollbackVerificationFailures[0].message}${writeFailureContext}`, + ); + throw integrityError; + } + + throw error; + } +} + +function patchLinuxChromeNativeHostRuntimeAssets(extractedDir, { + writeFileSync = fs.writeFileSync, + readFileSync = fs.readFileSync, +} = {}) { const buildDir = path.join(extractedDir, ".vite", "build"); if (!fs.existsSync(buildDir)) { const reason = `Could not find build directory in ${buildDir}`; @@ -330,32 +522,85 @@ function patchLinuxChromeNativeHostRuntimeAssets(extractedDir) { return { matched: 0, changed: 0, reason }; } - let matched = 0; - let changed = 0; + const records = []; for (const fileName of readDirectoryNames(buildDir).filter((name) => name.endsWith(".js")).sort()) { const filePath = path.join(buildDir, fileName); const source = fs.readFileSync(filePath, "utf8"); - if ( - !source.includes("Missing bundled Electron runtime required to sync Chrome native host resources") && - !source.includes("codexLinuxChromeNativeHostRuntimeFile") && - !( - source.includes("CODEX_BROWSER_USE_NODE_PATH") && - source.includes("nodeReplPathSource") && - source.includes("resolvePrimaryRuntimeNodePath") - ) - ) { + const classification = classifyChromeNativeHostRuntimeSource(source); + if (classification.state === "irrelevant") { continue; } + records.push({ classification, filePath, source }); + } + + if (records.length === 0) { + return { matched: 0, changed: 0 }; + } + if (records.some(({ classification }) => classification.state === "drifted")) { + const reason = "Could not identify complete current Chrome native host runtime asset set"; + console.warn(`WARN: ${reason} — skipping Linux runtime path patch`); + return { matched: records.length, changed: 0, reason }; + } - matched += 1; - const patched = applyLinuxChromeNativeHostRuntimePatch(source); - if (patched !== source) { - fs.writeFileSync(filePath, patched, "utf8"); - changed += 1; + const shapeCounts = new Map(); + for (const { classification } of records) { + for (const shape of classification.shapes) { + shapeCounts.set(shape, (shapeCounts.get(shape) ?? 0) + 1); } } + const missingOrAmbiguousShapes = ["modern", "app-server"].filter( + (shape) => shapeCounts.get(shape) !== 1, + ); + if (missingOrAmbiguousShapes.length > 0) { + const reason = `Expected exactly one current Chrome native host runtime ${missingOrAmbiguousShapes.join(" and ")} asset`; + console.warn(`WARN: ${reason} — skipping Linux runtime path patch`); + return { matched: records.length, changed: 0, reason }; + } + if (records.some(({ classification }) => classification.state === "partial")) { + const reason = "Found incomplete Chrome native host runtime patch"; + console.warn(`WARN: ${reason} — skipping Linux runtime path patch`); + return { matched: records.length, changed: 0, reason }; + } + + const allComplete = records.every(({ classification }) => classification.state === "complete"); + if (allComplete) { + return { matched: records.length, changed: 0 }; + } + const allClean = records.every(({ classification }) => classification.state === "clean"); + if (!allClean) { + const reason = "Found mixed current Chrome native host runtime patch state"; + console.warn(`WARN: ${reason} — skipping Linux runtime path patch`); + return { matched: records.length, changed: 0, reason }; + } + + const candidates = []; + for (const record of records) { + const patched = applyLinuxChromeNativeHostRuntimePatch(record.source); + if (patched === record.source || classifyChromeNativeHostRuntimeSource(patched).state !== "complete") { + const reason = "Could not complete current Chrome native host runtime patch contract"; + console.warn(`WARN: ${reason} — skipping Linux runtime path patch`); + return { matched: records.length, changed: 0, reason }; + } + candidates.push({ ...record, patched }); + } + + try { + writeChromeNativeHostRuntimeAssetCandidates( + candidates, + writeFileSync, + readFileSync, + ); + } catch (error) { + if (isPatchIntegrityError(error)) { + throw error; + } + + const reason = "Could not write current Chrome native host runtime asset set"; + console.warn(`WARN: ${reason} — skipping Linux runtime path patch`); + return { matched: records.length, changed: 0, reason }; + } - return { matched, changed }; + return { matched: records.length, changed: candidates.length }; } module.exports = { diff --git a/scripts/patches/impl/chrome-plugin.test.js b/scripts/patches/impl/chrome-plugin.test.js new file mode 100644 index 000000000..1d855086c --- /dev/null +++ b/scripts/patches/impl/chrome-plugin.test.js @@ -0,0 +1,724 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +process.env.CODEX_LINUX_FEATURES_CONFIG = path.join( + __dirname, + "..", + "..", + "..", + "linux-features", + "features.example.json", +); + +const { + createPatchReport, + criticalFailuresFromReport, + optionalDriftFromReport, +} = require("../../lib/patch-report.js"); +const { validateReport } = require("../../ci/validate-patch-report.js"); +const { + corePatchDescriptors, + patchExtractedApp, +} = require("../../patches/runner.js"); +const { + applyExtractedAppPatchDescriptors, +} = require("../../patches/engine.js"); +const { + applyLinuxChromeNativeHostRuntimePatch, + patchLinuxChromeNativeHostRuntimeAssets, +} = require("./chrome-plugin.js"); +const { + createCurrentChromeNativeHostRuntimeAssetsFixture, + currentChromePluginAppServerSourceBundleFixture, + electron42BrowserUseRuntimeResolverBundleFixture, +} = require("../test-fixtures/current-dmg.js"); + +function captureWarns(fn) { + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args.map(String).join(" ")); + try { + return { value: fn(), warnings }; + } finally { + console.warn = originalWarn; + } +} + +function assetSources(candidate) { + return new Map([ + [candidate.mainPath, fs.readFileSync(candidate.mainPath, "utf8")], + [candidate.srcPath, fs.readFileSync(candidate.srcPath, "utf8")], + ]); +} + +const CHROME_EXTENSION_ID = "hehggadaopoacecdllhhajmbjkdcmajg"; + +function createChromeRuntimeCaches(root, arch = process.arch) { + const codexHome = path.join(root, "codex-home"); + const installedRoot = path.join( + codexHome, + "plugins", + "cache", + "openai-bundled", + "chrome", + ); + const installedVersion = path.join(installedRoot, "26.test"); + const installedHost = path.join( + installedVersion, + "extension-host", + "linux", + arch, + "extension-host", + ); + fs.mkdirSync(path.dirname(installedHost), { recursive: true }); + fs.writeFileSync(installedHost, "TAMPERED_INSTALLED_HOST\n"); + fs.symlinkSync("26.test", path.join(installedRoot, "latest")); + + const runtimeRoot = path.join( + codexHome, + "plugins", + "linux-runtime-cache", + "openai-bundled", + "chrome", + ); + const runtimeVersion = path.join(runtimeRoot, "26.test"); + const runtimeHost = path.join( + runtimeVersion, + "extension-host", + "linux", + arch, + "extension-host", + ); + const runtimeScripts = path.join(runtimeVersion, "scripts"); + fs.mkdirSync(path.dirname(runtimeHost), { recursive: true }); + fs.mkdirSync(runtimeScripts, { recursive: true }); + fs.writeFileSync(runtimeHost, "TRUSTED_RUNTIME_HOST\n"); + fs.writeFileSync( + path.join(runtimeScripts, "extension-ids.json"), + `${JSON.stringify({ extensionIds: [CHROME_EXTENSION_ID] })}\n`, + ); + fs.writeFileSync(path.join(runtimeScripts, "browser-client.mjs"), "TRUSTED_BROWSER_CLIENT\n"); + fs.symlinkSync("26.test", path.join(runtimeRoot, "latest")); + + for (const target of [ + codexHome, + path.join(codexHome, "plugins"), + path.join(codexHome, "plugins", "linux-runtime-cache"), + path.join(codexHome, "plugins", "linux-runtime-cache", "openai-bundled"), + runtimeRoot, + runtimeVersion, + path.join(runtimeVersion, "extension-host"), + path.join(runtimeVersion, "extension-host", "linux"), + path.dirname(runtimeHost), + runtimeScripts, + ]) { + fs.chmodSync(target, 0o755); + } + fs.chmodSync(runtimeHost, 0o755); + fs.chmodSync(path.join(runtimeScripts, "extension-ids.json"), 0o644); + fs.chmodSync(path.join(runtimeScripts, "browser-client.mjs"), 0o644); + + return { + arch, + codexHome, + installedHost, + installedLatest: path.join(installedRoot, "latest"), + installedRoot, + installedVersion, + runtimeHost, + runtimeLatest: path.join(runtimeRoot, "latest"), + runtimeVersion, + }; +} + +function registerChromeRuntime( + patched, + fixture, + geteuid = () => process.geteuid(), + arch = fixture.arch, + requireFn = require, +) { + return vm.runInNewContext( + `${patched};cq({codexHome:${JSON.stringify(fixture.codexHome)},extensionIds:["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],nativeHostName:"com.openai.codexextension",pluginRoot:${JSON.stringify(fixture.installedLatest)}});`, + { + process: { + geteuid, + platform: "linux", + arch, + }, + require: requireFn, + }, + ); +} + +function isLexicallyWithin(root, candidate) { + const relative = path.relative(path.resolve(root), path.resolve(candidate)); + return relative === "" || ( + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +test("patches the complete current Chrome runtime asset set transactionally", async () => { + const candidate = createCurrentChromeNativeHostRuntimeAssetsFixture(); + try { + const { value: first, warnings } = captureWarns(() => + patchLinuxChromeNativeHostRuntimeAssets(candidate.extractedDir), + ); + assert.deepEqual(first, { matched: 2, changed: 2 }); + assert.deepEqual(warnings, []); + + const mainPatched = fs.readFileSync(candidate.mainPath, "utf8"); + const srcPatched = fs.readFileSync(candidate.srcPath, "utf8"); + assert.match( + mainPatched, + /codexLinuxChromeNativeHostRuntimeEntry\(codexLinuxChromeNativeHostRuntimePath\(`codex`\),`linux-path`\)/, + ); + assert.match( + srcPatched, + /codexLinuxChromeNativeHostRuntimeEnv\(`CODEX_CLI_PATH`\)/, + ); + assert.match(srcPatched, /codexLinuxChromePluginAppServerSourcePath/); + assert.match(srcPatched, /codexLinuxChromePluginRuntimeConfig/); + + const files = new Set([ + "/home/josh/.local/bin/codex", + "/opt/codex/resources/node-runtime/bin/node", + "/opt/codex/resources/node_repl", + ]); + const runtime = await vm.runInNewContext( + `${srcPatched};vq({resourcesPath:"/opt/codex/resources",codexHome:"/tmp/codex",devRuntimeRepoRoot:null,nativeHostName:"com.openai.codexextension"});`, + { + require(moduleName) { + if (moduleName === "node:path") return path; + if (moduleName === "node:fs") { + return { + statSync(filePath) { + if (!files.has(filePath)) { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + } + return { isFile: () => true }; + }, + }; + } + return require(moduleName); + }, + process: { + env: { CODEX_CLI_PATH: "/home/josh/.local/bin/codex", PATH: "" }, + platform: "linux", + }, + }, + ); + assert.deepEqual(JSON.parse(JSON.stringify(runtime)), { + codexCliPath: "/home/josh/.local/bin/codex", + nodeModuleDirs: [], + nodePath: "/opt/codex/resources/node-runtime/bin/node", + nodeReplPath: "/opt/codex/resources/node_repl", + }); + + const beforeSecondPass = assetSources(candidate); + const second = captureWarns(() => + patchLinuxChromeNativeHostRuntimeAssets(candidate.extractedDir), + ); + assert.deepEqual(second.value, { matched: 2, changed: 0 }); + assert.deepEqual(second.warnings, []); + assert.deepEqual(assetSources(candidate), beforeSecondPass); + } finally { + fs.rmSync(candidate.extractedDir, { recursive: true, force: true }); + } +}); + +test("registers the trusted Linux runtime cache instead of the installed cache", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-chrome-runtime-trust-")); + try { + const fixture = createChromeRuntimeCaches(root); + const patched = applyLinuxChromeNativeHostRuntimePatch( + currentChromePluginAppServerSourceBundleFixture(), + ); + const bridgeRoot = path.join(fixture.installedRoot, ".codex-linux-runtime"); + fs.symlinkSync(fixture.installedHost, bridgeRoot); + const result = await registerChromeRuntime(patched, fixture); + + const bridgeHost = path.join( + bridgeRoot, + "extension-host", + "linux", + fixture.arch, + "extension-host", + ); + assert.equal(result.extensionHostPath, bridgeHost); + assert.equal( + result.browserClientPath, + path.join(bridgeRoot, "scripts", "browser-client.mjs"), + ); + assert.equal(fs.realpathSync(bridgeRoot), fixture.runtimeVersion); + assert.equal(fs.statSync(fixture.installedRoot).mode & 0o022, 0); + assert.deepEqual( + { + dev: fs.statSync(bridgeHost).dev, + ino: fs.statSync(bridgeHost).ino, + }, + { + dev: fs.statSync(fixture.runtimeHost).dev, + ino: fs.statSync(fixture.runtimeHost).ino, + }, + ); + assert.deepEqual( + JSON.parse(JSON.stringify(result.extensionIds)), + [CHROME_EXTENSION_ID], + ); + assert.equal(fs.readFileSync(fixture.installedHost, "utf8"), "TAMPERED_INSTALLED_HOST\n"); + assert.equal(fs.readFileSync(fixture.runtimeHost, "utf8"), "TRUSTED_RUNTIME_HOST\n"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("keeps bridge registration removable with a symlinked CODEX_HOME", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-chrome-runtime-home-link-")); + try { + const fixture = createChromeRuntimeCaches(root); + const lexicalCodexHome = path.join(root, "codex-home-link"); + fs.symlinkSync(fixture.codexHome, lexicalCodexHome, "dir"); + fixture.codexHome = lexicalCodexHome; + fixture.installedLatest = path.join( + lexicalCodexHome, + "plugins", + "cache", + "openai-bundled", + "chrome", + "latest", + ); + const patched = applyLinuxChromeNativeHostRuntimePatch( + currentChromePluginAppServerSourceBundleFixture(), + ); + const result = await registerChromeRuntime(patched, fixture); + const pluginCacheRoot = path.join( + lexicalCodexHome, + "plugins", + "cache", + "openai-bundled", + "chrome", + ); + + assert.equal(isLexicallyWithin(pluginCacheRoot, result.extensionHostPath), true); + assert.equal(isLexicallyWithin(pluginCacheRoot, result.browserClientPath), true); + assert.equal( + fs.realpathSync(path.join(pluginCacheRoot, ".codex-linux-runtime")), + fixture.runtimeVersion, + ); + + const unrelatedEntry = { + extensionHostPath: path.join(root, "unrelated", "extension-host"), + }; + const remainingEntries = [result, unrelatedEntry].filter( + (entry) => !isLexicallyWithin(pluginCacheRoot, entry.extensionHostPath), + ); + assert.deepEqual(remainingEntries, [unrelatedEntry]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("preserves the existing bridge when atomic replacement fails", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-chrome-runtime-rename-")); + try { + const fixture = createChromeRuntimeCaches(root); + const bridgeRoot = path.join(fixture.installedRoot, ".codex-linux-runtime"); + fs.symlinkSync(fixture.runtimeVersion, bridgeRoot, "dir"); + const patched = applyLinuxChromeNativeHostRuntimePatch( + currentChromePluginAppServerSourceBundleFixture(), + ); + const fsPromises = require("node:fs/promises"); + const requireWithFailedRename = (specifier) => { + if (specifier !== "node:fs/promises") { + return require(specifier); + } + return { + ...fsPromises, + rename: async () => { + const error = new Error("injected bridge rename failure"); + error.code = "EIO"; + throw error; + }, + }; + }; + + await assert.rejects( + registerChromeRuntime( + patched, + fixture, + () => process.geteuid(), + fixture.arch, + requireWithFailedRename, + ), + /injected bridge rename failure/, + ); + assert.equal(fs.lstatSync(bridgeRoot).isSymbolicLink(), true); + assert.equal(fs.realpathSync(bridgeRoot), fixture.runtimeVersion); + assert.deepEqual( + fs.readdirSync(fixture.installedRoot).filter((name) => name.includes(".tmp-")), + [], + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("registers the trusted arm64 runtime host", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-chrome-runtime-arm64-")); + try { + const fixture = createChromeRuntimeCaches(root, "arm64"); + const patched = applyLinuxChromeNativeHostRuntimePatch( + currentChromePluginAppServerSourceBundleFixture(), + ); + const result = await registerChromeRuntime( + patched, + fixture, + () => process.geteuid(), + "arm64", + ); + + assert.equal(result.extensionHostPath.includes("/linux/arm64/"), true); + assert.deepEqual( + { + dev: fs.statSync(result.extensionHostPath).dev, + ino: fs.statSync(result.extensionHostPath).ino, + }, + { + dev: fs.statSync(fixture.runtimeHost).dev, + ino: fs.statSync(fixture.runtimeHost).ino, + }, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("rejects a writable Linux Chrome runtime cache", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-chrome-runtime-writable-")); + try { + const fixture = createChromeRuntimeCaches(root); + fs.chmodSync(fixture.runtimeHost, 0o775); + const patched = applyLinuxChromeNativeHostRuntimePatch( + currentChromePluginAppServerSourceBundleFixture(), + ); + + await assert.rejects( + registerChromeRuntime(patched, fixture), + /Linux Chrome plugin runtime is not trusted/, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("rejects a foreign-owned or symlinked Linux Chrome runtime cache", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-chrome-runtime-owner-")); + try { + const fixture = createChromeRuntimeCaches(root); + const patched = applyLinuxChromeNativeHostRuntimePatch( + currentChromePluginAppServerSourceBundleFixture(), + ); + + await assert.rejects( + registerChromeRuntime(patched, fixture, () => process.geteuid() + 1), + /Linux Chrome plugin runtime (?:link |parent )?is not trusted/, + ); + + fs.symlinkSync("extension-ids.json", path.join(fixture.runtimeVersion, "unsafe-link")); + await assert.rejects( + registerChromeRuntime(patched, fixture), + /Linux Chrome plugin runtime is not trusted/, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("rejects mixed or partial current Chrome runtime asset sets without writes", () => { + const mixed = createCurrentChromeNativeHostRuntimeAssetsFixture(); + try { + fs.writeFileSync( + mixed.mainPath, + applyLinuxChromeNativeHostRuntimePatch( + fs.readFileSync(mixed.mainPath, "utf8"), + ), + "utf8", + ); + const before = assetSources(mixed); + const { value, warnings } = captureWarns(() => + patchLinuxChromeNativeHostRuntimeAssets(mixed.extractedDir), + ); + assert.equal(value.changed, 0); + assert.match(value.reason, /mixed current Chrome native host runtime patch state/); + assert.equal(warnings.length, 1); + assert.deepEqual(assetSources(mixed), before); + } finally { + fs.rmSync(mixed.extractedDir, { recursive: true, force: true }); + } + + const partial = createCurrentChromeNativeHostRuntimeAssetsFixture(); + try { + assert.deepEqual( + patchLinuxChromeNativeHostRuntimeAssets(partial.extractedDir), + { matched: 2, changed: 2 }, + ); + fs.writeFileSync( + partial.srcPath, + fs.readFileSync(partial.srcPath, "utf8").replace( + "/*codexLinuxChromeNativeHostAppServerRuntime*/", + "/*codexLinuxChromeNativeHostAppServerRuntimeCorrupt*/", + ), + "utf8", + ); + const before = assetSources(partial); + const { value, warnings } = captureWarns(() => + patchLinuxChromeNativeHostRuntimeAssets(partial.extractedDir), + ); + assert.equal(value.changed, 0); + assert.match(value.reason, /incomplete Chrome native host runtime patch/); + assert.equal(warnings.length, 1); + assert.deepEqual(assetSources(partial), before); + } finally { + fs.rmSync(partial.extractedDir, { recursive: true, force: true }); + } +}); + +test("rejects current Chrome runtime markers with a damaged contract body", () => { + const candidate = createCurrentChromeNativeHostRuntimeAssetsFixture(); + try { + assert.deepEqual( + patchLinuxChromeNativeHostRuntimeAssets(candidate.extractedDir), + { matched: 2, changed: 2 }, + ); + const patchedSource = fs.readFileSync(candidate.srcPath, "utf8"); + const variants = [ + patchedSource.replace( + "if(process.platform===`linux`)return codexLinuxChromePluginAppServerSourcePath(e);", + "if(process.platform===`linux`)return e.codexCliPath;", + ), + patchedSource.replace( + "??codexLinuxChromeNativeHostRuntimeEnv(`CODEX_CLI_PATH`)??codexLinuxChromeNativeHostRuntimePath(`codex`)", + "", + ), + patchedSource.replace( + "??codexLinuxChromeNativeHostRuntimeFile(e.resourcesPath,[[`node-runtime`,`bin`,process.platform===`win32`?`node.exe`:`node`]])", + "", + ), + patchedSource.replace( + "??codexLinuxChromeNativeHostRuntimeFile(e.resourcesPath,[[process.platform===`win32`?`node_repl.exe`:`node_repl`]])", + "", + ), + ]; + + for (const source of variants) { + fs.writeFileSync(candidate.srcPath, source, "utf8"); + const before = assetSources(candidate); + const { value, warnings } = captureWarns(() => + patchLinuxChromeNativeHostRuntimeAssets(candidate.extractedDir), + ); + assert.equal(value.changed, 0); + assert.match(value.reason, /incomplete Chrome native host runtime patch/); + assert.equal(warnings.length, 1); + assert.deepEqual(assetSources(candidate), before); + } + } finally { + fs.rmSync(candidate.extractedDir, { recursive: true, force: true }); + } +}); + +test("restores current Chrome runtime assets after a write failure", () => { + const candidate = createCurrentChromeNativeHostRuntimeAssetsFixture(); + try { + const before = assetSources(candidate); + let writeCount = 0; + const { value, warnings } = captureWarns(() => + patchLinuxChromeNativeHostRuntimeAssets(candidate.extractedDir, { + writeFileSync(filePath, source, encoding) { + writeCount += 1; + if (writeCount === 2) { + fs.writeFileSync(filePath, "partially-written", encoding); + throw new Error("simulated write failure"); + } + fs.writeFileSync(filePath, source, encoding); + }, + }), + ); + assert.equal(value.changed, 0); + assert.match(value.reason, /Could not write current Chrome/); + assert.equal(warnings.length, 1); + assert.deepEqual(assetSources(candidate), before); + } finally { + fs.rmSync(candidate.extractedDir, { recursive: true, force: true }); + } +}); + +test("keeps a verified rollback fail-soft when a rollback write throws after restoring bytes", () => { + const candidate = createCurrentChromeNativeHostRuntimeAssetsFixture(); + try { + const before = assetSources(candidate); + let writeCount = 0; + const { value, warnings } = captureWarns(() => + patchLinuxChromeNativeHostRuntimeAssets(candidate.extractedDir, { + writeFileSync(filePath, source, encoding) { + writeCount += 1; + if (writeCount === 2) { + fs.writeFileSync(filePath, "partially-written", encoding); + throw new Error("simulated write failure"); + } + fs.writeFileSync(filePath, source, encoding); + if (writeCount === 3) { + throw new Error("rollback writer threw after restoring bytes"); + } + }, + }), + ); + + assert.equal(value.changed, 0); + assert.match(value.reason, /Could not write current Chrome/); + assert.equal(warnings.length, 1); + assert.deepEqual(assetSources(candidate), before); + } finally { + fs.rmSync(candidate.extractedDir, { recursive: true, force: true }); + } +}); + +test("blocks acceptance when Chrome runtime rollback cannot restore bytes", () => { + const candidate = createCurrentChromeNativeHostRuntimeAssetsFixture(); + try { + const before = assetSources(candidate); + let writeCount = 0; + const applyWithRollbackFailure = (extractedDir) => + patchLinuxChromeNativeHostRuntimeAssets(extractedDir, { + writeFileSync(filePath, source, encoding) { + writeCount += 1; + if (writeCount === 2) { + fs.writeFileSync(filePath, "corrupt-Chrome-runtime-asset", encoding); + throw new Error("simulated write failure"); + } + if (writeCount === 3) { + throw new Error("simulated rollback failure"); + } + fs.writeFileSync(filePath, source, encoding); + }, + }); + assert.throws( + () => applyWithRollbackFailure(candidate.extractedDir), + (error) => + error?.code === "PATCH_INTEGRITY_FAILURE" && + /could not restore original bytes/i.test(error.message), + ); + assert.equal( + fs.readFileSync(candidate.mainPath, "utf8"), + before.get(candidate.mainPath), + ); + assert.equal( + fs.readFileSync(candidate.srcPath, "utf8"), + "corrupt-Chrome-runtime-asset", + ); + + const baseDescriptor = corePatchDescriptors().find( + ({ id }) => id === "linux-chrome-native-host-runtime", + ); + writeCount = 0; + fs.writeFileSync(candidate.mainPath, before.get(candidate.mainPath)); + fs.writeFileSync(candidate.srcPath, before.get(candidate.srcPath)); + const descriptor = { + ...baseDescriptor, + apply: applyWithRollbackFailure, + }; + const report = createPatchReport(); + assert.throws( + () => captureWarns(() => + applyExtractedAppPatchDescriptors( + candidate.extractedDir, + [descriptor], + {}, + report, + descriptor.phase, + ), + ), + (error) => error?.code === "PATCH_INTEGRITY_FAILURE", + ); + const [failure] = criticalFailuresFromReport(report); + assert.equal(failure?.name, descriptor.id); + assert.equal(failure?.status, "failed-integrity"); + assert.match( + failure?.reason ?? "", + /rollback byte verification failed.*rollback write also failed: simulated rollback failure/, + ); + assert.deepEqual(optionalDriftFromReport(report), []); + } finally { + fs.rmSync(candidate.extractedDir, { recursive: true, force: true }); + } +}); + +test("rejects partial Electron 42 Browser Use runtime markers", () => { + const patched = applyLinuxChromeNativeHostRuntimePatch( + electron42BrowserUseRuntimeResolverBundleFixture(), + ); + const variants = [ + patched.replace("`linux-path`", "`linux-path-corrupt`"), + patched.replace("`linux-node-runtime`", "`linux-node-runtime-corrupt`"), + patched.replace("`linux-node-repl-runtime`", "`linux-node-repl-runtime-corrupt`"), + patched.replace( + "codexLinuxChromeNativeHostRuntimeFile(u,[[`node-runtime`", + "codexLinuxChromeNativeHostRuntimeFileCorrupt(u,[[`node-runtime`", + ), + ]; + for (const source of variants) { + const { value, warnings } = captureWarns(() => + applyLinuxChromeNativeHostRuntimePatch(source), + ); + assert.equal(value, source); + assert.equal(warnings.length, 1); + } +}); + +test("reports drifted current Chrome runtime assets as optional drift", () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "codex-patch-report-chrome-runtime-drift-"), + ); + try { + const buildDir = path.join(tempRoot, ".vite", "build"); + fs.mkdirSync(buildDir, { recursive: true }); + fs.writeFileSync( + path.join(buildDir, "main.js"), + electron42BrowserUseRuntimeResolverBundleFixture().replace( + "resourcesPath:l}){let u=l??", + "resourcesPath:l}){const u=l??", + ), + ); + fs.writeFileSync( + path.join(buildDir, "src.js"), + currentChromePluginAppServerSourceBundleFixture(), + ); + + const report = createPatchReport(); + captureWarns(() => patchExtractedApp(tempRoot, { report })); + const runtimePatch = report.patches.find( + ({ name }) => name === "linux-chrome-native-host-runtime", + ); + assert.equal(runtimePatch.status, "skipped-optional"); + assert.ok( + !validateReport(report, "upstream-build").some((failure) => + failure.startsWith("linux-chrome-native-host-runtime:"), + ), + ); + assert.ok( + optionalDriftFromReport(report).some( + ({ name }) => name === "linux-chrome-native-host-runtime", + ), + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/scripts/patches/impl/computer-use.js b/scripts/patches/impl/computer-use.js index a432566d1..d88fd5236 100644 --- a/scripts/patches/impl/computer-use.js +++ b/scripts/patches/impl/computer-use.js @@ -4,6 +4,7 @@ const fs = require("node:fs"); const path = require("node:path"); const { + findLastRegexMatch, findMatchingBrace, requireName, } = require("../lib/minified-js.js"); @@ -401,6 +402,95 @@ function applyLinuxComputerUseFeaturePatch(currentSource) { return currentSource; } +// Scan to the `;` that closes the declaration the plugins query lives in, +// ignoring separators nested in calls, literals, or template substitutions. +function findStatementEnd(source, fromIndex) { + const stack = []; + let quote = null; + let escaped = false; + + for (let i = fromIndex; i < source.length; i += 1) { + const char = source[i]; + if (quote != null) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (quote === "`" && char === "$" && source[i + 1] === "{") { + stack.push("`"); + quote = null; + i += 1; + } else if (char === quote) { + quote = null; + } + continue; + } + + if (char === "'" || char === '"' || char === "`") { + quote = char; + } else if (char === "(" || char === "[" || char === "{") { + stack.push(char); + } else if (char === ")" || char === "]" || char === "}") { + const opener = stack.pop(); + if (opener === "`") { + quote = "`"; + } else if (opener == null) { + return -1; + } + } else if (char === ";" && stack.length === 0) { + return i; + } + } + + return -1; +} + +// The settings component is compiled with the React Compiler, so the plugin +// card is derived inside memo-cache branches rather than a plain declaration +// chain. Anchor on that derivation and inject before the query is first read. +function currentComputerUseCardTarget(source) { + const computerUsePluginNameVars = new Set( + [...source.matchAll(/([A-Za-z_$][\w$]*)=`computer-use`/g)].map((match) => match[1]), + ); + if (computerUsePluginNameVars.size === 0) { + return null; + } + + const derivations = [...source.matchAll( + /(?[A-Za-z_$][\w$]*)=(?[A-Za-z_$][\w$]*)\((?[A-Za-z_$][\w$]*)\.availablePlugins,(?[A-Za-z_$][\w$]*),(?[A-Za-z_$][\w$]*)\)/g, + )].filter((match) => computerUsePluginNameVars.has(match.groups.pluginName)); + if (derivations.length !== 1) { + return null; + } + + const derivation = derivations[0]; + const pluginsQueryVar = derivation.groups.plugins; + const declarationIndex = source.lastIndexOf(`${pluginsQueryVar}=`, derivation.index); + if (declarationIndex === -1) { + return null; + } + + const statementEnd = findStatementEnd(source, declarationIndex); + if (statementEnd === -1 || statementEnd >= derivation.index) { + return null; + } + + const platformVar = findLastRegexMatch( + source.slice(0, declarationIndex), + /\{computerUseAvailability:[A-Za-z_$][\w$]*,platform:([A-Za-z_$][\w$]*)\}=/g, + )?.[1]; + if (platformVar == null) { + return null; + } + + return { + insertAt: statementEnd + 1, + pluginsQueryVar, + pluginNameVar: derivation.groups.pluginName, + platformVar, + }; +} + function applyCurrentComputerUseSettingsContract(currentSource) { if ( !currentSource.includes("computerUseAvailability:") || @@ -445,41 +535,16 @@ function applyCurrentComputerUseSettingsContract(currentSource) { ); let cardChanged = false; - const cardPattern = - /let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*),([A-Za-z_$][\w$]*)\),([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\3\),((?:[A-Za-z_$][\w$]*=[A-Za-z_$][\w$]*\([A-Za-z_$][\w$]*\),)+)([A-Za-z_$][\w$]*);/g; - patchedSource = patchedSource.replace( - cardPattern, - ( - match, - pluginsQueryVar, - pluginsHookVar, - selectedHostVar, - emptyPluginsVar, - marketplacePathVar, - marketplacePathHookVar, - intermediateDeclarations, - computerUsePluginVar, - offset, - ) => { - const lookback = patchedSource.slice(Math.max(0, offset - 900), offset); - const nextSource = patchedSource.slice(offset + match.length, offset + match.length + 800); - const platformVar = lookback.match( - /\{computerUseAvailability:[A-Za-z_$][\w$]*,platform:([A-Za-z_$][\w$]*)\}=/, - )?.[1]; - const pluginNameVar = nextSource.match( - new RegExp( - String.raw`${computerUsePluginVar}=[A-Za-z_$][\w$]*\(${pluginsQueryVar}\.availablePlugins,([A-Za-z_$][\w$]*),${marketplacePathVar}\)`, - ), - )?.[1]; - if (platformVar == null || pluginNameVar == null) { - return match; - } - const bundledMarketplaceDonorVar = - `${computerUsePluginVar}BundledMarketplaceDonor`; - cardChanged = true; - return `let ${pluginsQueryVar}=${pluginsHookVar}(${selectedHostVar},${emptyPluginsVar}),${marketplacePathVar}=${marketplacePathHookVar}(${selectedHostVar}),${intermediateDeclarations.slice(0, -1)};let ${bundledMarketplaceDonorVar}=${pluginsQueryVar}.availablePlugins.find(e=>e.marketplaceName===\`openai-bundled\`&&typeof e.marketplacePath===\`string\`&&e.marketplacePath.startsWith(\`/\`)&&e.marketplacePath.endsWith(\`/.agents/plugins/marketplace.json\`));${platformVar}===\`linux\`&&${bundledMarketplaceDonorVar}!=null&&!${pluginsQueryVar}.availablePlugins.some(e=>e.plugin?.name===${pluginNameVar}||e.plugin?.id?.split(\`@\`)[0]===${pluginNameVar})&&(${pluginsQueryVar}={...${pluginsQueryVar},availablePlugins:[...${pluginsQueryVar}.availablePlugins,{marketplaceName:\`openai-bundled\`,marketplacePath:${bundledMarketplaceDonorVar}.marketplacePath,logoPath:new URL(\`computer-use-plugin-icon-linux.png\`,import.meta.url).href,logoDarkPath:new URL(\`computer-use-plugin-icon-linux.png\`,import.meta.url).href,plugin:{id:${pluginNameVar},name:${pluginNameVar},installed:!0,enabled:!0}}]});let ${computerUsePluginVar};`; - }, - ); + const cardTarget = currentComputerUseCardTarget(patchedSource); + if (cardTarget != null) { + const { insertAt, pluginsQueryVar, pluginNameVar, platformVar } = cardTarget; + const bundledMarketplaceDonorVar = `${pluginsQueryVar}BundledMarketplaceDonor`; + const linuxCard = + `let ${bundledMarketplaceDonorVar}=${pluginsQueryVar}.availablePlugins.find(e=>e.marketplaceName===\`openai-bundled\`&&typeof e.marketplacePath===\`string\`&&e.marketplacePath.startsWith(\`/\`)&&e.marketplacePath.endsWith(\`/.agents/plugins/marketplace.json\`));${platformVar}===\`linux\`&&${bundledMarketplaceDonorVar}!=null&&!${pluginsQueryVar}.availablePlugins.some(e=>e.plugin?.name===${pluginNameVar}||e.plugin?.id?.split(\`@\`)[0]===${pluginNameVar})&&(${pluginsQueryVar}={...${pluginsQueryVar},availablePlugins:[...${pluginsQueryVar}.availablePlugins,{marketplaceName:\`openai-bundled\`,marketplacePath:${bundledMarketplaceDonorVar}.marketplacePath,logoPath:new URL(\`computer-use-plugin-icon-linux.png\`,import.meta.url).href,logoDarkPath:new URL(\`computer-use-plugin-icon-linux.png\`,import.meta.url).href,plugin:{id:${pluginNameVar},name:${pluginNameVar},installed:!0,enabled:!0}}]});`; + patchedSource = + `${patchedSource.slice(0, insertAt)}${linuxCard}${patchedSource.slice(insertAt)}`; + cardChanged = true; + } if ( availabilityChanged && @@ -508,13 +573,10 @@ function applyLinuxComputerUseRendererAvailabilityPatch(currentSource) { return currentSource; } -function applyLinuxComputerUseHostPlatformPatch(currentSource) { - const currentRequiredFeaturesObjectPattern = - /([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\{areRequiredFeaturesEnabled:([A-Za-z_$][\w$]*),enabled:([A-Za-z_$][\w$]*),isAnyFeatureLoading:([A-Za-z_$][\w$]*),isComputerUseGateEnabled:([A-Za-z_$][\w$]*),isHostCompatiblePlatform:([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\),isPlatformLoading:([A-Za-z_$][\w$]*),windowType:`electron`\}\)/g; - +function applyCurrentComputerUseHostPlatformContract(currentSource) { let changed = false; const patchedSource = currentSource.replace( - currentRequiredFeaturesObjectPattern, + /([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\{areRequiredFeaturesEnabled:([A-Za-z_$][\w$]*),enabled:([A-Za-z_$][\w$]*),isAnyFeatureLoading:([A-Za-z_$][\w$]*),isComputerUseGateEnabled:([A-Za-z_$][\w$]*),isHostCompatiblePlatform:([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\),isPlatformLoading:([A-Za-z_$][\w$]*),windowType:`electron`\}\)/g, ( match, resultVar, @@ -547,13 +609,26 @@ function applyLinuxComputerUseHostPlatformPatch(currentSource) { return currentSource; } + return null; +} + +function matchesLinuxComputerUseHostPlatformContract(currentSource) { + return applyCurrentComputerUseHostPlatformContract(currentSource) != null; +} + +function applyLinuxComputerUseHostPlatformPatch(currentSource) { + const patchedSource = applyCurrentComputerUseHostPlatformContract(currentSource); + if (patchedSource != null) { + return patchedSource; + } + console.warn( "WARN: Could not find current Computer Use host-platform gate — skipping Linux Computer Use host-platform patch", ); return currentSource; } -function applyLinuxComputerUseInstallFlowPatch(currentSource) { +function applyCurrentComputerUseInstallFlowContract(currentSource) { if (currentSource.includes("plugin detail query requires pluginName")) { const markerPattern = /let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)&&([A-Za-z_$][\w$]*)!==`computer-use`,([A-Za-z_$][\w$]*);/; @@ -589,6 +664,19 @@ function applyLinuxComputerUseInstallFlowPatch(currentSource) { } } + return null; +} + +function matchesLinuxComputerUseInstallFlowContract(currentSource) { + return applyCurrentComputerUseInstallFlowContract(currentSource) != null; +} + +function applyLinuxComputerUseInstallFlowPatch(currentSource) { + const patchedSource = applyCurrentComputerUseInstallFlowContract(currentSource); + if (patchedSource != null) { + return patchedSource; + } + console.warn( "WARN: Could not find current Computer Use plugin detail availability gate — skipping Linux Computer Use install flow patch", ); @@ -781,4 +869,6 @@ module.exports = { applyLinuxComputerUseRendererAvailabilityPatch, isComputerUseUiEnabled, linuxComputerUseCursorBridgeRuntimeSource, + matchesLinuxComputerUseHostPlatformContract, + matchesLinuxComputerUseInstallFlowContract, }; diff --git a/scripts/patches/impl/keybinds-settings.js b/scripts/patches/impl/keybinds-settings.js index ea00d6fe8..bcf4badab 100644 --- a/scripts/patches/impl/keybinds-settings.js +++ b/scripts/patches/impl/keybinds-settings.js @@ -22,6 +22,9 @@ const linuxDesktopSettingsAsset = "linux-desktop-settings-linux.js"; const linuxKeybindOverridesKey = "codex-linux-keybind-overrides"; const linuxReactRuntimeExport = "codexLinuxReact"; const linuxJsxRuntimeExport = "codexLinuxJsx"; +const linuxDesktopSettingsSourceVersion = 1; +const linuxDesktopSettingsSourceMarker = + `var codexLinuxDesktopSettingsVersion=${linuxDesktopSettingsSourceVersion},KEYS={`; function versionedAssetSpecifier(assetName, source) { const digest = crypto.createHash("sha256").update(source).digest("hex").slice(0, 12); @@ -501,7 +504,10 @@ function resolveLinuxDesktopSettingsAsset(extractedDir) { includeHotkeySettings: false, }); - const source = buildLinuxDesktopSettingsSource(dependencies); + const source = buildLinuxDesktopSettingsSource(dependencies).replace( + "var KEYS={", + linuxDesktopSettingsSourceMarker, + ); return { filePath: path.join(webviewAssetsDir, linuxDesktopSettingsAsset), source, @@ -632,6 +638,38 @@ function collectLinuxDesktopVisibilityPatch(extractedDir) { }]; } +function collectLinuxDesktopNavigationGroupPatch(extractedDir) { + const webviewAssetsDir = path.join(extractedDir, "webview", "assets"); + if (!fs.existsSync(webviewAssetsDir)) { + throw new Error(`Required Keybinds settings patch failed: missing webview assets directory ${webviewAssetsDir}`); + } + + const candidates = fs + .readdirSync(webviewAssetsDir) + .filter((name) => /^settings-page-[^.]+\.js$/.test(name)) + .sort() + .filter((name) => { + const source = fs.readFileSync(path.join(webviewAssetsDir, name), "utf8"); + return source.includes("id:`settings.nav.heading.personal`"); + }); + + if (candidates.length !== 1) { + throw new Error( + `Required Keybinds settings patch failed: could not find exactly one current settings navigation group asset (found ${candidates.length})`, + ); + } + + const [candidate] = candidates; + const filePath = path.join(webviewAssetsDir, candidate); + const currentSource = fs.readFileSync(filePath, "utf8"); + return [{ + filePath, + currentSource, + patchedSource: applyLinuxDesktopSettingsNavigationGroupPatch(currentSource), + patchFn: applyLinuxDesktopSettingsNavigationGroupPatch, + }]; +} + function collectLinuxDesktopIconMapPatches(extractedDir) { const webviewAssetsDir = path.join(extractedDir, "webview", "assets"); if (!fs.existsSync(webviewAssetsDir)) { @@ -771,6 +809,88 @@ function applyCollectedAssetPatchWrites(patches) { return changed; } +function hasCompleteLinuxDesktopSettingsSource(previousSource) { + const requiredMarkers = [ + linuxDesktopSettingsSourceMarker, + `promptWindow:${JSON.stringify(linuxSettingsKeys.promptWindow)}`, + `systemTray:${JSON.stringify(linuxSettingsKeys.systemTray)}`, + `warmStart:${JSON.stringify(linuxSettingsKeys.warmStart)}`, + `autoUpdateOnExit:${JSON.stringify(linuxSettingsKeys.autoUpdateOnExit)}`, + "function codexLinuxChecked(", + "class LinuxToggle extends React.Component", + "class LinuxBuildInfoPanel extends React.Component", + "function LinuxDesktopSettings(){", + "title:\"Linux desktop\"", + "export{LinuxDesktopSettings,LinuxDesktopSettings as default};", + ]; + if (!requiredMarkers.every((marker) => previousSource.includes(marker))) { + return false; + } + const requiredConsumers = [ + "settingKey:KEYS.promptWindow", + "settingKey:KEYS.systemTray", + "settingKey:KEYS.warmStart", + "settingKey:KEYS.autoUpdateOnExit", + "$.jsx(LinuxBuildInfoPanel,{})", + ]; + if ( + !requiredConsumers.every( + (consumer) => previousSource.split(consumer).length - 1 === 1, + ) + ) { + return false; + } + + let executableSource = previousSource; + while (executableSource.startsWith("import")) { + const importEnd = executableSource.indexOf(";"); + if (importEnd === -1) { + return false; + } + executableSource = executableSource.slice(importEnd + 1); + } + + const exportMarker = "export{LinuxDesktopSettings,LinuxDesktopSettings as default};"; + const exportIndex = executableSource.lastIndexOf(exportMarker); + if (exportIndex === -1) { + return false; + } + executableSource = + executableSource.slice(0, exportIndex) + + executableSource.slice(exportIndex + exportMarker.length); + try { + new Function(executableSource); + return true; + } catch { + return false; + } +} + +function selectLinuxDesktopSettingsSource(previousSource, generatedSource) { + if ( + previousSource == null || + !previousSource.includes("codexLinuxDesktopSettingsVersion") + ) { + return generatedSource; + } + + const markerCount = + previousSource.split(linuxDesktopSettingsSourceMarker).length - 1; + const markerPrefixCount = + previousSource.split("codexLinuxDesktopSettingsVersion=").length - 1; + if ( + markerCount === 1 && + markerPrefixCount === 1 && + hasCompleteLinuxDesktopSettingsSource(previousSource) + ) { + return previousSource; + } + + throw new Error( + "Required Keybinds settings patch failed: generated Linux desktop settings marker is stale or incomplete", + ); +} + function patchKeybindsSettingsAssets(extractedDir) { try { if (!hasNativeKeyboardShortcutsSettings(extractedDir)) { @@ -782,6 +902,10 @@ function patchKeybindsSettingsAssets(extractedDir) { const previousSettingsSource = settingsAssetExists ? fs.readFileSync(settingsAsset.filePath, "utf8") : null; + const nextSettingsSource = selectLinuxDesktopSettingsSource( + previousSettingsSource, + settingsAsset.source, + ); // Treat generated updates as patches so a route bundle can receive both // the runtime exports and the Linux route insertion without one write // overwriting the other. @@ -801,6 +925,7 @@ function patchKeybindsSettingsAssets(extractedDir) { isSettingsSectionsMetadataBundleSource, applyLinuxDesktopSettingsSectionsPatch, ), + ...collectLinuxDesktopNavigationGroupPatch(extractedDir), ...collectLinuxDesktopVisibilityPatch(extractedDir), ...collectOptionalMatchingAssetPatches( extractedDir, @@ -819,8 +944,8 @@ function patchKeybindsSettingsAssets(extractedDir) { ), ]; - fs.writeFileSync(settingsAsset.filePath, settingsAsset.source, "utf8"); - let changed = previousSettingsSource !== settingsAsset.source ? 1 : 0; + fs.writeFileSync(settingsAsset.filePath, nextSettingsSource, "utf8"); + let changed = previousSettingsSource !== nextSettingsSource ? 1 : 0; changed += applyCollectedAssetPatchWrites(patches); return { matched: true, @@ -920,6 +1045,29 @@ function applyLinuxDesktopSettingsSectionsPatch(currentSource) { return patchedSource; } +function applyLinuxDesktopSettingsNavigationGroupPatch(currentSource) { + const unpatchedGroupPattern = + /(\{key:`personal`,heading:[^;]{0,1200}?id:`settings\.nav\.heading\.personal`[^;]{0,1200}?slugs:\[`general-settings`,)(?!`linux-desktop`,)/g; + const patchedGroupPattern = + /\{key:`personal`,heading:[^;]{0,1200}?id:`settings\.nav\.heading\.personal`[^;]{0,1200}?slugs:\[`general-settings`,`linux-desktop`,/g; + const unpatchedCount = currentSource.match(unpatchedGroupPattern)?.length ?? 0; + const patchedCount = currentSource.match(patchedGroupPattern)?.length ?? 0; + + if (unpatchedCount === 0 && patchedCount === 1) { + return currentSource; + } + if (unpatchedCount !== 1 || patchedCount !== 0) { + throw new Error( + `Required Keybinds settings patch failed: expected exactly one current personal settings navigation group (found ${unpatchedCount}, ${patchedCount} already patched)`, + ); + } + + return currentSource.replace( + unpatchedGroupPattern, + "$1`linux-desktop`,", + ); +} + // Inserts a new `titleForSection` switch case after the upstream // `general-settings` case. The minifier names the JSX factory, the message // component, and the memo-cache slot arbitrarily (e.g. `n` vs `r`, `t[2]` vs @@ -1217,6 +1365,7 @@ module.exports = { applyKeybindsSettingsSharedPatch, applyLinuxDesktopSettingsIndexPatch, applyLinuxDesktopSettingsIconPatch, + applyLinuxDesktopSettingsNavigationGroupPatch, applyLinuxDesktopSettingsRoutePatch, applyLinuxDesktopSettingsSectionsPatch, applyLinuxDesktopSettingsSharedPatch, diff --git a/scripts/patches/impl/keybinds-settings.test.js b/scripts/patches/impl/keybinds-settings.test.js new file mode 100644 index 000000000..fc04d9328 --- /dev/null +++ b/scripts/patches/impl/keybinds-settings.test.js @@ -0,0 +1,172 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const { + patchWrapperUpdateSettingsAssets, +} = require("../../../linux-features/codex-wrapper-updater/patch.js"); +const { + patchDeferredUpdateBuildSettingsAssets, +} = require("../../../linux-features/deferred-update-build/patch.js"); +const { + linuxDesktopSettingsAsset, + patchKeybindsSettingsAssets, +} = require("./keybinds-settings.js"); +const { + createModernNativeKeyboardShortcutsSettingsFixture, +} = require("../test-fixtures/current-dmg.js"); + +function captureWarns(fn) { + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args.map(String).join(" ")); + try { + return { value: fn(), warnings }; + } finally { + console.warn = originalWarn; + } +} + +function assetSources(assetsDir) { + return new Map( + fs.readdirSync(assetsDir).map((name) => [ + name, + fs.readFileSync(path.join(assetsDir, name), "utf8"), + ]), + ); +} + +test("preserves optional update settings extensions across Linux settings patch passes", () => { + const { extractedDir, assetsDir } = + createModernNativeKeyboardShortcutsSettingsFixture(); + try { + const firstCoreResult = patchKeybindsSettingsAssets(extractedDir); + assert.equal(firstCoreResult.matched, true); + + const settingsPath = path.join(assetsDir, linuxDesktopSettingsAsset); + assert.match( + fs.readFileSync(settingsPath, "utf8"), + /var codexLinuxDesktopSettingsVersion=1,KEYS=\{/, + ); + + const firstFeatureResult = patchWrapperUpdateSettingsAssets(extractedDir); + assert.deepEqual(firstFeatureResult, { matched: true, changed: 1 }); + assert.deepEqual( + patchDeferredUpdateBuildSettingsAssets(extractedDir), + { matched: true, changed: 1 }, + ); + const composedSource = fs.readFileSync(settingsPath, "utf8"); + assert.match( + composedSource, + /wrapperUpdates:"codex-linux-wrapper-updates-enabled"/, + ); + assert.match( + composedSource, + /featurePickerOnUpdate:"codex-linux-feature-picker-on-update"/, + ); + assert.match( + composedSource, + /settingKey:KEYS\.autoBuildUpdates,label:"Build updates automatically"/, + ); + + const secondCoreResult = patchKeybindsSettingsAssets(extractedDir); + assert.equal(secondCoreResult.matched, true); + assert.equal(secondCoreResult.changed, 0); + assert.equal(fs.readFileSync(settingsPath, "utf8"), composedSource); + + assert.deepEqual( + patchWrapperUpdateSettingsAssets(extractedDir), + { matched: true, changed: 0 }, + ); + assert.deepEqual( + patchDeferredUpdateBuildSettingsAssets(extractedDir), + { matched: true, changed: 0 }, + ); + assert.equal(fs.readFileSync(settingsPath, "utf8"), composedSource); + } finally { + fs.rmSync(extractedDir, { recursive: true, force: true }); + } +}); + +for (const [name, damage] of [ + [ + "rejects incomplete generated Linux settings markers without writing assets", + (source) => + source.replace( + "codexLinuxDesktopSettingsVersion=1", + "codexLinuxDesktopSettingsVersion=2", + ), + ], + [ + "rejects truncated generated Linux settings source without writing assets", + (source) => source.slice(0, source.indexOf("KEYS={") + "KEYS={".length), + ], + ...[ + "promptWindow", + "systemTray", + "warmStart", + "autoUpdateOnExit", + ].map((key) => [ + `rejects generated Linux settings without the ${key} control`, + (source) => + source.replace( + `settingKey:KEYS.${key}`, + `settingKey:MISSING.${key}`, + ), + ]), + [ + "rejects generated Linux settings without the build info panel consumer", + (source) => + source.replace( + "$.jsx(LinuxBuildInfoPanel,{})", + '$.jsx("div",{})', + ), + ], + [ + "rejects generated Linux settings without the build info panel owner", + (source) => + source.replace( + "class LinuxBuildInfoPanel extends React.Component", + "class LinuxBuildInfoPanelMissing extends React.Component", + ), + ], +]) { + test(name, () => { + const { extractedDir, assetsDir } = + createModernNativeKeyboardShortcutsSettingsFixture(); + try { + assert.equal(patchKeybindsSettingsAssets(extractedDir).matched, true); + const settingsPath = path.join(assetsDir, linuxDesktopSettingsAsset); + fs.writeFileSync( + settingsPath, + damage(fs.readFileSync(settingsPath, "utf8")), + "utf8", + ); + const before = assetSources(assetsDir); + + const { value: result, warnings } = captureWarns(() => + patchKeybindsSettingsAssets(extractedDir), + ); + + assert.equal(result.matched, false); + assert.equal(result.changed, 0); + assert.match( + result.reason, + /generated Linux desktop settings marker is stale or incomplete/, + ); + assert.ok( + warnings.some((warning) => + warning.includes( + "generated Linux desktop settings marker is stale or incomplete", + ), + ), + ); + assert.deepEqual(assetSources(assetsDir), before); + } finally { + fs.rmSync(extractedDir, { recursive: true, force: true }); + } + }); +} diff --git a/scripts/patches/impl/launch-actions.js b/scripts/patches/impl/launch-actions.js index f956b81cb..79cf4404d 100644 --- a/scripts/patches/impl/launch-actions.js +++ b/scripts/patches/impl/launch-actions.js @@ -7,7 +7,6 @@ const { findLastRegexMatch, findLinuxGlobalStateExpression, findMatchingBrace, - inferModuleAlias, } = require("../lib/minified-js.js"); const { linuxSettingsKeys, @@ -27,67 +26,29 @@ function applyLinuxSettingsPersistencePatch(currentSource) { if ( !patchedSource.includes('"set-global-state"') && - !patchedSource.includes(".codex-global-state.json") + !patchedSource.includes("function codexLinuxPersistSettingsState(") ) { return patchedSource; } if (!patchedSource.includes("function codexLinuxPersistSettingsState(")) { - const pathVar = inferModuleAlias(patchedSource, "node:path"); - const fsVar = inferModuleAlias(patchedSource, "node:fs"); const stateFileHelperSource = - (stateFileVar) => - `${stateFileVar == null ? "" : `var ${stateFileVar}=\`.codex-global-state.json\`;`}function codexLinuxSettingsAppId(){let e=process.env.CODEX_LINUX_APP_ID||process.env.CODEX_APP_ID||\`codex-desktop\`;return/^[A-Za-z0-9._-]+$/.test(e)?e:\`codex-desktop\`}function codexLinuxSettingsPath(){let e=process.env.CODEX_LINUX_SETTINGS_FILE;if(typeof e===\`string\`&&e.length>0)return e;let t=process.env.XDG_CONFIG_HOME||process.env.HOME&&${pathVar}.join(process.env.HOME,\`.config\`);return t?${pathVar}.join(t,codexLinuxSettingsAppId(),\`settings.json\`):null}function codexLinuxReadSettingsFile(){let e=codexLinuxSettingsPath();if(!e||!${fsVar}.existsSync(e))return{};try{let t=${fsVar}.readFileSync(e,\`utf8\`),n=JSON.parse(t);return n&&typeof n===\`object\`&&!Array.isArray(n)?n:{}}catch(e){return{}}}function codexLinuxPersistSettingsState(e,t){if(process.platform!==\`linux\`||!${persistedLinuxSettingsKeysSource()}.includes(e))return;try{let n=codexLinuxSettingsPath();if(!n)return;let r=codexLinuxReadSettingsFile();t===void 0?delete r[e]:r[e]=t,${fsVar}.mkdirSync(${pathVar}.dirname(n),{recursive:!0,mode:448}),${fsVar}.writeFileSync(n,JSON.stringify(r,null,2)+\`\\n\`,\`utf8\`)}catch(e){}}`; - const stateFileCommaRegex = /var ([A-Za-z_$][\w$]*)=`\.codex-global-state\.json`,/; - const stateFileSemicolonRegex = /var ([A-Za-z_$][\w$]*)=`\.codex-global-state\.json`;/; - if (pathVar == null || fsVar == null) { - console.warn("WARN: Could not find Linux settings state file marker — skipping settings persistence patch"); - return patchedSource; - } - if (stateFileCommaRegex.test(patchedSource)) { - patchedSource = patchedSource.replace( - stateFileCommaRegex, - (_match, stateFileVar) => `${stateFileHelperSource(stateFileVar)}var `, - ); - } else if (stateFileSemicolonRegex.test(patchedSource)) { - patchedSource = patchedSource.replace( - stateFileSemicolonRegex, - (_match, stateFileVar) => stateFileHelperSource(stateFileVar), - ); - } else { - const strictDirective = '"use strict";'; - const helperInsertionIndex = patchedSource.startsWith(strictDirective) - ? strictDirective.length - : 0; - patchedSource = - patchedSource.slice(0, helperInsertionIndex) + - stateFileHelperSource(null) + - patchedSource.slice(helperInsertionIndex); - } - } else if (!patchedSource.includes("function codexLinuxSettingsAppId()")) { - const legacySettingsPathRegex = - /function codexLinuxSettingsPath\(\)\{let ([A-Za-z_$][\w$]*)=process\.env\.XDG_CONFIG_HOME\|\|process\.env\.HOME&&([A-Za-z_$][\w$]*)\.join\(process\.env\.HOME,`\.config`\);return \1\?\2\.join\(\1,`codex-desktop`,`settings\.json`\):null\}/; - patchedSource = patchedSource.replace( - legacySettingsPathRegex, - (_match, _configVar, pathVar) => - `function codexLinuxSettingsAppId(){let e=process.env.CODEX_LINUX_APP_ID||process.env.CODEX_APP_ID||\`codex-desktop\`;return/^[A-Za-z0-9._-]+$/.test(e)?e:\`codex-desktop\`}function codexLinuxSettingsPath(){let e=process.env.CODEX_LINUX_SETTINGS_FILE;if(typeof e===\`string\`&&e.length>0)return e;let t=process.env.XDG_CONFIG_HOME||process.env.HOME&&${pathVar}.join(process.env.HOME,\`.config\`);return t?${pathVar}.join(t,codexLinuxSettingsAppId(),\`settings.json\`):null}`, - ); - } - - const settingsKeysGuard = `!${persistedLinuxSettingsKeysSource()}.includes(e)`; - if (!patchedSource.includes(settingsKeysGuard)) { - const oldSettingsKeysGuardRegex = /!\[[^\]]*`codex-linux-[^`]+`[^\]]*\]\.includes\(e\)/; - patchedSource = patchedSource.replace(oldSettingsKeysGuardRegex, settingsKeysGuard); + `function codexLinuxSettingsAppId(){let e=process.env.CODEX_LINUX_APP_ID||process.env.CODEX_APP_ID||\`codex-desktop\`;return/^[A-Za-z0-9._-]+$/.test(e)?e:\`codex-desktop\`}function codexLinuxSettingsPath(){let __codexSettingsFile=process.env.CODEX_LINUX_SETTINGS_FILE;if(typeof __codexSettingsFile===\`string\`&&__codexSettingsFile.length>0)return __codexSettingsFile;let __codexPath=require(\`node:path\`),__codexConfigRoot=process.env.XDG_CONFIG_HOME||process.env.HOME&&__codexPath.join(process.env.HOME,\`.config\`);return __codexConfigRoot?__codexPath.join(__codexConfigRoot,codexLinuxSettingsAppId(),\`settings.json\`):null}function codexLinuxReadSettingsFile(){let __codexSettingsPath=codexLinuxSettingsPath(),__codexFs=require(\`node:fs\`);if(!__codexSettingsPath||!__codexFs.existsSync(__codexSettingsPath))return{};try{let __codexSettingsText=__codexFs.readFileSync(__codexSettingsPath,\`utf8\`),__codexSettings=JSON.parse(__codexSettingsText);return __codexSettings&&typeof __codexSettings===\`object\`&&!Array.isArray(__codexSettings)?__codexSettings:{}}catch(e){return{}}}function codexLinuxPersistSettingsState(e,t){if(process.platform!==\`linux\`||!${persistedLinuxSettingsKeysSource()}.includes(e))return;try{let __codexSettingsPath=codexLinuxSettingsPath();if(!__codexSettingsPath)return;let __codexSettings=codexLinuxReadSettingsFile(),__codexFs=require(\`node:fs\`),__codexPath=require(\`node:path\`);t===void 0?delete __codexSettings[e]:__codexSettings[e]=t,__codexFs.mkdirSync(__codexPath.dirname(__codexSettingsPath),{recursive:!0,mode:448}),__codexFs.writeFileSync(__codexSettingsPath,JSON.stringify(__codexSettings,null,2)+\`\\n\`,\`utf8\`)}catch(e){}}`; + const strictDirective = '"use strict";'; + const helperInsertionIndex = patchedSource.startsWith(strictDirective) + ? strictDirective.length + : 0; + patchedSource = + patchedSource.slice(0, helperInsertionIndex) + + stateFileHelperSource + + patchedSource.slice(helperInsertionIndex); } - if (/"set-global-state":async\(\{key:[A-Za-z_$][\w$]*,value:[A-Za-z_$][\w$]*,origin:[A-Za-z_$][\w$]*\}\)=>\([\s\S]{0,300}?codexLinuxPersistSettingsState\(/.test(patchedSource)) { - return patchedSource; - } - if (/"set-global-state":async\(\{key:[A-Za-z_$][\w$]*,value:[A-Za-z_$][\w$]*,origin:[A-Za-z_$][\w$]*\}\)=>\(this\.setGlobalStateValue\([A-Za-z_$][\w$]*,[A-Za-z_$][\w$]*,[A-Za-z_$][\w$]*\),codexLinuxPersistSettingsState\(/.test(patchedSource)) { + if (/"set-global-state":async\(\{key:[A-Za-z_$][\w$]*,value:[A-Za-z_$][\w$]*\}\)=>\([\s\S]{0,300}?codexLinuxPersistSettingsState\(/.test(patchedSource)) { return patchedSource; } const setGlobalStateRegex = - /"set-global-state":async\(\{key:([A-Za-z_$][\w$]*),value:([A-Za-z_$][\w$]*),origin:([A-Za-z_$][\w$]*)\}\)=>\((this\.(?:globalState\.set\(\1,\2\)|setGlobalStateValue\(\1,\2,\3\))),/; + /"set-global-state":async\(\{key:([A-Za-z_$][\w$]*),value:([A-Za-z_$][\w$]*)\}\)=>\((this\.setGlobalStateValue\(\1,\2\)),/; if (!setGlobalStateRegex.test(patchedSource)) { console.warn("WARN: Could not find Linux set-global-state needle — skipping settings persistence hook"); return patchedSource; @@ -95,8 +56,8 @@ function applyLinuxSettingsPersistencePatch(currentSource) { return patchedSource.replace( setGlobalStateRegex, - (_match, keyVar, valueVar, originVar, setterCall) => - `"set-global-state":async({key:${keyVar},value:${valueVar},origin:${originVar}})=>(${setterCall},codexLinuxPersistSettingsState(${keyVar},${valueVar}),`, + (_match, keyVar, valueVar, setterCall) => + `"set-global-state":async({key:${keyVar},value:${valueVar}})=>(${setterCall},codexLinuxPersistSettingsState(${keyVar},${valueVar}),`, ); } diff --git a/scripts/patches/impl/main-process/browser.js b/scripts/patches/impl/main-process/browser.js index ff2cd8ab4..6f57122bc 100644 --- a/scripts/patches/impl/main-process/browser.js +++ b/scripts/patches/impl/main-process/browser.js @@ -495,40 +495,72 @@ function buildLinuxExternalOpenHelpers() { ); } -function applyLinuxExternalOpenEnvPatch(currentSource) { - const hasHelper = currentSource.includes("function codexLinuxPatchExternalOpen("); - const hasPatchedElectronRequire = /codexLinuxPatchExternalOpen\(require\(([`'"])electron\1\)\)/.test( - currentSource, - ); - let patchedAnyElectronRequire = false; - const patchedSource = currentSource.replace( - /([A-Za-z_$][\w$]*=)require\(([`'"])electron\2\)/g, - (_match, prefix, quote) => { - patchedAnyElectronRequire = true; - return `${prefix}codexLinuxPatchExternalOpen(require(${quote}electron${quote}))`; - }, +const LINUX_EXTERNAL_OPEN_TARGET_MARKER = + "/*codexLinuxExternalOpenTarget*/"; +const CURRENT_LINUX_EXTERNAL_OPEN_TARGET_COUNT = 2; + +function hasCompleteLinuxExternalOpenEnvPatch(source, helperPayload) { + if (source.split(helperPayload).length - 1 !== 1) { + return false; + } + const markerCount = + source.split(LINUX_EXTERNAL_OPEN_TARGET_MARKER).length - 1; + const targetPattern = + /\/\*codexLinuxExternalOpenTarget\*\/codexLinuxPatchExternalOpen\(require\(([`'"])electron\1\)\)/g; + return ( + markerCount === CURRENT_LINUX_EXTERNAL_OPEN_TARGET_COUNT && + [...source.matchAll(targetPattern)].length === + CURRENT_LINUX_EXTERNAL_OPEN_TARGET_COUNT ); +} - if (!patchedAnyElectronRequire) { - if (!(hasHelper && hasPatchedElectronRequire)) { - console.warn( - "WARN: Could not find Electron require initializer — skipping Linux external open environment patch", - ); +function applyLinuxExternalOpenEnvPatch(currentSource) { + const helperPayload = buildLinuxExternalOpenHelpers(); + const hasAnyPatchArtifact = + currentSource.includes("codexLinuxExternalOpenEnv") + || currentSource.includes("codexLinuxLaunchExternalUrl") + || currentSource.includes("codexLinuxOpenExternalWithFallback") + || currentSource.includes("codexLinuxPatchExternalOpen") + || currentSource.includes(LINUX_EXTERNAL_OPEN_TARGET_MARKER); + if (hasAnyPatchArtifact) { + if (hasCompleteLinuxExternalOpenEnvPatch(currentSource, helperPayload)) { + return currentSource; } + console.warn( + "WARN: Found incomplete Linux external open environment patch — skipping", + ); return currentSource; } - if (hasHelper) { - return patchedSource; + const electronRequireInitializerPattern = + /([A-Za-z_$][\w$]*=)require\(([`'"])electron\2\)/g; + const targetCount = [ + ...currentSource.matchAll(electronRequireInitializerPattern), + ].length; + if (targetCount !== CURRENT_LINUX_EXTERNAL_OPEN_TARGET_COUNT) { + console.warn( + `WARN: Expected ${CURRENT_LINUX_EXTERNAL_OPEN_TARGET_COUNT} current Electron require initializers, found ${targetCount} — skipping Linux external open environment patch`, + ); + return currentSource; } + const patchedSource = currentSource.replace( + electronRequireInitializerPattern, + (_match, prefix, quote) => { + return ( + `${prefix}${LINUX_EXTERNAL_OPEN_TARGET_MARKER}` + + `codexLinuxPatchExternalOpen(require(${quote}electron${quote}))` + ); + }, + ); + const strictDirective = '"use strict";'; const helperInsertionIndex = currentSource.startsWith(strictDirective) ? strictDirective.length : 0; return ( patchedSource.slice(0, helperInsertionIndex) + - buildLinuxExternalOpenHelpers() + + helperPayload + patchedSource.slice(helperInsertionIndex) ); } diff --git a/scripts/patches/impl/main-process/browser.test.js b/scripts/patches/impl/main-process/browser.test.js index 81b7ddb39..179c47c52 100644 --- a/scripts/patches/impl/main-process/browser.test.js +++ b/scripts/patches/impl/main-process/browser.test.js @@ -82,7 +82,8 @@ test("Linux IAB socket alignment patch hardens the directory and socket modes", }); test("Linux external open env patch wraps electron require with helper", () => { - const source = '"use strict";let e=require("electron");'; + const source = + '"use strict";let e=require("electron"),t=require("electron");'; const patched = applyLinuxExternalOpenEnvPatch(source); assert.match(patched, /codexLinuxPatchExternalOpen\(require\(("|`)electron\1\)\)/); @@ -90,7 +91,8 @@ test("Linux external open env patch wraps electron require with helper", () => { }); test("Linux external open env patch injects env var guard in helper", () => { - const source = '"use strict";let e=require("electron");'; + const source = + '"use strict";let e=require("electron"),t=require("electron");'; const patched = applyLinuxExternalOpenEnvPatch(source); assert.match( @@ -101,14 +103,102 @@ test("Linux external open env patch injects env var guard in helper", () => { }); test("Linux external open env patch is idempotent", () => { - const source = '"use strict";let e=require("electron");'; + const source = + '"use strict";let e=require("electron"),t=require("electron");'; const first = applyLinuxExternalOpenEnvPatch(source); const second = applyLinuxExternalOpenEnvPatch(first); assert.equal(second, first, "second application should not change the source"); }); -test("Linux external open env patch warns when no electron require found", () => { +test("Linux external open env patch preserves exact aliases with prefix and prototype names", () => { + const sources = [ + '"use strict";let e=require("electron"),be=require("electron");', + '"use strict";let constructor=require("electron"),__proto__=require("electron");', + ]; + + for (const source of sources) { + const first = applyLinuxExternalOpenEnvPatch(source); + const warnings = []; + const originalWarn = console.warn; + console.warn = (message) => warnings.push(message); + try { + assert.equal(applyLinuxExternalOpenEnvPatch(first), first); + } finally { + console.warn = originalWarn; + } + assert.deepEqual(warnings, []); + } +}); + +test("Linux external open env patch ignores feature-owned Electron requires after its complete marker", () => { + const source = + '"use strict";let e=require("electron"),t=require("electron");'; + const patched = applyLinuxExternalOpenEnvPatch(source); + const composed = + `${patched}function featureRuntime(){let featureElectron=require(\`electron\`);return featureElectron.app}`; + const warnings = []; + const originalWarn = console.warn; + console.warn = (message) => warnings.push(message); + try { + assert.equal(applyLinuxExternalOpenEnvPatch(composed), composed); + } finally { + console.warn = originalWarn; + } + + assert.deepEqual(warnings, []); + assert.match(composed, /featureElectron=require\(`electron`\)/); +}); + +test("Linux external open env patch rejects partial marker states byte-identically", () => { + const complete = applyLinuxExternalOpenEnvPatch( + '"use strict";let e=require("electron"),t=require("electron");', + ); + const variants = [ + '"use strict";function codexLinuxPatchExternalOpen(e){return e}let featureElectron=require(`electron`);', + '"use strict";let e=codexLinuxPatchExternalOpen(require(`electron`));', + complete.replace("return __codexEnv}", "return process.env}"), + complete + complete, + ]; + + for (const source of variants) { + const warnings = []; + const originalWarn = console.warn; + console.warn = (message) => warnings.push(message); + try { + assert.equal(applyLinuxExternalOpenEnvPatch(source), source); + } finally { + console.warn = originalWarn; + } + assert.deepEqual(warnings, [ + "WARN: Found incomplete Linux external open environment patch — skipping", + ]); + } +}); + +test("Linux external open env patch rejects a partially restored core target", () => { + const complete = applyLinuxExternalOpenEnvPatch( + '"use strict";let e=require("electron"),t=require("electron");', + ); + const partial = complete.replace( + "codexLinuxPatchExternalOpen(require(\"electron\"))", + "require(\"electron\")", + ); + const warnings = []; + const originalWarn = console.warn; + console.warn = (message) => warnings.push(message); + try { + assert.equal(applyLinuxExternalOpenEnvPatch(partial), partial); + } finally { + console.warn = originalWarn; + } + + assert.deepEqual(warnings, [ + "WARN: Found incomplete Linux external open environment patch — skipping", + ]); +}); + +test("Linux external open env patch warns when current Electron require targets are missing", () => { const source = '"use strict";const fs=require("node:fs");'; const warnings = []; const originalWarn = console.warn; @@ -117,7 +207,10 @@ test("Linux external open env patch warns when no electron require found", () => const patched = applyLinuxExternalOpenEnvPatch(source); assert.equal(patched, source, "source should be unchanged"); assert.ok(warnings.length > 0, "should have warned about missing require"); - assert.match(warnings[0], /Could not find Electron require initializer/); + assert.match( + warnings[0], + /Expected 2 current Electron require initializers, found 0/, + ); } finally { console.warn = originalWarn; } diff --git a/scripts/patches/impl/main-process/quit-lifecycle.js b/scripts/patches/impl/main-process/quit-lifecycle.js index 19dca2366..a7488ed06 100644 --- a/scripts/patches/impl/main-process/quit-lifecycle.js +++ b/scripts/patches/impl/main-process/quit-lifecycle.js @@ -1,5 +1,9 @@ "use strict"; +const { + findMatchingBrace, +} = require("../../lib/minified-js.js"); + function applyLinuxQuitGuardPatch(currentSource) { if (currentSource.includes("codexLinuxExplicitQuitApproved=!1")) { return currentSource; @@ -27,33 +31,220 @@ function linuxExplicitQuitExpression() { return "typeof codexLinuxPrepareForExplicitQuit===`function`?codexLinuxPrepareForExplicitQuit():typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress(),"; } -function applyLinuxWillQuitDrainTimeoutPatch(currentSource) { - let patchedSource = currentSource; +function parseCurrentWillQuitDrainBody(body, eventVar, listenerElectronVar) { + const identifier = "[A-Za-z_$][\\w$]*"; + const outerMatch = body.match(new RegExp( + `^if\\((?${identifier})=!0,(?${identifier})\\)return;let (?${identifier})=\\(\\)=>\\{(?[^;]+)\\};if\\((?${identifier})\\.shouldSkipDrainBeforeQuit\\(\\)\\)\\{(?[^;]+);return\\}(?.+)$`, + )); + if (outerMatch?.groups == null) { + return null; + } - const explicitQuitDrainGuard = - "process.platform===`linux`&&(typeof codexLinuxIsQuitInProgress===`function`&&codexLinuxIsQuitInProgress())"; - let patchedAny = false; + const finalizerMatch = outerMatch.groups.finalizer.match(new RegExp( + `^(?${identifier})\\((?${identifier}),(?${identifier})\\)\\.then\\(\\(\\)=>\\{(?${identifier})\\.dispose\\(\\),(?${identifier})\\.app\\.quit\\(\\)\\}\\)$`, + )); + const reducedMatch = outerMatch.groups.reduced.match(new RegExp( + `^(?${identifier})\\.preventDefault\\(\\),(?${identifier})=!0,(?${identifier})\\.dispose\\(\\),(?${identifier})\\.dispose\\(\\),Promise\\.allSettled\\(\\[(?${identifier})\\.flush\\(\\),(?${identifier})\\(\\),(?${identifier})\\(\\)\\]\\)\\.then\\((?${identifier})\\)$`, + )); + const fullMatch = outerMatch.groups.full.match(new RegExp( + `^(?${identifier})\\.preventDefault\\(\\),(?${identifier})=!0,(?${identifier})\\.dispose\\(\\),(?${identifier})\\.dispose\\(\\),Promise\\.allSettled\\(\\[(?${identifier})\\.flush\\(\\),(?${identifier})\\.flush\\(\\),(?${identifier})\\(\\),(?${identifier})\\(\\)\\]\\)\\.then\\((?${identifier})\\)$`, + )); + if (finalizerMatch?.groups == null || reducedMatch?.groups == null || fullMatch?.groups == null) { + return null; + } - const drainRegex = - /Promise\.all\(\[([A-Za-z_$][\w$]*)\.flush\(\),([A-Za-z_$][\w$]*)\.flush\(\)\]\)\.finally\(\(\)=>\{([A-Za-z_$][\w$]*)\(\),([A-Za-z_$][\w$]*)\.dispose\(\),([A-Za-z_$][\w$]*)\.app\.quit\(\)\}\)/g; - patchedSource = patchedSource.replace( - drainRegex, - (_match, firstDrainVar, secondDrainVar, flushDisposeVar, disposablesVar, electronVar) => { - patchedAny = true; - return `(()=>{let codexLinuxFinalizeQuit=()=>{${flushDisposeVar}(),${disposablesVar}.dispose(),${electronVar}.app.quit()},codexLinuxDrainPromise=Promise.all([${firstDrainVar}.flush(),${secondDrainVar}.flush()]);if(${explicitQuitDrainGuard}){Promise.race([codexLinuxDrainPromise,new Promise(e=>setTimeout(e,typeof codexLinuxExplicitQuitDrainTimeoutMs===\`number\`?codexLinuxExplicitQuitDrainTimeoutMs:3e3))]).finally(codexLinuxFinalizeQuit);return}codexLinuxDrainPromise.finally(codexLinuxFinalizeQuit)})()`; - }, + const outer = outerMatch.groups; + const finalizer = finalizerMatch.groups; + const reduced = reducedMatch.groups; + const full = fullMatch.groups; + if ( + finalizer.electron !== listenerElectronVar || + reduced.event !== eventVar || + full.event !== eventVar || + reduced.draining !== outer.draining || + full.draining !== outer.draining || + reduced.hotkey !== full.hotkey || + reduced.dictation !== full.dictation || + reduced.globalState !== full.globalState || + reduced.stop !== full.stop || + reduced.trace !== full.trace || + reduced.finalize !== outer.upstreamFinalize || + full.finalize !== outer.upstreamFinalize + ) { + return null; + } + + return { outer, finalizer, reduced, full }; +} + +function currentWillQuitDrainCandidates(currentSource) { + const listenerNeedle = ".app.on(`will-quit`,"; + const candidates = []; + let searchFrom = 0; + + while (searchFrom < currentSource.length) { + const listenerIndex = currentSource.indexOf(listenerNeedle, searchFrom); + if (listenerIndex === -1) { + break; + } + searchFrom = listenerIndex + listenerNeedle.length; + + const electronMatch = currentSource + .slice(Math.max(0, listenerIndex - 100), listenerIndex) + .match(/([A-Za-z_$][\w$]*)$/); + const handlerPrefix = currentSource.slice(searchFrom, searchFrom + 100); + const handlerMatch = handlerPrefix.match(/^([A-Za-z_$][\w$]*)=>\{/); + if (electronMatch == null || handlerMatch == null) { + continue; + } + + const openBrace = searchFrom + handlerMatch[0].length - 1; + const closeBrace = findMatchingBrace(currentSource, openBrace); + if (closeBrace === -1) { + continue; + } + const body = currentSource.slice(openBrace + 1, closeBrace); + const shape = parseCurrentWillQuitDrainBody(body, handlerMatch[1], electronMatch[1]); + if (shape != null) { + candidates.push({ body, openBrace, closeBrace, shape }); + } + } + + return candidates; +} + +function hasAppliedWillQuitCleanupPostcondition(currentSource, appliedFinalizerStart) { + const listenerNeedle = ".app.on(`will-quit`,"; + const listenerIndex = currentSource.lastIndexOf( + listenerNeedle, + appliedFinalizerStart, ); + if (listenerIndex === -1) { + return false; + } + + const handlerStart = listenerIndex + listenerNeedle.length; + const handlerMatch = currentSource + .slice(handlerStart, handlerStart + 100) + .match(/^[A-Za-z_$][\w$]*=>\{/); + if (handlerMatch == null) { + return false; + } + const openBrace = handlerStart + handlerMatch[0].length - 1; + const closeBrace = findMatchingBrace(currentSource, openBrace); if ( - !patchedAny && - !patchedSource.includes("codexLinuxDrainPromise=Promise.all(") && - patchedSource.includes("n.app.on(`will-quit`,") && - patchedSource.includes(".flush()") + closeBrace === -1 || + appliedFinalizerStart <= openBrace || + appliedFinalizerStart >= closeBrace ) { - console.warn("WARN: Could not find will-quit drain sequence — skipping Linux explicit quit drain timeout patch"); + return false; } - return patchedSource; + const handlerBody = currentSource.slice(openBrace + 1, closeBrace); + const reducedBranchStart = handlerBody.indexOf( + ".shouldSkipDrainBeforeQuit()){", + ); + const reducedBranchEnd = handlerBody.indexOf( + ";return}", + reducedBranchStart, + ); + if (reducedBranchStart === -1 || reducedBranchEnd === -1) { + return false; + } + + const identifier = "[A-Za-z_$][\\w$]*"; + const cleanupCall = /codexLinuxRunQuitCleanup\(\(\)=>\{/g; + const reducedBody = handlerBody.slice( + reducedBranchStart, + reducedBranchEnd, + ); + const fullBody = handlerBody.slice(reducedBranchEnd + 8); + if ( + (reducedBody.match(cleanupCall) ?? []).length !== 1 || + (fullBody.match(cleanupCall) ?? []).length !== 1 + ) { + return false; + } + + const reducedMatch = reducedBody.match(new RegExp( + `codexLinuxRunQuitCleanup\\(\\(\\)=>\\{(?${identifier})\\.dispose\\(\\),(?${identifier})\\.dispose\\(\\);return Promise\\.allSettled\\(\\[(?${identifier})\\.flush\\(\\),(?${identifier})\\(\\),(?${identifier})\\(\\)\\]\\)\\}\\)`, + )); + const fullMatch = fullBody.match(new RegExp( + `codexLinuxRunQuitCleanup\\(\\(\\)=>\\{(?${identifier})\\.dispose\\(\\),(?${identifier})\\.dispose\\(\\);return Promise\\.allSettled\\(\\[(?${identifier})\\.flush\\(\\),(?${identifier})\\.flush\\(\\),(?${identifier})\\(\\),(?${identifier})\\(\\)\\]\\)\\}\\)`, + )); + if (reducedMatch?.groups == null || fullMatch?.groups == null) { + return false; + } + + return ( + reducedMatch.groups.hotkey === fullMatch.groups.hotkey && + reducedMatch.groups.dictation === fullMatch.groups.dictation && + reducedMatch.groups.globalState === fullMatch.groups.globalState && + reducedMatch.groups.stop === fullMatch.groups.stop && + reducedMatch.groups.trace === fullMatch.groups.trace + ); +} + +function applyLinuxWillQuitDrainTimeoutPatch(currentSource) { + const linuxQuitDrainGuard = "process.platform===`linux`"; + const appliedMarkers = [ + "codexLinuxLogQuitDrainResults=e=>{", + "codexLinuxFinalizeQuit=()=>{", + "codexLinuxRunQuitCleanup=e=>{if(process.platform===`linux`){Promise.race([Promise.resolve().then(e)", + "Linux quit cleanup timed out", + "WARN: Linux quit drain cleanup failed", + "WARN: Linux quit context cleanup failed", + "WARN: Linux quit cleanup failed", + "WARN: Linux quit disposables cleanup failed", + ]; + const appliedFinalizerStart = currentSource.indexOf(appliedMarkers[0]); + const appliedFinalizerEnd = currentSource.indexOf( + ",codexLinuxRunQuitCleanup=", + appliedFinalizerStart, + ); + const hasAppliedFinalizerPostcondition = + appliedFinalizerStart !== -1 && + appliedFinalizerEnd > appliedFinalizerStart && + /finally\{[A-Za-z_$][\w$]*\.app\.exit\(0\)\}/.test( + currentSource.slice(appliedFinalizerStart, appliedFinalizerEnd), + ) && + hasAppliedWillQuitCleanupPostcondition( + currentSource, + appliedFinalizerStart, + ); + if ( + appliedMarkers.every((marker) => currentSource.includes(marker)) && + hasAppliedFinalizerPostcondition + ) { + return currentSource; + } + + const candidates = currentWillQuitDrainCandidates(currentSource); + if (candidates.length !== 1) { + console.warn("WARN: Could not uniquely match current will-quit drain sequence — skipping Linux explicit quit drain timeout patch"); + return currentSource; + } + + const candidate = candidates[0]; + const { outer, finalizer, reduced, full } = candidate.shape; + const originalFinalizer = `${outer.upstreamFinalize}=()=>{${outer.finalizer}}`; + const linuxFinalizer = + `codexLinuxLogQuitDrainResults=e=>{for(let t of e)if(t.status===\`rejected\`)try{console.warn(\`WARN: Linux quit drain cleanup failed\`,t.reason)}catch{};return e},codexLinuxFinalizeQuit=()=>{try{${finalizer.disposables}.dispose()}catch(e){try{console.warn(\`WARN: Linux quit disposables cleanup failed\`,e)}catch{}}finally{${finalizer.electron}.app.exit(0)}},codexLinuxRunQuitCleanup=e=>{if(${linuxQuitDrainGuard}){Promise.race([Promise.resolve().then(e).then(codexLinuxLogQuitDrainResults).catch(e=>{try{console.warn(\`WARN: Linux quit drain cleanup failed\`,e)}catch{}}).then(()=>Promise.resolve().then(()=>${finalizer.contextDispose}(${finalizer.contextArg},${finalizer.contextTimeout})).catch(e=>{try{console.warn(\`WARN: Linux quit context cleanup failed\`,e)}catch{}})),new Promise((_,e)=>setTimeout(()=>e(Error(\`Linux quit cleanup timed out\`)),typeof codexLinuxExplicitQuitDrainTimeoutMs===\`number\`?codexLinuxExplicitQuitDrainTimeoutMs:3e3))]).catch(e=>{try{console.warn(\`WARN: Linux quit cleanup failed\`,e)}catch{}}).then(codexLinuxFinalizeQuit);return}e().then(${outer.upstreamFinalize})}`; + let patchedBody = candidate.body.replace( + `let ${originalFinalizer};`, + `let ${originalFinalizer},${linuxFinalizer};`, + ); + patchedBody = patchedBody.replace( + `${reduced.hotkey}.dispose(),${reduced.dictation}.dispose(),Promise.allSettled([${reduced.globalState}.flush(),${reduced.stop}(),${reduced.trace}()]).then(${outer.upstreamFinalize})`, + `codexLinuxRunQuitCleanup(()=>{${reduced.hotkey}.dispose(),${reduced.dictation}.dispose();return Promise.allSettled([${reduced.globalState}.flush(),${reduced.stop}(),${reduced.trace}()])})`, + ); + patchedBody = patchedBody.replace( + `${full.hotkey}.dispose(),${full.dictation}.dispose(),Promise.allSettled([${full.globalState}.flush(),${full.settings}.flush(),${full.stop}(),${full.trace}()]).then(${outer.upstreamFinalize})`, + `codexLinuxRunQuitCleanup(()=>{${full.hotkey}.dispose(),${full.dictation}.dispose();return Promise.allSettled([${full.globalState}.flush(),${full.settings}.flush(),${full.stop}(),${full.trace}()])})`, + ); + + return `${currentSource.slice(0, candidate.openBrace + 1)}${patchedBody}${currentSource.slice(candidate.closeBrace)}`; } function applyLinuxExplicitQuitPromptBypassPatch(currentSource) { diff --git a/scripts/patches/impl/main-process/tray.js b/scripts/patches/impl/main-process/tray.js index 394bf1cf7..f128b1648 100644 --- a/scripts/patches/impl/main-process/tray.js +++ b/scripts/patches/impl/main-process/tray.js @@ -2,6 +2,110 @@ const { requireName } = require("../../lib/minified-js.js"); +function findMatchingParenthesis(source, openIndex) { + let depth = 0; + let quote = null; + let escaped = false; + + for (let index = openIndex; index < source.length; index += 1) { + const char = source[index]; + if (quote != null) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + quote = null; + } + continue; + } + + if (char === "'" || char === '"' || char === "`") { + quote = char; + } else if (char === "(") { + depth += 1; + } else if (char === ")") { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + + return -1; +} + +function findTopLevelArgumentSeparator(source) { + let parentheses = 0; + let brackets = 0; + let braces = 0; + let quote = null; + let escaped = false; + + for (let index = 0; index < source.length; index += 1) { + const char = source[index]; + if (quote != null) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + quote = null; + } + continue; + } + + if (char === "'" || char === '"' || char === "`") { + quote = char; + } else if (char === "(") { + parentheses += 1; + } else if (char === ")") { + parentheses -= 1; + } else if (char === "[") { + brackets += 1; + } else if (char === "]") { + brackets -= 1; + } else if (char === "{") { + braces += 1; + } else if (char === "}") { + braces -= 1; + } else if (char === "," && parentheses === 0 && brackets === 0 && braces === 0) { + return index; + } + } + + return -1; +} + +function findTrayConstructor(source) { + const retainedPattern = + /([A-Za-z_$][\w$]*)=codexLinuxRegisterTray\(new ([A-Za-z_$][\w$]*)\.Tray\(/g; + const unwrappedPattern = + /([A-Za-z_$][\w$]*)=new ([A-Za-z_$][\w$]*)\.Tray\(/g; + + for (const [pattern, retained] of [[retainedPattern, true], [unwrappedPattern, false]]) { + const match = pattern.exec(source); + if (match == null) { + continue; + } + const openIndex = match.index + match[0].length - 1; + const closeIndex = findMatchingParenthesis(source, openIndex); + if (closeIndex === -1 || (retained && source[closeIndex + 1] !== ")")) { + return null; + } + return { + args: source.slice(openIndex + 1, closeIndex), + closeIndex, + electronVar: match[2], + retained, + startIndex: match.index, + trayVar: match[1], + }; + } + + return null; +} + function applyLinuxTrayPatch(currentSource, iconPathExpression) { let patchedSource = currentSource; @@ -70,16 +174,7 @@ function applyLinuxTrayPatch(currentSource, iconPathExpression) { ); } - const conditionalTrayConstructorPattern = - /([A-Za-z_$][\w$]*)=typeof codexLinuxRegisterTray===`function`\?codexLinuxRegisterTray\(new ([A-Za-z_$][\w$]*)\.Tray\(([^;]+?)\)\):new \2\.Tray\(\3\)/; - const retainedTrayConstructorPattern = - /([A-Za-z_$][\w$]*)=codexLinuxRegisterTray\(new ([A-Za-z_$][\w$]*)\.Tray\(([^;]+?)\)\)/; - const trayConstructorPattern = - /([A-Za-z_$][\w$]*)=new ([A-Za-z_$][\w$]*)\.Tray\(([^;)]+)\)/; - const constructorMatch = - patchedSource.match(conditionalTrayConstructorPattern) ?? - patchedSource.match(retainedTrayConstructorPattern) ?? - patchedSource.match(trayConstructorPattern); + const constructorMatch = findTrayConstructor(patchedSource); if ( constructorMatch == null || !patchedSource.includes("if(process.platform===`linux`){") || @@ -89,13 +184,25 @@ function applyLinuxTrayPatch(currentSource, iconPathExpression) { return currentSource; } - const [, trayVar, electronVar, constructorArgs] = constructorMatch; + const { + args: constructorArgs, + closeIndex: constructorCloseIndex, + electronVar, + retained, + startIndex: constructorStartIndex, + trayVar, + } = constructorMatch; + const argumentSeparator = findTopLevelArgumentSeparator(constructorArgs); + const trayConstructor = argumentSeparator === -1 + ? `new ${electronVar}.Tray(${constructorArgs})` + : `new ${electronVar}.Tray(...(process.platform===\`linux\`?[${constructorArgs.slice(0, argumentSeparator)}]:[${constructorArgs}]))`; const retainedConstructor = - `${trayVar}=codexLinuxRegisterTray(new ${electronVar}.Tray(${constructorArgs}))`; - if (conditionalTrayConstructorPattern.test(patchedSource)) { - patchedSource = patchedSource.replace(conditionalTrayConstructorPattern, retainedConstructor); - } else if (!retainedTrayConstructorPattern.test(patchedSource)) { - patchedSource = patchedSource.replace(trayConstructorPattern, retainedConstructor); + `${trayVar}=codexLinuxRegisterTray(${trayConstructor})`; + if (!retained) { + patchedSource = + patchedSource.slice(0, constructorStartIndex) + + retainedConstructor + + patchedSource.slice(constructorCloseIndex + 1); } if (!patchedSource.includes("codexLinuxRegisterTray=e=>")) { @@ -189,12 +296,11 @@ function applyLinuxBuildInfoTrayPatch(currentSource) { } const trayMenuRegex = /getNativeTrayMenuItems\(\)\{[^]*?return\[/g; const classRegex = /var [A-Za-z_$][\w$]*=class\{[^]*?getNativeTrayMenuItems\(\)\{[^]*?return\[/; - const helpMenuPattern = /\{role:`help`,id:[A-Za-z_$][\w$]*\.bn\.help,submenu:\[/; - const currentHelpMenuPattern = /\{role:`help`,id:[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*\.help,submenu:\[/; + const helpMenuPattern = /role:`help`,id:[A-Za-z_$][\w$]*\.help,submenu:\[/; const helperInsertionIndex = findLinuxBuildInfoHelperInsertionIndex( currentSource, currentSource.match(classRegex), - currentSource.match(helpMenuPattern) ?? currentSource.match(currentHelpMenuPattern), + currentSource.match(helpMenuPattern), ); const canInstallHelper = hasHelper || helperInsertionIndex != null; const trayMenuMatch = patchedSource.match(trayMenuRegex); @@ -210,9 +316,9 @@ function applyLinuxBuildInfoTrayPatch(currentSource) { changed = true; } - const helpMenuRegex = /\{role:`help`,id:[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*\.help,submenu:\[/g; + const helpMenuRegex = /role:`help`,id:[A-Za-z_$][\w$]*\.help,submenu:\[/g; if ( - !/\{role:`help`,id:[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*\.help,submenu:\[\.\.\.process\.platform===`linux`\?\[\{label:`Build Information`,click:\(\)=>\{codexLinuxShowBuildInfo\(\)\}\},\{type:`separator`\}\]:\[\],/.test(patchedSource) + !/role:`help`,id:[A-Za-z_$][\w$]*\.help,submenu:\[\.\.\.process\.platform===`linux`\?\[\{label:`Build Information`,click:\(\)=>\{codexLinuxShowBuildInfo\(\)\}\},\{type:`separator`\}\]:\[\],/.test(patchedSource) ) { if (canInstallHelper) { let patchedHelpMenu = false; @@ -238,7 +344,7 @@ function applyLinuxBuildInfoTrayPatch(currentSource) { } const classMatch = patchedSource.match(classRegex); - const helpMenuMatch = patchedSource.match(helpMenuPattern) ?? patchedSource.match(currentHelpMenuPattern); + const helpMenuMatch = patchedSource.match(helpMenuPattern); const helperIndex = findLinuxBuildInfoHelperInsertionIndex(patchedSource, classMatch, helpMenuMatch); if (helperIndex == null) { console.warn("WARN: Could not find build info helper insertion point — skipping Linux build info patch"); diff --git a/scripts/patches/impl/main-process/window.js b/scripts/patches/impl/main-process/window.js index 5b7a67f80..2569b5598 100644 --- a/scripts/patches/impl/main-process/window.js +++ b/scripts/patches/impl/main-process/window.js @@ -4,9 +4,17 @@ const { escapeRegExp, findMatchingBrace, } = require("../../lib/minified-js.js"); +const { + patchDelegationState, +} = require("../../lib/composition-delegation.js"); +const { + recordStrategy, +} = require("../../strategy-telemetry.js"); const LINUX_TITLEBAR_OVERLAY_HEIGHT = 30; const LINUX_TITLEBAR_OVERLAY_HELPER = "codexLinuxTitleBarOverlay"; +const LINUX_TITLEBAR_PATCH_MARKER = "/*codexLinuxNativeTitlebarPatch*/"; +const LINUX_TITLEBAR_PATCH_ID = "linux-native-titlebar"; function linuxTitlebarOverlayHelperSource( electronAlias, @@ -161,14 +169,102 @@ function findMinifiedMethod(source, signatureRegex) { }; } -function applyLinuxNativeTitlebarPatch(currentSource) { +function markLinuxNativeTitlebarPatch(source) { + const helperNeedle = `function ${LINUX_TITLEBAR_OVERLAY_HELPER}(`; + const helperIndex = source.indexOf(helperNeedle); + if (helperIndex === -1) { + return null; + } + return ( + source.slice(0, helperIndex) + + LINUX_TITLEBAR_PATCH_MARKER + + source.slice(helperIndex) + ); +} + +function regexMatchCount(source, pattern) { + const flags = pattern.flags.includes("g") + ? pattern.flags + : `${pattern.flags}g`; + return source.match(new RegExp(pattern.source, flags))?.length ?? 0; +} + +function hasCompleteLinuxNativeTitlebarPatch(source, helperFunctionRegex) { + const markerCount = + source.split(LINUX_TITLEBAR_PATCH_MARKER).length - 1; + if ( + markerCount !== 1 || + regexMatchCount(source, helperFunctionRegex) !== 1 + ) { + return false; + } + + const nativePrimary = + /case`quickChat`:case`primary`:return [^;]{0,2000}?titleBarOverlay:([A-Za-z_$][\w$]*)===`linux`\?codexLinuxTitleBarOverlay\(([A-Za-z_$][\w$]*)\):([A-Za-z_$][\w$]*)\(\2\)/u; + const nativeZoom = + /setWindowZoom\([^)]*\)\{[\s\S]{0,800}?\(process\.platform===`win32`\|\|process\.platform===`linux`\)&&\(this\.windowZooms\.set\(([A-Za-z_$][\w$]*)\.id,([A-Za-z_$][\w$]*)\),\1\.setTitleBarOverlay\(process\.platform===`linux`\?codexLinuxTitleBarOverlay\(\2\):([A-Za-z_$][\w$]*)\(\2\)\)\)/u; + const nativeSync = + /install[A-Za-z_$][\w$]*TitleBarOverlaySync\(([A-Za-z_$][\w$]*),([A-Za-z_$][\w$]*)\)\{if\(process\.platform!==`win32`&&process\.platform!==`linux`\|\|\2!==`primary`&&\2!==`quickChat`\)return;let [A-Za-z_$][\w$]*=\(\)=>\{[\s\S]{0,300}?\1\.setTitleBarOverlay\(process\.platform===`linux`\?codexLinuxTitleBarOverlay\(this\.windowZooms\.get\(\1\.id\)\):([A-Za-z_$][\w$]*)\(this\.windowZooms\.get\(\1\.id\)\)\)/u; + const zoomOwner = + /setWindowZoom\([^)]*\)\{[\s\S]{0,800}?this\.windowAppearances\.get\(/u; + const syncOwner = + /install[A-Za-z_$][\w$]*TitleBarOverlaySync\([^)]*\)\{/u; + const zoomOwnerCount = regexMatchCount(source, zoomOwner); + const syncOwnerCount = regexMatchCount(source, syncOwner); + return ( + regexMatchCount(source, nativePrimary) === 1 && + zoomOwnerCount === 1 && + regexMatchCount(source, nativeZoom) === 1 && + syncOwnerCount === 1 && + regexMatchCount(source, nativeSync) === 1 + ); +} + +function applyLinuxNativeTitlebarPatch(currentSource, context = {}) { const helperFunctionRegex = new RegExp( 'function ' + escapeRegExp(LINUX_TITLEBAR_OVERLAY_HELPER) + - '\\([^)]*\\)\\{return\\{color:([A-Za-z_$][\\w$]*)\\.nativeTheme\\.shouldUseDarkColors\\?`#111111`:([A-Za-z_$][\\w$]*),symbolColor:\\1\\.nativeTheme\\.shouldUseDarkColors\\?([A-Za-z_$][\\w$]*):([A-Za-z_$][\\w$]*),height:Math\\.round\\(' + + '\\(e=1\\)\\{return\\{color:([A-Za-z_$][\\w$]*)\\.nativeTheme\\.shouldUseDarkColors\\?`#111111`:([A-Za-z_$][\\w$]*),symbolColor:\\1\\.nativeTheme\\.shouldUseDarkColors\\?([A-Za-z_$][\\w$]*):([A-Za-z_$][\\w$]*),height:Math\\.round\\(' + LINUX_TITLEBAR_OVERLAY_HEIGHT + - '\\*[A-Za-z_$][\\w$]*\\)\\}\\}', + '\\*e\\)\\}\\}', ); + const delegation = patchDelegationState( + currentSource, + LINUX_TITLEBAR_PATCH_ID, + { + allowedFeatureIds: + context.patchCompositionDelegates?.[LINUX_TITLEBAR_PATCH_ID], + enabledFeatureIds: context.enabledFeatureIds, + ownerMarker: LINUX_TITLEBAR_PATCH_MARKER, + }, + ); + if (delegation.state === "enabled") { + return currentSource; + } + if (delegation.state !== "none") { + console.warn( + "WARN: Found inactive or invalid Linux native titlebar patch delegation — skipping", + ); + return currentSource; + } + const markerCount = + currentSource.split(LINUX_TITLEBAR_PATCH_MARKER).length - 1; + if (markerCount > 0) { + if (hasCompleteLinuxNativeTitlebarPatch(currentSource, helperFunctionRegex)) { + return currentSource; + } + console.warn( + "WARN: Found incomplete Linux native titlebar patch marker — skipping", + ); + return currentSource; + } + if (currentSource.includes(`function ${LINUX_TITLEBAR_OVERLAY_HELPER}(`)) { + console.warn( + "WARN: Found unmarked Linux native titlebar patch state — skipping", + ); + return currentSource; + } + const primaryTitlebarRegex = /(case`quickChat`:case`primary`:return [^;]{0,2000}?([A-Za-z_$][\w$]*)===`win32`\|\|\2===`linux`\?\{titleBarStyle:`hidden`,titleBarOverlay:)([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)/; const patchedPrimaryTitlebarRegex = new RegExp( @@ -227,23 +323,63 @@ function applyLinuxNativeTitlebarPatch(currentSource) { electronAlias = helperFunctionMatch[1]; } + const zoomMethod = findMinifiedMethod( + patchedSource, + /setWindowZoom\([^)]*\)\{/, + ); + if (zoomMethod == null) { + console.warn( + "WARN: Could not find setWindowZoom owner — skipping Linux native titlebar patch", + ); + return currentSource; + } const zoomOverlayRegex = /\(process\.platform===`win32`\|\|process\.platform===`linux`\)&&\(this\.windowZooms\.set\(([A-Za-z_$][\w$]*)\.id,([A-Za-z_$][\w$]*)\),\1\.setTitleBarOverlay\(([A-Za-z_$][\w$]*)\(\2\)\)\)/g; - patchedSource = patchedSource.replace( + if (regexMatchCount(zoomMethod.text, zoomOverlayRegex) !== 1) { + console.warn( + "WARN: Could not find the current setWindowZoom titlebar overlay consumer — skipping Linux native titlebar patch", + ); + return currentSource; + } + const patchedZoomMethod = zoomMethod.text.replace( zoomOverlayRegex, (_match, windowAlias, zoomAlias, overlayHelperAlias) => `(process.platform===\`win32\`||process.platform===\`linux\`)&&(this.windowZooms.set(${windowAlias}.id,${zoomAlias}),${windowAlias}.setTitleBarOverlay(process.platform===\`linux\`?${LINUX_TITLEBAR_OVERLAY_HELPER}(${zoomAlias}):${overlayHelperAlias}(${zoomAlias})))`, ); + patchedSource = + patchedSource.slice(0, zoomMethod.start) + + patchedZoomMethod + + patchedSource.slice(zoomMethod.end); const overlaySyncMethod = findMinifiedMethod( patchedSource, /install[A-Za-z_$][\w$]*TitleBarOverlaySync\(([A-Za-z_$][\w$]*),([A-Za-z_$][\w$]*)\)\{/, ); if (overlaySyncMethod == null) { - return patchedSource; + const completedSource = markLinuxNativeTitlebarPatch(patchedSource); + if ( + completedSource != null && + hasCompleteLinuxNativeTitlebarPatch(completedSource, helperFunctionRegex) + ) { + return completedSource; + } + console.warn( + "WARN: Could not complete Linux native titlebar consumers — skipping", + ); + return currentSource; } if (overlaySyncMethod.text.includes(`setTitleBarOverlay(process.platform===\`linux\`?${LINUX_TITLEBAR_OVERLAY_HELPER}(`)) { - return patchedSource; + const completedSource = markLinuxNativeTitlebarPatch(patchedSource); + if ( + completedSource != null && + hasCompleteLinuxNativeTitlebarPatch(completedSource, helperFunctionRegex) + ) { + return completedSource; + } + console.warn( + "WARN: Could not complete Linux native titlebar consumers — skipping", + ); + return currentSource; } const windowAlias = overlaySyncMethod.match[1]; @@ -253,7 +389,7 @@ function applyLinuxNativeTitlebarPatch(currentSource) { const overlayCallMatch = overlaySyncMethod.text.match(overlayCallRegex); if (overlayCallMatch == null) { console.warn("WARN: Could not patch titleBarOverlay nativeTheme sync for Linux"); - return patchedSource; + return currentSource; } const windowsOverlayHelperAlias = overlayCallMatch[1]; @@ -261,42 +397,302 @@ function applyLinuxNativeTitlebarPatch(currentSource) { overlayCallRegex, `${windowAlias}.setTitleBarOverlay(process.platform===\`linux\`?${LINUX_TITLEBAR_OVERLAY_HELPER}(this.windowZooms.get(${windowAlias}.id)):${windowsOverlayHelperAlias}(this.windowZooms.get(${windowAlias}.id)))`, ); - return ( + const completedSource = ( patchedSource.slice(0, overlaySyncMethod.start) + patchedMethod + patchedSource.slice(overlaySyncMethod.end) ); + const markedSource = markLinuxNativeTitlebarPatch(completedSource); + if ( + markedSource != null && + hasCompleteLinuxNativeTitlebarPatch(markedSource, helperFunctionRegex) + ) { + return markedSource; + } + console.warn( + "WARN: Could not complete Linux native titlebar consumers — skipping", + ); + return currentSource; +} + +const MINIFIED_IDENTIFIER = "[A-Za-z_$][\\w$]*"; +const LINUX_MANAGED_WINDOW_MENU_STRATEGY = "linux-managed-window-menu"; +const LINUX_GNOME_X11_SYSTEM_CONTEXT_MENU_GUARD = + "process.platform===`linux`&&(process.env.XDG_SESSION_TYPE??``).trim().toLowerCase()===`x11`&&/(^|:)gnome(:|$)/i.test((process.env.XDG_CURRENT_DESKTOP??``).trim())"; + +function linuxGnomeX11SystemContextMenuListenerFor(windowAlias, eventAlias = "e") { + return ( + `${LINUX_GNOME_X11_SYSTEM_CONTEXT_MENU_GUARD}&&` + + `${windowAlias}.on(\`system-context-menu\`,${eventAlias}=>` + + `${eventAlias}.preventDefault()),` + ); +} + +function linuxSystemContextMenuPatchFor(windowAlias) { + return ( + linuxGnomeX11SystemContextMenuListenerFor(windowAlias) + + `process.platform===\`linux\`&&${windowAlias}.removeMenu(),` + + `process.platform===\`win32\`&&${windowAlias}.removeMenu(),` + ); +} + +function linuxManagedWindowSystemContextMenuPatchFor(windowAlias) { + return ( + linuxGnomeX11SystemContextMenuListenerFor(windowAlias) + + `(process.platform===\`win32\`||process.platform===\`linux\`)&&` + + `${windowAlias}.removeMenu(),` + ); +} + +function semanticLinuxSystemContextMenuRegex(windowAlias, flags = "") { + const escapedWindowAlias = escapeRegExp(windowAlias); + return new RegExp( + `${escapeRegExp(LINUX_GNOME_X11_SYSTEM_CONTEXT_MENU_GUARD)}&&` + + `${escapedWindowAlias}\\.on\\(\`system-context-menu\`,(${MINIFIED_IDENTIFIER})=>` + + `\\1\\.preventDefault\\(\\)\\),process\\.platform===\`linux\`&&` + + `${escapedWindowAlias}\\.removeMenu\\(\\),process\\.platform===\`win32\`&&` + + `${escapedWindowAlias}\\.removeMenu\\(\\),`, + flags, + ); +} + +function semanticLinuxManagedWindowSystemContextMenuRegex(windowAlias, flags = "") { + const escapedWindowAlias = escapeRegExp(windowAlias); + return new RegExp( + `${escapeRegExp(LINUX_GNOME_X11_SYSTEM_CONTEXT_MENU_GUARD)}&&` + + `${escapedWindowAlias}\\.on\\(\`system-context-menu\`,(${MINIFIED_IDENTIFIER})=>` + + `\\1\\.preventDefault\\(\\)\\),\\(process\\.platform===\`win32\`\\|\\|` + + `process\\.platform===\`linux\`\\)&&${escapedWindowAlias}\\.removeMenu\\(\\),`, + flags, + ); +} + +function managedWindowMenuTargetRegex(windowAlias, flags = "") { + const escapedWindowAlias = escapeRegExp(windowAlias); + return new RegExp( + `\\(process\\.platform===\`win32\`\\|\\|process\\.platform===\`linux\`\\)&&` + + `${escapedWindowAlias}\\.removeMenu\\(\\),`, + flags, + ); +} + +function managedWindowRemoveMenuCallRegex(windowAlias, flags = "") { + return new RegExp( + `${escapeRegExp(windowAlias)}\\.removeMenu\\(\\)`, + flags, + ); +} + +// The current bundle also creates a browser-comment popup inside createWindow. +// Tie the managed-window patch to the BrowserWindow that the WindowManager registers, +// so an auxiliary popup can never satisfy the managed-window contract. +function findManagedBrowserWindowCreateCandidates(currentSource) { + const signatureRegex = new RegExp( + `async createWindow\\((${MINIFIED_IDENTIFIER})=\\{\\}\\)\\{`, + "g", + ); + const candidates = []; + let malformedMethod = false; + let signatureMatch; + + while ((signatureMatch = signatureRegex.exec(currentSource)) != null) { + const openBraceIndex = signatureMatch.index + signatureMatch[0].length - 1; + const closeBraceIndex = findMatchingBrace(currentSource, openBraceIndex); + if (closeBraceIndex === -1) { + malformedMethod = true; + continue; + } + + const methodText = currentSource.slice(signatureMatch.index, closeBraceIndex + 1); + const appearanceMatch = methodText.match( + new RegExp( + `^async createWindow\\(${escapeRegExp(signatureMatch[1])}=\\{\\}\\)\\{` + + `let ${MINIFIED_IDENTIFIER}=process\\.platform===\`win32\`&&` + + `\\(${escapeRegExp(signatureMatch[1])}\\.appearance\\?\\?\`primary\`\\)===` + + `\`primary\`\\?${MINIFIED_IDENTIFIER}\\.screen\\.getPrimaryDisplay\\(\\)` + + `\\.workArea:null,\\{[^}]*appearance:(${MINIFIED_IDENTIFIER})(?:=[^,}]*)?`, + ), + ); + if (appearanceMatch == null) { + signatureRegex.lastIndex = closeBraceIndex + 1; + continue; + } + + const browserWindowAliases = new Set( + [...methodText.matchAll( + new RegExp(`(${MINIFIED_IDENTIFIER})=new ${MINIFIED_IDENTIFIER}\\.BrowserWindow\\(`, "g"), + )].map((match) => match[1]), + ); + const registeredWindowAliases = new Set( + [...methodText.matchAll( + new RegExp( + `this\\.registerWindow\\((${MINIFIED_IDENTIFIER}),[^,]*,[^,]*,` + + `${escapeRegExp(appearanceMatch[1])},\`register\`\\)`, + "g", + ), + )].map((match) => match[1]), + ); + for (const windowAlias of registeredWindowAliases) { + if (!browserWindowAliases.has(windowAlias)) { + continue; + } + candidates.push({ + start: signatureMatch.index, + end: closeBraceIndex + 1, + text: methodText, + windowAlias, + }); + } + signatureRegex.lastIndex = closeBraceIndex + 1; + } + + return { candidates, malformedMethod }; +} + +function applyLinuxManagedWindowSystemContextMenuPatch(currentSource) { + const { candidates, malformedMethod } = + findManagedBrowserWindowCreateCandidates(currentSource); + if (malformedMethod) { + recordStrategy(LINUX_MANAGED_WINDOW_MENU_STRATEGY, "malformed-create-window"); + throw new Error( + "Could not parse WindowManager.createWindow while patching its managed BrowserWindow menu", + ); + } + if (candidates.length === 0) { + recordStrategy(LINUX_MANAGED_WINDOW_MENU_STRATEGY, "none"); + throw new Error( + "Could not identify the managed BrowserWindow in WindowManager.createWindow", + ); + } + if (candidates.length > 1) { + recordStrategy(LINUX_MANAGED_WINDOW_MENU_STRATEGY, "ambiguous"); + throw new Error( + `Found ${candidates.length} managed BrowserWindow candidates in createWindow methods`, + ); + } + + const candidate = candidates[0]; + const { windowAlias } = candidate; + const listenerRegex = new RegExp( + `${escapeRegExp(windowAlias)}\\.(?:on|addListener|once|prependListener|prependOnceListener)` + + `\\(\\s*(?:\`system-context-menu\`|"system-context-menu"|'system-context-menu')\\s*,`, + "g", + ); + const listenerCount = [...candidate.text.matchAll(listenerRegex)].length; + const removeMenuCallCount = [ + ...candidate.text.matchAll( + managedWindowRemoveMenuCallRegex(windowAlias, "g"), + ), + ].length; + const semanticPatchRegex = + semanticLinuxManagedWindowSystemContextMenuRegex(windowAlias, "g"); + const semanticMatches = [...candidate.text.matchAll(semanticPatchRegex)]; + + if (semanticMatches.length === 1 && listenerCount === 1) { + if (removeMenuCallCount !== 1) { + recordStrategy(LINUX_MANAGED_WINDOW_MENU_STRATEGY, "ambiguous"); + throw new Error( + `Found multiple menu targets for managed BrowserWindow '${windowAlias}'`, + ); + } + recordStrategy(LINUX_MANAGED_WINDOW_MENU_STRATEGY, "already-applied"); + return currentSource; + } + if (listenerCount > 0 || semanticMatches.length > 0) { + recordStrategy(LINUX_MANAGED_WINDOW_MENU_STRATEGY, "non-canonical-listener"); + throw new Error( + `Managed BrowserWindow '${windowAlias}' has a non-canonical or duplicate system-context-menu listener`, + ); + } + + const targetMatches = [ + ...candidate.text.matchAll( + managedWindowMenuTargetRegex(windowAlias, "g"), + ), + ]; + if (targetMatches.length === 0) { + recordStrategy(LINUX_MANAGED_WINDOW_MENU_STRATEGY, "none"); + throw new Error( + `Could not find the menu-removal target for managed BrowserWindow '${windowAlias}'`, + ); + } + if (targetMatches.length > 1) { + recordStrategy(LINUX_MANAGED_WINDOW_MENU_STRATEGY, "ambiguous"); + throw new Error( + `Found ${targetMatches.length} menu-removal targets for managed BrowserWindow '${windowAlias}'`, + ); + } + if (removeMenuCallCount !== 1) { + recordStrategy(LINUX_MANAGED_WINDOW_MENU_STRATEGY, "ambiguous"); + throw new Error( + `Found ${removeMenuCallCount} removeMenu calls for managed BrowserWindow '${windowAlias}'`, + ); + } + + const targetMatch = targetMatches[0]; + const patchedMethod = + candidate.text.slice(0, targetMatch.index) + + linuxManagedWindowSystemContextMenuPatchFor(windowAlias) + + candidate.text.slice(targetMatch.index + targetMatch[0].length); + const patchedListenerCount = [ + ...patchedMethod.matchAll( + new RegExp( + `${escapeRegExp(windowAlias)}\\.on\\(\`system-context-menu\`,`, + "g", + ), + ), + ].length; + if ( + patchedListenerCount !== 1 || + !semanticLinuxManagedWindowSystemContextMenuRegex(windowAlias).test( + patchedMethod, + ) + ) { + recordStrategy(LINUX_MANAGED_WINDOW_MENU_STRATEGY, "validation-failed"); + throw new Error( + `Failed to validate the system-context-menu patch for managed BrowserWindow '${windowAlias}'`, + ); + } + + recordStrategy(LINUX_MANAGED_WINDOW_MENU_STRATEGY, "upstream-combined"); + return ( + currentSource.slice(0, candidate.start) + + patchedMethod + + currentSource.slice(candidate.end) + ); } function applyLinuxMenuPatch(currentSource) { const menuRegex = /process\.platform===`win32`&&([A-Za-z_$][\w$]*)\.removeMenu\(\),/g; - const linuxMenuPatchFor = (windowVar) => - `process.platform===\`linux\`&&(${windowVar}.on(\`system-context-menu\`,e=>e.preventDefault()),${windowVar}.removeMenu()),`; - let patchedSource = currentSource - .replace( - /process\.platform===`linux`&&\(([A-Za-z_$][\w$]*)\.setMenuBarVisibility\(!1\),\1\.removeMenu\?\.\(\)\),process\.platform===`win32`&&\1\.removeMenu\(\),/g, - (_match, windowVar) => `${linuxMenuPatchFor(windowVar)}process.platform===\`win32\`&&${windowVar}.removeMenu(),`, - ) - .replace( - /process\.platform===`linux`&&([A-Za-z_$][\w$]*)\.setMenuBarVisibility\(!1\),process\.platform===`win32`&&\1\.removeMenu\(\),/g, - (_match, windowVar) => `${linuxMenuPatchFor(windowVar)}process.platform===\`win32\`&&${windowVar}.removeMenu(),`, - ) - .replace( - /process\.platform===`linux`&&([A-Za-z_$][\w$]*)\.removeMenu\(\),process\.platform===`win32`&&\1\.removeMenu\(\),/g, - (_match, windowVar) => `${linuxMenuPatchFor(windowVar)}process.platform===\`win32\`&&${windowVar}.removeMenu(),`, + let patchedAny = false; + const patchedSource = currentSource.replace(menuRegex, (match, windowVar, offset, source) => { + const linuxPatch = linuxSystemContextMenuPatchFor(windowVar); + const linuxPrefixRegex = new RegExp( + `${semanticLinuxSystemContextMenuRegex(windowVar).source}$`, ); - let patchedAny = patchedSource !== currentSource; - patchedSource = patchedSource.replace(menuRegex, (match, windowVar, offset, source) => { - const linuxPatch = linuxMenuPatchFor(windowVar); - if (source.slice(Math.max(0, offset - linuxPatch.length), offset) === linuxPatch) { + const prefixWithoutWindowsSuffix = + linuxPatch.slice(0, -match.length); + if ( + source.slice(Math.max(0, offset - prefixWithoutWindowsSuffix.length), offset) === + prefixWithoutWindowsSuffix || + linuxPrefixRegex.test( + source.slice(0, offset + match.length), + ) + ) { return match; } patchedAny = true; - return `${linuxPatch}${match}`; + return linuxPatch; }); const hasWindowsRemoveMenu = /process\.platform===`win32`&&[A-Za-z_$][\w$]*\.removeMenu\(\),/.test(patchedSource); - const hasLinuxRemoveMenu = /process\.platform===`linux`&&\(([A-Za-z_$][\w$]*)\.on\(`system-context-menu`,[A-Za-z_$][\w$]*=>[A-Za-z_$][\w$]*\.preventDefault\(\)\),\1\.removeMenu\(\)\),process\.platform===`win32`&&\1\.removeMenu\(\),/.test(patchedSource); + const hasLinuxRemoveMenu = new RegExp( + `${escapeRegExp(LINUX_GNOME_X11_SYSTEM_CONTEXT_MENU_GUARD)}&&` + + `(${MINIFIED_IDENTIFIER})\\.on\\(\`system-context-menu\`,` + + `(${MINIFIED_IDENTIFIER})=>\\2\\.preventDefault\\(\\)\\),` + + `process\\.platform===\`linux\`&&\\1\\.removeMenu\\(\\),` + + `process\\.platform===\`win32\`&&\\1\\.removeMenu\\(\\),`, + ).test(patchedSource); if (!patchedAny && hasWindowsRemoveMenu && !hasLinuxRemoveMenu) { console.warn("WARN: Could not find window menu visibility snippet — skipping menu patch"); } @@ -626,6 +1022,7 @@ function applyLinuxOpaqueBackgroundPatch(currentSource) { module.exports = { applyLinuxAppReloadShortcutsPatch, applyLinuxApplicationMenuPatch, + applyLinuxManagedWindowSystemContextMenuPatch, applyLinuxMenuPatch, applyLinuxNativeTitlebarPatch, applyLinuxOpaqueBackgroundPatch, diff --git a/scripts/patches/impl/webview-browser-use-external.test.js b/scripts/patches/impl/webview-browser-use-external.test.js new file mode 100644 index 000000000..0804c7899 --- /dev/null +++ b/scripts/patches/impl/webview-browser-use-external.test.js @@ -0,0 +1,240 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +const { + patchLinuxBrowserUseExternalAvailabilityAssets, +} = require("./webview/index.js"); + +const currentChromeLinuxRegistry = + "linux:{installations:[{commands:[`google-chrome`,`google-chrome-stable`],userDataDirName:`google-chrome`},{commands:[`chromium`,`chromium-browser`],userDataDirName:`chromium`},{commands:[`google-chrome-beta`],userDataDirName:`google-chrome-beta`},{commands:[`google-chrome-unstable`],userDataDirName:`google-chrome-unstable`},{commands:[`google-chrome-for-testing`],userDataDirName:`google-chrome-for-testing`}],nativeMessagingManifestDirectories:[`.config/google-chrome/NativeMessagingHosts`,`.config/chromium/NativeMessagingHosts`,`.config/google-chrome-beta/NativeMessagingHosts`,`.config/google-chrome-unstable/NativeMessagingHosts`,`.config/google-chrome-for-testing/NativeMessagingHosts`],processNames:[`chrome`],userDataDirectorySegments:[`.config`,`google-chrome`]}"; +const currentEdgeLinuxRegistry = + "linux:{installations:[{commands:[`microsoft-edge`,`microsoft-edge-stable`],userDataDirName:`microsoft-edge`}],nativeMessagingManifestDirectories:[`.config/microsoft-edge/NativeMessagingHosts`],processNames:[`msedge`],userDataDirectorySegments:[`.config`,`microsoft-edge`]}"; +const currentBraveLinuxRegistry = + "linux:{installations:[{commands:[`brave-browser`,`brave-browser-stable`,`brave`],userDataDirName:`BraveSoftware/Brave-Browser`}],nativeMessagingManifestDirectories:[`.config/BraveSoftware/Brave-Browser/NativeMessagingHosts`],processNames:[`brave`,`brave-browser`],userDataDirectorySegments:[`.config`,`BraveSoftware`,`Brave-Browser`]}"; +const currentOperaLinuxRegistry = + "linux:{installations:[{commands:[`opera`,`opera-stable`],userDataDirName:`opera`}],nativeMessagingManifestDirectories:[`.config/opera/NativeMessagingHosts`],processNames:[`opera`],userDataDirectorySegments:[`.config`,`opera`]}"; +const currentVivaldiLinuxRegistry = + "linux:{installations:[{commands:[`vivaldi`,`vivaldi-stable`],userDataDirName:`vivaldi`}],nativeMessagingManifestDirectories:[`.config/vivaldi/NativeMessagingHosts`],processNames:[`vivaldi`,`vivaldi-bin`],userDataDirectorySegments:[`.config`,`vivaldi`]}"; + +function currentBrowserRegistry(variableName) { + return `var ${variableName}={chrome:{backendCompatibilityKey:\`chrome\`,displayName:\`Google Chrome\`,${currentChromeLinuxRegistry}},edge:{backendCompatibilityKey:\`chrome\`,displayName:\`Microsoft Edge\`,${currentEdgeLinuxRegistry}},brave:{backendCompatibilityKey:\`chrome\`,displayName:\`Brave\`,${currentBraveLinuxRegistry}},opera:{backendCompatibilityKey:\`chrome\`,displayName:\`Opera\`,${currentOperaLinuxRegistry}},vivaldi:{backendCompatibilityKey:\`chrome\`,displayName:\`Vivaldi\`,${currentVivaldiLinuxRegistry}}};`; +} + +function currentMainRegistryFixture() { + return [ + currentBrowserRegistry("Oy"), + "function Iy(e){return Object.hasOwn(Oy,e)}", + "function validateBrowserRegistry(){return Object.keys(Oy).filter(Iy)}", + "Object.defineProperty(exports,\"Eo\",{enumerable:!0,get:function(){return Oy}}),Object.defineProperty(exports,\"ko\",{enumerable:!0,get:function(){return Iy}});", + ].join(""); +} + +function currentMainCallerFixture() { + return [ + "let n=exports;function dl(e){return installedCommands.has(e)?`/usr/bin/${e}`:null}async function ml(e,t){launches.push([e,t])}", + "async function mne({browserFamily:e,platform:t=process.platform}){return t===`darwin`?!1:t===`win32`?!1:t===`linux`&&jl(e)!=null}", + "function Ol({browserFamily:e=`chrome`,extensionId:t,platform:o=process.platform}){return o===`linux`&&n.Eo[e].linux.installations.some(e=>e.commands.includes(t))}", + "async function hne({browserFamily:e=`chrome`,extensionId:t,platform:o=process.platform}){return o===`linux`&&jl(e,t)!=null}", + "async function gne({browserFamily:e,platform:t=process.platform,runCommand:n=ml,url:r}){await kl({browserFamily:e,platform:t,runCommand:n,unsupportedPlatformError:`unsupported`,url:r})}", + "async function kl({browserFamily:e,platform:t,runCommand:r,url:i}){let a=n.Eo[e];if(t===`linux`){let t=jl(e);if(t==null)throw Error(`${a.displayName} is not installed`);await r(t,[i]);return}throw Error(`unsupported`)}", + "function jl(e,t){let r=n.Eo[e].linux.installations;for(let e of r){let t=Ml(e);if(t!=null)return t}return null}function Ml(e){for(let t of e.commands){let e=dl(t);if(e!=null)return e}return null}", + "var Cce={parse:e=>e},wce=class{async getInstalledBrowserFamilies(){let e=Object.keys(n.Eo).filter(n.ko);return(await Promise.all(e.map(async e=>({browserFamily:e,installed:await mne({browserFamily:e})})))).flatMap(({browserFamily:e,installed:t})=>t?[e]:[])}async openUrl({browserFamily:e,url:t}){await gne({browserFamily:Cce.parse(e),url:t})}};globalThis.BrowserService=wce;", + ].join(""); +} + +function currentRendererFixture() { + return [ + currentBrowserRegistry("Fu"), + "function Pu(e){return Object.hasOwn(Fu,e)}", + "function rendererLinuxRegistry(){return Object.keys(Fu).filter(Pu).map(e=>({browserFamily:e,installations:Fu[e].linux.installations,manifestDirectories:Fu[e].linux.nativeMessagingManifestDirectories,processNames:Fu[e].linux.processNames}))}", + "function wfi(){return{enabled:!1,featureName:`browser_use_external`,gate:`410065390`}}", + "function Sfi({isExternalBrowserUseFeatureEnabled:e,isExternalBrowserUseFeatureLoading:t,isExternalBrowserUseGateEnabled:n,runCodexInWsl:r,windowType:i}){return i===`chrome-extension`?`available`:t?`loading`:n?e?r?`wsl-disabled`:`available`:`config-requirement-disabled`:`statsig-disabled`}", + "globalThis.rendererLinuxRegistry=rendererLinuxRegistry;", + ].join(""); +} + +function createCurrentExternalBrowserUseAssets() { + const extractedDir = fs.mkdtempSync( + path.join(os.tmpdir(), "codex-current-external-browser-use-"), + ); + const buildDir = path.join(extractedDir, ".vite", "build"); + const assetsDir = path.join(extractedDir, "webview", "assets"); + fs.mkdirSync(buildDir, { recursive: true }); + fs.mkdirSync(assetsDir, { recursive: true }); + const mainPath = path.join(buildDir, "main-DU-1HLYt.js"); + const srcPath = path.join(buildDir, "src-Bn_6ASpg.js"); + const rendererPath = path.join(assetsDir, "app-initial-YjNFxVhk.js"); + fs.writeFileSync(mainPath, currentMainCallerFixture(), "utf8"); + fs.writeFileSync(srcPath, currentMainRegistryFixture(), "utf8"); + fs.writeFileSync(rendererPath, currentRendererFixture(), "utf8"); + return { extractedDir, mainPath, rendererPath, srcPath }; +} + +function evaluateMainBrowserService(srcSource, mainSource, installedCommandNames) { + const context = { + exports: {}, + installedCommands: new Set(installedCommandNames), + launches: [], + process: { platform: "linux" }, + }; + vm.runInNewContext(`${srcSource};${mainSource}`, context); + return { context, service: new context.BrowserService() }; +} + +function jsonValue(value) { + return JSON.parse(JSON.stringify(value)); +} + +test("patches exact-DMG Browser Use availability while preserving native browser registries", async () => { + const fixture = createCurrentExternalBrowserUseAssets(); + try { + assert.deepEqual( + patchLinuxBrowserUseExternalAvailabilityAssets(fixture.extractedDir), + { matched: 3, changed: 1 }, + ); + + const mainSource = fs.readFileSync(fixture.mainPath, "utf8"); + const srcSource = fs.readFileSync(fixture.srcPath, "utf8"); + const rendererSource = fs.readFileSync(fixture.rendererPath, "utf8"); + + const braveOnly = evaluateMainBrowserService(srcSource, mainSource, ["brave-browser"]); + assert.deepEqual(jsonValue(await braveOnly.service.getInstalledBrowserFamilies()), ["brave"]); + await braveOnly.service.openUrl({ browserFamily: "brave", url: "https://example.com/brave" }); + assert.deepEqual(jsonValue(braveOnly.context.launches), [ + ["/usr/bin/brave-browser", ["https://example.com/brave"]], + ]); + + const chromeOnly = evaluateMainBrowserService(srcSource, mainSource, ["google-chrome"]); + assert.deepEqual(jsonValue(await chromeOnly.service.getInstalledBrowserFamilies()), ["chrome"]); + await chromeOnly.service.openUrl({ browserFamily: "chrome", url: "https://example.com/chrome" }); + assert.deepEqual(jsonValue(chromeOnly.context.launches), [ + ["/usr/bin/google-chrome", ["https://example.com/chrome"]], + ]); + + const rendererContext = {}; + vm.runInNewContext(rendererSource, rendererContext); + const rendererRegistry = jsonValue(rendererContext.rendererLinuxRegistry()); + const rendererChrome = rendererRegistry.find(({ browserFamily }) => browserFamily === "chrome"); + const rendererBrave = rendererRegistry.find(({ browserFamily }) => browserFamily === "brave"); + const rendererEdge = rendererRegistry.find(({ browserFamily }) => browserFamily === "edge"); + assert.ok(rendererChrome.installations.some(({ commands }) => commands.includes("google-chrome"))); + assert.ok(rendererChrome.installations.some(({ commands }) => commands.includes("chromium"))); + assert.ok( + rendererBrave.installations.some( + ({ userDataDirName }) => userDataDirName === "BraveSoftware/Brave-Browser", + ), + ); + assert.ok( + rendererBrave.manifestDirectories.includes( + ".config/BraveSoftware/Brave-Browser/NativeMessagingHosts", + ), + ); + assert.ok(rendererBrave.processNames.includes("brave")); + assert.ok(rendererBrave.processNames.includes("brave-browser")); + assert.deepEqual(rendererEdge.installations[0].commands, [ + "microsoft-edge", + "microsoft-edge-stable", + ]); + assert.match( + rendererSource, + /return i===`chrome-extension`\|\|navigator\.userAgent\.includes\(`Linux`\)\?`available`:/, + ); + + const beforeSecondPass = new Map([ + [fixture.srcPath, srcSource], + [fixture.rendererPath, rendererSource], + ]); + assert.deepEqual( + patchLinuxBrowserUseExternalAvailabilityAssets(fixture.extractedDir), + { matched: 3, changed: 0 }, + ); + for (const [filePath, source] of beforeSecondPass) { + assert.equal(fs.readFileSync(filePath, "utf8"), source); + } + } finally { + fs.rmSync(fixture.extractedDir, { recursive: true, force: true }); + } +}); + +test("leaves every exact-DMG Browser Use asset unchanged when a registry seam drifts", () => { + const fixture = createCurrentExternalBrowserUseAssets(); + try { + fs.writeFileSync( + fixture.rendererPath, + currentRendererFixture().replace("processNames:[`chrome`]", "processNames:[`chrome`,`chromium`]"), + "utf8", + ); + const before = new Map([ + [fixture.mainPath, fs.readFileSync(fixture.mainPath, "utf8")], + [fixture.srcPath, fs.readFileSync(fixture.srcPath, "utf8")], + [fixture.rendererPath, fs.readFileSync(fixture.rendererPath, "utf8")], + ]); + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + let result; + try { + result = patchLinuxBrowserUseExternalAvailabilityAssets(fixture.extractedDir); + } finally { + console.warn = originalWarn; + } + + assert.deepEqual(result, { + matched: 0, + changed: 0, + reason: "Could not identify complete current Browser Use external availability and browser registry contract", + }); + assert.equal(warnings.length, 1); + for (const [filePath, source] of before) { + assert.equal(fs.readFileSync(filePath, "utf8"), source); + } + } finally { + fs.rmSync(fixture.extractedDir, { recursive: true, force: true }); + } +}); + +test("rolls back the exact-DMG Browser Use availability file when its write fails", () => { + const fixture = createCurrentExternalBrowserUseAssets(); + try { + const before = new Map([ + [fixture.rendererPath, fs.readFileSync(fixture.rendererPath, "utf8")], + ]); + let writeCount = 0; + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + let result; + try { + result = patchLinuxBrowserUseExternalAvailabilityAssets(fixture.extractedDir, { + writeFileSync(filePath, source, encoding) { + writeCount += 1; + if (writeCount === 1) { + fs.writeFileSync(filePath, "partially-written", encoding); + throw new Error("simulated renderer write failure"); + } + fs.writeFileSync(filePath, source, encoding); + }, + }); + } finally { + console.warn = originalWarn; + } + + assert.deepEqual(result, { + matched: 3, + changed: 0, + reason: "Could not write complete current Browser Use external availability assets: simulated renderer write failure", + }); + assert.equal(warnings.length, 1); + for (const [filePath, source] of before) { + assert.equal(fs.readFileSync(filePath, "utf8"), source); + } + } finally { + fs.rmSync(fixture.extractedDir, { recursive: true, force: true }); + } +}); diff --git a/scripts/patches/impl/webview/index.js b/scripts/patches/impl/webview/index.js index 2a78eaedc..e5f8cfd90 100644 --- a/scripts/patches/impl/webview/index.js +++ b/scripts/patches/impl/webview/index.js @@ -9,12 +9,22 @@ const { escapeRegExp, findMatchingBrace, } = require("../../lib/minified-js.js"); +const { + patchDelegationState, +} = require("../../lib/composition-delegation.js"); +const { + PatchIntegrityError, +} = require("../../integrity-error.js"); // Webview asset patches target hashed browser chunks copied out of app.asar. // They stay fail-soft because upstream chunk names and minified symbols drift. const LINUX_TOOLTIP_COLLISION_PADDING_TOP = 44; const LINUX_WINDOW_CONTROLS_SAFE_AREA_RIGHT = 138; const LINUX_WINDOW_CONTROLS_SAFE_AREA_PROP = "codexLinuxUseWindowControlsSafeArea"; +const LINUX_WINDOW_CONTROLS_SAFE_AREA_MARKER = + "/*codexLinuxWindowControlsSafeAreaPatch*/"; +const LINUX_WINDOW_CONTROLS_SAFE_AREA_PATCH_ID = + "linux-window-controls-safe-area"; function applyLinuxSettingsSearchVisibilityPatch(currentSource) { if (currentSource.includes("function codexLinuxFilterSettingsSearchSection(")) { @@ -184,7 +194,104 @@ function applyLinuxHeaderSlotSafeAreaPatch(currentSource) { .replace(slotSource, patchedSlotSource); } -function applyLinuxWindowControlsSafeAreaPatch(currentSource) { +function markLinuxWindowControlsSafeAreaPatch(source) { + const strictDirective = '"use strict";'; + const insertionIndex = source.startsWith(strictDirective) + ? strictDirective.length + : 0; + return ( + source.slice(0, insertionIndex) + + LINUX_WINDOW_CONTROLS_SAFE_AREA_MARKER + + source.slice(insertionIndex) + ); +} + +function hasCompleteLinuxWindowControlsSafeAreaPatch(source) { + const markerCount = + source.split(LINUX_WINDOW_CONTROLS_SAFE_AREA_MARKER).length - 1; + if (markerCount !== 1) { + return false; + } + + const insetMatches = [ + ...source.matchAll( + /applicationMenu:Object\.freeze\(\{left:0,right:([^}]+)\}\)/gu, + ), + ]; + const slotSignatureMatches = source.match( + new RegExp( + `function [A-Za-z_$][\\w$]*\\(\\{entries:[A-Za-z_$][\\w$]*,fitWidth:[A-Za-z_$][\\w$]*,side:[A-Za-z_$][\\w$]*,slotWidth:[A-Za-z_$][\\w$]*,${LINUX_WINDOW_CONTROLS_SAFE_AREA_PROP}\\}\\)`, + "gu", + ), + ) ?? []; + const paddingMatches = source.match( + new RegExp( + `"pe-2":([A-Za-z_$][\\w$]*)===\`start\`&&[A-Za-z_$][\\w$]*\\|\\|\\1===\`end\`&&!${LINUX_WINDOW_CONTROLS_SAFE_AREA_PROP},"pe-\\(--spacing-token-safe-header-right\\)":\\1===\`end\`&&${LINUX_WINDOW_CONTROLS_SAFE_AREA_PROP}`, + "gu", + ), + ) ?? []; + const nativeHeaderMatches = source.match( + new RegExp( + `${LINUX_WINDOW_CONTROLS_SAFE_AREA_PROP}:![A-Za-z_$][\\w$]*,side:\`end\``, + "gu", + ), + ) ?? []; + const hasSharedConsumers = + insetMatches.length > 0 && + slotSignatureMatches.length === 1 && + paddingMatches.length === 1; + if (!hasSharedConsumers) { + return false; + } + + return ( + insetMatches.every((match) => + match[1] === String(LINUX_WINDOW_CONTROLS_SAFE_AREA_RIGHT) + ) && + nativeHeaderMatches.length === 1 + ); +} + +function applyLinuxWindowControlsSafeAreaPatch(currentSource, context = {}) { + const delegation = patchDelegationState( + currentSource, + LINUX_WINDOW_CONTROLS_SAFE_AREA_PATCH_ID, + { + allowedFeatureIds: + context.patchCompositionDelegates?.[ + LINUX_WINDOW_CONTROLS_SAFE_AREA_PATCH_ID + ], + enabledFeatureIds: context.enabledFeatureIds, + ownerMarker: LINUX_WINDOW_CONTROLS_SAFE_AREA_MARKER, + }, + ); + if (delegation.state === "enabled") { + return currentSource; + } + if (delegation.state !== "none") { + console.warn( + "WARN: Found inactive or invalid Linux window-controls safe-area patch delegation — skipping", + ); + return currentSource; + } + const markerCount = + currentSource.split(LINUX_WINDOW_CONTROLS_SAFE_AREA_MARKER).length - 1; + if (markerCount > 0) { + if (hasCompleteLinuxWindowControlsSafeAreaPatch(currentSource)) { + return currentSource; + } + console.warn( + "WARN: Found incomplete Linux window-controls safe-area patch marker — skipping", + ); + return currentSource; + } + if (currentSource.includes(LINUX_WINDOW_CONTROLS_SAFE_AREA_PROP)) { + console.warn( + "WARN: Found unmarked Linux window-controls safe-area patch state — skipping", + ); + return currentSource; + } + const currentInset = `applicationMenu:Object.freeze({left:0,right:${LINUX_WINDOW_CONTROLS_SAFE_AREA_RIGHT}})`; const defaultInset = "applicationMenu:Object.freeze({left:0,right:0})"; @@ -211,7 +318,14 @@ function applyLinuxWindowControlsSafeAreaPatch(currentSource) { patchedSource.includes(LINUX_WINDOW_CONTROLS_SAFE_AREA_PROP) ) ) { - return patchedSource; + const completedSource = markLinuxWindowControlsSafeAreaPatch(patchedSource); + if (hasCompleteLinuxWindowControlsSafeAreaPatch(completedSource)) { + return completedSource; + } + console.warn( + "WARN: Could not complete Linux window-controls safe-area consumers — skipping", + ); + return currentSource; } if ( @@ -702,7 +816,7 @@ function hasCompleteLinuxBrowserUseWebviewRemountStorePatch(source) { source.includes("for(let e of this.linuxBrowserUseRecoveryStates.keys())") && source.includes("this.linuxBrowserUseRecoveryStates.clear()") && source.includes("this.linuxBrowserUseRecoveryStates.set(") && - (source.match(/linuxBrowserUseRecoveryStates\.delete\(/gu) ?? []).length >= 7 + (source.match(/linuxBrowserUseRecoveryStates\.delete\(/gu) ?? []).length >= 6 ); } @@ -734,9 +848,12 @@ function applyLinuxBrowserUseWebviewRemountStorePatch(currentSource) { /this\.snapshots\.get\(([A-Za-z_$][\w$]*)\(/u, )?.[1]; const activeMethodMatch = - /setBrowserUseActive\(([A-Za-z_$][\w$]*),\.\.\.([A-Za-z_$][\w$]*)\)\{let ([A-Za-z_$][\w$]*)=typeof \2\[0\]==`boolean`\?([A-Za-z_$][\w$]*)\(\1,void 0\):\2\[0\],([A-Za-z_$][\w$]*)=typeof \2\[0\]==`boolean`\?\2\[0\]:\2\[1\],/u.exec( - classSource, - ); + keyHelper == null + ? null + : new RegExp( + `setBrowserUseActive\\(([A-Za-z_$][\\w$]*),([A-Za-z_$][\\w$]*),([A-Za-z_$][\\w$]*)\\)\\{let ([A-Za-z_$][\\w$]*)=${escapeRegExp(keyHelper)}\\(\\1,\\2\\),`, + "u", + ).exec(classSource); const removeTabMatch = keyHelper == null ? null @@ -755,10 +872,6 @@ function applyLinuxBrowserUseWebviewRemountStorePatch(currentSource) { `releaseBrowserUseTab\\(([A-Za-z_$][\\w$]*),([A-Za-z_$][\\w$]*)\\)\\{let ([A-Za-z_$][\\w$]*)=${escapeRegExp(keyHelper)}\\(\\1,\\2\\),`, "u", ).exec(classSource); - const siblingDeactivateMatch = - /for\(let ([A-Za-z_$][\w$]*) of Array\.from\(this\.browserUseActiveTabKeys\)\)\{if\(\1===([A-Za-z_$][\w$]*)\|\|!\1\.startsWith\(([A-Za-z_$][\w$]*)\)\)continue;this\.browserUseActiveTabKeys\.delete\(\1\);let /u.exec( - classSource, - ); const reassociateMethodIndex = classSource.indexOf("reassociateTabState("); const reassociateMethodOpenIndex = reassociateMethodIndex === -1 @@ -798,7 +911,6 @@ function applyLinuxBrowserUseWebviewRemountStorePatch(currentSource) { removeTabMatch == null || removeConversationTabsMatch == null || releaseBrowserUseTabMatch == null || - siblingDeactivateMatch == null || reassociateKeysMatch == null || reassociateStateMatch == null || disposeAllMatch == null || @@ -815,13 +927,12 @@ function applyLinuxBrowserUseWebviewRemountStorePatch(currentSource) { const [ activeMethodNeedle, activeConversationVar, - activeArgsVar, activeBrowserTabVar, - activeDefaultTabHelper, activeValueVar, + activeKeyVar, ] = activeMethodMatch; const activeMethodPatch = - `setBrowserUseActive(${activeConversationVar},...${activeArgsVar}){let ${activeBrowserTabVar}=typeof ${activeArgsVar}[0]==\`boolean\`?${activeDefaultTabHelper}(${activeConversationVar},void 0):${activeArgsVar}[0],${activeValueVar}=typeof ${activeArgsVar}[0]==\`boolean\`?${activeArgsVar}[0]:${activeArgsVar}[1];${activeValueVar}||this.linuxBrowserUseRecoveryStates.delete(${keyHelper}(${activeConversationVar},${activeBrowserTabVar}));let `; + `setBrowserUseActive(${activeConversationVar},${activeBrowserTabVar},${activeValueVar}){let ${activeKeyVar}=${keyHelper}(${activeConversationVar},${activeBrowserTabVar});${activeValueVar}||this.linuxBrowserUseRecoveryStates.delete(${activeKeyVar});let `; const method = `linuxStartWebviewRecovery(e,t,n){let r=${keyHelper}(e,t),i=this.linuxBrowserUseRecoveryStates.get(r);return i??(i={attempt:0,deadlineAt:n},this.linuxBrowserUseRecoveryStates.set(r,i)),i}linuxCompleteWebviewRecovery(e,t,n){let r=${keyHelper}(e,t);this.webviews.get(r)===n&&this.linuxBrowserUseRecoveryStates.delete(r)}linuxFailWebviewRecovery(e,t,n){let r=${keyHelper}(e,t);this.webviews.get(r)===n&&this.linuxBrowserUseRecoveryStates.set(r,{attempt:2,deadlineAt:null})}linuxRemountWebview(e,t,n,r){let i=${keyHelper}(e,t),a=this.linuxBrowserUseRecoveryStates.get(i);if(a?.attempt>=1)return{started:!1,state:a};if(this.webviews.get(i)!==n)return null;let o={attempt:1,deadlineAt:r};return this.linuxBrowserUseRecoveryStates.set(i,o),this.disposeWebviewHost(e,t,i,\`web\`),this.emitChange(),{started:!0,state:o}}`; const [ removeTabNeedle, @@ -844,11 +955,6 @@ function applyLinuxBrowserUseWebviewRemountStorePatch(currentSource) { const releaseBrowserUseTabPatch = `releaseBrowserUseTab(${releaseConversationVar},${releaseBrowserTabVar}){let ${releaseKeyVar}=${keyHelper}(${releaseConversationVar},${releaseBrowserTabVar});` + `this.linuxBrowserUseRecoveryStates.delete(${releaseKeyVar});let `; - const [siblingDeactivateNeedle, siblingKeyVar] = siblingDeactivateMatch; - const siblingDeactivatePatch = siblingDeactivateNeedle.replace( - ";let ", - `;this.linuxBrowserUseRecoveryStates.delete(${siblingKeyVar});let `, - ); const reassociateStateNeedle = reassociateStateMatch[0]; const reassociateStateVar = reassociateStateMatch[1]; const reassociateSourceKeyVar = reassociateKeysMatch[1]; @@ -868,7 +974,6 @@ function applyLinuxBrowserUseWebviewRemountStorePatch(currentSource) { .replace(removeTabNeedle, removeTabPatch) .replace(removeConversationNeedle, removeConversationPatch) .replace(releaseBrowserUseTabNeedle, releaseBrowserUseTabPatch) - .replace(siblingDeactivateNeedle, siblingDeactivatePatch) .replace(reassociateStateNeedle, reassociateStatePatch) .replace(disposeAllMatch[0], disposeAllPatch); if (!hasCompleteLinuxBrowserUseWebviewRemountStorePatch(patchedClass)) { @@ -1000,130 +1105,224 @@ function applyLinuxBrowserUseWebviewHostRecoveryPatch(currentSource) { ); } -function applyLinuxBrowserUseHiddenHostOwnershipPatch(currentSource) { - const keyMatch = /browserUseTabIdsKey:([A-Za-z_$][\w$]*)/u.exec(currentSource); - if (keyMatch == null) { - console.warn( - "WARN: Could not find hidden Browser Use host tab ownership key — skipping Linux inactive-route host patch", - ); +const CURRENT_BROWSER_USE_CHROME_LINUX_REGISTRY = + "linux:{installations:[{commands:[`google-chrome`,`google-chrome-stable`],userDataDirName:`google-chrome`},{commands:[`chromium`,`chromium-browser`],userDataDirName:`chromium`},{commands:[`google-chrome-beta`],userDataDirName:`google-chrome-beta`},{commands:[`google-chrome-unstable`],userDataDirName:`google-chrome-unstable`},{commands:[`google-chrome-for-testing`],userDataDirName:`google-chrome-for-testing`}],nativeMessagingManifestDirectories:[`.config/google-chrome/NativeMessagingHosts`,`.config/chromium/NativeMessagingHosts`,`.config/google-chrome-beta/NativeMessagingHosts`,`.config/google-chrome-unstable/NativeMessagingHosts`,`.config/google-chrome-for-testing/NativeMessagingHosts`],processNames:[`chrome`],userDataDirectorySegments:[`.config`,`google-chrome`]}"; +const CURRENT_BROWSER_USE_EDGE_LINUX_REGISTRY = + "linux:{installations:[{commands:[`microsoft-edge`,`microsoft-edge-stable`],userDataDirName:`microsoft-edge`}],nativeMessagingManifestDirectories:[`.config/microsoft-edge/NativeMessagingHosts`],processNames:[`msedge`],userDataDirectorySegments:[`.config`,`microsoft-edge`]}"; +const CURRENT_BROWSER_USE_BRAVE_LINUX_REGISTRY = + "linux:{installations:[{commands:[`brave-browser`,`brave-browser-stable`,`brave`],userDataDirName:`BraveSoftware/Brave-Browser`}],nativeMessagingManifestDirectories:[`.config/BraveSoftware/Brave-Browser/NativeMessagingHosts`],processNames:[`brave`,`brave-browser`],userDataDirectorySegments:[`.config`,`BraveSoftware`,`Brave-Browser`]}"; +const CURRENT_BROWSER_USE_OPERA_LINUX_REGISTRY = + "linux:{installations:[{commands:[`opera`,`opera-stable`],userDataDirName:`opera`}],nativeMessagingManifestDirectories:[`.config/opera/NativeMessagingHosts`],processNames:[`opera`],userDataDirectorySegments:[`.config`,`opera`]}"; +const CURRENT_BROWSER_USE_VIVALDI_LINUX_REGISTRY = + "linux:{installations:[{commands:[`vivaldi`,`vivaldi-stable`],userDataDirName:`vivaldi`}],nativeMessagingManifestDirectories:[`.config/vivaldi/NativeMessagingHosts`],processNames:[`vivaldi`,`vivaldi-bin`],userDataDirectorySegments:[`.config`,`vivaldi`]}"; +const CURRENT_BROWSER_USE_CONTRACT_MISSING_REASON = + "Could not identify complete current Browser Use external availability and browser registry contract"; + +const externalBrowserUseAvailabilityPatchedPattern = + /function [A-Za-z_$][\w$]*\(\{isExternalBrowserUseFeatureEnabled:[A-Za-z_$][\w$]*,isExternalBrowserUseFeatureLoading:[A-Za-z_$][\w$]*,isExternalBrowserUseGateEnabled:[A-Za-z_$][\w$]*,runCodexInWsl:[A-Za-z_$][\w$]*,windowType:([A-Za-z_$][\w$]*)\}\)\{return \1===`chrome-extension`\|\|navigator\.userAgent\.includes\(`Linux`\)\?`available`:/; +const externalBrowserUseAvailabilityCurrentPattern = + /(function [A-Za-z_$][\w$]*\(\{isExternalBrowserUseFeatureEnabled:[A-Za-z_$][\w$]*,isExternalBrowserUseFeatureLoading:[A-Za-z_$][\w$]*,isExternalBrowserUseGateEnabled:[A-Za-z_$][\w$]*,runCodexInWsl:[A-Za-z_$][\w$]*,windowType:([A-Za-z_$][\w$]*)\}\)\{return )\2===`chrome-extension`\?`available`:/; + +function patchCurrentExternalBrowserUseAvailabilitySource(currentSource) { + if (externalBrowserUseAvailabilityPatchedPattern.test(currentSource)) { return currentSource; } + const patchedSource = currentSource.replace( + externalBrowserUseAvailabilityCurrentPattern, + (match, prefix, windowTypeVar) => + `${prefix}${windowTypeVar}===\`chrome-extension\`||navigator.userAgent.includes(\`Linux\`)?\`available\`:`, + ); + return patchedSource === currentSource ? null : patchedSource; +} - const browserUseTabIdsKeyVar = keyMatch[1]; - const componentStartIndex = currentSource.lastIndexOf("function ", keyMatch.index); - const componentOpenIndex = currentSource.indexOf("{", componentStartIndex); - const componentCloseIndex = - componentOpenIndex === -1 - ? -1 - : findMatchingBrace(currentSource, componentOpenIndex); - const componentSource = - componentStartIndex === -1 || componentCloseIndex === -1 - ? "" - : currentSource.slice(componentStartIndex, componentCloseIndex + 1); - const parsedTabIdsMatch = new RegExp( - `${escapeRegExp(browserUseTabIdsKeyVar)}\\.split\\(\`\\\\0\`\\)\\.map\\(([A-Za-z_$][\\w$]*)\\)\\.filter`, - "u", - ).exec(componentSource); - const guardMatch = - /if\(!([A-Za-z_$][\w$]*)&&([A-Za-z_$][\w$]*)\.size>0(?:&&([A-Za-z_$][\w$]*)\.split\(`\\0`\)\.map\(([A-Za-z_$][\w$]*)\)\.every\(([A-Za-z_$][\w$]*)=>\2\.has\(\5\)\))?\)return null;/u.exec( - componentSource, - ); - - if ( - guardMatch != null && - guardMatch[3] === browserUseTabIdsKeyVar && - guardMatch[4] === parsedTabIdsMatch?.[1] - ) { - return currentSource; +function applyLinuxBrowserUseExternalAvailabilityPatch(currentSource) { + const externalFeatureNeedle = "featureName:`browser_use_external`"; + const statsigNeedle = "410065390"; + const patchedSource = patchCurrentExternalBrowserUseAvailabilitySource(currentSource); + if (patchedSource != null) { + return patchedSource; } - if ( - componentStartIndex === -1 || - componentCloseIndex === -1 || - parsedTabIdsMatch == null || - guardMatch == null - ) { + + if (currentSource.includes(externalFeatureNeedle) && currentSource.includes(statsigNeedle)) { console.warn( - "WARN: Could not find hidden Browser Use host ownership guard — skipping Linux inactive-route host patch", + "WARN: Could not find Browser Use external availability gate — skipping Linux external Browser Use availability patch", ); - return currentSource; } - const [guardNeedle, routeOwnerVar, visibleTabIdsVar] = guardMatch; - const parseBrowserTabIdVar = parsedTabIdsMatch[1]; - const visibleTabIdVar = "codexLinuxBrowserUseTabId"; - const guardPatch = - `if(!${routeOwnerVar}&&${visibleTabIdsVar}.size>0&&` + - `${browserUseTabIdsKeyVar}.split(\`\\0\`).map(${parseBrowserTabIdVar}).every(` + - `${visibleTabIdVar}=>${visibleTabIdsVar}.has(${visibleTabIdVar})))return null;`; - const patchedComponent = componentSource.replace(guardNeedle, guardPatch); - return ( - `${currentSource.slice(0, componentStartIndex)}${patchedComponent}` + - `${currentSource.slice(componentCloseIndex + 1)}` - ); + return currentSource; } -function applyLinuxBrowserUseExternalAvailabilityPatch(currentSource) { - const externalFeatureNeedle = "featureName:`browser_use_external`"; - const statsigNeedle = "410065390"; - let changed = false; +function currentNativeBrowserUseRegistryContract(source, registryVar, checkerVar) { + const exactLinuxRegistries = [ + CURRENT_BROWSER_USE_CHROME_LINUX_REGISTRY, + CURRENT_BROWSER_USE_EDGE_LINUX_REGISTRY, + CURRENT_BROWSER_USE_BRAVE_LINUX_REGISTRY, + CURRENT_BROWSER_USE_OPERA_LINUX_REGISTRY, + CURRENT_BROWSER_USE_VIVALDI_LINUX_REGISTRY, + ]; + return source.includes( + `${registryVar}={chrome:{backendCompatibilityKey:\`chrome\`,displayName:\`Google Chrome\``, + ) && + source.includes("},edge:{backendCompatibilityKey:`chrome`,displayName:`Microsoft Edge`") && + source.includes("},brave:{backendCompatibilityKey:`chrome`,displayName:`Brave`") && + source.includes("},opera:{backendCompatibilityKey:`chrome`,displayName:`Opera`") && + source.includes("},vivaldi:{backendCompatibilityKey:`chrome`,displayName:`Vivaldi`") && + exactLinuxRegistries.every( + (registry) => source.split(registry).length - 1 === 1, + ) && + source.includes(`function ${checkerVar}(e){return Object.hasOwn(${registryVar},e)}`); +} - const alreadyPatched = () => - /featureName:`browser_use_external`[\s\S]{0,900}?navigator\.userAgent\.includes\(`Linux`\)/.test(currentSource); +function currentBrowserUseMainCallerContract(source) { + return source.includes("async function mne({browserFamily:") && + source.includes("function Ol({browserFamily:") && + source.includes("async function hne({browserFamily:") && + source.includes("async function gne({browserFamily:") && + source.includes("function jl(e,t){let r=n.Eo[e].linux.installations;") && + source.includes( + "getInstalledBrowserFamilies(){let e=Object.keys(n.Eo).filter(n.ko);", + ) && + source.includes( + "async openUrl({browserFamily:e,url:t}){await gne({browserFamily:", + ); +} - const availabilityPattern = - /let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\),([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)===`chrome-extension`\|\|([A-Za-z_$][\w$]*)&&\1\.enabled&&!\1\.isLoading,([A-Za-z_$][\w$]*)=\5===`chrome-extension`\?!1:\1\.isLoading,/g; +function currentBrowserUseMainRegistryContract(source) { + return currentNativeBrowserUseRegistryContract(source, "Oy", "Iy") && + source.includes( + "Object.defineProperty(exports,\"Eo\",{enumerable:!0,get:function(){return Oy}})", + ) && + source.includes( + "Object.defineProperty(exports,\"ko\",{enumerable:!0,get:function(){return Iy}})", + ) && + source.includes("Object.keys(Oy).filter(Iy)"); +} - let patchedSource = currentSource.replace( - availabilityPattern, - ( - match, - featureQueryVar, - featureQueryFn, - featureQueryArg, - availableVar, - windowTypeVar, - statsigVar, - loadingVar, - offset, - ) => { - const contextStart = Math.max(0, offset - 700); - const context = currentSource.slice(contextStart, offset + match.length); - if (!context.includes(externalFeatureNeedle) || !context.includes(statsigNeedle)) { - return match; - } +function currentBrowserUseRendererContract(source) { + return currentNativeBrowserUseRegistryContract(source, "Fu", "Pu") && + source.includes("featureName:`browser_use_external`") && + source.includes("410065390") && + source.includes("Object.keys(Fu).filter(Pu)") && + (externalBrowserUseAvailabilityCurrentPattern.test(source) || + externalBrowserUseAvailabilityPatchedPattern.test(source)); +} - changed = true; - return `let ${featureQueryVar}=${featureQueryFn}(${featureQueryArg}),${availableVar}=${windowTypeVar}===\`chrome-extension\`||navigator.userAgent.includes(\`Linux\`)||${statsigVar}&&${featureQueryVar}.enabled&&!${featureQueryVar}.isLoading,${loadingVar}=${windowTypeVar}===\`chrome-extension\`||navigator.userAgent.includes(\`Linux\`)?!1:${featureQueryVar}.isLoading,`; - }, - ); +function findUniqueCurrentBrowserUseAsset(directory, fileNamePattern, contract) { + if (!fs.existsSync(directory)) { + return null; + } + const matches = fs.readdirSync(directory) + .filter((fileName) => fileNamePattern.test(fileName)) + .sort() + .map((fileName) => { + const filePath = path.join(directory, fileName); + return { filePath, source: fs.readFileSync(filePath, "utf8") }; + }) + .filter(({ source }) => contract(source)); + return matches.length === 1 ? matches[0] : null; +} - if (!changed) { - // 26.623 refactored the inline availability gate into a status-string helper: - // function X({isExternalBrowserUseFeatureEnabled:e,isExternalBrowserUseFeatureLoading:t, - // isExternalBrowserUseGateEnabled:n,windowType:r}){return r===`chrome-extension`?`available`:...} - // Treat Linux like chrome-extension so the resolved status is `available`. - const statusFnPattern = - /(function [A-Za-z_$][\w$]*\(\{isExternalBrowserUseFeatureEnabled:[A-Za-z_$][\w$]*,isExternalBrowserUseFeatureLoading:[A-Za-z_$][\w$]*,isExternalBrowserUseGateEnabled:[A-Za-z_$][\w$]*,windowType:([A-Za-z_$][\w$]*)\}\)\{return )\2===`chrome-extension`\?`available`:/; - patchedSource = patchedSource.replace( - statusFnPattern, - (match, prefix, windowTypeVar) => { - changed = true; - return `${prefix}${windowTypeVar}===\`chrome-extension\`||navigator.userAgent.includes(\`Linux\`)?\`available\`:`; - }, - ); +function writeCurrentBrowserUseAssetCandidates(candidates, writeFileSync, readFileSync) { + const attempted = []; + try { + for (const candidate of candidates) { + attempted.push(candidate); + writeFileSync(candidate.filePath, candidate.patched, "utf8"); + } + } catch (error) { + const rollbackWriteFailures = []; + for (const candidate of attempted.reverse()) { + try { + writeFileSync(candidate.filePath, candidate.source, "utf8"); + } catch (rollbackError) { + rollbackWriteFailures.push(rollbackError); + } + } + const rollbackVerificationFailures = []; + for (const candidate of attempted) { + try { + if (readFileSync(candidate.filePath, "utf8") !== candidate.source) { + rollbackVerificationFailures.push( + new Error(`rollback byte verification failed for ${candidate.filePath}`), + ); + } + } catch (rollbackError) { + rollbackVerificationFailures.push(rollbackError); + } + } + if (rollbackVerificationFailures.length > 0) { + const writeFailureContext = rollbackWriteFailures[0] == null + ? "" + : `; rollback write also failed: ${rollbackWriteFailures[0].message}`; + throw new PatchIntegrityError( + `Browser Use external availability rollback could not restore original bytes: ${rollbackVerificationFailures[0].message}${writeFailureContext}`, + ); + } + throw error; } +} - if (changed || alreadyPatched()) { - return patchedSource; +function patchLinuxBrowserUseExternalAvailabilityAssets(extractedDir, { + writeFileSync = fs.writeFileSync, + readFileSync = fs.readFileSync, +} = {}) { + const buildDir = path.join(extractedDir, ".vite", "build"); + const assetsDir = path.join(extractedDir, "webview", "assets"); + const mainCaller = findUniqueCurrentBrowserUseAsset( + buildDir, + /^main-[^.]+\.js$/u, + currentBrowserUseMainCallerContract, + ); + const mainRegistry = findUniqueCurrentBrowserUseAsset( + buildDir, + /^src-[^.]+\.js$/u, + currentBrowserUseMainRegistryContract, + ); + const renderer = findUniqueCurrentBrowserUseAsset( + assetsDir, + /^app-initial-[^.]+\.js$/u, + currentBrowserUseRendererContract, + ); + if (mainCaller == null || mainRegistry == null || renderer == null) { + console.warn( + `WARN: ${CURRENT_BROWSER_USE_CONTRACT_MISSING_REASON} — skipping Linux external Browser Use availability patch`, + ); + return { + matched: 0, + changed: 0, + reason: CURRENT_BROWSER_USE_CONTRACT_MISSING_REASON, + }; } - if (currentSource.includes(externalFeatureNeedle) && currentSource.includes(statsigNeedle)) { + const patchedRenderer = patchCurrentExternalBrowserUseAvailabilitySource(renderer.source); + if (patchedRenderer == null) { console.warn( - "WARN: Could not find Browser Use external availability gate — skipping Linux external Browser Use availability patch", + `WARN: ${CURRENT_BROWSER_USE_CONTRACT_MISSING_REASON} — skipping Linux external Browser Use availability patch`, ); + return { + matched: 0, + changed: 0, + reason: CURRENT_BROWSER_USE_CONTRACT_MISSING_REASON, + }; } - return currentSource; + const candidates = [ + { ...renderer, patched: patchedRenderer }, + ].filter(({ source, patched }) => source !== patched); + if (candidates.length === 0) { + return { matched: 3, changed: 0 }; + } + try { + writeCurrentBrowserUseAssetCandidates(candidates, writeFileSync, readFileSync); + } catch (error) { + if (error?.code === "PATCH_INTEGRITY_FAILURE") { + throw error; + } + const reason = `Could not write complete current Browser Use external availability assets: ${error.message}`; + console.warn(`WARN: ${reason} — leaving Browser Use assets unchanged`); + return { matched: 3, changed: 0, reason }; + } + return { matched: 3, changed: candidates.length }; } function applyLinuxAppServerFeatureEnablementPatch(currentSource) { @@ -1246,7 +1445,7 @@ function applyLinuxAppServerFeatureEnablementPatch(currentSource) { const AUTOMATION_UPDATE_EAGER_MARKER_PATTERN = /[A-Za-z_$][\w$]*\.name===`automation_update`&&delete [A-Za-z_$][\w$]*\.deferLoading/u; const AUTOMATION_UPDATE_DYNAMIC_TOOLS_PATTERN = - /\.map\(([A-Za-z_$][\w$]*)=>\(\{type:`function`,\.\.\.\1,\.\.\.([A-Za-z_$][\w$]*)\.has\(\1\.name\)\?\{\}:\{deferLoading:!0\}\}\)\)/u; + /\.map\(([A-Za-z_$][\w$]*)=>\(\{type:`function`,\.\.\.\1,\.\.\.([A-Za-z_$][\w$]*)&&!([A-Za-z_$][\w$]*)\.has\(\1\.name\)\?\{deferLoading:!0\}:\{\}\}\)\)/u; function matchesAutomationUpdateEagerToolContract(currentSource) { return ( @@ -1271,9 +1470,9 @@ function applyAutomationUpdateEagerToolPatch(currentSource) { return currentSource.replace( AUTOMATION_UPDATE_DYNAMIC_TOOLS_PATTERN, - (_match, toolVar, eagerToolsVar) => { + (_match, toolVar, namespaceFlagVar, eagerToolsVar) => { const descriptorVar = toolVar === "t" ? "codexLinuxAutomationDescriptor" : "t"; - return `.map(${toolVar}=>{let ${descriptorVar}={type:\`function\`,...${toolVar},...${eagerToolsVar}.has(${toolVar}.name)?{}:{deferLoading:!0}};return ${toolVar}.name===\`automation_update\`&&delete ${descriptorVar}.deferLoading,${descriptorVar}})`; + return `.map(${toolVar}=>{let ${descriptorVar}={type:\`function\`,...${toolVar},...${namespaceFlagVar}&&!${eagerToolsVar}.has(${toolVar}.name)?{deferLoading:!0}:{}};return ${toolVar}.name===\`automation_update\`&&delete ${descriptorVar}.deferLoading,${descriptorVar}})`; }, ); } @@ -1730,13 +1929,13 @@ function applyLocalEnvironmentActionModalDraftPatch(currentSource) { function applyBrowserAnnotationScreenshotPatch(currentSource) { const storedAnchorRegex = - /if\([A-Za-z_$][\w$]*&&([A-Za-z_$][\w$]*)\?\.annotation\.anchor\.kind===`element`\)\{[^;{}]+;let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\1\.annotation\.anchor\);([A-Za-z_$][\w$]*)=void 0,/; + /if\([A-Za-z_$][\w$]*&&([A-Za-z_$][\w$]*)\?\.annotation\.anchor\.kind===`element`\)\{let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\(\1\.annotation\.anchor\);([A-Za-z_$][\w$]*)=void 0,/; if (storedAnchorRegex.test(currentSource)) { return currentSource; } const liveAnchorRegex = - /(if\([A-Za-z_$][\w$]*&&([A-Za-z_$][\w$]*)\?\.annotation\.anchor\.kind===`element`\)\{[^;{}]+;)let e=([A-Za-z_$][\w$]*)==null\?null:[A-Za-z_$][\w$]*\(\3\),([A-Za-z_$][\w$]*)=e\?\.rect\?\?([A-Za-z_$][\w$]*)\(\2\.annotation\.anchor\);([A-Za-z_$][\w$]*)=e\?\.borderRadius,/; + /(if\([A-Za-z_$][\w$]*&&([A-Za-z_$][\w$]*)\?\.annotation\.anchor\.kind===`element`\)\{)let ([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)==null\?null:[A-Za-z_$][\w$]*\(\4\),([A-Za-z_$][\w$]*)=\3\?\.rect\?\?([A-Za-z_$][\w$]*)\(\2\.annotation\.anchor\);([A-Za-z_$][\w$]*)=\3\?\.borderRadius,/; const match = currentSource.match(liveAnchorRegex); if (match == null) { console.warn( @@ -1745,7 +1944,7 @@ function applyBrowserAnnotationScreenshotPatch(currentSource) { return currentSource; } - const [, prefix, selectedAnnotationVar, , rectVar, anchorRectFn, radiusVar] = match; + const [, prefix, selectedAnnotationVar, , , rectVar, anchorRectFn, radiusVar] = match; return currentSource.replace( liveAnchorRegex, `${prefix}let ${rectVar}=${anchorRectFn}(${selectedAnnotationVar}.annotation.anchor);${radiusVar}=void 0,`, @@ -2283,19 +2482,24 @@ function applyLinuxSkillsListDedupePatch(currentSource) { .replace("function IJ(e){return e.skills}", `${helper}function IJ(e){return e.skills}`); } -function patchCommentPreloadBundle(extractedDir) { - const commentPreloadBundle = path.join(extractedDir, ".vite", "build", "comment-preload.js"); - if (!fs.existsSync(commentPreloadBundle)) { +function patchBrowserPagePreloadBundle(extractedDir) { + const browserPagePreloadBundle = path.join( + extractedDir, + ".vite", + "build", + "browser-page-preload.js", + ); + if (!fs.existsSync(browserPagePreloadBundle)) { console.warn( - `WARN: Could not find comment preload bundle in ${path.dirname(commentPreloadBundle)} — skipping annotation screenshot patch`, + `WARN: Could not find browser page preload bundle in ${path.dirname(browserPagePreloadBundle)} — skipping annotation screenshot patch`, ); return { matched: false, changed: false }; } - const source = fs.readFileSync(commentPreloadBundle, "utf8"); + const source = fs.readFileSync(browserPagePreloadBundle, "utf8"); const patchedSource = applyBrowserAnnotationScreenshotPatch(source); if (patchedSource !== source) { - fs.writeFileSync(commentPreloadBundle, patchedSource, "utf8"); + fs.writeFileSync(browserPagePreloadBundle, patchedSource, "utf8"); return { matched: true, changed: true }; } return { matched: true, changed: false }; @@ -2310,7 +2514,7 @@ module.exports = { applyLinuxChatSearchHydrationPatch, applyLinuxBrowserUseAvailabilityPatch, applyLinuxBrowserUseExternalAvailabilityPatch, - applyLinuxBrowserUseHiddenHostOwnershipPatch, + patchLinuxBrowserUseExternalAvailabilityAssets, applyLinuxBrowserUseNonLocalNavigationPatch, applyLinuxBrowserUseWebviewHostRecoveryPatch, applyLinuxBrowserUseWebviewRemountStorePatch, @@ -2329,5 +2533,5 @@ module.exports = { applyLocalEnvironmentActionModalDraftPatch, applySubagentNicknameMetadataPatch, codexLinuxWatchBrowserWebviewAttachment, - patchCommentPreloadBundle, + patchBrowserPagePreloadBundle, }; diff --git a/scripts/patches/integrity-error.js b/scripts/patches/integrity-error.js new file mode 100644 index 000000000..ea7787b0e --- /dev/null +++ b/scripts/patches/integrity-error.js @@ -0,0 +1,19 @@ +"use strict"; + +class PatchIntegrityError extends Error { + constructor(message, options) { + super(message, options); + this.name = "PatchIntegrityError"; + this.code = "PATCH_INTEGRITY_FAILURE"; + } +} + +function isPatchIntegrityError(error) { + return error instanceof PatchIntegrityError || + error?.code === "PATCH_INTEGRITY_FAILURE"; +} + +module.exports = { + PatchIntegrityError, + isPatchIntegrityError, +}; diff --git a/scripts/patches/lib/composition-delegation.js b/scripts/patches/lib/composition-delegation.js new file mode 100644 index 000000000..9e760d1e3 --- /dev/null +++ b/scripts/patches/lib/composition-delegation.js @@ -0,0 +1,98 @@ +"use strict"; + +const PATCH_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +const FEATURE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +const DELEGATION_MARKER_PATTERN = + /\/\*codexLinuxPatchDelegated:([a-z0-9][a-z0-9-]*):([a-z0-9][a-z0-9-]*)\*\//gu; + +function assertId(value, pattern, label) { + if (typeof value !== "string" || !pattern.test(value)) { + throw new Error(`${label} must match ${pattern}`); + } + return value; +} + +function patchDelegationMarker(ownerPatchId, featureId) { + const owner = assertId(ownerPatchId, PATCH_ID_PATTERN, "ownerPatchId"); + const feature = assertId(featureId, FEATURE_ID_PATTERN, "featureId"); + return `/*codexLinuxPatchDelegated:${owner}:${feature}*/`; +} + +function patchDelegations(source, ownerPatchId) { + const owner = assertId(ownerPatchId, PATCH_ID_PATTERN, "ownerPatchId"); + return [...source.matchAll(DELEGATION_MARKER_PATTERN)] + .filter((match) => match[1] === owner) + .map((match) => ({ marker: match[0], featureId: match[2] })); +} + +function patchDelegationState( + source, + ownerPatchId, + { + allowedFeatureIds = [], + enabledFeatureIds = [], + ownerMarker = null, + } = {}, +) { + const delegations = patchDelegations(source, ownerPatchId); + if (delegations.length === 0) { + return { state: "none", featureId: null }; + } + if ( + typeof ownerMarker === "string" && + ownerMarker.length > 0 && + source.includes(ownerMarker) + ) { + return { + state: "invalid", + featureId: delegations.length === 1 ? delegations[0].featureId : null, + }; + } + if (delegations.length !== 1) { + return { state: "invalid", featureId: null }; + } + + const [{ featureId }] = delegations; + const allowed = new Set( + Array.isArray(allowedFeatureIds) ? allowedFeatureIds : [], + ); + if (!allowed.has(featureId)) { + return { state: "invalid", featureId }; + } + const enabled = new Set( + Array.isArray(enabledFeatureIds) ? enabledFeatureIds : [], + ); + return { + state: enabled.has(featureId) ? "enabled" : "disabled", + featureId, + }; +} + +function delegatePatchMarker( + source, + ownerMarker, + ownerPatchId, + featureId, +) { + const delegatedMarker = patchDelegationMarker(ownerPatchId, featureId); + const ownerMarkerCount = source.split(ownerMarker).length - 1; + const delegations = patchDelegations(source, ownerPatchId); + if ( + ownerMarkerCount === 0 && + delegations.length === 1 && + delegations[0].marker === delegatedMarker + ) { + return source; + } + if (ownerMarkerCount !== 1 || delegations.length !== 0) { + return null; + } + return source.replace(ownerMarker, delegatedMarker); +} + +module.exports = { + delegatePatchMarker, + patchDelegationMarker, + patchDelegationState, + patchDelegations, +}; diff --git a/scripts/patches/lib/composition-delegation.test.js b/scripts/patches/lib/composition-delegation.test.js new file mode 100644 index 000000000..0876a5ce0 --- /dev/null +++ b/scripts/patches/lib/composition-delegation.test.js @@ -0,0 +1,100 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { + delegatePatchMarker, + patchDelegationMarker, + patchDelegationState, +} = require("./composition-delegation.js"); + +test("delegates one owner marker to one enabled feature", () => { + const ownerMarker = "/*owner*/"; + const delegated = delegatePatchMarker( + `before${ownerMarker}after`, + ownerMarker, + "owner-patch", + "sample-feature", + ); + const marker = patchDelegationMarker("owner-patch", "sample-feature"); + + assert.equal(delegated, `before${marker}after`); + assert.deepEqual( + patchDelegationState(delegated, "owner-patch", { + allowedFeatureIds: ["sample-feature"], + enabledFeatureIds: ["sample-feature"], + }), + { state: "enabled", featureId: "sample-feature" }, + ); + assert.deepEqual( + patchDelegationState(delegated, "owner-patch", { + allowedFeatureIds: ["sample-feature"], + }), + { state: "disabled", featureId: "sample-feature" }, + ); +}); + +test("rejects missing, duplicate, and competing patch delegations", () => { + const ownerMarker = "/*owner*/"; + const marker = patchDelegationMarker("owner-patch", "sample-feature"); + + assert.equal( + delegatePatchMarker( + "without-owner", + ownerMarker, + "owner-patch", + "sample-feature", + ), + null, + ); + assert.equal( + delegatePatchMarker( + `${ownerMarker}${ownerMarker}`, + ownerMarker, + "owner-patch", + "sample-feature", + ), + null, + ); + assert.equal( + delegatePatchMarker( + `${ownerMarker}${marker}`, + ownerMarker, + "owner-patch", + "sample-feature", + ), + null, + ); + assert.deepEqual( + patchDelegationState( + `${marker}${marker}`, + "owner-patch", + { + allowedFeatureIds: ["sample-feature"], + enabledFeatureIds: ["sample-feature"], + }, + ), + { state: "invalid", featureId: null }, + ); + assert.deepEqual( + patchDelegationState(`${ownerMarker}${marker}`, "owner-patch", { + allowedFeatureIds: ["sample-feature"], + enabledFeatureIds: ["sample-feature"], + ownerMarker, + }), + { state: "invalid", featureId: "sample-feature" }, + ); +}); + +test("rejects an enabled feature that is not authorized for the owner", () => { + const marker = patchDelegationMarker("owner-patch", "unrelated-feature"); + + assert.deepEqual( + patchDelegationState(marker, "owner-patch", { + allowedFeatureIds: ["sample-feature"], + enabledFeatureIds: ["sample-feature", "unrelated-feature"], + }), + { state: "invalid", featureId: "unrelated-feature" }, + ); +}); diff --git a/scripts/patches/lib/minified-js.js b/scripts/patches/lib/minified-js.js index 958c7d24e..8cfbb7246 100644 --- a/scripts/patches/lib/minified-js.js +++ b/scripts/patches/lib/minified-js.js @@ -15,7 +15,7 @@ function requireName(source, moduleName) { if (moduleName === "electron") { const wrappedMatch = source.match( new RegExp( - `([A-Za-z_$][\\w$]*)=codexLinuxPatchExternalOpen\\(require\\(([\\\`"'])${escaped}\\2\\)\\)`, + `([A-Za-z_$][\\w$]*)=(?:\\/\\*codexLinuxExternalOpenTarget\\*\\/)?codexLinuxPatchExternalOpen\\(require\\(([\\\`"'])${escaped}\\2\\)\\)`, ), ); return wrappedMatch?.[1] ?? null; diff --git a/scripts/patches/lib/minified-js.test.js b/scripts/patches/lib/minified-js.test.js index cd964ce38..cdef07bfb 100644 --- a/scripts/patches/lib/minified-js.test.js +++ b/scripts/patches/lib/minified-js.test.js @@ -23,6 +23,12 @@ test("requireName finds wrapped require with codexLinuxPatchExternalOpen", () => assert.strictEqual(requireName(source, "electron"), "electronAlias"); }); +test("requireName finds locally marked external-open targets", () => { + const source = + 'let electronAlias=/*codexLinuxExternalOpenTarget*/codexLinuxPatchExternalOpen(require("electron"))'; + assert.strictEqual(requireName(source, "electron"), "electronAlias"); +}); + test("requireName rejects an arbitrary require wrapper", () => { const source = `let a=1,electronAlias=myCustomWrapper(require(\`electron\`)),c=3`; assert.strictEqual(requireName(source, "electron"), null); diff --git a/scripts/patches/runner.js b/scripts/patches/runner.js index d26e96bca..9e104a2d0 100644 --- a/scripts/patches/runner.js +++ b/scripts/patches/runner.js @@ -2,6 +2,7 @@ const fs = require("node:fs"); const path = require("node:path"); +const vm = require("node:vm"); const { PATCH_STATUS_FAILED_REQUIRED, @@ -24,6 +25,8 @@ const { applyExtractedAppPatchDescriptors, applyMainBundlePatchDescriptors, applyWebviewAssetPatchDescriptors, + descriptorAppliesTo, + descriptorEnabled, discoverCorePatchDescriptors, normalizePatchDescriptors, } = require("./engine.js"); @@ -53,6 +56,15 @@ function recordMainProcessUiPatch(report, status, reason = null) { }); } +function mainBundleSyntaxError(source, target) { + try { + new vm.Script(source, { filename: target }); + return null; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + function normalizeDiscoveredCorePatchDescriptors(options = {}) { const root = options.corePatchRoot ?? CORE_PATCH_ROOT; return normalizePatchDescriptors(discoverCorePatchDescriptors({ root })); @@ -75,15 +87,19 @@ function featurePatchOptions(options = {}) { function createMainBundleContext(iconAsset, options = {}) { const linux = options.linuxTarget ?? detectLinuxTargetContext(options.linuxTargetOptions); + const currentFeaturePatchOptions = featurePatchOptions(options); + const enabledFeatureIds = options.enabledFeatureIds ?? + enabledLinuxFeatureIds(currentFeaturePatchOptions); return { enableComputerUseUi: isComputerUseUiEnabled(), + enabledFeatureIds: [...enabledFeatureIds], iconAsset, iconPathExpression: iconAsset == null ? null : `process.resourcesPath+\`/../content/webview/assets/${iconAsset}\``, linux, linuxTarget: linux, corePatchRoot: options.corePatchRoot, - featurePatchOptions: featurePatchOptions(options), + featurePatchOptions: currentFeaturePatchOptions, }; } @@ -113,8 +129,76 @@ function mainBundlePatchDescriptors(context) { ]); } +function patchCompositionDelegates(descriptors, context = {}) { + const coreOwners = new Map( + descriptors + .filter((descriptor) => descriptor.sourceKind === "core") + .map((descriptor) => [descriptor.id, descriptor]), + ); + const delegates = new Map(); + for (const descriptor of descriptors) { + if ( + descriptor.sourceKind !== "feature" || + typeof descriptor.featureId !== "string" || + !Array.isArray(descriptor.composesPatches) + ) { + continue; + } + if ( + !descriptorAppliesTo(descriptor, context) || + !descriptorEnabled(descriptor, context) + ) { + continue; + } + for (const ownerPatchId of descriptor.composesPatches) { + const owner = coreOwners.get(ownerPatchId); + if (owner == null) { + throw new Error( + `Feature descriptor '${descriptor.id}' composes unknown core patch '${ownerPatchId}'`, + ); + } + if (owner.phase !== descriptor.phase) { + throw new Error( + `Feature descriptor '${descriptor.id}' composes core patch '${ownerPatchId}' across phases`, + ); + } + if ( + !descriptorAppliesTo(owner, context) || + !descriptorEnabled(owner, context) + ) { + throw new Error( + `Feature descriptor '${descriptor.id}' composes inactive core patch '${ownerPatchId}'`, + ); + } + if (descriptor.order <= owner.order) { + throw new Error( + `Feature descriptor '${descriptor.id}' must run after composed core patch '${ownerPatchId}'`, + ); + } + const existing = delegates.get(ownerPatchId); + if (existing != null) { + throw new Error( + `Core patch '${ownerPatchId}' has multiple active composition delegates: '${existing}' and '${descriptor.id}'`, + ); + } + delegates.set(ownerPatchId, descriptor.featureId); + } + } + return Object.fromEntries( + [...delegates.entries()].map(([ownerPatchId, featureId]) => [ + ownerPatchId, + [featureId], + ]), + ); +} + function applyMainBundlePatches(source, context, report) { - return applyMainBundlePatchDescriptors(source, mainBundlePatchDescriptors(context), context, report); + const descriptors = mainBundlePatchDescriptors(context); + context.patchCompositionDelegates = { + ...(context.patchCompositionDelegates ?? {}), + ...patchCompositionDelegates(descriptors, context), + }; + return applyMainBundlePatchDescriptors(source, descriptors, context, report); } function patchMainBundleSource(source, iconAsset, options = {}) { @@ -132,7 +216,7 @@ function patchExtractedApp(extractedDir, options = {}) { setReportLinuxTarget(report, baseContext.linux); if (report != null) { - report.enabledFeatures = enabledLinuxFeatureIds(featuresOptions); + report.enabledFeatures = [...baseContext.enabledFeatureIds]; } const main = findMainBundle(extractedDir); @@ -158,27 +242,41 @@ function patchExtractedApp(extractedDir, options = {}) { const assetContext = createMainBundleContext(iconAsset, { ...options, + enabledFeatureIds: baseContext.enabledFeatureIds, linuxTarget: baseContext.linux, }); + assetContext.patchCompositionDelegates = + patchCompositionDelegates(patchDescriptors, assetContext); assetContext.report = report; if (main != null) { const target = path.join(main.buildDir, main.mainBundle); const source = fs.readFileSync(target, "utf8"); const { patchedSource, requiredCoreWarnings } = applyMainBundlePatches(source, assetContext, report); - if (patchedSource !== source) { + const sourceSyntaxError = mainBundleSyntaxError(source, target); + const patchedSyntaxError = mainBundleSyntaxError(patchedSource, target); + const syntaxWarning = sourceSyntaxError == null && patchedSyntaxError != null + ? `WARN: Patched main bundle has invalid JavaScript syntax: ${patchedSyntaxError}` + : null; + if (syntaxWarning != null) { + console.warn(syntaxWarning); + } else if (patchedSource !== source) { fs.writeFileSync(target, patchedSource, "utf8"); } + const aggregateWarnings = [ + ...requiredCoreWarnings, + ...(syntaxWarning == null ? [] : [syntaxWarning]), + ]; recordPatch( report, "main-process-ui", - patchStatusFromChange(patchedSource !== source, requiredCoreWarnings, REQUIRED_UPSTREAM), - requiredCoreWarnings[0] ?? null, + patchStatusFromChange(patchedSource !== source, aggregateWarnings, REQUIRED_UPSTREAM), + aggregateWarnings[0] ?? null, { phase: "main-bundle", ciPolicy: REQUIRED_UPSTREAM, sourceKind: "core", - ...(requiredCoreWarnings.length > 0 ? { warnings: [...requiredCoreWarnings] } : {}), + ...(aggregateWarnings.length > 0 ? { warnings: aggregateWarnings } : {}), }, ); } @@ -255,6 +353,7 @@ module.exports = { createMainBundleContext, featurePatchDescriptors, patchExtractedApp, + patchCompositionDelegates, patchMainBundleSource, requiredPatchNamesForProfile, }; diff --git a/scripts/patches/runner.test.js b/scripts/patches/runner.test.js index f4a9a7ab3..1d2807809 100644 --- a/scripts/patches/runner.test.js +++ b/scripts/patches/runner.test.js @@ -8,9 +8,123 @@ const { createPatchReport, } = require("../lib/patch-report.js"); const { + corePatchDescriptors, + createMainBundleContext, + featurePatchDescriptors, patchExtractedApp, + patchCompositionDelegates, } = require("./runner.js"); +test("runner context exposes enabled feature ids to every patch phase", () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "codex-runner-enabled-feature-context-"), + ); + try { + const featuresConfigPath = path.join(tempRoot, "features.json"); + fs.writeFileSync( + featuresConfigPath, + JSON.stringify({ enabled: ["frameless-titlebar"] }), + ); + + const context = createMainBundleContext(null, { featuresConfigPath }); + assert.deepEqual(context.enabledFeatureIds, ["frameless-titlebar"]); + assert.deepEqual(context.featurePatchOptions, { featuresConfigPath }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test("runner derives authorized patch delegates from enabled feature descriptors", () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "codex-runner-patch-delegates-"), + ); + try { + const featuresConfigPath = path.join(tempRoot, "features.json"); + fs.writeFileSync( + featuresConfigPath, + JSON.stringify({ enabled: ["frameless-titlebar"] }), + ); + const descriptors = [ + ...corePatchDescriptors(), + ...featurePatchDescriptors({ featuresConfigPath }), + ]; + + assert.deepEqual(patchCompositionDelegates(descriptors), { + "linux-native-titlebar": ["frameless-titlebar"], + "linux-window-controls-safe-area": ["frameless-titlebar"], + }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test("runner authorizes only active same-phase feature composition descriptors", () => { + const core = { + id: "linux-owner", + phase: "main-bundle", + sourceKind: "core", + order: 10, + }; + const feature = (overrides = {}) => ({ + id: "feature:sample:compose", + phase: "main-bundle", + sourceKind: "feature", + featureId: "sample", + composesPatches: ["linux-owner"], + order: 20, + ...overrides, + }); + + assert.deepEqual( + patchCompositionDelegates([core, feature()], {}), + { "linux-owner": ["sample"] }, + ); + assert.deepEqual( + patchCompositionDelegates([core, feature({ enabled: () => false })], {}), + {}, + ); + assert.deepEqual( + patchCompositionDelegates([core, feature({ appliesTo: () => false })], {}), + {}, + ); + assert.throws( + () => patchCompositionDelegates([feature()], {}), + /composes unknown core patch 'linux-owner'/, + ); + assert.throws( + () => patchCompositionDelegates([ + { ...core, phase: "webview-asset" }, + feature(), + ], {}), + /composes core patch 'linux-owner' across phases/, + ); + assert.throws( + () => patchCompositionDelegates([ + { ...core, enabled: () => false }, + feature(), + ], {}), + /composes inactive core patch 'linux-owner'/, + ); + assert.throws( + () => patchCompositionDelegates([ + core, + feature({ order: 5 }), + ], {}), + /must run after composed core patch 'linux-owner'/, + ); + assert.throws( + () => patchCompositionDelegates([ + core, + feature(), + feature({ + id: "feature:other:compose", + featureId: "other", + }), + ], {}), + /has multiple active composition delegates/, + ); +}); + test("runner executes descriptor phases explicitly and sorts order only within each phase", () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codex-runner-phase-order-")); try { diff --git a/scripts/patches/test-fixtures/current-dmg.js b/scripts/patches/test-fixtures/current-dmg.js new file mode 100644 index 000000000..cc4fd6399 --- /dev/null +++ b/scripts/patches/test-fixtures/current-dmg.js @@ -0,0 +1,158 @@ +"use strict"; + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +function electron42BrowserUseRuntimeResolverBundleFixture() { + return [ + "let s=require(`node:path`),l=require(`node:fs`);", + "function tt({resourcesPath:e}){return e}", + "function Kn(e){return e===`linux`?`/primary/node`:null}", + "function Hn({env:e=process.env,isPackaged:n=!0,platform:r=process.platform,repoRoot:i=process.cwd(),resolveCodexPath:a=t.Wn,resolveNodePath:o=t.Gn,resolveNodeReplPath:s=t.Kn,resolvePrimaryRuntimeNodePath:c=Kn,resourcesPath:l}){let u=l??tt({env:e,resourcesPath:process.resourcesPath}),d=c(r),f=Gn({platform:r,rawValue:e.CODEX_CLI_PATH,resolveWindowsAppsPath:a})??Wn({devRelativePathSegments:[`extension`,`bin`,`codex`],isPackaged:n,platform:r,repoRoot:i,resolveBundledPath:a,resourcesPath:u}),p=Wn({devRelativePathSegments:null,isPackaged:n,platform:r,repoRoot:i,resolveBundledPath:o,resourcesPath:u}),m=Gn({platform:r,rawValue:e.CODEX_BROWSER_USE_NODE_PATH,resolveWindowsAppsPath:o})??(p.path==null&&d!=null?{path:d,source:`primary-runtime`}:p),h=Gn({platform:r,rawValue:e.CODEX_NODE_REPL_PATH,resolveWindowsAppsPath:s})??Wn({devRelativePathSegments:null,isPackaged:n,platform:r,repoRoot:i,resolveBundledPath:s,resourcesPath:u});return{codexCliPath:f.path,codexCliPathSource:f.source,nodeModuleDirs:t.Vn(u),nodePath:m.path,nodePathSource:m.source,nodeReplPath:h.path,nodeReplPathSource:h.source,platform:r}}", + "function Wn(e){return{path:null,source:`missing`}}function Gn({rawValue:e}){return e==null?null:{path:e,source:`env-override`}}", + ].join(""); +} + +function currentChromePluginAppServerSourceBundleFixture() { + return [ + "let i=require(`node:path`),c=require(`node:fs`);", + "var _G=`com.openai.codexextension`,gG=`.plugin-appserver`;", + "async function TG(e){let t=e.nativeHostName===_G;return t?`isolated:${e.codexCliPath}`:e.codexCliPath}", + "async function vq(e){let t=yq(e),n=GN(e.resourcesPath),r=WN(e.resourcesPath),i=[t==null?`codex`:null,n==null?`node`:null,r==null?`node_repl`:null].filter(e=>e!=null);if(i.length>0)throw Error(`Missing bundled Electron runtime required to sync Chrome native host resources for ${e.nativeHostName}: ${i.join(`, `)} (resourcesPath: ${e.resourcesPath}).`);if(t==null||n==null||r==null)throw Error(`Missing bundled Electron runtime required to sync Chrome native host resources for ${e.nativeHostName}.`);return{codexCliPath:await TG({codexCliPath:t,codexHome:e.codexHome,nativeHostName:e.nativeHostName}),nodePath:n,nodeModuleDirs:KN(e.resourcesPath),nodeReplPath:r}}", + "async function UK(e){let t=yq(e);if(t==null)throw Error(`Missing bundled Electron Codex runtime required to sync Chrome plugin app server for ${e.nativeHostName} (resourcesPath: ${e.resourcesPath??``}).`);return TG({codexCliPath:t,codexHome:e.codexHome,nativeHostName:e.nativeHostName})}", + "async function cq(e){let t=[...new Set([...e.extensionIds,...nb(e.nativeHostName)])],n=Aq(),r=await kq({pluginRoot:e.pluginRoot,target:n});return{browserClientPath:i.join(e.pluginRoot,`scripts`,`browser-client.mjs`),extensionIds:t,target:n,extensionHostPath:r}}", + "function nb(){return[]}function Aq(){return{platform:process.platform,architecture:process.arch,filename:`extension-host`}}async function kq(e){return i.join(e.pluginRoot,`extension-host`,e.target.platform,e.target.architecture,e.target.filename)}", + "function yq(e){return null}function GN(e){return null}function WN(e){return null}function KN(e){return []}", + ].join(""); +} + +function createCurrentChromeNativeHostRuntimeAssetsFixture() { + const extractedDir = fs.mkdtempSync( + path.join(os.tmpdir(), "codex-current-chrome-runtime-assets-"), + ); + const buildDir = path.join(extractedDir, ".vite", "build"); + fs.mkdirSync(buildDir, { recursive: true }); + const mainPath = path.join(buildDir, "main-current.js"); + const srcPath = path.join(buildDir, "src-current.js"); + fs.writeFileSync( + mainPath, + electron42BrowserUseRuntimeResolverBundleFixture(), + "utf8", + ); + fs.writeFileSync( + srcPath, + currentChromePluginAppServerSourceBundleFixture(), + "utf8", + ); + return { extractedDir, mainPath, srcPath }; +} + +function settingsSharedBundleFixture() { + return [ + '"general-settings":{id:`settings.nav.general-settings`,defaultMessage:`General`,description:`Title for general settings section`},appearance:{id:`settings.nav.appearance`,defaultMessage:`Appearance`,description:`Title for appearance settings section`},', + "function titleForSection(e){switch(e){case`general-settings`:{let e;return t[2]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,d.jsx)(n,{id:`settings.section.general-settings`,defaultMessage:`General`,description:`Title for general settings section`}),t[2]=e):e=t[2],e}case`appearance`:return (0,d.jsx)(n,{id:`settings.section.appearance`,defaultMessage:`Appearance`,description:`Title for appearance settings section`})}}", + ].join(""); +} + +function createModernNativeKeyboardShortcutsSettingsFixture() { + const extractedDir = fs.mkdtempSync( + path.join(os.tmpdir(), "codex-modern-native-shortcuts-"), + ); + const assetsDir = path.join(extractedDir, "webview", "assets"); + fs.mkdirSync(assetsDir, { recursive: true }); + + const writeAsset = (name, source = "") => { + fs.writeFileSync(path.join(assetsDir, name), source, "utf8"); + }; + + writeAsset( + "rolldown-runtime-A.js", + "function n(e){return e}function s(e){return e}export{n,s};", + ); + writeAsset( + "shared-runtime-A.js", + 'import{s as s}from"./rolldown-runtime-A.js";function jsxFactory(){return{jsx(){},jsxs(){},Fragment:"Fragment"}}function reactFactory(){return{useState(){},useCallback(){},useEffect(){}}}function memoCache(){}export{jsxFactory as I,memoCache as L,reactFactory as R};', + ); + writeAsset( + "setting-storage-A.js", + 'async function requestCodex(...args){let[request]=args,{params:params,source:source}=request;return send("vscode://codex/",params)}export{requestCodex as z};', + ); + writeAsset( + "toggle-A.js", + 'function t({checked,disabled,onChange,ariaLabel}){return {role:"switch","aria-checked":checked,"aria-label":ariaLabel,disabled,onClick:()=>onChange(!checked)}}export{t};', + ); + writeAsset( + "settings-row-A.js", + "function a(e){let{label:t,description:n,control:r}=e;return null}export{a as r};", + ); + writeAsset("settings-content-layout-A.js", "export{n,r,t};"); + writeAsset("settings-group-A.js", "export{n,t};"); + writeAsset("settings-surface-A.js", "export{t};"); + writeAsset( + "keyboard-shortcuts-settings-A.js", + [ + 'import{n as __module,s as __toESM}from"./rolldown-runtime-A.js";', + 'import{I as __jsxFactory,L as __memoCache,R as __reactFactory}from"./shared-runtime-A.js";', + "function KeyboardShortcutsSettings(){let t=(0,React.useState)(null);return (0,$.jsx)(`div`,{children:t})}", + "var React,$;__module(()=>{React=__toESM(__reactFactory(),1),$=__jsxFactory()})();", + "slug:`keyboard-shortcuts`;export{KeyboardShortcutsSettings};", + ].join(""), + ); + writeAsset( + "app-initial-BTphDPeq.js", + [ + 'import{n as routeModule,s as routeToESM}from"./rolldown-runtime-A.js";', + 'import{I as routeJsxFactory,R as routeReactFactory}from"./shared-runtime-A.js";', + "function DecoyState(){let t=(0,DecoyReact.useState)(null);return t}", + "function DecoyView(){return (0,DecoyJsx.jsx)(`div`,{})}", + "var DecoyReact,DecoyJsx;routeModule(()=>{DecoyReact=routeToESM(routeReactFactory(),1)});routeModule(()=>{DecoyJsx=routeJsxFactory()})();", + "function Ya(e){let r=(0,RouteReact.lazy)(e);function SettingsRouteWrapper(){let t=(0,RouteReact.useState)(null);return (0,RouteJsx.jsx)(r,{children:t})}return SettingsRouteWrapper}", + "var RouteReact,RouteJsx;routeModule(()=>{RouteReact=routeToESM(routeReactFactory(),1),RouteJsx=routeJsxFactory()})();", + 'var Zn={"general-settings":Ya(async()=>(await Pr(async()=>{let{GeneralSettings:e}=await import(`./general-settings-A.js`);return{GeneralSettings:e}},[],import.meta.url)).GeneralSettings),"keyboard-shortcuts":Ya(async()=>(await Pr(async()=>{let{KeyboardShortcutsSettings:e}=await import(`./keyboard-shortcuts-settings-A.js`);return{KeyboardShortcutsSettings:e}},[],import.meta.url)).KeyboardShortcutsSettings)};', + "var Wn=[`general-settings`,`import`,`profile`,`keyboard-shortcuts`];", + "var Qn=[{key:`app`,slugs:[`general-settings`,`import`,`profile`,`keyboard-shortcuts`]}];", + "function loading(H){let W=!1;if(H)bb0:switch(H.slug){case`appearance`:case`general-settings`:case`agent`:case`git-settings`:case`data-controls`:case`personalization`:W=!1;break bb0;case`keyboard-shortcuts`:W=!1;break bb0}return W}", + "export{SettingsRouteWrapper};", + ].join(""), + ); + writeAsset( + "settings-page-A.js", + [ + "var nn=`general-settings.import.profile.appearance.voice.agent.personalization.pets.keyboard-shortcuts.usage.debug`.split(`.`),", + "rn=[{key:`personal`,heading:d({id:`settings.nav.heading.personal`,defaultMessage:`Personal`,description:`Heading for personal settings in the settings navigation`}),", + "slugs:[`general-settings`,`import`,`profile`,`appearance`,`voice`,`agent`,`personalization`,`pets`,`keyboard-shortcuts`,`usage`,`debug`]}];", + ].join(""), + ); + writeAsset( + "use-visible-settings-sections-A.js", + [ + 'var Hn={"general-settings":wt,import:it,profile:pt,"keyboard-shortcuts":xn};', + "function visible(e){switch(e.slug){case`profile`:return y;case`general-settings`:case`agent`:case`personalization`:return!0;case`keyboard-shortcuts`:return!0}}", + "export{Hn};", + ].join(""), + ); + writeAsset( + "app-initial~app-main~page~remote-conversation-page~new-thread-panel-page~settings-page~shared-A.js", + settingsSharedBundleFixture(), + ); + writeAsset( + "app-initial~app-main~remote-conversation-page~settings-page~hotkey-window-thread-page~mcp-s-A.js", + [ + "var c,l=e((()=>{c=`general-settings.import.profile.keyboard-shortcuts.codex-micro.appshots.appearance.pets.agent.git-settings.data-controls.cloud-settings.cloud-environments.code-review.personalization.usage.browser-use.computer-use.local-environments.worktrees.environments.mcp-settings.hooks-settings.connections.plugins-settings.skills-settings`.split(`.`)})),u,d,f,p=e((()=>{", + "l(),u=`general-settings`,d=function(e){return e.String=`string`,e.Array=`array`,e.Record=`record`,e}({}),", + "f=[{slug:`general-settings`},{slug:`import`},{slug:`profile`},{slug:`appearance`},{slug:`pets`},{slug:`appshots`},{slug:`git-settings`},{slug:`connections`},{slug:`cloud-settings`},{slug:`cloud-environments`},{slug:`code-review`},{slug:`local-environments`},{slug:`worktrees`},{slug:`agent`},{slug:`personalization`},{slug:`keyboard-shortcuts`},{slug:`usage`},{slug:`browser-use`},{slug:`computer-use`},{slug:`mcp-settings`},{slug:`hooks-settings`},{slug:`plugins-settings`},{slug:`skills-settings`},{slug:`data-controls`}]", + "}));", + ].join(""), + ); + + return { extractedDir, assetsDir }; +} + +module.exports = { + createCurrentChromeNativeHostRuntimeAssetsFixture, + createModernNativeKeyboardShortcutsSettingsFixture, + currentChromePluginAppServerSourceBundleFixture, + electron42BrowserUseRuntimeResolverBundleFixture, + settingsSharedBundleFixture, +}; diff --git a/tests/launcher_warm_start_recovery.sh b/tests/launcher_warm_start_recovery.sh index 37a967a22..79500240e 100755 --- a/tests/launcher_warm_start_recovery.sh +++ b/tests/launcher_warm_start_recovery.sh @@ -5,6 +5,10 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" TMP_DIR="$(mktemp -d)" APP_DIR="$TMP_DIR/app" +REMOUNT_APP_DIR="$TMP_DIR/remounted-app" +SECOND_APP_DIR="$APP_DIR" +APPIMAGE_PATH="$TMP_DIR/codex-desktop.AppImage" +APPIMAGE_RECOVERY="${CODEX_TEST_APPIMAGE_REMOUNT:-0}" HOME_DIR="$TMP_DIR/home" RUNTIME_DIR="$TMP_DIR/runtime" STATE_DIR="$HOME_DIR/.local/state/codex-desktop" @@ -16,6 +20,10 @@ LAUNCHER_PID="" SOCKET_PID="" HOOK_PID="" +if [ "$APPIMAGE_RECOVERY" = "1" ]; then + SECOND_APP_DIR="$REMOUNT_APP_DIR" +fi + cleanup() { local pid if [ -f "$STATE_DIR/app.pid" ]; then @@ -30,9 +38,11 @@ cleanup() { pid="${cmdline#/proc/}" pid="${pid%/cmdline}" IFS= read -r -d '' arg0 < "$cmdline" 2>/dev/null || true - if [ "${arg0:-}" = "$APP_DIR/electron" ]; then - kill "$pid" 2>/dev/null || true - fi + case "${arg0:-}" in + "$APP_DIR/electron"|"$REMOUNT_APP_DIR/electron") + kill "$pid" 2>/dev/null || true + ;; + esac arg0="" done rm -rf "$TMP_DIR" @@ -165,6 +175,11 @@ HOOK chmod +x "$APP_DIR/.codex-linux/prelaunch.d/blocking-test-hook" fi +if [ "$APPIMAGE_RECOVERY" = "1" ]; then + cp -a "$APP_DIR" "$REMOUNT_APP_DIR" + touch "$APPIMAGE_PATH" +fi + python3 - "$SOCKET_PATH" <<'PY' & import os import socket @@ -201,8 +216,14 @@ COMMON_ENV=( if [ "${CODEX_TEST_DISABLE_PIDFD:-0}" = "1" ]; then COMMON_ENV+=("PYTHONPATH=$TMP_DIR/python-site") fi +FIRST_APPIMAGE_ENV=() +SECOND_APPIMAGE_ENV=() +if [ "$APPIMAGE_RECOVERY" = "1" ]; then + FIRST_APPIMAGE_ENV=("APPIMAGE=$APPIMAGE_PATH" "APPDIR=$APP_DIR") + SECOND_APPIMAGE_ENV=("APPIMAGE=$APPIMAGE_PATH" "APPDIR=$SECOND_APP_DIR") +fi -"${COMMON_ENV[@]}" "$APP_DIR/start.sh" > "$FIRST_LOG" 2>&1 & +"${COMMON_ENV[@]}" "${FIRST_APPIMAGE_ENV[@]}" "$APP_DIR/start.sh" > "$FIRST_LOG" 2>&1 & LAUNCHER_PID=$! if [ "${CODEX_TEST_KILL_DURING_PRELAUNCH:-0}" = "1" ]; then @@ -214,7 +235,7 @@ if [ "${CODEX_TEST_KILL_DURING_PRELAUNCH:-0}" = "1" ]; then rm -f "$APP_DIR/.codex-linux/prelaunch.d/blocking-test-hook" SECONDS=0 - "${COMMON_ENV[@]}" "$APP_DIR/start.sh" > "$SECOND_LOG" 2>&1 & + "${COMMON_ENV[@]}" "${SECOND_APPIMAGE_ENV[@]}" "$SECOND_APP_DIR/start.sh" > "$SECOND_LOG" 2>&1 & LAUNCHER_PID=$! replacement_is_ready() { pid_file_is_live && webview_is_ready @@ -257,7 +278,7 @@ wait_for "webview parent-death cleanup" webview_is_down kill -0 "$FIRST_ELECTRON_PID" 2>/dev/null \ || fail "Electron should survive the launcher SIGKILL" -"${COMMON_ENV[@]}" "$APP_DIR/start.sh" > "$SECOND_LOG" 2>&1 & +"${COMMON_ENV[@]}" "${SECOND_APPIMAGE_ENV[@]}" "$SECOND_APP_DIR/start.sh" > "$SECOND_LOG" 2>&1 & LAUNCHER_PID=$! new_electron_is_ready() { @@ -279,4 +300,8 @@ kill "$SECOND_ELECTRON_PID" wait "$LAUNCHER_PID" LAUNCHER_PID="" -printf '%s\n' "launcher recovery test passed (warm-start disabled=${CODEX_TEST_DISABLE_WARM_START:-0})" +if [ "$APPIMAGE_RECOVERY" = "1" ]; then + printf '%s\n' "launcher AppImage remount recovery test passed" +else + printf '%s\n' "launcher recovery test passed (warm-start disabled=${CODEX_TEST_DISABLE_WARM_START:-0})" +fi diff --git a/tests/launcher_window_reopen_behavior.sh b/tests/launcher_window_reopen_behavior.sh new file mode 100755 index 000000000..d28898f1a --- /dev/null +++ b/tests/launcher_window_reopen_behavior.sh @@ -0,0 +1,548 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +MUTATION_DETECTED_EXIT=86 +PIDFD_UNAVAILABLE_EXIT=77 + +pidfd_cleanup_probe() { + [ "${CODEX_TEST_FORCE_NO_PIDFD:-0}" != "1" ] || return "$PIDFD_UNAVAILABLE_EXIT" + python3 - <<'PY' +import errno +import os +import signal +import sys + +if not hasattr(os, "pidfd_open") or not hasattr(signal, "pidfd_send_signal"): + raise SystemExit(77) + +try: + pidfd = os.pidfd_open(os.getpid(), 0) +except OSError as error: + if error.errno in {errno.EACCES, errno.EINVAL, errno.ENOSYS, errno.EPERM}: + raise SystemExit(77) + print(f"pidfd capability probe failed: {error}", file=sys.stderr) + raise SystemExit(1) + +try: + signal.pidfd_send_signal(pidfd, 0, None, 0) +except OSError as error: + if error.errno in {errno.EACCES, errno.EINVAL, errno.ENOSYS, errno.EPERM}: + raise SystemExit(77) + print(f"pidfd signal probe failed: {error}", file=sys.stderr) + raise SystemExit(1) +finally: + os.close(pidfd) +PY +} + +set +e +pidfd_cleanup_probe +pidfd_probe_status=$? +set -e +if [ "$pidfd_probe_status" -eq "$PIDFD_UNAVAILABLE_EXIT" ]; then + printf '%s\n' '{"outcome":"skipped","reason":"pidfd-cleanup-unavailable"}' + printf '%s\n' 'launcher window-reopen behavior test skipped: pidfd cleanup unavailable' + exit "$PIDFD_UNAVAILABLE_EXIT" +fi +if [ "$pidfd_probe_status" -ne 0 ]; then + printf '%s\n' 'launcher window-reopen behavior test failed: pidfd capability probe failed' >&2 + exit 1 +fi + +TMP_DIR="$(mktemp -d)" +APP_DIR="$TMP_DIR/app" +REMOUNT_APP_DIR="$TMP_DIR/remounted-app" +FOREIGN_APP_DIR="$TMP_DIR/foreign-app" +SECOND_APP_DIR="$APP_DIR" +HOME_DIR="$TMP_DIR/home" +RUNTIME_DIR="$TMP_DIR/runtime" +STATE_DIR="$HOME_DIR/.local/state/codex-desktop" +SOCKET_PATH="$RUNTIME_DIR/codex-desktop/launch-action.sock" +HANDOFF_RESULT="$TMP_DIR/handoff.json" +FIRST_LOG="$TMP_DIR/first-launch.log" +SECOND_LOG="$TMP_DIR/second-launch.log" +FOREIGN_LOG="$TMP_DIR/foreign-launch.log" +APP_LOG="$HOME_DIR/.cache/codex-desktop/launcher.log" +FOREIGN_CACHE_DIR="$TMP_DIR/foreign-cache" +FOREIGN_APP_LOG="$FOREIGN_CACHE_DIR/codex-desktop/launcher.log" +APPIMAGE_PATH="$TMP_DIR/codex-desktop.AppImage" +FOREIGN_APPIMAGE_PATH="$TMP_DIR/other-codex-desktop.AppImage" +APPIMAGE_REOPEN="${CODEX_TEST_APPIMAGE_REMOUNT:-0}" +SECOND_LAUNCH_ARG="--new-chat" +LAUNCHER_PID="" +SECOND_LAUNCHER_PID="" +SOCKET_PID="" +DECOY_PID="" +FIRST_ELECTRON_PID="" +FIRST_WEBVIEW_PID="" +FINAL_ELECTRON_PID="" +HANDOFF_STATUS="not-attempted" +TIMEOUT_STATUS="false" +ERROR_STATUS="false" + +if [ "$APPIMAGE_REOPEN" = "1" ]; then + SECOND_APP_DIR="$REMOUNT_APP_DIR" + SECOND_LAUNCH_ARG="--show" +fi + +count_test_main_processes() { + local count=0 + local cmdline + local pid + + for cmdline in /proc/[0-9]*/cmdline; do + [ -r "$cmdline" ] || continue + pid="${cmdline#/proc/}" + pid="${pid%/cmdline}" + IFS= read -r -d '' arg0 < "$cmdline" 2>/dev/null || true + case "${arg0:-}" in + "$APP_DIR/electron"|"$REMOUNT_APP_DIR/electron"|"$FOREIGN_APP_DIR/electron") + count=$((count + 1)) + ;; + esac + arg0="" + done + printf '%s\n' "$count" +} + +record_result() { + local outcome="$1" + local main_process_count + local marker_pid="" + + marker_pid="$(cat "$STATE_DIR/app.pid" 2>/dev/null || true)" + main_process_count="$(count_test_main_processes)" + + printf '{"outcome":"%s","initialPid":"%s","finalPid":"%s","mainProcessCount":%s,"handoff":"%s","markerPid":"%s","webviewMarkerPresent":%s,"socketPresent":%s,"timedOut":%s,"userVisibleError":%s}\n' \ + "$outcome" \ + "$FIRST_ELECTRON_PID" \ + "$FINAL_ELECTRON_PID" \ + "$main_process_count" \ + "$HANDOFF_STATUS" \ + "$marker_pid" \ + "$([ -s "$STATE_DIR/webview.pid" ] && printf true || printf false)" \ + "$([ -S "$SOCKET_PATH" ] && printf true || printf false)" \ + "$TIMEOUT_STATUS" \ + "$ERROR_STATUS" +} + +stop_owned_process_bounded() { + local pid="$1" + local match_mode="$2" + local expected="$3" + + [[ "$pid" =~ ^[0-9]+$ ]] || return 0 + python3 - "$pid" "$match_mode" "$expected" <<'PY' +import os +import select +import signal +import sys + +pid = int(sys.argv[1]) +match_mode = sys.argv[2] +expected = sys.argv[3] + +try: + pidfd = os.pidfd_open(pid, 0) +except ProcessLookupError: + raise SystemExit(0) +except OSError as error: + print(f"failed to open pidfd for {pid}: {error}", file=sys.stderr) + raise SystemExit(1) + +try: + try: + raw_cmdline = open(f"/proc/{pid}/cmdline", "rb").read() + except FileNotFoundError: + raise SystemExit(0) + except OSError as error: + print(f"failed to read process identity for {pid}: {error}", file=sys.stderr) + raise SystemExit(1) + argv = [part.decode(errors="surrogateescape") for part in raw_cmdline.split(b"\0") if part] + matches = bool(argv) and ( + (match_mode == "arg0" and argv[0] == expected) + or (match_mode == "argv" and expected in argv) + ) + if not matches: + raise SystemExit(0) + + try: + signal.pidfd_send_signal(pidfd, signal.SIGTERM) + except ProcessLookupError: + raise SystemExit(0) + poller = select.poll() + poller.register(pidfd, select.POLLIN) + if not poller.poll(1000): + try: + signal.pidfd_send_signal(pidfd, signal.SIGKILL) + except ProcessLookupError: + raise SystemExit(0) + if not poller.poll(1000): + print(f"process {pid} did not exit after bounded TERM/KILL", file=sys.stderr) + raise SystemExit(1) +finally: + os.close(pidfd) +PY +} + +cleanup() { + local original_status=$? + local cleanup_failed=0 + local cmdline + local pid + local webview_pid + + trap - EXIT + set +e + webview_pid="$(cat "$STATE_DIR/webview.pid" 2>/dev/null || true)" + stop_owned_process_bounded "$LAUNCHER_PID" argv "$APP_DIR/start.sh" || cleanup_failed=1 + stop_owned_process_bounded "$SECOND_LAUNCHER_PID" argv "$SECOND_APP_DIR/start.sh" || cleanup_failed=1 + stop_owned_process_bounded "$SOCKET_PID" argv "$SOCKET_PATH" || cleanup_failed=1 + for pid in "$FIRST_WEBVIEW_PID" "$webview_pid"; do + stop_owned_process_bounded "$pid" argv "$APP_DIR/.codex-linux/webview-server.py" || cleanup_failed=1 + stop_owned_process_bounded "$pid" argv "$REMOUNT_APP_DIR/.codex-linux/webview-server.py" || cleanup_failed=1 + stop_owned_process_bounded "$pid" argv "$FOREIGN_APP_DIR/.codex-linux/webview-server.py" || cleanup_failed=1 + done + stop_owned_process_bounded "$DECOY_PID" arg0 "$TMP_DIR/decoy-electron" || cleanup_failed=1 + for cmdline in /proc/[0-9]*/cmdline; do + [ -r "$cmdline" ] || continue + pid="${cmdline#/proc/}" + pid="${pid%/cmdline}" + IFS= read -r -d '' arg0 < "$cmdline" 2>/dev/null || true + case "${arg0:-}" in + "$APP_DIR/electron"|"$REMOUNT_APP_DIR/electron"|"$FOREIGN_APP_DIR/electron") + IFS= read -r -d '' revalidated_arg0 < "$cmdline" 2>/dev/null || true + if [ "${revalidated_arg0:-}" = "$arg0" ]; then + stop_owned_process_bounded "$pid" arg0 "$arg0" || cleanup_failed=1 + fi + ;; + esac + arg0="" + revalidated_arg0="" + done + rm -rf "$TMP_DIR" || cleanup_failed=1 + if [ "$cleanup_failed" -ne 0 ]; then + printf '%s\n' 'launcher window-reopen behavior cleanup failed' >&2 + exit 1 + fi + exit "$original_status" +} +trap cleanup EXIT + +fail() { + local message="$*" + if grep -Eqi 'notify-send|zenity|could not safely|failed to' "$SECOND_LOG" "$APP_LOG" 2>/dev/null; then + ERROR_STATUS="true" + fi + printf 'launcher window-reopen behavior test failed: %s\n' "$message" >&2 + record_result "failed" >&2 + printf '%s\n' '--- first launch ---' >&2 + sed -n '1,200p' "$FIRST_LOG" >&2 2>/dev/null || true + printf '%s\n' '--- second launch ---' >&2 + sed -n '1,240p' "$SECOND_LOG" >&2 2>/dev/null || true + printf '%s\n' '--- app launcher log ---' >&2 + sed -n '1,300p' "$APP_LOG" >&2 2>/dev/null || true + exit 1 +} + +mutation_detected() { + FINAL_ELECTRON_PID="$(read_live_app_pid 2>/dev/null || true)" + printf '%s\n' 'launcher window-reopen behavior mutation detected: healthy resident replacement' >&2 + record_result "resident-replacement-detected" >&2 + exit "$MUTATION_DETECTED_EXIT" +} + +wait_for() { + local description="$1" + shift + local attempt + + for attempt in $(seq 1 100); do + "$@" && return 0 + sleep 0.05 + done + TIMEOUT_STATUS="true" + fail "timed out waiting for $description" +} + +read_live_app_pid() { + local pid + + pid="$(cat "$STATE_DIR/app.pid" 2>/dev/null || true)" + [[ "$pid" =~ ^[0-9]+$ ]] || return 1 + kill -0 "$pid" 2>/dev/null || return 1 + printf '%s\n' "$pid" +} + +pid_file_is_live() { + read_live_app_pid >/dev/null +} + +handoff_was_recorded() { + [ -s "$HANDOFF_RESULT" ] +} + +launcher_lock_is_available() { + flock -n "$STATE_DIR/launcher.lock" true +} + +resident_policy_regressed() { + local marker_pid + + marker_pid="$(read_live_app_pid 2>/dev/null || true)" + if [ -n "$marker_pid" ] && [ "$marker_pid" != "$FIRST_ELECTRON_PID" ]; then + return 0 + fi + [ "$(count_test_main_processes)" -ne 1 ] +} + +mkdir -p \ + "$APP_DIR/.codex-linux/cold-start.d" \ + "$APP_DIR/.codex-linux/env.d" \ + "$APP_DIR/.codex-linux/features" \ + "$APP_DIR/.codex-linux/prelaunch.d" \ + "$APP_DIR/.codex-linux/electron-args.d" \ + "$APP_DIR/.codex-linux/launcher.d" \ + "$APP_DIR/.codex-linux/after-exit.d" \ + "$APP_DIR/content/webview" \ + "$APP_DIR/resources/node-runtime/bin" \ + "$HOME_DIR/.config/codex-desktop" \ + "$RUNTIME_DIR/codex-desktop" + +printf '%s\n' '{"codex-linux-warm-start-enabled":true}' \ + > "$HOME_DIR/.config/codex-desktop/settings.json" +PORT="$(python3 - <<'PY' +import socket +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +)" + +{ + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -Eeuo pipefail' \ + 'CODEX_LINUX_APP_ID=codex-desktop' \ + 'CODEX_LINUX_APP_DISPLAY_NAME="ChatGPT Desktop"' \ + 'CODEX_LINUX_WEBVIEW_PORT="${CODEX_WEBVIEW_PORT:-5175}"' + cat "$REPO_DIR/launcher/start.sh.template" +} > "$APP_DIR/start.sh" +chmod +x "$APP_DIR/start.sh" +if [ "${CODEX_TEST_FORCE_RESIDENT_REPLACEMENT:-0}" = "1" ] \ + && [ "${CODEX_TEST_MUTATION_CONTROL_ONLY:-0}" != "1" ]; then + python3 - "$APP_DIR/start.sh" <<'PY' +import sys + +path = sys.argv[1] +source = open(path, encoding="utf-8").read() +needle = "\nprepare_launch_state_under_lock\n" +mutation = r''' +prepare_launch_state_under_lock +if running_app_is_active; then + controlled_resident_pid="$RUNNING_APP_PID" + kill "$controlled_resident_pid" + for _ in $(seq 1 100); do + kill -0 "$controlled_resident_pid" 2>/dev/null || break + sleep 0.05 + done + rm -f "$APP_PID_FILE" "$LAUNCH_ACTION_SOCKET" + refresh_launch_state_quick +fi +''' +if source.count(needle) != 1: + raise SystemExit("unable to install controlled resident-replacement mutation") +open(path, "w", encoding="utf-8").write(source.replace(needle, "\n" + mutation, 1)) +PY +fi +cp "$REPO_DIR/launcher/webview-server.py" "$APP_DIR/.codex-linux/webview-server.py" +cp "$REPO_DIR/launcher/cli-launch-path.py" "$APP_DIR/.codex-linux/cli-launch-path.py" +ln -s "$(command -v node)" "$APP_DIR/resources/node-runtime/bin/node" +printf '%s\n' 'Codex
' \ + > "$APP_DIR/content/webview/index.html" + +g++ -x c++ -O2 -o "$APP_DIR/electron" - <<'CPP' +#include +#include + +static volatile sig_atomic_t running = 1; +static void stop(int) { running = 0; } + +int main() { + std::signal(SIGTERM, stop); + std::signal(SIGINT, stop); + while (running) pause(); + return 0; +} +CPP +cp "$APP_DIR/electron" "$TMP_DIR/decoy-electron" +"$TMP_DIR/decoy-electron" --app-id=codex-desktop & +DECOY_PID=$! + +if [ "$APPIMAGE_REOPEN" = "1" ]; then + cp -a "$APP_DIR" "$REMOUNT_APP_DIR" + cp -a "$APP_DIR" "$FOREIGN_APP_DIR" + touch "$APPIMAGE_PATH" "$FOREIGN_APPIMAGE_PATH" +fi + +COMMON_ENV=( + env -i + "PATH=$PATH" + "HOME=$HOME_DIR" + "XDG_RUNTIME_DIR=$RUNTIME_DIR" + "CODEX_CLI_PATH=$(command -v true)" + "CODEX_WEBVIEW_PORT=$PORT" +) +FIRST_APPIMAGE_ENV=() +SECOND_APPIMAGE_ENV=() +if [ "$APPIMAGE_REOPEN" = "1" ]; then + FIRST_APPIMAGE_ENV=("APPIMAGE=$APPIMAGE_PATH" "APPDIR=$APP_DIR") + SECOND_APPIMAGE_ENV=("APPIMAGE=$APPIMAGE_PATH" "APPDIR=$SECOND_APP_DIR") +fi + +"${COMMON_ENV[@]}" "${FIRST_APPIMAGE_ENV[@]}" "$APP_DIR/start.sh" > "$FIRST_LOG" 2>&1 & +LAUNCHER_PID=$! +wait_for "first Electron marker" pid_file_is_live +wait_for "first launcher lock release" launcher_lock_is_available +FIRST_ELECTRON_PID="$(read_live_app_pid)" +FIRST_WEBVIEW_PID="$(cat "$STATE_DIR/webview.pid" 2>/dev/null || true)" +[[ "$FIRST_WEBVIEW_PID" =~ ^[0-9]+$ ]] && kill -0 "$FIRST_WEBVIEW_PID" 2>/dev/null \ + || fail "first packaged webview server is not live" + +python3 - "$SOCKET_PATH" "$HANDOFF_RESULT" <<'PY' & +import json +import os +import socket +import sys + +socket_path, result_path = sys.argv[1:] +os.makedirs(os.path.dirname(socket_path), exist_ok=True) +try: + os.unlink(socket_path) +except FileNotFoundError: + pass + +with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server: + server.bind(socket_path) + server.listen() + server.settimeout(10) + client, _ = server.accept() + with client: + client.settimeout(2) + payload = client.recv(65536) + request = json.loads(payload.decode("utf-8").strip()) + with open(result_path, "w", encoding="utf-8") as result: + json.dump({"argv": request.get("argv", []), "status": "acknowledged"}, result) + client.sendall(b"ok\n") +PY +SOCKET_PID=$! +wait_for "controlled handoff socket" test -S "$SOCKET_PATH" + +if [ "$APPIMAGE_REOPEN" = "1" ]; then + set +e + timeout 8s "${COMMON_ENV[@]}" \ + "APPIMAGE=$FOREIGN_APPIMAGE_PATH" \ + "APPDIR=$FOREIGN_APP_DIR" \ + "XDG_CACHE_HOME=$FOREIGN_CACHE_DIR" \ + "$FOREIGN_APP_DIR/start.sh" --show > "$FOREIGN_LOG" 2>&1 + rc=$? + set -e + [ "$rc" -ne 0 ] && [ "$rc" -ne 124 ] \ + || fail "different AppImage launcher was not rejected safely (status $rc)" + [ ! -e "$HANDOFF_RESULT" ] \ + || fail "different AppImage reached the resident launch-action socket" + kill -0 "$FIRST_ELECTRON_PID" 2>/dev/null \ + || fail "different AppImage stopped the healthy resident Electron" + kill -0 "$FIRST_WEBVIEW_PID" 2>/dev/null \ + || fail "different AppImage stopped the resident webview server" + [ "$(cat "$STATE_DIR/app.pid" 2>/dev/null || true)" = "$FIRST_ELECTRON_PID" ] \ + || fail "different AppImage changed the resident app marker" + [ "$(cat "$STATE_DIR/webview.pid" 2>/dev/null || true)" = "$FIRST_WEBVIEW_PID" ] \ + || fail "different AppImage changed the resident webview marker" + [ -S "$SOCKET_PATH" ] && kill -0 "$SOCKET_PID" 2>/dev/null \ + || fail "different AppImage removed the resident launch-action socket" + grep -Fq "Foreign ChatGPT Desktop process:" "$FOREIGN_APP_LOG" \ + || fail "different AppImage rejection did not report the cross-install conflict" +fi + +if [ "${CODEX_TEST_FORCE_RESIDENT_REPLACEMENT:-0}" = "1" ]; then + "${COMMON_ENV[@]}" "${SECOND_APPIMAGE_ENV[@]}" \ + "$SECOND_APP_DIR/start.sh" "$SECOND_LAUNCH_ARG" > "$SECOND_LOG" 2>&1 & + SECOND_LAUNCHER_PID=$! + if [ "${CODEX_TEST_MUTATION_CONTROL_ONLY:-0}" = "1" ]; then + set +e + wait "$SECOND_LAUNCHER_PID" + rc=$? + set -e + SECOND_LAUNCHER_PID="" + [ "$rc" -eq 0 ] \ + || fail "mutation control launcher invocation failed (status $rc)" + wait_for "mutation control handoff acknowledgement" handoff_was_recorded + HANDOFF_STATUS="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["status"])' "$HANDOFF_RESULT")" + FINAL_ELECTRON_PID="$(read_live_app_pid)" + [ "$FINAL_ELECTRON_PID" = "$FIRST_ELECTRON_PID" ] \ + || fail "mutation control changed the healthy resident PID" + [ "$(count_test_main_processes)" -eq 1 ] \ + || fail "mutation control did not preserve exactly one controlled Electron process" + [ "$HANDOFF_STATUS" = "acknowledged" ] \ + || fail "mutation control handoff was not acknowledged" + record_result "mutation-control-preserved" + printf '%s\n' 'launcher window-reopen behavior mutation control passed' + exit 0 + fi + wait_for "unconditional resident replacement regression" resident_policy_regressed + mutation_detected +fi + +set +e +timeout 8s "${COMMON_ENV[@]}" "${SECOND_APPIMAGE_ENV[@]}" \ + "$SECOND_APP_DIR/start.sh" "$SECOND_LAUNCH_ARG" > "$SECOND_LOG" 2>&1 +rc=$? +set -e +if [ "$rc" -ne 0 ]; then + if [ "$rc" -eq 124 ]; then + TIMEOUT_STATUS="true" + fi + fail "second launcher invocation did not complete successfully (status $rc)" +fi + +wait_for "reopen handoff acknowledgement" handoff_was_recorded +HANDOFF_STATUS="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["status"])' "$HANDOFF_RESULT")" +FINAL_ELECTRON_PID="$(read_live_app_pid)" + +[ "$FINAL_ELECTRON_PID" = "$FIRST_ELECTRON_PID" ] \ + || fail "healthy resident PID changed from $FIRST_ELECTRON_PID to $FINAL_ELECTRON_PID" +kill -0 "$FIRST_ELECTRON_PID" 2>/dev/null \ + || fail "healthy resident Electron did not survive reopen handoff" +[ "$(cat "$STATE_DIR/app.pid")" = "$FIRST_ELECTRON_PID" ] \ + || fail "runtime marker no longer identifies the healthy resident" +[ "$HANDOFF_STATUS" = "acknowledged" ] \ + || fail "controlled resident did not acknowledge the reopen handoff" +python3 - "$HANDOFF_RESULT" "$SECOND_LAUNCH_ARG" <<'PY' \ + || fail "reopen handoff did not preserve the $SECOND_LAUNCH_ARG argument" +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as result: + assert json.load(result)["argv"] == [sys.argv[2]] +PY +[ "$(count_test_main_processes)" -eq 1 ] \ + || fail "reopen handoff left more than one controlled Electron process" +[ -s "$STATE_DIR/webview.pid" ] \ + || fail "webview runtime marker disappeared during reopen handoff" +kill -0 "$DECOY_PID" 2>/dev/null \ + || fail "launcher signalled a decoy process outside the isolated app identity" +if grep -Eqi 'notify-send|zenity|could not safely|failed to' "$SECOND_LOG" "$APP_LOG" 2>/dev/null; then + ERROR_STATUS="true" + fail "reopen handoff emitted a user-visible error" +fi + +if [ "$APPIMAGE_REOPEN" = "1" ]; then + record_result "appimage-remount-preserved" + printf '%s\n' "launcher AppImage remount window-reopen behavior test passed" +else + record_result "preserved" + printf '%s\n' "launcher window-reopen behavior test passed" +fi diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index ce46a4a9e..6b3406b6f 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -163,7 +163,7 @@ JSON {"name":"browser","version":"0.1.0-alpha2","interface":{"category":"Engineering"}} JSON cat > "$resources_dir/plugins/openai-bundled/plugins/browser/scripts/browser-client.mjs" <<'JS' -import{env as Ub}from"node:process";function lu(e){let t=globalThis.nodeRepl?.env[e];return typeof t=="string"?t:void 0}function Me(){let e=globalThis.nodeRepl;return e?.config==null?void 0:e}function th(){let e=import.meta.__codexNativePipe;return e==null||typeof e.createConnection!="function"?null:e}var I2=new Set(["about:blank"]);function Gb(e){if(I2.has(e))return!0;let t;try{t=new URL(e)}catch{return!1}return t.protocol==="http:"||t.protocol==="https:"}class Uf{async fetchBlocked(e,t){let r=await bS(e.endpoint,{method:"GET"});if(!r.ok)throw new Error(ae(`${t} cannot determine if ${e.displayUrl} is allowed. Please try again later or use another source.`));let n=await r.json();return TF(n)}}var kE=t=>t==="win32"?"\\\\.\\pipe\\codex-browser-use":"/tmp/codex-browser-use";var Cb=kE(hV.platform()),EV=()=>_P()==="win32"?TV():CV(),CV=async()=>(await yP(Cb)).map(e=>wP.resolve(Cb,e)),TV=async()=>[];export function setupAtlasRuntime() {return Ub.XDG_CONFIG_HOME} +import{env as Ub}from"node:process";function lu(e){let t=globalThis.nodeRepl?.env[e];return typeof t=="string"?t:void 0}function Me(){let e=globalThis.nodeRepl;return e?.config==null?void 0:e}async function cM(e){let t=e.createElicitation.bind(e),r={...e,platform:`linux`,setResponseMeta:e.setResponseMeta,get requestMeta(){return e.requestMeta},async createElicitation(o){return await t(o)}},n=await $K(e,r);return n!=null&&(r.gaas=n),r}async function $K(){return null}function th(){let e=import.meta.__codexNativePipe;return e==null||typeof e.createConnection!="function"?null:e}var I2=new Set(["about:blank"]);function Gb(e){if(I2.has(e))return!0;let t;try{t=new URL(e)}catch{return!1}return t.protocol==="http:"||t.protocol==="https:"}class Uf{async fetchBlocked(e,t){let r=await bS(e.endpoint,{method:"GET"});if(!r.ok)throw new Error(ae(`${t} cannot determine if ${e.displayUrl} is allowed. Please try again later or use another source.`));let n=await r.json();return TF(n)}}var kE=t=>t==="win32"?"\\\\.\\pipe\\codex-browser-use":"/tmp/codex-browser-use";var Q6=e=>e.platform==="win32"?t4(e):e4(e),e4=async e=>{let t=kE(e.platform);return(await yP(t)).map(n=>wP.resolve(t,n))},t4=async e=>[];export function setupAtlasRuntime() {return Ub.XDG_CONFIG_HOME} JS } @@ -622,8 +622,10 @@ SCRIPT assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/scripts/lib/linux-target-context.js" assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/scripts/patches/descriptor.js" assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/scripts/patches/engine.js" + assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/scripts/patches/integrity-error.js" assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/scripts/patches/runner.js" assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/scripts/patches/lib/assets.js" + assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/scripts/patches/lib/composition-delegation.js" assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/scripts/patches/lib/minified-js.js" assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/scripts/patches/lib/settings-keys.js" assert_file_exists "$pkg_root/opt/codex-desktop/update-builder/scripts/patches/impl/webview/index.js" @@ -1429,6 +1431,7 @@ SCRIPT assert_contains "$capture_dir/PKGBUILD" "pkgver=2026.03.24.120000+manual" assert_contains "$capture_dir/PKGBUILD" "pkgrel=1" assert_contains "$capture_dir/PKGBUILD" "ampersand&tmp" + assert_contains "$capture_dir/PKGBUILD" "cp -a --no-preserve=ownership" assert_not_contains "$capture_dir/PKGBUILD" "__STAGING_DIR__" assert_contains "$capture_dir/PKGBUILD" "install=codex-desktop.install" assert_occurrence_count "$capture_dir/PKGBUILD" "'polkit'" "1" @@ -1535,7 +1538,7 @@ SCRIPT assert_file_not_exists "$capture_dir/AppDir/usr/lib/systemd/user/codex-update-manager.service" assert_file_not_exists "$capture_dir/AppDir/usr/share/polkit-1/actions/com.github.ilysenko.codex-desktop-linux.update.policy" assert_file_not_exists "$capture_dir/AppDir/opt/codex-desktop/update-builder" - assert_contains "$capture_dir/AppDir/codex-desktop.desktop" "Exec=AppRun %u" + assert_contains "$capture_dir/AppDir/codex-desktop.desktop" "Exec=AppRun --show %u" assert_contains "$capture_dir/AppDir/codex-desktop.desktop" "Icon=codex-desktop" assert_contains "$capture_dir/AppDir/codex-desktop.desktop" "Keywords=codex;openai;ai;coding;" assert_contains "$capture_dir/AppDir/codex-desktop.desktop" "X-AppImage-Version=2026.03.24.120000+appimage" @@ -1591,7 +1594,7 @@ SCRIPT assert_contains "$capture_dir/AppDir/AppRun" "resources/codex-cli/bin/codex" assert_contains "$capture_dir/AppDir/AppRun" "export CODEX_CLI_PATH" - printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\n" "${CODEX_CLI_PATH:-}"' > "$capture_dir/AppDir/opt/codex-desktop/start.sh" + printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\n" "${CODEX_CLI_PATH:-}" "$@"' > "$capture_dir/AppDir/opt/codex-desktop/start.sh" chmod 0755 "$capture_dir/AppDir/opt/codex-desktop/start.sh" local app_run_output local app_run_path @@ -1600,6 +1603,8 @@ SCRIPT [ "$app_run_output" = "$bundled_cli" ] || fail "Expected AppRun to select bundled Codex CLI: $app_run_output" app_run_output="$(env -i PATH="$app_run_path" HOME="$workspace/home" APPDIR="$capture_dir/AppDir" CODEX_CLI_PATH=/custom/codex "$BASH_BIN" "$capture_dir/AppDir/AppRun")" [ "$app_run_output" = "/custom/codex" ] || fail "Expected explicit CODEX_CLI_PATH to override bundled CLI: $app_run_output" + app_run_output="$(env -i PATH="$app_run_path" HOME="$workspace/home" APPDIR="$capture_dir/AppDir" "$BASH_BIN" "$capture_dir/AppDir/AppRun" --show)" + [ "$app_run_output" = "$bundled_cli"$'\n''--show' ] || fail "Expected AppRun to preserve the desktop --show action: $app_run_output" [ "$("$BASH_BIN" "$bundled_cli" --version)" = "v22.22.2" ] || fail "Expected bundled CLI wrapper to use the managed Node runtime" rm -rf "$platform_source" "$capture_dir" @@ -3947,7 +3952,9 @@ make_update_nix_hash_fixture() { local hash_a="sha256-VVQNu/E7Wuyxfsy93Gorknr0t7H7wy9kxMOiBZYOo/o=" mkdir -p "$fixture/scripts/ci" "$fixture/nix/native-modules" "$fixture/bin" + cp "$REPO_DIR/scripts/ci/download-upstream-dmg.sh" "$fixture/scripts/ci/download-upstream-dmg.sh" cp "$REPO_DIR/scripts/ci/update-nix-hashes.sh" "$fixture/scripts/ci/update-nix-hashes.sh" + chmod +x "$fixture/scripts/ci/download-upstream-dmg.sh" chmod +x "$fixture/scripts/ci/update-nix-hashes.sh" cat > "$fixture/flake.nix" < "$probe" + cat >> "$probe" <<'EOF' +GIO_LAUNCHED_DESKTOP_FILE=/home/user/.local/share/applications/chatgpt.desktop +GIO_LAUNCHED_DESKTOP_FILE_PID="$BASHPID" +capture_launched_desktop_entry +[ "$CODEX_LINUX_LAUNCHED_DESKTOP_ENTRY" = chatgpt ] || exit 2 + +GIO_LAUNCHED_DESKTOP_FILE=/usr/share/applications/org.gnome.Terminal.desktop +GIO_LAUNCHED_DESKTOP_FILE_PID="$((BASHPID + 1))" +capture_launched_desktop_entry +[ "${CODEX_LINUX_LAUNCHED_DESKTOP_ENTRY+x}" != x ] || exit 3 +EOF + + "$BASH_BIN" "$probe" || fail "Expected launcher to reject stale GIO desktop-entry metadata" +} + test_packaged_runtime_keeps_managed_node_out_of_user_service_path() { info "Checking packaged runtime exports the user PATH to user services" local workspace="$TMP_DIR/packaged-runtime-user-path" @@ -5466,6 +5497,7 @@ test_launcher_template_sanity() { assert_contains "$REPO_DIR/launcher/start.sh.template" "codex-browser-sidebar" assert_contains "$REPO_DIR/launcher/start.sh.template" "codex-linux-warm-start-enabled" assert_contains "$REPO_DIR/launcher/start.sh.template" "--new-instance" + assert_contains "$REPO_DIR/launcher/start.sh.template" "--show" assert_contains "$REPO_DIR/launcher/start.sh.template" "CODEX_MULTI_LAUNCH" assert_contains "$REPO_DIR/launcher/start.sh.template" "CODEX_MULTI_LAUNCH_PORT_RANGE" assert_contains "$REPO_DIR/launcher/start.sh.template" "choose_multi_launch_port" @@ -5523,6 +5555,10 @@ adopt_body = source.split("adopt_existing_webview_server() {", 1)[1].split("star ensure_body = source.split("start_webview_server() {", 1)[1].split("wait_for_webview_server", 1)[0] reconcile_body = source.split("reconcile_runtime_state() {", 1)[1].split("set_electron_defaults() {", 1)[0] match_executable_body = source.split("pid_matches_executable() {", 1)[1].split("find_running_app_pid() {", 1)[0] +match_appimage_body = source.split("pid_matches_appimage_install() {", 1)[1].split("pid_matches_app_install() {", 1)[0] +match_install_body = source.split("pid_matches_app_install() {", 1)[1].split("pid_matches_running_app() {", 1)[0] +match_running_body = source.split("pid_matches_running_app() {", 1)[1].split("pid_is_foreign_codex_electron() {", 1)[0] +find_running_body = source.split("find_running_app_pid() {", 1)[1].split("pid_in_same_launch_instance() {", 1)[0] arg0_path_body = source.split("pid_cmdline_arg0_path() {", 1)[1].split("pid_arg0_matches_path() {", 1)[0] arg0_match_body = source.split("pid_arg0_matches_path() {", 1)[1].split("pid_environ_lines() {", 1)[0] foreign_body = source.split("pid_is_foreign_codex_electron() {", 1)[1].split("discover_running_app_pid() {", 1)[0] @@ -5575,6 +5611,8 @@ if 'send_warm_start_launch_action "${LAUNCHER_ARGS[@]}"' not in source: raise SystemExit("warm-start handoff must not receive launcher-only multi-launch flags") if "client.shutdown(socket.SHUT_WR)" not in send_body or "response = client.recv(32)" not in send_body: raise SystemExit("warm-start IPC client must read the Electron socket acknowledgement") +if "running_app_is_active || return 1" not in send_body: + raise SystemExit("warm-start IPC must revalidate the shared resident identity before socket handoff") if 'launch_electron "${LAUNCHER_ARGS[@]}"' not in source: raise SystemExit("Electron launch must receive sanitized launcher args") if 'FEATURE_LAUNCHER_HOOK_DIR="$SCRIPT_DIR/.codex-linux/launcher.d"' not in source: @@ -5587,8 +5625,10 @@ if 'Adopted concurrently-started verified webview server' not in source: raise SystemExit("launcher must tolerate a concurrent verified webview server winning the bind race") if 'set_detected_running_app "$pid"' not in detect_body: raise SystemExit("detect_warm_start must record a pid-file running app even when warm start is disabled") -if 'runtime_recovery_scan_needed && pid="$(discover_running_app_pid)"' not in detect_body: +if 'if runtime_recovery_scan_needed; then' not in detect_body or 'pid="$(discover_running_app_pid)"' not in detect_body: raise SystemExit("detect_warm_start must limit the running-app scan to recovery cases") +if detect_body.index('pid="$(discover_running_app_pid)"') > detect_body.index("detect_cross_install_conflict"): + raise SystemExit("detect_warm_start must reject a foreign install only after same-install recovery discovery") if '[ -S "$LAUNCH_ACTION_SOCKET" ]' in detect_body: raise SystemExit("detect_warm_start must not gate the running-app scan on launch socket existence; hidden instances can lose the socket") if not re.search(r'if ! linux_setting_enabled "codex-linux-warm-start-enabled" 1; then.*?return 0', source, re.S): @@ -5605,7 +5645,7 @@ if "terminate_stale_electron_with_pidfd" not in warm_recovery_body: raise SystemExit("warm-start recovery must terminate only an identity-verified stale Electron") if "os.pidfd_open" not in terminate_body or "signal.pidfd_send_signal" not in terminate_body: raise SystemExit("stale Electron termination must bind signals to a pidfd") -for identity_guard in ("expected_start_time", "expected_executable", "expected_app_id", "expected_instance_id"): +for identity_guard in ("expected_start_time", "expected_executable", "expected_appimage", "expected_app_id", "expected_instance_id"): if identity_guard not in terminate_body: raise SystemExit(f"pidfd termination is missing identity guard: {identity_guard}") if 'running_app_is_active || return 0' not in warm_recovery_body or '[ "$WARM_START" -eq 1 ]' in warm_recovery_body: @@ -5616,7 +5656,7 @@ if not re.search(r'trap cleanup_launcher EXIT.*?recover_unhealthy_running_app.*? raise SystemExit("launcher must recover an unhealthy packaged origin before warm-start IPC") if launch_body.count("unset ELECTRON_RUN_AS_NODE") != 2: raise SystemExit("launch_electron must clear ELECTRON_RUN_AS_NODE before both Electron launch paths") -if 'pid_matches_executable "$RUNNING_APP_PID" "$SCRIPT_DIR/electron"' not in launch_body: +if 'pid_matches_running_app "$RUNNING_APP_PID"' not in launch_body: raise SystemExit("launch_electron must not overwrite APP_PID_FILE for second-instance handoff") if 'echo "$ELECTRON_PID" > "$APP_PID_FILE"' not in launch_body: raise SystemExit("launch_electron must still write APP_PID_FILE for normal cold launches") @@ -5634,6 +5674,20 @@ if "command -v timeout" in source or re.search(r'(^|[ \t])timeout[ \t]+"?\\$', s raise SystemExit("launcher hot path must not require external timeout") if match_executable_body.index('actual="$(pid_cmdline_arg0_path "$pid")"') > match_executable_body.index('pid_is_current_user "$pid"'): raise SystemExit("launcher process discovery must check cmdline arg0 before reading /proc status for UID") +if 'pid_matches_executable "$pid" "$SCRIPT_DIR/electron" || pid_matches_appimage_install "$pid"' not in match_install_body: + raise SystemExit("resident install matching must keep exact executable identity before the AppImage fallback") +if 'pid_environ_value "$pid" APPIMAGE' not in match_appimage_body or '[ "$resident_appimage" = "$APPIMAGE" ]' not in match_appimage_body: + raise SystemExit("resident install matching must use the stable AppImage path across transient remounts") +native_fast_path = 'if pid_matches_executable "$pid" "$SCRIPT_DIR/electron"; then\n return 0\n fi' +if native_fast_path not in match_running_body or match_running_body.index(native_fast_path) > match_running_body.index("pid_matches_appimage_install"): + raise SystemExit("native resident matching must return before AppImage and additional environment reads") +for identity_guard in ("pid_matches_app_identity", "pid_in_same_launch_instance"): + if identity_guard not in match_running_body: + raise SystemExit(f"resident matching is missing shared identity guard: {identity_guard}") +if "pid_matches_running_app" not in find_running_body: + raise SystemExit("app.pid discovery must use the shared resident identity contract") +if '! pid_matches_app_install "$pid"' not in foreign_body: + raise SystemExit("cross-install detection must exclude AppImage remounts from foreign residents") if 'basename "$actual"' in foreign_body: raise SystemExit("foreign Electron detection must not fork basename for every /proc candidate") if 'readlink "/proc/$pid/cwd"' in summary_body: @@ -5819,8 +5873,8 @@ if 'clear_stale_pid_file' not in reconcile_body: if 'if [ -z "$webview_pid" ] || { ! pid_is_webview_server "$webview_pid" && ! pid_is_stale_webview_server "$webview_pid"; }; then' not in reconcile_body: raise SystemExit("reconcile_runtime_state must clear stale launcher webview ownership markers without touching valid orphaned servers") discover_body = source.split("discover_running_app_pid() {", 1)[1].split("running_app_is_active() {", 1)[0] -if 'pid_in_same_launch_instance "$pid"' not in discover_body: - raise SystemExit("discover_running_app_pid must filter by launch instance so default and side-by-side apps never adopt each other") +if 'pid_matches_running_app "$pid"' not in discover_body or 'pid_in_same_launch_instance "$pid"' not in discover_body: + raise SystemExit("discover_running_app_pid must use the shared resident identity contract") instance_match_body = source.split("pid_in_same_launch_instance() {", 1)[1].split("discover_running_app_pid() {", 1)[0] if 'CODEX_LINUX_INSTANCE_ID=$CODEX_LINUX_INSTANCE_ID' not in instance_match_body or 'CODEX_LINUX_MULTI_LAUNCH=1' not in instance_match_body: raise SystemExit("pid_in_same_launch_instance must match instance identity from the process environment") @@ -6367,7 +6421,8 @@ EOF assert_contains "$REPO_DIR/launcher/start.sh.template" "make_tree_owner_trusted" assert_contains "$REPO_DIR/launcher/start.sh.template" "clear_bundled_marketplace_tmp_cache" assert_not_contains "$REPO_DIR/launcher/start.sh.template" "monitor_bundled_marketplace_tmp_permissions" - assert_contains "$REPO_DIR/launcher/start.sh.template" "extension-id.json" + assert_contains "$REPO_DIR/launcher/start.sh.template" "extension-ids.json" + assert_not_contains "$REPO_DIR/launcher/start.sh.template" 'scripts_dir / "extension-id.json"' assert_contains "$REPO_DIR/launcher/start.sh.template" ".config/BraveSoftware/Brave-Browser/NativeMessagingHosts" assert_contains "$REPO_DIR/launcher/start.sh.template" ".config/chromium/NativeMessagingHosts" assert_contains "$REPO_DIR/launcher/start.sh.template" "scripts/check-extension-installed.js" @@ -6489,11 +6544,17 @@ assertCacheLinks({ const chromeBody = functionBody("sync_chrome_bundled_plugin_cache", "sync_computer_use_bundled_plugin_cache"); for (const required of [ 'make_path_owner_trusted', + 'cache_root="$codex_home/plugins/linux-runtime-cache/openai-bundled/chrome"', 'path_has_unsafe_write', 'tree_has_unsafe_write "$cache_plugin"', 'cache_was_untrusted=1', 'make_tree_owner_trusted "$tmp_plugin"', 'make_tree_owner_trusted "$cache_plugin"', + 'managed_cache_root="$codex_home/plugins/cache/openai-bundled/chrome"', + 'managed_backup_plugin="$managed_cache_root/.chrome-$version.linux-backup.$$"', + 'mv -T -- "$managed_cache_plugin" "$managed_backup_plugin"', + 'previous cache was restored', + 'Chrome app-server cache refreshed from bundled resources', 'write_chrome_native_host_manifests "$host_path" "$cache_root/latest"', ]) { if (!chromeBody.includes(required)) { @@ -6563,7 +6624,8 @@ SCRIPT_DIR="$root/app" HOME="$root/home" CODEX_HOME="$HOME/.codex" source_plugin="$SCRIPT_DIR/resources/plugins/openai-bundled/plugins/chrome" -cache_root="$CODEX_HOME/plugins/cache/openai-bundled/chrome" +source_client="$source_plugin/scripts/browser-client.mjs" +cache_root="$CODEX_HOME/plugins/linux-runtime-cache/openai-bundled/chrome" cache_plugin="$cache_root/26.test" bundled_plugin_version() { printf '%s\n' 26.test; } @@ -6616,19 +6678,68 @@ chmod 0755 "$cache_root/native-host" # Simulate a cache and relevant ancestor created under umask 0002. The four # files used by the old partial comparison still match, while an imported # module that was not compared has been changed. -chmod 775 "$CODEX_HOME" "$CODEX_HOME/plugins" "$CODEX_HOME/plugins/cache" \ - "$CODEX_HOME/plugins/cache/openai-bundled" "$cache_root" "$cache_plugin" +chmod 775 "$CODEX_HOME" "$CODEX_HOME/plugins" \ + "$CODEX_HOME/plugins/linux-runtime-cache" \ + "$CODEX_HOME/plugins/linux-runtime-cache/openai-bundled" \ + "$cache_root" "$cache_plugin" chmod 664 "$cache_plugin/scripts/node_modules/classic-level.mjs" chmod -R go-w "$SCRIPT_DIR" +official_cache="$CODEX_HOME/plugins/cache/openai-bundled/chrome" +official_plugin="$official_cache/26.test" +official_host="$official_plugin/extension-host/linux/x64/extension-host" +official_client="$official_plugin/scripts/browser-client.mjs" +mkdir -p "$(dirname "$official_host")" "$(dirname "$official_client")" +cat > "$official_host" <<'HOST' +#!/usr/bin/env bash +printf '%s\n' OFFICIAL +HOST +printf '%s\n' stale-client > "$official_client" +chmod 0755 "$official_host" +ln -s 26.test "$official_cache/latest" +chmod 0775 \ + "$CODEX_HOME/plugins/cache" \ + "$CODEX_HOME/plugins/cache/openai-bundled" \ + "$official_cache" \ + "$official_plugin" \ + "$official_plugin/extension-host" \ + "$official_plugin/extension-host/linux" \ + "$official_plugin/extension-host/linux/x64" + sync_chrome_bundled_plugin_cache grep -qx trusted-module "$cache_plugin/scripts/node_modules/classic-level.mjs" +grep -qx trusted-client "$official_client" + +# A failed promotion must restore the previous app-server-owned cache and must +# not abort the cold-start sync under set -e. +printf '%s\n' replacement-client > "$source_client" +mv() { + local args=("$@") + local argc="${#args[@]}" + local source="${args[$((argc - 2))]}" + local destination="${args[$((argc - 1))]}" + if [[ "$source" == *".linux-refresh."* ]] && [ "$destination" = "$official_plugin" ]; then + return 73 + fi + command mv "$@" +} +sync_chrome_bundled_plugin_cache > "$root/managed-cache-promotion-failure.log" 2>&1 +grep -qx trusted-client "$official_client" +grep -q "previous cache was restored" "$root/managed-cache-promotion-failure.log" +if find "$official_cache" -mindepth 1 -maxdepth 1 -type d \ + \( -name '*.linux-refresh.*' -o -name '*.linux-backup.*' \) -print -quit | grep -q .; then + echo "Chrome app-server cache refresh left temporary or backup directories after restoration" >&2 + exit 1 +fi +unset -f mv +printf '%s\n' trusted-client > "$source_client" + for trusted_path in \ "$CODEX_HOME" \ "$CODEX_HOME/plugins" \ - "$CODEX_HOME/plugins/cache" \ - "$CODEX_HOME/plugins/cache/openai-bundled" \ + "$CODEX_HOME/plugins/linux-runtime-cache" \ + "$CODEX_HOME/plugins/linux-runtime-cache/openai-bundled" \ "$cache_root"; do if find "$trusted_path" -maxdepth 0 ! -type l -perm /022 -print -quit | grep -q .; then echo "Chrome cache ancestor remained group/world writable: $trusted_path" >&2 @@ -6639,8 +6750,22 @@ if find "$cache_plugin" ! -type l -perm /022 -print -quit | grep -q .; then echo "Chrome plugin cache remained group/world writable" >&2 exit 1 fi +if ! find "$official_plugin" -maxdepth 0 -perm /022 -print -quit | grep -q .; then + echo "Launcher unexpectedly blessed the app-server-owned Chrome cache" >&2 + exit 1 +fi test -L "$cache_root/latest" test "$(readlink "$cache_root/latest")" = 26.test +marketplace_plugin="$CODEX_HOME/.tmp/bundled-marketplaces/openai-bundled/plugins/chrome" +test -L "$marketplace_plugin" +test "$(readlink "$marketplace_plugin")" = "$cache_root/latest" + +# app-server owns and replaces the official install cache. That operation +# must not consume the Linux marketplace source or native-host runtime. +printf '%s\n' stale > "$official_cache/stale" +rm -rf "$official_cache" +test -f "$marketplace_plugin/.codex-plugin/plugin.json" +test -x "$cache_root/native-host" grep -qx untouched "$root/predictable-temp-target" if grep -q PRESEEDED_PAYLOAD "$cache_root/native-host"; then echo "Chrome native host launcher retained a pre-seeded executable" >&2 @@ -6857,6 +6982,23 @@ proxy_output="$(env -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \ PATH="$no_setsid_bin" "$native_host_path")" test "$proxy_output" = 'ARCH=x64' test ! -e "$probe_called_file" + +# The app-server registry is patched to reference the trusted Linux runtime +# cache. An installed-cache executable must never override the wrapper target. +official_plugin="$CODEX_HOME/plugins/cache/openai-bundled/chrome/26.test" +official_host="$official_plugin/extension-host/linux/x64/extension-host" +mkdir -p "$(dirname "$official_host")" +cat > "$official_host" <<'HOST' +#!/usr/bin/env bash +printf '%s\n' OFFICIAL +HOST +chmod 0755 "$official_host" +ln -s 26.test "$CODEX_HOME/plugins/cache/openai-bundled/chrome/latest" +chmod -R go-w "$CODEX_HOME/plugins/cache" +official_output="$(env -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \ + -u http_proxy -u https_proxy -u all_proxy -u NO_PROXY -u no_proxy \ + PATH="$no_setsid_bin" "$native_host_path")" +test "$official_output" = 'ARCH=x64' ''' ) PY @@ -7900,6 +8042,7 @@ test_browser_plugin_renamed_upstream_staging() { assert_not_contains "$browser_dir/scripts/browser-client.mjs" 'globalThis.nodeRepl?.env\[e\]' assert_contains "$browser_dir/scripts/browser-client.mjs" "codexLinuxBrowserUseDefineNodeReplMethod" assert_contains "$browser_dir/scripts/browser-client.mjs" "addAfterSubmittedCodeHook" + assert_contains "$browser_dir/scripts/browser-client.mjs" "codexLinuxBrowserUseRuntimeCloneShim" assert_contains "$browser_dir/scripts/browser-client.mjs" "nativePipe??import.meta.__codexNativePipe" assert_not_contains "$browser_dir/scripts/browser-client.mjs" "let e=import.meta.__codexNativePipe;return" assert_contains "$browser_dir/scripts/browser-client.mjs" "codexLinuxSiteStatusAllowlistFallback" @@ -8552,14 +8695,16 @@ JS Use the browser bound to `browser` for tasks in this skill. MD - cat > "$chrome_dir/scripts/extension-id.json" <<'JSON' -{"extensionId":"hehggadaopoacecdllhhajmbjkdcmajg","extensionHostName":"com.openai.codexextension"} + cat > "$chrome_dir/scripts/extension-ids.json" <<'JSON' +{"extensionIds":["hehggadaopoacecdllhhajmbjkdcmajg"],"extensionHostName":"com.openai.codexextension"} JSON cat > "$chrome_dir/scripts/browser-client.mjs" <<'JS' const browserPreference={};function preferredWindowIdFor(){}function getForUrl(){}const extensionInstanceId=null; -var kE=t=>t==="win32"?"\\\\.\\pipe\\codex-browser-use":"/tmp/codex-browser-use";var Cb=kE(hV.platform()),EV=()=>_P()==="win32"?TV():CV(),CV=async()=>(await yP(Cb)).map(e=>wP.resolve(Cb,e)),TV=async()=>[]; +var kE=t=>t==="win32"?"\\\\.\\pipe\\codex-browser-use":"/tmp/codex-browser-use";var Q6=e=>e.platform==="win32"?t4(e):e4(e),e4=async e=>{let t=kE(e.platform);return(await yP(t)).map(n=>wP.resolve(t,n))},t4=async e=>[]; function lu(e){let t=globalThis.nodeRepl?.env[e];return typeof t=="string"?t:void 0} function Me(){let e=globalThis.nodeRepl;return e?.config==null?void 0:e} +async function cM(e){let t=e.createElicitation.bind(e),r={...e,platform:`linux`,setResponseMeta:e.setResponseMeta,get requestMeta(){return e.requestMeta},async createElicitation(o){return await t(o)}},n=await $K(e,r);return n!=null&&(r.gaas=n),r} +async function $K(){return null} import{platform as yT}from"node:os";import{env as Ub}from"node:process";function eh(){return"privileged native pipe bridge is not available; browser-client is not trusted"}function th(){let e=globalThis.nodeRepl?.nativePipe;return e==null||typeof e.createConnection!="function"?null:e}var ml=class e{constructor(t){this.socket=t}static async create(t){let r=th();if(r!=null){let n=await r.createConnection(t);return new e(n)}throw new Error(eh())}};var chromeConfigHome=Ub.CHROME_CONFIG_HOME; async fetchBlocked(e,t){let r=await bS(e.endpoint,{method:"GET"});if(!r.ok)throw new Error(ae(`${t} cannot determine if ${e.displayUrl} is allowed. Please try again later or use another source.`));let n=await r.json();return TF(n)} JS @@ -8754,6 +8899,7 @@ test_chrome_plugin_staging() { assert_contains "$chrome_dir/scripts/browser-client.mjs" 'Object.defineProperty(prototype, "config"' assert_contains "$chrome_dir/scripts/browser-client.mjs" "codexLinuxBrowserUseDefineNodeReplMethod" assert_contains "$chrome_dir/scripts/browser-client.mjs" "addAfterSubmittedCodeHook" + assert_contains "$chrome_dir/scripts/browser-client.mjs" "codexLinuxBrowserUseRuntimeCloneShim" assert_contains "$chrome_dir/scripts/browser-client.mjs" "codexLinuxBrowserUseConfigShim();let e=globalThis.nodeRepl" assert_contains "$chrome_dir/scripts/browser-client.mjs" "nativePipe??import.meta.__codexNativePipe" assert_not_contains "$chrome_dir/scripts/browser-client.mjs" "codexLinuxNativePipeFallback" @@ -8841,8 +8987,8 @@ test_chrome_native_host_manifest_writer() { mkdir -p "$plugin_dir/scripts" "$home_dir" "$app_dir/.codex-linux" "$(dirname "$host_path")" printf '#!/bin/sh\n' > "$host_path" chmod +x "$host_path" - cat > "$plugin_dir/scripts/extension-id.json" <<'JSON' -{"extensionId":"abcdefghijklmnopabcdefghijklmnop","extensionHostName":"com.example.codextest"} + cat > "$plugin_dir/scripts/extension-ids.json" <<'JSON' +{"extensionIds":["abcdefghijklmnopabcdefghijklmnop"],"extensionHostName":"com.example.codextest"} JSON printf '%s\n' ".config/example-browser/NativeMessagingHosts" > "$app_dir/.codex-linux/chrome-native-host-manifest-paths" @@ -8920,7 +9066,20 @@ test_linux_file_manager_patch_smoke() { node "$REPO_DIR/scripts/patch-linux-window-ui.js" "$extracted" >"$output_log" 2>&1 assert_contains "$extracted/.vite/build/main-test.js" 'detect:()=>`linux-file-manager`' assert_contains "$extracted/.vite/build/main-test.js" 'linux:{label:`File Manager`' - assert_contains "$extracted/.vite/build/main-test.js" 'process.platform===`linux`&&(D.on(`system-context-menu`,e=>e.preventDefault()),D.removeMenu()),process.platform===`win32`&&D.removeMenu(),' + node - "$extracted/.vite/build/main-test.js" <<'NODE' +const fs = require("node:fs"); +const source = fs.readFileSync(process.argv[2], "utf8"); +const expected = + "process.platform===`linux`&&" + + "(process.env.XDG_SESSION_TYPE??``).trim().toLowerCase()===`x11`&&" + + "/(^|:)gnome(:|$)/i.test((process.env.XDG_CURRENT_DESKTOP??``).trim())&&" + + "D.on(`system-context-menu`,e=>e.preventDefault())," + + "process.platform===`linux`&&D.removeMenu()," + + "process.platform===`win32`&&D.removeMenu(),"; +if (!source.includes(expected)) { + throw new Error("Expected the generic window listener to be scoped to GNOME/X11"); +} +NODE assert_not_contains "$extracted/.vite/build/main-test.js" 'D.setMenuBarVisibility(!1)' assert_contains "$extracted/.vite/build/main-test.js" '&&D.setIcon(' assert_contains "$extracted/webview/assets/app-initial-test.js" '`subAgent`in e?e.subAgent:`subagent`in e?e.subagent:null' @@ -8933,6 +9092,90 @@ test_linux_file_manager_patch_smoke() { assert_not_contains "$output_log" 'Failed to apply Linux File Manager Patch' } +test_linux_titlebar_context_menu_patch_smoke() { + info "Checking managed-window Linux titlebar context-menu patch behavior" + local workspace="$TMP_DIR/titlebar-context-menu-patch" + local extracted="$workspace/extracted" + local first_report="$workspace/first-report.json" + local second_report="$workspace/second-report.json" + local output_log="$workspace/output.log" + local first_hash + local second_hash + local bundle_body + + mkdir -p "$workspace" + bundle_body='const electron=require(`electron`);class WindowManager{registerWindow(){}async createWindow(e={}){let t=process.platform===`win32`&&(e.appearance??`primary`)===`primary`?electron.screen.getPrimaryDisplay().workArea:null,{appearance:o=`primary`}=e,N=new electron.BrowserWindow({});(process.platform===`win32`||process.platform===`linux`)&&N.removeMenu(),this.registerWindow(N,0,!0,o,`register`);host.on(`did-create-window`,()=>{let e=new electron.BrowserWindow({});process.platform===`win32`&&e.removeMenu(),e.show()});return N}}' + make_fake_extracted_asar "$extracted" "$bundle_body" + + node "$REPO_DIR/scripts/patch-linux-window-ui.js" \ + --report-json "$first_report" \ + "$extracted" >"$output_log" 2>&1 + assert_occurrence_count \ + "$extracted/.vite/build/main-test.js" \ + 'system-context-menu' \ + '2' + node - "$extracted/.vite/build/main-test.js" <<'NODE' +const fs = require("node:fs"); +const source = fs.readFileSync(process.argv[2], "utf8"); +const expected = + "process.platform===`linux`&&" + + "(process.env.XDG_SESSION_TYPE??``).trim().toLowerCase()===`x11`&&" + + "/(^|:)gnome(:|$)/i.test((process.env.XDG_CURRENT_DESKTOP??``).trim())&&" + + "N.on(`system-context-menu`,e=>e.preventDefault())," + + "(process.platform===`win32`||process.platform===`linux`)&&N.removeMenu(),"; +if (!source.includes(expected)) { + throw new Error("Expected the managed-window listener to be scoped to GNOME/X11"); +} +const popupExpected = + "process.platform===`linux`&&" + + "(process.env.XDG_SESSION_TYPE??``).trim().toLowerCase()===`x11`&&" + + "/(^|:)gnome(:|$)/i.test((process.env.XDG_CURRENT_DESKTOP??``).trim())&&" + + "e.on(`system-context-menu`,e=>e.preventDefault())," + + "process.platform===`linux`&&e.removeMenu()," + + "process.platform===`win32`&&e.removeMenu(),"; +if (!source.includes(popupExpected)) { + throw new Error("Expected the popup listener to be scoped to GNOME/X11"); +} +NODE + node - "$first_report" <<'NODE' +const fs = require("node:fs"); +const report = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const entry = report.patches.find( + (patch) => patch.name === "linux-managed-window-system-context-menu", +); +if (entry?.status !== "applied") { + throw new Error(`Expected managed-window patch to be applied, got ${entry?.status}`); +} +if (entry?.ciPolicy !== "optional") { + throw new Error(`Expected managed-window patch to be optional, got ${entry?.ciPolicy}`); +} +if ( + JSON.stringify(entry.strategies) !== + JSON.stringify([{ group: "linux-managed-window-menu", strategy: "upstream-combined" }]) +) { + throw new Error(`Unexpected managed-window strategy: ${JSON.stringify(entry?.strategies)}`); +} +NODE + + first_hash="$(sha256sum "$extracted/.vite/build/main-test.js" | awk '{print $1}')" + node "$REPO_DIR/scripts/patch-linux-window-ui.js" \ + --report-json "$second_report" \ + "$extracted" >"$output_log" 2>&1 + second_hash="$(sha256sum "$extracted/.vite/build/main-test.js" | awk '{print $1}')" + [ "$second_hash" = "$first_hash" ] \ + || fail "Expected second titlebar context-menu patch pass to preserve the main bundle hash" + node - "$second_report" <<'NODE' +const fs = require("node:fs"); +const report = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const entry = report.patches.find( + (patch) => patch.name === "linux-managed-window-system-context-menu", +); +if (entry?.status !== "already-applied") { + throw new Error(`Expected managed-window patch to be idempotent, got ${entry?.status}`); +} +NODE +} + test_linux_translucent_sidebar_default_patch_smoke() { info "Checking Linux translucent sidebar default patch behavior" local workspace="$TMP_DIR/translucent-sidebar-patch" @@ -9101,7 +9344,7 @@ const x={o:e=>e};let s=require(`node:url`),n=require(`electron`);n=x.o(n);let l= var pb=class{getNativeTrayMenuItems(){return[{label:this.systemQuitMenuItemLabel,click:()=>{n.app.quit()}}]}}; function qB(r,o){if(o.type===`quit-app`){n.app.quit();return}return o} n.app.on(`before-quit`,o=>{let s=BI(),c=t.sr().some(e=>e.status===`ACTIVE`);if(e||i.canQuitWithoutPrompt()||r||!s&&!c){g=!0,a.markAppQuitting();return}let l=n.app.getName();if(n.dialog.showMessageBoxSync({type:`warning`,buttons:[`Quit`,`Cancel`],defaultId:0,cancelId:1,noLink:!0,title:`Quit ${l}?`,message:`Quit ${l}?`,detail:vB({hasInProgressLocalConversation:s,hasEnabledAutomations:c})})!==0){o.preventDefault();return}i.markQuitApproved(),g=!0,a.markAppQuitting()}); -n.app.on(`will-quit`,e=>{if(g=!0,!h){if(i.shouldSkipDrainBeforeQuit()){mB({hotkeyWindowLifecycleManager:c,globalDictationLifecycleManager:l,flushAndDisposeContexts:d,disposables:f});return}e.preventDefault(),h=!0,c.dispose(),l.dispose(),Promise.all([u.flush(),p.flush()]).finally(()=>{d(),f.dispose(),n.app.quit()})}}); +l.app.on(`will-quit`,e=>{if(y=!0,v)return;let t=()=>{U5(h,N5).then(()=>{g.dispose(),l.app.quit()})};if(r.shouldSkipDrainBeforeQuit()){e.preventDefault(),v=!0,c.dispose(),u.dispose(),Promise.allSettled([d.flush(),p(),m()]).then(t);return}e.preventDefault(),v=!0,c.dispose(),u.dispose(),Promise.allSettled([d.flush(),f.flush(),p(),m()]).then(t)}); JS )" make_fake_extracted_asar "$extracted" "$bundle_body" @@ -9113,9 +9356,20 @@ JS assert_contains "$extracted/.vite/build/main-test.js" 'if(o.type===`quit-app`){typeof codexLinuxPrepareForExplicitQuit===`function`?codexLinuxPrepareForExplicitQuit():typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress(),n.app.quit();return}' assert_contains "$extracted/.vite/build/main-test.js" 'if((typeof codexLinuxShouldBypassQuitPrompt===`function`&&codexLinuxShouldBypassQuitPrompt())||e||i.canQuitWithoutPrompt()||r||!s&&!c){process.platform===`linux`&&typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress(),g=!0,a.markAppQuitting();return}' assert_contains "$extracted/.vite/build/main-test.js" 'process.platform===`linux`&&typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress(),i.markQuitApproved(),g=!0,a.markAppQuitting()' - assert_contains "$extracted/.vite/build/main-test.js" 'codexLinuxFinalizeQuit=()=>{d(),f.dispose(),n.app.quit()},codexLinuxDrainPromise=Promise.all(' + assert_contains "$extracted/.vite/build/main-test.js" 'codexLinuxLogQuitDrainResults=e=>{' + assert_contains "$extracted/.vite/build/main-test.js" 'codexLinuxFinalizeQuit=()=>{' + assert_not_contains "$extracted/.vite/build/main-test.js" 'codexLinuxQuitFinalized' + assert_contains "$extracted/.vite/build/main-test.js" 'WARN: Linux quit drain cleanup failed' + assert_contains "$extracted/.vite/build/main-test.js" 'WARN: Linux quit context cleanup failed' + assert_contains "$extracted/.vite/build/main-test.js" 'WARN: Linux quit cleanup failed' + assert_contains "$extracted/.vite/build/main-test.js" 'WARN: Linux quit disposables cleanup failed' + assert_contains "$extracted/.vite/build/main-test.js" 'finally{l.app.exit(0)}' + assert_not_contains "$extracted/.vite/build/main-test.js" 'finally{l.app.quit()}' + assert_occurrence_count "$extracted/.vite/build/main-test.js" 'codexLinuxRunQuitCleanup(()=>{' '2' + assert_contains "$extracted/.vite/build/main-test.js" 'Promise.resolve().then(e).then(codexLinuxLogQuitDrainResults).catch' + assert_contains "$extracted/.vite/build/main-test.js" '.then(()=>Promise.resolve().then(()=>U5(h,N5)).catch' assert_contains "$extracted/.vite/build/main-test.js" 'codexLinuxExplicitQuitDrainTimeoutMs' - assert_contains "$extracted/.vite/build/main-test.js" 'setTimeout(e,typeof codexLinuxExplicitQuitDrainTimeoutMs' + assert_contains "$extracted/.vite/build/main-test.js" 'setTimeout(()=>e(Error(`Linux quit cleanup timed out`)),typeof codexLinuxExplicitQuitDrainTimeoutMs' assert_not_contains "$extracted/.vite/build/main-test.js" '\`number\`' assert_not_contains "$output_log" 'WARN: Could not find tray quit menu handler' assert_not_contains "$output_log" 'WARN: Could not find quit-app IPC handler' @@ -9219,7 +9473,9 @@ NODE assert_occurrence_count "$extracted/.vite/build/main-test.js" 'codexLinuxShouldBypassQuitPrompt=()=>codexLinuxExplicitQuitApproved===!0' '1' assert_occurrence_count "$extracted/.vite/build/main-test.js" 'typeof codexLinuxPrepareForExplicitQuit===`function`?codexLinuxPrepareForExplicitQuit():typeof codexLinuxMarkQuitInProgress===`function`&&codexLinuxMarkQuitInProgress()' '2' assert_occurrence_count "$extracted/.vite/build/main-test.js" 'typeof codexLinuxShouldBypassQuitPrompt===`function`&&codexLinuxShouldBypassQuitPrompt()' '1' - assert_occurrence_count "$extracted/.vite/build/main-test.js" 'codexLinuxDrainPromise=Promise.all(' '1' + assert_occurrence_count "$extracted/.vite/build/main-test.js" 'codexLinuxLogQuitDrainResults=e=>{' '1' + assert_occurrence_count "$extracted/.vite/build/main-test.js" 'codexLinuxFinalizeQuit=()=>{' '1' + assert_occurrence_count "$extracted/.vite/build/main-test.js" 'codexLinuxRunQuitCleanup(()=>{' '2' } test_keybinds_settings_tab_patch_smoke() { @@ -9239,6 +9495,9 @@ import{t as d}from"./jsx-runtime-test.js";var c={"general-settings":{id:`setting JS cat > "$extracted/webview/assets/use-visible-settings-sections-test.js" <<'JS' var Xge={"general-settings":xh,"keyboard-shortcuts":ks,appearance:Pf,agent:gU};function n_e(){let e=e=>{switch(e.slug){case`general-settings`:case`agent`:case`personalization`:return!0;case`keyboard-shortcuts`:return!0}}} +JS + cat > "$extracted/webview/assets/settings-page-test.js" <<'JS' +var nn=`general-settings.import.profile.appearance.keyboard-shortcuts`.split(`.`),rn=[{key:`personal`,heading:d({id:`settings.nav.heading.personal`,defaultMessage:`Personal`,description:`Heading for personal settings in the settings navigation`}),slugs:[`general-settings`,`import`,`profile`,`appearance`,`keyboard-shortcuts`]}]; JS cat > "$extracted/webview/assets/app-initial-BTphDPeq.js" <<'JS' import{n as routeModule,s as routeToESM}from"./rolldown-runtime-test.js";import{I as routeJsxFactory,R as routeReactFactory}from"./shared-runtime-test.js";function Z(e){let r=(0,RouteReact.lazy)(e);function SettingsRouteWrapper(){let t=(0,RouteReact.useState)(null);return (0,RouteJsx.jsx)(r,{children:t})}return SettingsRouteWrapper}var RouteReact,RouteJsx;routeModule(()=>{RouteReact=routeToESM(routeReactFactory(),1),RouteJsx=routeJsxFactory()})();var c_e={"general-settings":Z(async()=>(await s(async()=>{let{GeneralSettings:e}=await import(`./general-settings-DZbwMmWz.js`);return{GeneralSettings:e}},[],import.meta.url)).GeneralSettings),"keyboard-shortcuts":Z(async()=>(await s(async()=>{let{KeyboardShortcutsSettings:e}=await import(`./keyboard-shortcuts-settings-test.js`);return{KeyboardShortcutsSettings:e}},[],import.meta.url)).KeyboardShortcutsSettings)};export{Z}; @@ -9271,6 +9530,7 @@ JS assert_contains "$extracted/webview/assets/settings-shared-test.js" "settings.section.linux-desktop" assert_contains "$extracted/webview/assets/use-visible-settings-sections-test.js" '"linux-desktop":xh,"general-settings":xh' assert_contains "$extracted/webview/assets/use-visible-settings-sections-test.js" 'case`linux-desktop`:return!0;case`general-settings`' + assert_contains "$extracted/webview/assets/settings-page-test.js" 'slugs:\[`general-settings`,`linux-desktop`,`import`' assert_contains "$extracted/webview/assets/app-initial-BTphDPeq.js" "linux-desktop-settings-linux.js?v=" assert_contains "$extracted/webview/assets/app-initial-BTphDPeq.js" 'export{Z,' assert_contains "$extracted/webview/assets/app-initial-BTphDPeq.js" 'RouteReact as codexLinuxReact,RouteJsx as codexLinuxJsx' @@ -9284,6 +9544,7 @@ JS assert_occurrence_count "$extracted/webview/assets/settings-shared-test.js" "settings.section.linux-desktop" '1' assert_occurrence_count "$extracted/webview/assets/use-visible-settings-sections-test.js" '"linux-desktop"' '1' assert_occurrence_count "$extracted/webview/assets/use-visible-settings-sections-test.js" 'case`linux-desktop`' '1' + assert_occurrence_count "$extracted/webview/assets/settings-page-test.js" '`linux-desktop`' '1' assert_occurrence_count "$extracted/webview/assets/app-initial-BTphDPeq.js" "linux-desktop-settings-linux.js" '1' } @@ -9330,20 +9591,19 @@ test_browser_annotation_screenshot_patch_smoke() { mkdir -p "$workspace" make_fake_extracted_asar "$extracted" 'let D={removeMenu(){},setMenuBarVisibility(){},setIcon(){},once(){}};let n=require(`electron`),t=require(`node:path`),a=require(`node:fs`);...process.platform===`win32`?{autoHideMenuBar:!0}:{},process.platform===`win32`&&D.removeMenu(),foo)}),D.once(`ready-to-show`,()=>{})' - cat > "$extracted/.vite/build/comment-preload.js" <<'JS' -let mt=Te;M?.kind===`comment`?mt=pt?[M.annotation]:Te:pt||P?mt=[]:ft!=null&&(mt=Te.filter(e=>e.id!==ft.id)); -let ht=mt.flatMap(e=>[e]),kt=null,At=`hover-box`,jt,Mt=0,I=[]; -if(P&&M?.annotation.anchor.kind===`element`){Mt=xt[0]??0;let e=bt==null?null:hs(bt),t=e?.rect??Ss(M.annotation.anchor);jt=e?.borderRadius,At=Vs(M.annotation.anchor,t,C.width,C.height),kt=Is(M.annotation.anchor,t,bt),I=bc(F,C,{clipToVisibleArea:!0})} + cat > "$extracted/.vite/build/browser-page-preload.js" <<'JS' +let Nt=Mt==null?[]:Pl(Mt),Pt=F==null?Nt:[],Ft=null,It=`hover-box`,Lt,Rt=[]; +if(pt&&N?.annotation.anchor.kind===`element`){let e=Dt==null?null:as(Dt),t=e?.rect??fs(N.annotation.anchor);Lt=e?.borderRadius,It=js(N.annotation.anchor,t,w.width,w.height),Ft=Es(N.annotation.anchor,t,Dt),Rt=uc(Ot,w,{clipToVisibleArea:!0,selectionIndexOffset:1,viewportSize:N.annotation.viewportSize})} JS node "$REPO_DIR/scripts/patch-linux-window-ui.js" "$extracted" >"$output_log" 2>&1 - assert_contains "$extracted/.vite/build/comment-preload.js" 'let t=Ss(M.annotation.anchor);jt=void 0,At=Vs' - assert_contains "$extracted/.vite/build/comment-preload.js" 'M?\.kind===`comment`?mt=pt?\[M\.annotation\]:Te' - assert_not_contains "$extracted/.vite/build/comment-preload.js" 'e?.rect??Ss' + assert_contains "$extracted/.vite/build/browser-page-preload.js" 'let t=fs(N.annotation.anchor);Lt=void 0,It=js' + assert_contains "$extracted/.vite/build/browser-page-preload.js" 'selectionIndexOffset:1' + assert_not_contains "$extracted/.vite/build/browser-page-preload.js" 'e?.rect??fs' node "$REPO_DIR/scripts/patch-linux-window-ui.js" "$extracted" >"$output_log" 2>&1 - assert_occurrence_count "$extracted/.vite/build/comment-preload.js" 'let t=Ss(M.annotation.anchor)' '1' - assert_occurrence_count "$extracted/.vite/build/comment-preload.js" 'M?\.kind===`comment`?mt=pt?\[M\.annotation\]:Te' '1' + assert_occurrence_count "$extracted/.vite/build/browser-page-preload.js" 'let t=fs(N.annotation.anchor)' '1' + assert_occurrence_count "$extracted/.vite/build/browser-page-preload.js" 'selectionIndexOffset:1' '1' } test_linux_single_instance_patch_smoke() { @@ -9484,6 +9744,11 @@ function makeWindow(id) { }, focus() { state.windowCalls.push(`${id}:focus`); + if (state.focusError) { + const error = state.focusError; + state.focusError = null; + throw error; + } }, }; } @@ -9524,6 +9789,7 @@ function resetCalls() { state.ensureHostWindowCalls = []; state.createFreshLocalWindowCalls = []; state.focusCalls = []; + state.focusError = null; state.windowCalls = []; state.errors = []; state.ieCalls = 0; @@ -9708,6 +9974,14 @@ async function boot(settings = {}, env = { CODEX_DESKTOP_LAUNCH_ACTION_SOCKET: " assert(state.navigateCalls.length === 1 && state.navigateCalls[0].path === "/", "--new-chat should navigate the warm primary window to /"); assert(state.messages.length === 0, "--new-chat should not send a quick-chat message"); + resetCalls(); + state.primaryWindow = state.primary; + state.focusError = new Error("focus rejected"); + await runSecondInstance(["codex-desktop", "--new-chat"]); + assert(state.errors.length === 1 && state.errors[0].meta.kind === "linux-launch-action-failed", "a rejected launch action should be reported"); + assert(state.ieCalls === 1, "a rejected launch action should run the previous focus fallback"); + assert(state.focusCalls.length === 2 && state.focusCalls.every((id) => id === "primary"), "fallback should retry focus after a rejected launch action"); + resetCalls(); state.primaryWindow = state.primary; await runSecondInstance(["codex-desktop", "--quick-chat"]); @@ -9756,6 +10030,21 @@ async function boot(settings = {}, env = { CODEX_DESKTOP_LAUNCH_ACTION_SOCKET: " assert(socket.outputs[0] === "ok\n", "warm-start socket should acknowledge fallback focus args"); assert(state.ieCalls === 1, "warm-start socket should use the focus fallback for args without launch flags"); + resetCalls(); + state.primaryWindow = state.primary; + socket = await runSocketArgs(["--show"]); + assert(socket.outputs[0] === "ok\n", "warm-start socket should acknowledge the AppImage show action"); + assert(state.ieCalls === 1, "AppImage --show should reuse the existing focus fallback"); + assert(state.focusCalls.length === 1 && state.focusCalls[0] === "primary", "AppImage --show should focus the warm primary window"); + + resetCalls(); + state.primaryWindow = state.primary; + const firstShow = runSocketArgs(["--show"]); + const secondShow = runSocketArgs(["--show"]); + const [firstShowSocket, secondShowSocket] = await Promise.all([firstShow, secondShow]); + assert(firstShowSocket.outputs[0] === "ok\n" && secondShowSocket.outputs[0] === "ok\n", "overlapping AppImage activations should both be acknowledged"); + assert(state.ieCalls === 2 && state.focusCalls.length === 2, "overlapping AppImage activations should both reach the idempotent focus fallback"); + resetCalls(); state.primaryWindow = state.primary; await runSecondInstance(["codex://thread/abc", "--quick-chat"]); @@ -9812,6 +10101,12 @@ async function boot(settings = {}, env = { CODEX_DESKTOP_LAUNCH_ACTION_SOCKET: " assert(state.navigateCalls.length === 1 && state.navigateCalls[0].path === "/", "initial --new-chat should navigate an existing window to /"); assert(state.focusCalls.length === 1 && state.focusCalls[0] === "primary", "initial --new-chat should focus the main window"); + resetCalls(); + state.primaryWindow = state.primary; + await runInitialArgs(["codex-desktop", "--show"]); + assert(state.ieCalls === 1, "initial AppImage --show should reuse the existing focus fallback"); + assert(state.focusCalls.length === 1 && state.focusCalls[0] === "primary", "initial AppImage --show should focus the main window"); + await boot({ promptChatEnabled: false }); resetCalls(); state.primaryWindow = state.primary; @@ -9899,13 +10194,11 @@ test_linux_computer_use_ui_opt_in_smoke() { local fake_home="$workspace/home" local output_log="$workspace/output.log" local main_bundle="$extracted/.vite/build/main-test.js" - local settings_asset="$extracted/webview/assets/computer-use-settings-DsM_pz8i.js" - local host_platform_asset="$extracted/webview/assets/app-initial~artifact-tab-content.electron~notebook-preview-panel~app-main~settings-command-~ekwfx4j1-test.js" - local install_flow_asset="$extracted/webview/assets/app-initial~avatarOverlayCompositionSurface~artifact-tab-content.electron~notebook-preview-~iaq4jiqv-test.js" + local settings_asset="$extracted/webview/assets/computer-use-settings-BzkBOuLk.js" + local app_initial_asset="$extracted/webview/assets/app-initial-BHB6SClA.js" local bundle_body local settings_body - local host_platform_body - local install_flow_body + local app_initial_body mkdir -p "$workspace" "$fake_home/.config/codex-desktop" @@ -9920,22 +10213,18 @@ var h={handlers:{"native-desktop-apps":async()=>({apps:[]})}}; JS )" settings_body="$(cat <<'JS' -function Ht(){let e=cache(24),{selectedHostId:t}=host(),n=data(t),i={hostId:t};let a=useAvailability(i),{platform:o}=usePlatform(),s=hostKind(t)===`local`,c=flag(`188145323`);let f=jsx(Settings,{computerUseAvailability:a,platform:o});let h=a.available?jsx(AllowedApps,{}):null;return jsx(Page,{children:[f,h]})}function Wt(e){let t=cache(35),{computerUseAvailability:n,platform:i}=e,{selectedHostId:s}=host();let g=[];let _=usePlugins(s,g),v=useMarketplacePath(s),y=useFlag(firstFlag),b=useFlag(secondFlag),x;x=selectPlugin(_.availablePlugins,computerUsePluginName,v);return x} -JS -)" - host_platform_body="$(cat <<'JS' -function Se(e){return e===`macOS`||e===`windows`}function Ce(e){let t=cache(16),{enabled:n,hostId:r}=e,i=n===void 0?!0:n,{isLoading:a,platform:o}=usePlatform(),s=flag(`1506311413`),c;t[0]===r?c=t[1]:(c={featureName:`computer_use`,hostId:r},t[0]=r,t[1]=c);let l=useFeature(c),u=o===`windows`&&!a,d=i&&u,f;t[2]===d?f=t[3]:(f={enabled:d},t[2]=d,t[3]=f);let p=useWindowsFeature(f),m=l.isLoading||u&&p.isLoading,h=l.enabled&&(!u||p.enabled),g;t[4]!==h||t[5]!==i||t[6]!==m||t[7]!==s||t[8]!==a||t[9]!==o?(g=resolveAvailability({areRequiredFeaturesEnabled:h,enabled:i,isAnyFeatureLoading:m,isComputerUseGateEnabled:s,isHostCompatiblePlatform:Se(o),isPlatformLoading:a,windowType:`electron`}),t[4]=h,t[5]=i,t[6]=m,t[7]=s,t[8]=a,t[9]=o,t[10]=g):g=t[10];return g} +var computerUsePluginName=`computer-use`;function Ht(){let e=cache(24),{selectedHostId:t}=host(),n=data(t),i={hostId:t};let a=useAvailability(i),{platform:o}=usePlatform(),s=hostKind(t)===`local`,c=flag(`188145323`);let f=jsx(Settings,{computerUseAvailability:a,platform:o});let h=a.available?jsx(AllowedApps,{}):null;return jsx(Page,{children:[f,h]})}function Wt(e){let t=cache(35),{computerUseAvailability:n,platform:i}=e,{selectedHostId:s}=host();let g=[];let _=usePlugins(s,g),v=useMarketplacePath(s),y=useFlag(firstFlag),b=useFlag(secondFlag),x;x=selectPlugin(_.availablePlugins,computerUsePluginName,v);return x} JS )" - install_flow_body="$(cat <<'JS' -function Ke(e){let t=cache(31),{hostId:n,marketplacePath:r,pluginName:i,remoteMarketplaceName:a,enabled:o}=e,c=o===void 0?!0:o,l=n??`local`,d;t[0]===l?d=t[1]:(d={hostId:l},t[0]=l,t[1]=d);let f=hostReady(d),p=environment(),m;t[2]===i?m=t[3]:(m=i!=null&&isAvailabilityGated(i),t[2]=i,t[3]=m);let g=m,_;t[4]!==l||t[5]!==g?(_={enabled:g,hostId:l},t[4]=l,t[5]=g,t[6]=_):_=t[6];let v=useComputerUseAvailability(_),y=(r!=null||a!=null)&&i!=null,b=f&&c&&y&&g&&v.isLoading,x=f&&c&&y&&(!g||v.available);let query=async()=>{if(i==null)throw Error(`plugin detail query requires pluginName`);return read(`read-plugin`,{hostId:l,pluginName:i})};return useQuery({queryFn:query,enabled:x})} + app_initial_body="$(cat <<'JS' +function K3r(e){return e===`macOS`||e===`windows`}function q3r(e){let t=cache(16),{enabled:n,hostId:r}=e,i=n===void 0?!0:n,{isLoading:a,platform:o}=usePlatform(),s=flag(`1506311413`),c;t[0]===r?c=t[1]:(c={featureName:`computer_use`,hostId:r},t[0]=r,t[1]=c);let l=useFeature(c),u=o===`windows`&&!a,d=i&&u,f;t[2]===d?f=t[3]:(f={enabled:d},t[2]=d,t[3]=f);let p=useWindowsFeature(f),m=l.isLoading||u&&p.isLoading,h=l.enabled&&(!u||p.enabled),g;t[4]!==h||t[5]!==i||t[6]!==m||t[7]!==s||t[8]!==a||t[9]!==o?(g=X3r({areRequiredFeaturesEnabled:h,enabled:i,isAnyFeatureLoading:m,isComputerUseGateEnabled:s,isHostCompatiblePlatform:K3r(o),isPlatformLoading:a,windowType:`electron`}),t[4]=h,t[5]=i,t[6]=m,t[7]=s,t[8]=a,t[9]=o,t[10]=g):g=t[10];return g} +function i4i(e){let t=cache(31),{hostId:n,marketplacePath:r,pluginName:i,remoteMarketplaceName:a,enabled:o}=e,s=o===void 0?!0:o,c=n??`local`,l;t[0]===c?l=t[1]:(l={hostId:c},t[0]=c,t[1]=l);let u=hostReady(l),d=environment(),f;t[2]===i?f=t[3]:(f=i!=null&&isAvailabilityGated(i),t[2]=i,t[3]=f);let p=f,m;t[4]!==c||t[5]!==p?(m={enabled:p,hostId:c},t[4]=c,t[5]=p,t[6]=m):m=t[6];let h=useComputerUseAvailability(m),g=(r!=null||a!=null)&&i!=null,v=u&&s&&g&&(!p||h.available);let b=async()=>{if(i==null)throw Error(`plugin detail query requires pluginName`);return read(`read-plugin`,{hostId:c,marketplacePath:r,pluginName:i})};return useQuery({queryFn:b,enabled:v})} JS )" make_fake_extracted_asar "$extracted" "$bundle_body" printf '%s\n' "$settings_body" > "$settings_asset" - printf '%s\n' "$host_platform_body" > "$host_platform_asset" - printf '%s\n' "$install_flow_body" > "$install_flow_asset" + printf '%s\n' "$app_initial_body" > "$app_initial_asset" env -u CODEX_LINUX_ENABLE_COMPUTER_USE_UI -u CODEX_LINUX_APP_ID -u CODEX_APP_ID -u CODEX_LINUX_SETTINGS_FILE \ HOME="$fake_home" XDG_CONFIG_HOME="$fake_home/.config" \ @@ -9944,14 +10233,13 @@ JS assert_not_contains "$main_bundle" 'return n===`linux`?{...e,computerUse:!0,computerUseNodeRepl:!0}' assert_not_contains "$settings_asset" 'available:!0,isFetching:!1,isLoading:!1' assert_not_contains "$settings_asset" 'marketplaceName:`openai-bundled`' - assert_not_contains "$host_platform_asset" 'isHostCompatiblePlatform:o===`linux`' - assert_not_contains "$install_flow_asset" '!==`computer-use`' + assert_not_contains "$app_initial_asset" 'isHostCompatiblePlatform:o===`linux`' + assert_not_contains "$app_initial_asset" '!==`computer-use`' - rm "$main_bundle" "$settings_asset" "$host_platform_asset" "$install_flow_asset" + rm "$main_bundle" "$settings_asset" "$app_initial_asset" printf '%s\n' "$bundle_body" > "$main_bundle" printf '%s\n' "$settings_body" > "$settings_asset" - printf '%s\n' "$host_platform_body" > "$host_platform_asset" - printf '%s\n' "$install_flow_body" > "$install_flow_asset" + printf '%s\n' "$app_initial_body" > "$app_initial_asset" env -u CODEX_LINUX_APP_ID -u CODEX_APP_ID -u CODEX_LINUX_SETTINGS_FILE \ CODEX_LINUX_ENABLE_COMPUTER_USE_UI=1 HOME="$fake_home" XDG_CONFIG_HOME="$fake_home/.config" \ @@ -9960,20 +10248,19 @@ JS assert_contains "$main_bundle" 'codexLinuxNativeDesktopApps' assert_contains "$settings_asset" 'available:!0,isFetching:!1,isLoading:!1' assert_contains "$settings_asset" 'marketplaceName:`openai-bundled`' - assert_contains "$host_platform_asset" 'isHostCompatiblePlatform:o===`linux`||Se(o)' - assert_contains "$install_flow_asset" 'let g=m&&i!==`computer-use`,_;' + assert_contains "$app_initial_asset" 'isHostCompatiblePlatform:o===`linux`||K3r(o)' + assert_contains "$app_initial_asset" 'let p=f&&i!==`computer-use`,m;' node "$REPO_DIR/scripts/patch-linux-window-ui.js" "$extracted" >"$output_log" 2>&1 assert_occurrence_count "$settings_asset" 'available:!0,isFetching:!1,isLoading:!1' '1' assert_occurrence_count "$settings_asset" 'marketplaceName:`openai-bundled`' '1' - assert_occurrence_count "$host_platform_asset" 'isHostCompatiblePlatform:o===`linux`' '1' - assert_occurrence_count "$install_flow_asset" '!==`computer-use`' '1' + assert_occurrence_count "$app_initial_asset" 'isHostCompatiblePlatform:o===`linux`' '1' + assert_occurrence_count "$app_initial_asset" '!==`computer-use`' '1' - rm "$main_bundle" "$settings_asset" "$host_platform_asset" "$install_flow_asset" + rm "$main_bundle" "$settings_asset" "$app_initial_asset" printf '%s\n' "$bundle_body" > "$main_bundle" printf '%s\n' "$settings_body" > "$settings_asset" - printf '%s\n' "$host_platform_body" > "$host_platform_asset" - printf '%s\n' "$install_flow_body" > "$install_flow_asset" + printf '%s\n' "$app_initial_body" > "$app_initial_asset" printf '%s\n' '{"codex-linux-computer-use-ui-enabled": true}' > "$fake_home/.config/codex-desktop/settings.json" env -u CODEX_LINUX_ENABLE_COMPUTER_USE_UI -u CODEX_LINUX_APP_ID -u CODEX_APP_ID -u CODEX_LINUX_SETTINGS_FILE \ @@ -9983,8 +10270,8 @@ JS assert_contains "$main_bundle" 'codexLinuxNativeDesktopApps' assert_contains "$settings_asset" 'available:!0,isFetching:!1,isLoading:!1' assert_contains "$settings_asset" 'marketplaceName:`openai-bundled`' - assert_contains "$host_platform_asset" 'isHostCompatiblePlatform:o===`linux`||Se(o)' - assert_contains "$install_flow_asset" 'let g=m&&i!==`computer-use`,_;' + assert_contains "$app_initial_asset" 'isHostCompatiblePlatform:o===`linux`||K3r(o)' + assert_contains "$app_initial_asset" 'let p=f&&i!==`computer-use`,m;' } test_linux_file_manager_patch_fails_soft() { @@ -10815,6 +11102,7 @@ EOF test_launcher_warm_start_recovery() { info "Checking warm-start recovery after launcher SIGKILL" bash "$REPO_DIR/tests/launcher_warm_start_recovery.sh" + CODEX_TEST_APPIMAGE_REMOUNT=1 bash "$REPO_DIR/tests/launcher_warm_start_recovery.sh" CODEX_TEST_DISABLE_WARM_START=1 bash "$REPO_DIR/tests/launcher_warm_start_recovery.sh" CODEX_TEST_KILL_DURING_PRELAUNCH=1 bash "$REPO_DIR/tests/launcher_warm_start_recovery.sh" CODEX_TEST_DISABLE_PIDFD=1 CODEX_TEST_NORMAL_LOCK_ONLY=1 \ @@ -10823,6 +11111,129 @@ test_launcher_warm_start_recovery() { bash "$REPO_DIR/tests/launcher_warm_start_recovery.sh" } +test_launcher_window_reopen_behavior() { + local nominal_log="$TMP_DIR/launcher-window-reopen-nominal.log" + local appimage_remount_log="$TMP_DIR/launcher-window-reopen-appimage-remount.log" + local mutation_log="$TMP_DIR/launcher-window-reopen-mutation.log" + local mutation_control_log="$TMP_DIR/launcher-window-reopen-mutation-control.log" + local no_pidfd_log="$TMP_DIR/launcher-window-reopen-no-pidfd.log" + local no_pidfd_tmp="$TMP_DIR/launcher-window-reopen-no-pidfd-missing" + local probe_failure_bin="$TMP_DIR/launcher-window-reopen-probe-failure-bin" + local probe_failure_log="$TMP_DIR/launcher-window-reopen-probe-failure.log" + local status + + info "Checking healthy resident reopen handoff behavior" + set +e + bash "$REPO_DIR/tests/launcher_window_reopen_behavior.sh" \ + > "$nominal_log" 2>&1 + status=$? + set -e + if [ "$status" -eq 77 ]; then + if ! grep -Fxq \ + 'launcher window-reopen behavior test skipped: pidfd cleanup unavailable' \ + "$nominal_log" \ + || ! grep -Fq '"reason":"pidfd-cleanup-unavailable"' "$nominal_log"; then + cat "$nominal_log" >&2 + fail "Window-reopen behavior harness returned an invalid pidfd skip result" + fi + cat "$nominal_log" + return 0 + fi + if [ "$status" -ne 0 ] \ + || ! grep -Fxq 'launcher window-reopen behavior test passed' "$nominal_log" \ + || ! grep -Fq '"outcome":"preserved"' "$nominal_log"; then + cat "$nominal_log" >&2 + fail "Window-reopen behavior harness nominal run failed (status $status)" + fi + cat "$nominal_log" + + set +e + CODEX_TEST_APPIMAGE_REMOUNT=1 \ + bash "$REPO_DIR/tests/launcher_window_reopen_behavior.sh" \ + > "$appimage_remount_log" 2>&1 + status=$? + set -e + if [ "$status" -ne 0 ] \ + || ! grep -Fxq \ + 'launcher AppImage remount window-reopen behavior test passed' \ + "$appimage_remount_log" \ + || ! grep -Fq '"outcome":"appimage-remount-preserved"' "$appimage_remount_log"; then + cat "$appimage_remount_log" >&2 + fail "Window-reopen behavior harness AppImage remount run failed (status $status)" + fi + cat "$appimage_remount_log" + + set +e + CODEX_TEST_FORCE_RESIDENT_REPLACEMENT=1 \ + bash "$REPO_DIR/tests/launcher_window_reopen_behavior.sh" \ + > "$mutation_log" 2>&1 + status=$? + set -e + if [ "$status" -ne 86 ] \ + || ! grep -Fxq \ + 'launcher window-reopen behavior mutation detected: healthy resident replacement' \ + "$mutation_log" \ + || ! grep -Fq '"outcome":"resident-replacement-detected"' "$mutation_log"; then + cat "$mutation_log" >&2 + fail "Window-reopen behavior harness did not report the expected resident-replacement regression (status $status)" + fi + + set +e + CODEX_TEST_FORCE_RESIDENT_REPLACEMENT=1 \ + CODEX_TEST_MUTATION_CONTROL_ONLY=1 \ + bash "$REPO_DIR/tests/launcher_window_reopen_behavior.sh" \ + > "$mutation_control_log" 2>&1 + status=$? + set -e + if [ "$status" -ne 0 ] \ + || ! grep -Fxq \ + 'launcher window-reopen behavior mutation control passed' \ + "$mutation_control_log" \ + || ! grep -Fq '"outcome":"mutation-control-preserved"' "$mutation_control_log" \ + || grep -Fq 'resident-replacement-detected' "$mutation_control_log"; then + cat "$mutation_control_log" >&2 + fail "Window-reopen behavior harness mutation control failed (status $status)" + fi + + rm -rf "$no_pidfd_tmp" + set +e + CODEX_TEST_FORCE_NO_PIDFD=1 TMPDIR="$no_pidfd_tmp" \ + bash "$REPO_DIR/tests/launcher_window_reopen_behavior.sh" \ + > "$no_pidfd_log" 2>&1 + status=$? + set -e + if [ "$status" -ne 77 ] \ + || ! grep -Fxq \ + 'launcher window-reopen behavior test skipped: pidfd cleanup unavailable' \ + "$no_pidfd_log" \ + || ! grep -Fq '"reason":"pidfd-cleanup-unavailable"' "$no_pidfd_log"; then + cat "$no_pidfd_log" >&2 + fail "Window-reopen behavior harness did not report the expected safe no-pidfd skip (status $status)" + fi + [ ! -e "$no_pidfd_tmp" ] \ + || fail "Window-reopen behavior harness created a workspace before the pidfd capability gate" + + mkdir -p "$probe_failure_bin" + printf '%s\n' '#!/usr/bin/env bash' 'exit 9' > "$probe_failure_bin/python3" + chmod +x "$probe_failure_bin/python3" + set +e + PATH="$probe_failure_bin:$PATH" TMPDIR="$no_pidfd_tmp" \ + bash "$REPO_DIR/tests/launcher_window_reopen_behavior.sh" \ + > "$probe_failure_log" 2>&1 + status=$? + set -e + if [ "$status" -ne 1 ] \ + || ! grep -Fxq \ + 'launcher window-reopen behavior test failed: pidfd capability probe failed' \ + "$probe_failure_log" \ + || grep -Fq 'pidfd-cleanup-unavailable' "$probe_failure_log"; then + cat "$probe_failure_log" >&2 + fail "Window-reopen behavior harness misclassified a pidfd probe setup failure (status $status)" + fi + [ ! -e "$no_pidfd_tmp" ] \ + || fail "Window-reopen behavior harness created a workspace after a pidfd probe failure" +} + test_notification_actions_bridge_accepts_prebuilt_binary() { local workspace="$TMP_DIR/notification-actions-bridge" local source_binary="$workspace/prebuilt/codex-notification-actions-linux" @@ -10964,6 +11375,7 @@ main() { test_chrome_native_host_manifest_writer test_launcher_managed_node_handles_unset_path test_launcher_captures_original_ld_library_path_state + test_launcher_captures_desktop_entry_for_current_process_only test_packaged_runtime_keeps_managed_node_out_of_user_service_path test_launcher_extra_bundled_plugin_cache_rollback test_launcher_extra_bundled_plugin_cache_concurrent_destination @@ -10971,12 +11383,14 @@ main() { test_launcher_marketplace_metadata_atomic_staging test_launcher_template_sanity test_launcher_warm_start_recovery + test_launcher_window_reopen_behavior test_launcher_cli_resolution_policy test_webview_server_cache_policy test_process_detection_helper_cmdline_shapes test_webview_probe_equivalence test_side_by_side_launcher_identity test_linux_file_manager_patch_smoke + test_linux_titlebar_context_menu_patch_smoke test_linux_translucent_sidebar_default_patch_smoke test_keybinds_settings_tab_patch_smoke test_keybinds_settings_patch_warns_on_bundle_shape_miss diff --git a/updater/Cargo.toml b/updater/Cargo.toml index ca28eb01a..a700755de 100644 --- a/updater/Cargo.toml +++ b/updater/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-update-manager" -version = "0.10.3" +version = "0.11.1" edition = "2021" [dependencies] diff --git a/updater/src/app.rs b/updater/src/app.rs index c4dbc9dd9..36fb5b59e 100644 --- a/updater/src/app.rs +++ b/updater/src/app.rs @@ -52,6 +52,23 @@ const POLKIT_AUTH_AGENT_PROCESS_TOKENS: &[&str] = &[ /// Runs the updater command-line entrypoint. pub async fn run(cli: Cli) -> Result<()> { + if let Commands::RunNpmSupervisor { + owner_pid, + timeout_millis, + install_lock_fd, + program, + args, + } = &cli.command + { + return codex_cli::run_npm_supervisor( + *owner_pid, + *timeout_millis, + *install_lock_fd, + program, + args, + ); + } + let paths = RuntimePaths::detect()?; if let Commands::Diagnose { json } = &cli.command { return run_diagnose_command(&paths, *json).await; @@ -108,6 +125,10 @@ pub async fn run(cli: Cli) -> Result<()> { install_dir, print_path, } => run_recover_standalone_cli(codex_home, install_dir, print_path), + Commands::RepairCli => run_repair_cli(&mut state, &paths), + Commands::RunNpmSupervisor { .. } => { + unreachable!("npm supervisor is handled before runtime writes") + } Commands::PromptInstallCli { cli_path, print_path, @@ -137,7 +158,7 @@ async fn run_diagnose_command(paths: &RuntimePaths, json: bool) -> Result<()> { } fn persist_state(paths: &RuntimePaths, state: &PersistedState) -> Result<()> { - state.save(&paths.state_file) + state.save_updater(&paths.state_file) } fn persist_if_changed( @@ -156,6 +177,10 @@ fn effective_auto_install(config: &RuntimeConfig) -> bool { crate::config::settings_auto_install_override().unwrap_or(config.auto_install_on_app_exit) } +fn should_build_detected_update(config: &RuntimeConfig, explicit_build: bool) -> bool { + explicit_build || crate::config::settings_auto_build_updates_override(config).unwrap_or(true) +} + fn sync_runtime_state(config: &RuntimeConfig, state: &mut PersistedState) { state.auto_install_on_app_exit = effective_auto_install(config); if state.status != UpdateStatus::WaitingForAppExit { @@ -406,6 +431,15 @@ enum CheckLockBehavior { Wait, } +#[derive(Debug, Clone, Copy)] +struct CheckCycleOptions { + lock_behavior: CheckLockBehavior, + if_stale: bool, + recover_entrypoint_state: bool, + reconcile_after_check: bool, + explicit_build: bool, +} + fn try_acquire_check_lock(paths: &RuntimePaths) -> Result> { let lock_path = paths.state_dir.join("check.lock"); let mut file = OpenOptions::new() @@ -470,25 +504,30 @@ fn update_install_is_pending(status: &UpdateStatus) -> bool { // Failed attempts and transient states persisted before fallible download or // build work must retry after the next checker acquires the check lock. A // still-running checker continues to own that lock and prevents duplicate work. -fn update_check_should_retry(status: &UpdateStatus) -> bool { +fn stable_deferred_candidate(state: &PersistedState) -> bool { + state.status == UpdateStatus::UpdateDetected && state.deferred_build +} + +fn update_check_should_retry(state: &PersistedState) -> bool { matches!( - status, + state.status, UpdateStatus::Failed | UpdateStatus::DownloadingDmg - | UpdateStatus::UpdateDetected | UpdateStatus::PreparingWorkspace | UpdateStatus::PatchingApp | UpdateStatus::BuildingPackage - ) + ) || (state.status == UpdateStatus::UpdateDetected && !state.deferred_build) } fn prepare_upstream_check(state: &mut PersistedState, paths: &RuntimePaths) -> Result { - let retrying_update = update_check_should_retry(&state.status); + let retrying_update = update_check_should_retry(state); // Keep a retryable status durable until the metadata request completes. If // the updater exits while that request is in flight, the next run must not // mistake the interrupted rebuild for an ordinary unchanged-upstream check. - if !retrying_update { + // A deliberately deferred candidate is also durable while its HEAD request + // runs so an offline background check cannot erase the pending update. + if !retrying_update && !stable_deferred_candidate(state) { state.status = UpdateStatus::CheckingUpstream; } state.last_check_at = Some(Utc::now()); @@ -586,7 +625,19 @@ async fn run_check_now( } else { CheckLockBehavior::Wait }; - run_check_cycle_with_options(config, state, paths, lock_behavior, if_stale, true, true).await + run_check_cycle_with_options( + config, + state, + paths, + CheckCycleOptions { + lock_behavior, + if_stale, + recover_entrypoint_state: true, + reconcile_after_check: true, + explicit_build: !if_stale, + }, + ) + .await } /// Detects a newer wrapper release and records it into state. Returns @@ -668,7 +719,7 @@ fn detect_and_record_wrapper_update( persist_if_changed(paths, state, &original_state)?; Ok(false) } - (Aligned, _) => { + (Aligned | NoRebuildNeeded, _) => { state.clear_wrapper_update_candidate(); state.wrapper_dev_mode = Some(false); persist_if_changed(paths, state, &original_state)?; @@ -872,6 +923,28 @@ fn run_recover_standalone_cli( Ok(()) } +fn run_repair_cli(state: &mut PersistedState, paths: &RuntimePaths) -> Result<()> { + let outcome = codex_cli::repair_cli(state, paths)?; + if outcome.quarantine_paths.is_empty() { + println!( + "Codex CLI repaired at version {}. The stale npm directory was already absent.", + outcome.installed_version + ); + } else { + println!( + "Codex CLI repaired at version {}. Quarantines preserved at {}", + outcome.installed_version, + outcome + .quarantine_paths + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") + ); + } + Ok(()) +} + #[derive(Debug, Clone, PartialEq, Eq)] enum PromptInstallCliOutcome { Installed(PathBuf), @@ -1037,10 +1110,13 @@ async fn run_check_cycle_from_disk( config, state, paths, - CheckLockBehavior::SkipIfBusy, - false, - false, - false, + CheckCycleOptions { + lock_behavior: CheckLockBehavior::SkipIfBusy, + if_stale: false, + recover_entrypoint_state: false, + reconcile_after_check: false, + explicit_build: false, + }, ) .await } @@ -1056,24 +1132,46 @@ async fn run_check_cycle( config, state, paths, - CheckLockBehavior::SkipIfBusy, - false, - false, - false, + CheckCycleOptions { + lock_behavior: CheckLockBehavior::SkipIfBusy, + if_stale: false, + recover_entrypoint_state: false, + reconcile_after_check: false, + explicit_build: true, + }, ) .await } +async fn build_pending_detected_update( + config: &RuntimeConfig, + state: &mut PersistedState, + paths: &RuntimePaths, +) -> Result<()> { + let candidate_version = state + .candidate_version + .clone() + .context("detected update is missing its candidate version")?; + let dmg_path = state + .artifact_paths + .dmg_path + .clone() + .context("detected update is missing its downloaded DMG")?; + if !dmg_path.is_file() { + anyhow::bail!("detected update DMG is missing: {}", dmg_path.display()); + } + + builder::build_update(config, state, paths, &candidate_version, &dmg_path).await?; + maybe_notify_update_ready(state, paths, config.notifications) +} + async fn run_check_cycle_with_options( config: &RuntimeConfig, state: &mut PersistedState, paths: &RuntimePaths, - lock_behavior: CheckLockBehavior, - if_stale: bool, - recover_entrypoint_state: bool, - reconcile_after_check: bool, + options: CheckCycleOptions, ) -> Result<()> { - let Some(_check_lock) = acquire_check_lock(paths, lock_behavior).await? else { + let Some(_check_lock) = acquire_check_lock(paths, options.lock_behavior).await? else { info!("skipping upstream check because another check is already active"); return Ok(()); }; @@ -1082,7 +1180,7 @@ async fn run_check_cycle_with_options( // below can persist the complete state document, so using a snapshot read // before the lock could overwrite an active checker's workspace metadata. reload_state_from_disk(config, state, paths)?; - if recover_entrypoint_state { + if options.recover_entrypoint_state { recover_interrupted_install(state, paths)?; complete_current_dmg_update_if_already_installed(config, state, paths)?; normalize_workspace_dir_and_persist(state, paths)?; @@ -1095,10 +1193,12 @@ async fn run_check_cycle_with_options( warn!(?error, "wrapper update detection failed during check cycle"); } + let build_detected_update = should_build_detected_update(config, options.explicit_build); + if update_install_is_pending(&state.status) { info!("skipping upstream check because an update is already pending"); maybe_prune_caches(config, state); - if reconcile_after_check { + if options.reconcile_after_check { reconcile_pending_install(config, state, paths).await?; } return Ok(()); @@ -1111,21 +1211,27 @@ async fn run_check_cycle_with_options( ); } - if if_stale - && !update_check_should_retry(&state.status) + let stable_deferred = stable_deferred_candidate(state); + + if options.if_stale + && !(stable_deferred && build_detected_update) + && !update_check_should_retry(state) && upstream_check_is_fresh(config, state) { info!("skipping check-now because the last successful upstream check is still fresh"); maybe_prune_caches(config, state); - if reconcile_after_check { + if options.reconcile_after_check { reconcile_pending_install(config, state, paths).await?; } return Ok(()); } + let deferred_refresh_snapshot = + (stable_deferred && !build_detected_update).then(|| state.clone()); let client = upstream::http_client()?; let retrying_update = prepare_upstream_check(state, paths)?; + let mut candidate_refresh_committed = false; let result: Result<()> = async { let metadata = upstream::fetch_remote_metadata(&client, &config.dmg_url).await?; @@ -1137,9 +1243,29 @@ async fn run_check_cycle_with_options( && state.dmg_sha256.is_some() && !retrying_update { - set_status(state, paths, UpdateStatus::Idle)?; - info!("upstream fingerprint unchanged; skipping download"); - return Ok(()); + if stable_deferred { + if state + .artifact_paths + .dmg_path + .as_deref() + .is_some_and(Path::is_file) + { + if build_detected_update { + state.deferred_build = false; + persist_state(paths, state)?; + info!("upstream fingerprint unchanged; building cached deferred DMG"); + build_pending_detected_update(config, state, paths).await?; + } else { + persist_state(paths, state)?; + info!("upstream fingerprint unchanged; reusing cached deferred DMG"); + } + return Ok(()); + } + } else { + set_status(state, paths, UpdateStatus::Idle)?; + info!("upstream fingerprint unchanged; skipping download"); + return Ok(()); + } } set_status(state, paths, UpdateStatus::DownloadingDmg)?; @@ -1173,7 +1299,10 @@ async fn run_check_cycle_with_options( return Ok(()); } - if state.dmg_sha256.as_deref() == Some(downloaded.sha256.as_str()) && !retrying_update { + if state.dmg_sha256.as_deref() == Some(downloaded.sha256.as_str()) + && !retrying_update + && !stable_deferred + { state.status = UpdateStatus::Idle; state.artifact_paths.dmg_path = Some(downloaded.path); persist_state(paths, state)?; @@ -1183,11 +1312,13 @@ async fn run_check_cycle_with_options( rollback::record_current_package_as_known_good(state); state.status = UpdateStatus::UpdateDetected; + state.deferred_build = !build_detected_update; state.candidate_version = Some(downloaded.candidate_version.clone()); state.dmg_sha256 = Some(downloaded.sha256.clone()); state.artifact_paths.dmg_path = Some(downloaded.path.clone()); state.notified_events.clear(); - state.save(&paths.state_file)?; + state.save_updater(&paths.state_file)?; + candidate_refresh_committed = true; maybe_notify( state, @@ -1195,16 +1326,20 @@ async fn run_check_cycle_with_options( config.notifications, "update_detected", "New ChatGPT Desktop update detected", - "Preparing a local Linux package from the new upstream DMG.", + if build_detected_update { + "Preparing a local Linux package from the new upstream DMG." + } else { + "Automatic update builds are off. Open ChatGPT Desktop and choose Check for updates to build it." + }, )?; - let candidate_version = state - .candidate_version - .clone() - .expect("candidate version should be set before local build"); - builder::build_update(config, state, paths, &candidate_version, &downloaded.path).await?; + if !build_detected_update { + info!("automatic update builds are disabled; keeping the current DMG pending"); + return Ok(()); + } + + build_pending_detected_update(config, state, paths).await?; drop(downloaded); - maybe_notify_update_ready(state, paths, config.notifications)?; Ok(()) } .await; @@ -1213,12 +1348,24 @@ async fn run_check_cycle_with_options( // DMG lease before bounded cache cleanup runs here. maybe_prune_caches(config, state); if let Err(error) = result { + if let Some(mut snapshot) = deferred_refresh_snapshot { + if !candidate_refresh_committed { + snapshot.last_check_at = state.last_check_at; + *state = snapshot; + persist_state(paths, state)?; + warn!( + ?error, + "background refresh failed; preserving deferred update" + ); + return Ok(()); + } + } mark_failed_and_persist(state, paths, error.to_string())?; let _ = notify_failure(config, state, paths, &error); return Err(error); } - if reconcile_after_check { + if options.reconcile_after_check { reconcile_pending_install(config, state, paths).await?; } @@ -1584,6 +1731,7 @@ fn clear_dmg_update_candidate( ) -> Result<()> { state.status = UpdateStatus::Idle; state.waiting_for_app_exit_auto_install = false; + state.deferred_build = false; state.candidate_version = None; if let Some(sha256) = sha256 { state.dmg_sha256 = Some(sha256); @@ -2182,6 +2330,8 @@ mod tests { Mock, MockServer, ResponseTemplate, }; + mod cli_repair_process_tests; + fn test_paths(root: &std::path::Path) -> RuntimePaths { RuntimePaths { config_file: root.join("config/config.toml"), @@ -2230,6 +2380,61 @@ mod tests { Ok(()) } + fn configure_deferred_build_feature( + root: &Path, + config: &RuntimeConfig, + enabled: bool, + auto_build: bool, + ) -> Result { + let settings_path = root.join("settings.json"); + let enabled_features = if enabled { + r#"["deferred-update-build"]"# + } else { + "[]" + }; + let policy_path = config + .builder_bundle_root + .join("linux-features/deferred-update-build/updater-policy.json"); + std::fs::create_dir_all( + policy_path + .parent() + .expect("policy path should have parent"), + )?; + std::fs::write( + policy_path, + r#"{"schemaVersion":1,"autoBuildUpdatesSettingKey":"codex-linux-auto-build-updates"}"#, + )?; + std::fs::write( + root.join("linux-features.json"), + format!(r#"{{"enabled":{enabled_features}}}"#), + )?; + std::fs::write( + &settings_path, + format!(r#"{{"codex-linux-auto-build-updates":{auto_build}}}"#), + )?; + std::env::set_var("CODEX_LINUX_SETTINGS_FILE", &settings_path); + Ok(settings_path) + } + + async fn mount_dmg(server: &MockServer, etag: &str, body: &[u8]) { + Mock::given(method("HEAD")) + .and(path("/Codex.dmg")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("ETag", etag) + .insert_header("Content-Length", body.len().to_string()), + ) + .expect(1) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/Codex.dmg")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(body.to_vec())) + .expect(1) + .mount(server) + .await; + } + #[test] fn upstream_check_freshness_respects_configured_interval() { let config = RuntimeConfig { @@ -2267,9 +2472,16 @@ mod tests { UpdateStatus::PatchingApp, UpdateStatus::BuildingPackage, ] { - assert!(update_check_should_retry(&status), "status: {status:?}"); + let mut state = PersistedState::new(true); + state.status = status.clone(); + assert!(update_check_should_retry(&state), "status: {status:?}"); } + let mut deferred = PersistedState::new(true); + deferred.status = UpdateStatus::UpdateDetected; + deferred.deferred_build = true; + assert!(!update_check_should_retry(&deferred)); + for status in [ UpdateStatus::Idle, UpdateStatus::CheckingUpstream, @@ -2278,7 +2490,9 @@ mod tests { UpdateStatus::Installing, UpdateStatus::Installed, ] { - assert!(!update_check_should_retry(&status), "status: {status:?}"); + let mut state = PersistedState::new(true); + state.status = status.clone(); + assert!(!update_check_should_retry(&state), "status: {status:?}"); } } @@ -2314,9 +2528,237 @@ mod tests { assert_eq!(fresh_state.status, UpdateStatus::CheckingUpstream); let persisted = PersistedState::load_or_default(&paths.state_file, true)?; assert_eq!(persisted.status, UpdateStatus::CheckingUpstream); + + let mut deferred = PersistedState::new(true); + deferred.status = UpdateStatus::UpdateDetected; + deferred.deferred_build = true; + assert!(!prepare_upstream_check(&mut deferred, &paths)?); + assert_eq!(deferred.status, UpdateStatus::UpdateDetected); Ok(()) } + #[test] + fn fresh_deferred_candidate_is_stable_for_if_stale_checks() -> Result<()> { + let _env_guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&[ + "CODEX_LINUX_SETTINGS_FILE", + "CODEX_UPDATE_MANAGER_SKIP_SYSTEM_CLI_LOOKUP", + ]); + let runtime = tokio::runtime::Runtime::new()?; + let temp = tempfile::tempdir()?; + let paths = test_paths(temp.path()); + paths.ensure_dirs()?; + std::env::set_var("CODEX_UPDATE_MANAGER_SKIP_SYSTEM_CLI_LOOKUP", "1"); + let mut config = test_config(temp.path()); + config.dmg_url = "https://invalid.example/Codex.dmg".to_string(); + configure_deferred_build_feature(temp.path(), &config, true, false)?; + + let dmg_path = temp.path().join("cached-candidate.dmg"); + std::fs::write(&dmg_path, b"candidate-a")?; + let mut state = PersistedState::new(true); + state.status = UpdateStatus::UpdateDetected; + state.deferred_build = true; + state.candidate_version = Some("candidate-a".to_string()); + state.dmg_sha256 = Some("candidate-a-sha".to_string()); + state.artifact_paths.dmg_path = Some(dmg_path.clone()); + state.last_successful_check_at = Some(Utc::now()); + state.save(&paths.state_file)?; + + runtime.block_on(run_check_now(&config, &mut state, &paths, true))?; + + assert_eq!(state.status, UpdateStatus::UpdateDetected); + assert!(state.deferred_build); + assert_eq!(state.candidate_version.as_deref(), Some("candidate-a")); + assert_eq!( + state.artifact_paths.dmg_path.as_deref(), + Some(dmg_path.as_path()) + ); + Ok(()) + } + + #[test] + fn stale_deferred_candidate_reuses_cached_dmg_after_head() -> Result<()> { + let _env_guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&[ + "CODEX_LINUX_SETTINGS_FILE", + "CODEX_UPDATE_MANAGER_SKIP_SYSTEM_CLI_LOOKUP", + ]); + let runtime = tokio::runtime::Runtime::new()?; + + runtime.block_on(async { + let server = MockServer::start().await; + let body = b"candidate-a"; + Mock::given(method("HEAD")) + .and(path("/Codex.dmg")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("ETag", "\"candidate-a\"") + .insert_header("Content-Length", body.len().to_string()), + ) + .expect(1) + .mount(&server) + .await; + + let temp = tempfile::tempdir()?; + let paths = test_paths(temp.path()); + paths.ensure_dirs()?; + std::env::set_var("CODEX_UPDATE_MANAGER_SKIP_SYSTEM_CLI_LOOKUP", "1"); + let mut config = test_config(temp.path()); + config.dmg_url = format!("{}/Codex.dmg", server.uri()); + configure_deferred_build_feature(temp.path(), &config, true, false)?; + + let dmg_path = temp.path().join("cached-candidate.dmg"); + std::fs::write(&dmg_path, body)?; + let mut state = PersistedState::new(true); + state.status = UpdateStatus::UpdateDetected; + state.deferred_build = true; + state.candidate_version = Some("candidate-a".to_string()); + state.dmg_sha256 = Some("candidate-a-sha".to_string()); + state.artifact_paths.dmg_path = Some(dmg_path.clone()); + state.remote_headers_fingerprint = Some(format!( + "etag=\"candidate-a\"|last_modified=|content_length={}", + body.len() + )); + state.last_successful_check_at = Some(Utc::now() - ChronoDuration::hours(7)); + state.save(&paths.state_file)?; + + run_check_now(&config, &mut state, &paths, true).await?; + + assert_eq!(state.status, UpdateStatus::UpdateDetected); + assert!(state.deferred_build); + assert_eq!(state.candidate_version.as_deref(), Some("candidate-a")); + assert_eq!( + state.artifact_paths.dmg_path.as_deref(), + Some(dmg_path.as_path()) + ); + assert_eq!(std::fs::read(&dmg_path)?, body); + server.verify().await; + let requests = server.received_requests().await.unwrap_or_default(); + assert_eq!( + requests.len(), + 1, + "an unchanged cached candidate needs only HEAD" + ); + assert_eq!(requests[0].method.as_str(), "HEAD"); + Ok(()) + }) + } + + #[test] + fn fresh_deferred_candidate_builds_when_automatic_builds_resume() -> Result<()> { + let _env_guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&[ + "CODEX_LINUX_SETTINGS_FILE", + "CODEX_UPDATE_MANAGER_SKIP_SYSTEM_CLI_LOOKUP", + ]); + let runtime = tokio::runtime::Runtime::new()?; + + runtime.block_on(async { + let server = MockServer::start().await; + let body = b"candidate-a"; + Mock::given(method("HEAD")) + .and(path("/Codex.dmg")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("ETag", "\"candidate-a\"") + .insert_header("Content-Length", body.len().to_string()), + ) + .expect(1) + .mount(&server) + .await; + + let temp = tempfile::tempdir()?; + let paths = test_paths(temp.path()); + paths.ensure_dirs()?; + std::env::set_var("CODEX_UPDATE_MANAGER_SKIP_SYSTEM_CLI_LOOKUP", "1"); + let mut config = test_config(temp.path()); + config.dmg_url = format!("{}/Codex.dmg", server.uri()); + configure_deferred_build_feature(temp.path(), &config, true, true)?; + + let dmg_path = temp.path().join("cached-candidate.dmg"); + std::fs::write(&dmg_path, body)?; + let mut state = PersistedState::new(true); + state.status = UpdateStatus::UpdateDetected; + state.deferred_build = true; + state.candidate_version = Some("candidate-a".to_string()); + state.dmg_sha256 = Some("candidate-a-sha".to_string()); + state.artifact_paths.dmg_path = Some(dmg_path); + state.remote_headers_fingerprint = Some(format!( + "etag=\"candidate-a\"|last_modified=|content_length={}", + body.len() + )); + state.last_successful_check_at = Some(Utc::now()); + state.save(&paths.state_file)?; + + let error = run_check_now(&config, &mut state, &paths, true) + .await + .expect_err("resumed automatic builds should reach the missing test builder"); + + assert!(error + .to_string() + .contains("Required builder bundle path is missing")); + assert_eq!(state.status, UpdateStatus::Failed); + assert!(!state.deferred_build); + server.verify().await; + Ok(()) + }) + } + + #[test] + fn offline_background_checks_preserve_deferred_candidate() -> Result<()> { + let _env_guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&[ + "CODEX_LINUX_SETTINGS_FILE", + "CODEX_UPDATE_MANAGER_SKIP_SYSTEM_CLI_LOOKUP", + ]); + let runtime = tokio::runtime::Runtime::new()?; + + runtime.block_on(async { + let server = MockServer::start().await; + Mock::given(method("HEAD")) + .and(path("/Codex.dmg")) + .respond_with(ResponseTemplate::new(503)) + .expect(2) + .mount(&server) + .await; + + let temp = tempfile::tempdir()?; + let paths = test_paths(temp.path()); + paths.ensure_dirs()?; + std::env::set_var("CODEX_UPDATE_MANAGER_SKIP_SYSTEM_CLI_LOOKUP", "1"); + let mut config = test_config(temp.path()); + config.dmg_url = format!("{}/Codex.dmg", server.uri()); + configure_deferred_build_feature(temp.path(), &config, true, false)?; + + let dmg_path = temp.path().join("cached-candidate.dmg"); + std::fs::write(&dmg_path, b"candidate-a")?; + let mut state = PersistedState::new(true); + state.status = UpdateStatus::UpdateDetected; + state.deferred_build = true; + state.candidate_version = Some("candidate-a".to_string()); + state.dmg_sha256 = Some("candidate-a-sha".to_string()); + state.artifact_paths.dmg_path = Some(dmg_path.clone()); + state.remote_headers_fingerprint = Some("candidate-a-fingerprint".to_string()); + state.last_successful_check_at = Some(Utc::now() - ChronoDuration::hours(7)); + state.save(&paths.state_file)?; + + run_check_now(&config, &mut state, &paths, true).await?; + run_check_cycle_from_disk(&config, &mut state, &paths).await?; + + assert_eq!(state.status, UpdateStatus::UpdateDetected); + assert!(state.deferred_build); + assert_eq!(state.candidate_version.as_deref(), Some("candidate-a")); + assert_eq!(state.dmg_sha256.as_deref(), Some("candidate-a-sha")); + assert_eq!( + state.artifact_paths.dmg_path.as_deref(), + Some(dmg_path.as_path()) + ); + assert_eq!(state.error_message, None); + server.verify().await; + Ok(()) + }) + } + #[test] fn disabled_wrapper_tracking_clears_stale_candidate() -> Result<()> { let temp = tempfile::tempdir()?; @@ -2677,6 +3119,213 @@ mod tests { Ok(()) } + #[test] + fn deferred_candidate_is_revalidated_before_explicit_build() -> Result<()> { + let _env_guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&[ + "CODEX_LINUX_SETTINGS_FILE", + "CODEX_UPDATE_MANAGER_SKIP_SYSTEM_CLI_LOOKUP", + ]); + let runtime = tokio::runtime::Runtime::new()?; + + runtime.block_on(async { + let server = MockServer::start().await; + let candidate_a = b"candidate-a"; + let candidate_b = b"candidate-b"; + mount_dmg(&server, "\"candidate-a\"", candidate_a).await; + + let temp = tempfile::tempdir()?; + let paths = test_paths(temp.path()); + paths.ensure_dirs()?; + std::env::set_var("CODEX_UPDATE_MANAGER_SKIP_SYSTEM_CLI_LOOKUP", "1"); + + let mut config = test_config(temp.path()); + config.dmg_url = format!("{}/Codex.dmg", server.uri()); + configure_deferred_build_feature(temp.path(), &config, true, false)?; + let mut state = PersistedState::new(true); + + state.save(&paths.state_file)?; + run_check_cycle_with_options( + &config, + &mut state, + &paths, + CheckCycleOptions { + lock_behavior: CheckLockBehavior::SkipIfBusy, + if_stale: false, + recover_entrypoint_state: false, + reconcile_after_check: false, + explicit_build: false, + }, + ) + .await?; + assert_eq!(state.status, UpdateStatus::UpdateDetected); + assert!(state.deferred_build); + assert!(state.candidate_version.is_some()); + let candidate_a_path = state + .artifact_paths + .dmg_path + .clone() + .context("deferred candidate should retain its DMG")?; + assert_eq!(std::fs::read(&candidate_a_path)?, candidate_a); + assert_eq!(state.artifact_paths.workspace_dir, None); + assert_eq!(state.artifact_paths.package_path, None); + + server.verify().await; + server.reset().await; + mount_dmg(&server, "\"candidate-b\"", candidate_b).await; + + let error = run_check_now(&config, &mut state, &paths, false) + .await + .expect_err("an explicit check should enter the intentionally missing builder"); + assert!(error + .to_string() + .contains("Required builder bundle path is missing")); + assert_eq!(state.status, UpdateStatus::Failed); + assert!(!state.deferred_build); + let candidate_b_path = state + .artifact_paths + .dmg_path + .as_deref() + .context("explicit build should retain the revalidated DMG")?; + assert_ne!(candidate_b_path, candidate_a_path); + assert_eq!(std::fs::read(candidate_b_path)?, candidate_b); + server.verify().await; + Ok(()) + }) + } + + #[test] + fn explicit_check_redownloads_a_deleted_deferred_dmg() -> Result<()> { + let _env_guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&[ + "CODEX_LINUX_SETTINGS_FILE", + "CODEX_UPDATE_MANAGER_SKIP_SYSTEM_CLI_LOOKUP", + ]); + let runtime = tokio::runtime::Runtime::new()?; + + runtime.block_on(async { + let server = MockServer::start().await; + let body = b"candidate-a"; + mount_dmg(&server, "\"candidate-a\"", body).await; + let temp = tempfile::tempdir()?; + let paths = test_paths(temp.path()); + paths.ensure_dirs()?; + std::env::set_var("CODEX_UPDATE_MANAGER_SKIP_SYSTEM_CLI_LOOKUP", "1"); + let mut config = test_config(temp.path()); + config.dmg_url = format!("{}/Codex.dmg", server.uri()); + configure_deferred_build_feature(temp.path(), &config, true, false)?; + let mut state = PersistedState::new(true); + state.save(&paths.state_file)?; + + run_check_cycle_with_options( + &config, + &mut state, + &paths, + CheckCycleOptions { + lock_behavior: CheckLockBehavior::SkipIfBusy, + if_stale: false, + recover_entrypoint_state: false, + reconcile_after_check: false, + explicit_build: false, + }, + ) + .await?; + let dmg_path = state + .artifact_paths + .dmg_path + .clone() + .context("deferred candidate should retain its DMG")?; + std::fs::remove_file(&dmg_path)?; + + server.verify().await; + server.reset().await; + mount_dmg(&server, "\"candidate-a\"", body).await; + let error = run_check_now(&config, &mut state, &paths, false) + .await + .expect_err("the redownload should reach the intentionally missing builder"); + assert!(error + .to_string() + .contains("Required builder bundle path is missing")); + assert_eq!(std::fs::read(&dmg_path)?, body); + assert!(!state.deferred_build); + server.verify().await; + Ok(()) + }) + } + + #[test] + fn disabling_feature_builds_a_persisted_deferred_candidate() -> Result<()> { + let _env_guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&[ + "CODEX_LINUX_SETTINGS_FILE", + "CODEX_UPDATE_MANAGER_SKIP_SYSTEM_CLI_LOOKUP", + ]); + let runtime = tokio::runtime::Runtime::new()?; + + runtime.block_on(async { + let server = MockServer::start().await; + let body = b"candidate-a"; + mount_dmg(&server, "\"candidate-a\"", body).await; + let temp = tempfile::tempdir()?; + let paths = test_paths(temp.path()); + paths.ensure_dirs()?; + std::env::set_var("CODEX_UPDATE_MANAGER_SKIP_SYSTEM_CLI_LOOKUP", "1"); + let mut config = test_config(temp.path()); + config.dmg_url = format!("{}/Codex.dmg", server.uri()); + configure_deferred_build_feature(temp.path(), &config, true, false)?; + let mut state = PersistedState::new(true); + state.save(&paths.state_file)?; + + run_check_cycle_with_options( + &config, + &mut state, + &paths, + CheckCycleOptions { + lock_behavior: CheckLockBehavior::SkipIfBusy, + if_stale: false, + recover_entrypoint_state: false, + reconcile_after_check: false, + explicit_build: false, + }, + ) + .await?; + assert!(state.deferred_build); + + let mut persisted = serde_json::to_value(&state)?; + persisted["status"] = serde_json::Value::String("update_available".to_string()); + persisted + .as_object_mut() + .expect("state should serialize as an object") + .remove("deferred_build"); + std::fs::write(&paths.state_file, serde_json::to_vec_pretty(&persisted)?)?; + std::fs::write(temp.path().join("linux-features.json"), r#"{"enabled":[]}"#)?; + server.verify().await; + server.reset().await; + mount_dmg(&server, "\"candidate-a\"", body).await; + + let error = run_check_cycle_with_options( + &config, + &mut state, + &paths, + CheckCycleOptions { + lock_behavior: CheckLockBehavior::SkipIfBusy, + if_stale: false, + recover_entrypoint_state: false, + reconcile_after_check: false, + explicit_build: false, + }, + ) + .await + .expect_err("disabling the feature should restore automatic builds"); + assert!(error + .to_string() + .contains("Required builder bundle path is missing")); + assert!(!state.deferred_build); + server.verify().await; + Ok(()) + }) + } + #[tokio::test] async fn interrupted_download_with_cached_hash_reaches_build_path() -> Result<()> { let server = MockServer::start().await; @@ -2966,7 +3615,13 @@ mod tests { root.join("missing-settings.json"), ) .env_remove("CODEX_UPDATE_MANAGER_ASSUME_NO_POLKIT_AGENT") - .env("CODEX_UPDATE_MANAGER_ASSUME_POLKIT_AGENT", "1"); + .env("CODEX_UPDATE_MANAGER_ASSUME_POLKIT_AGENT", "1") + .env_remove("CODEX_CLI_PATH") + .env_remove("FNM_DIR") + .env_remove("FNM_MULTISHELL_PATH") + .env_remove("HOMEBREW_PREFIX") + .env_remove("NVM_DIR") + .env_remove("XDG_DATA_HOME"); command.process_group(0); } @@ -3160,6 +3815,34 @@ mod tests { &config, &mut state, &paths, )) } + "cli-preflight" => { + let cli_path = std::env::var_os("CODEX_UPDATE_MANAGER_TEST_CLI_PATH") + .map(PathBuf::from) + .context("missing process test CLI path")?; + runtime.block_on(run(Cli { + command: Commands::CliPreflight { + cli_path: Some(cli_path), + print_path: false, + allow_install_missing: false, + }, + })) + } + "cli-preflight-install-missing" => runtime.block_on(run(Cli { + command: Commands::CliPreflight { + cli_path: None, + print_path: false, + allow_install_missing: true, + }, + })), + "cli-status" => runtime.block_on(run(Cli { + command: Commands::Status { json: true }, + })), + "repair-cli" => runtime.block_on(run(Cli { + command: Commands::RepairCli, + })), + "diagnose" => runtime.block_on(run(Cli { + command: Commands::Diagnose { json: false }, + })), other => anyhow::bail!("Unknown updater process test role {other}"), } } @@ -4560,6 +5243,7 @@ mod tests { temp.path() .join("cache/workspaces/2026.04.28.082247+abcdef12"), ); + state.save(&paths.state_file)?; let original_home = std::env::var_os("HOME"); let original_path = std::env::var_os("PATH"); diff --git a/updater/src/app/tests/cli_repair_process_tests.rs b/updater/src/app/tests/cli_repair_process_tests.rs new file mode 100644 index 000000000..ff786ec2c --- /dev/null +++ b/updater/src/app/tests/cli_repair_process_tests.rs @@ -0,0 +1,408 @@ +use super::*; +use std::{ + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, +}; + +fn write_executable(path: &Path, contents: &str) -> Result<()> { + std::fs::write(path, contents)?; + let mut permissions = std::fs::metadata(path)?.permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions)?; + Ok(()) +} + +fn secure_tree(path: &Path) -> Result<()> { + let metadata = std::fs::symlink_metadata(path)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Ok(()); + } + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?; + for entry in std::fs::read_dir(path)? { + secure_tree(&entry?.path())?; + } + Ok(()) +} + +struct CliRepairProcessFixture { + root: PathBuf, + paths: RuntimePaths, + bin_dir: PathBuf, + active_package: PathBuf, + stale_directory: PathBuf, + cli_path: PathBuf, + install_log: PathBuf, + install_started: PathBuf, + install_release: PathBuf, + install_overlap: PathBuf, + owner_dir: PathBuf, + view_log: PathBuf, +} + +impl CliRepairProcessFixture { + fn env(&self) -> Vec<(&'static str, &Path)> { + vec![ + ("PATH", &self.bin_dir), + ("CODEX_UPDATE_MANAGER_TEST_CLI_PATH", &self.cli_path), + ("CODEX_UPDATE_MANAGER_SKIP_SYSTEM_CLI_LOOKUP", &self.root), + ("NPM_ACTIVE_PACKAGE", &self.active_package), + ("NPM_RETIREMENT_PATH", &self.stale_directory), + ("NPM_MANAGED_CLI", &self.cli_path), + ("NPM_INSTALL_LOG", &self.install_log), + ("NPM_INSTALL_STARTED", &self.install_started), + ("NPM_INSTALL_RELEASE", &self.install_release), + ("NPM_INSTALL_OVERLAP", &self.install_overlap), + ("NPM_OWNER_DIR", &self.owner_dir), + ("NPM_VIEW_LOG", &self.view_log), + ] + } +} + +fn prepare_fixture(root: &Path) -> Result { + let paths = process_test_paths(root); + paths.ensure_dirs()?; + let home = root.join("home"); + let bin_dir = root.join("cli-bin"); + let prefix = home.join(".codex-cli-npm"); + let managed_bin = prefix.join("bin"); + let active_package = prefix.join("lib/node_modules/@openai/codex"); + let stale_directory = prefix + .join("lib/node_modules/@openai") + .join(".codex-cqYkmGXr"); + std::fs::create_dir_all(&active_package)?; + std::fs::create_dir_all(&stale_directory)?; + std::fs::create_dir_all(&bin_dir)?; + std::fs::create_dir_all(&managed_bin)?; + + let mut config = test_config(root); + config.workspace_root = paths.cache_dir.clone(); + std::fs::write(&paths.config_file, toml::to_string(&config)?)?; + + let cli_path = managed_bin.join("codex"); + write_executable( + &cli_path, + "#!/bin/sh\nif [ \"$1\" = \"--version\" ] || [ \"$1\" = \"version\" ]; then\n echo 'codex-cli v0.42.0'\n exit 0\nfi\nexit 1\n", + )?; + write_executable(&bin_dir.join("node"), "#!/bin/sh\nexit 0\n")?; + write_executable( + &bin_dir.join("npm"), + r#"#!/bin/sh + if [ "$1" = "view" ]; then + printf 'view\n' >> "$NPM_VIEW_LOG" + if [ "${NPM_VIEW_RESULT:-success}" = "failure" ]; then + printf 'registry unavailable\n' >&2 + exit 43 + fi + echo '0.42.1' + exit 0 +fi +if [ "$1" = "install" ]; then + printf '%s\n' "$$" >> "$NPM_INSTALL_LOG" + if /bin/mkdir "$NPM_OWNER_DIR" 2>/dev/null; then + /bin/touch "$NPM_INSTALL_STARTED" + while [ ! -e "$NPM_INSTALL_RELEASE" ]; do + /bin/sleep 0.01 + done + else + /bin/touch "$NPM_INSTALL_OVERLAP" + fi + if [ "${NPM_INSTALL_RESULT:-stale}" = "success" ]; then + printf '%s\n' '#!/bin/sh' 'echo "codex-cli v0.42.1"' > "$NPM_MANAGED_CLI" + /bin/chmod 755 "$NPM_MANAGED_CLI" + exit 0 + fi + printf '%s\n' \ + 'npm error code ENOTEMPTY' \ + 'npm error syscall rename' \ + "npm error path $NPM_ACTIVE_PACKAGE" \ + "npm error dest $NPM_RETIREMENT_PATH" >&2 + exit 217 +fi +exit 1 +"#, + )?; + secure_tree(&home)?; + + let mut state = PersistedState::new(true); + state.cli_path = Some(cli_path.clone()); + state.cli_install_channel = Some(crate::state::CliInstallChannel::Npm); + state.save(&paths.state_file)?; + Ok(CliRepairProcessFixture { + root: root.to_path_buf(), + paths, + bin_dir, + active_package, + stale_directory, + cli_path, + install_log: root.join("npm-install.log"), + install_started: root.join("npm-install.started"), + install_release: root.join("npm-install.release"), + install_overlap: root.join("npm-install.overlap"), + owner_dir: root.join("npm-install.owner"), + view_log: root.join("npm-view.log"), + }) +} + +#[test] +fn waiting_status_revalidates_successful_cli_install_before_running_npm() -> Result<()> { + let _env_guard = crate::test_util::env_lock(); + let temp = tempfile::tempdir()?; + let fixture = prepare_fixture(temp.path())?; + let lock_waiting = temp.path().join("cli-install-lock.waiting"); + let mut common_env = fixture.env(); + common_env.push(("NPM_INSTALL_RESULT", Path::new("success"))); + + let first = spawn_process_test_child( + temp.path(), + "cli-preflight", + &common_env, + &[&fixture.install_release], + )?; + wait_for_process_test_path(&fixture.install_started, "first successful npm install")?; + + let mut persisted = PersistedState::load_or_default(&fixture.paths.state_file, true)?; + persisted.cli_last_check_at = None; + persisted.remote_headers_fingerprint = Some("must-survive-success-race".to_string()); + persisted.save(&fixture.paths.state_file)?; + + let mut second_env = common_env.clone(); + second_env.push(( + "CODEX_UPDATE_MANAGER_TEST_CLI_INSTALL_LOCK_WAITING", + &lock_waiting, + )); + let second = spawn_process_test_child(temp.path(), "cli-status", &second_env, &[])?; + wait_for_process_test_path(&lock_waiting, "status CLI install lock wait")?; + + std::fs::write(&fixture.install_release, b"continue")?; + first.wait()?; + second.wait()?; + + assert_eq!( + std::fs::read_to_string(&fixture.install_log)? + .lines() + .count(), + 1 + ); + assert!(!fixture.install_overlap.exists()); + assert!(crate::npm_cli_repair::load(&fixture.paths)?.is_none()); + let persisted = PersistedState::load_or_default(&fixture.paths.state_file, true)?; + assert_eq!(persisted.cli_status, CliStatus::UpToDate); + assert_eq!(persisted.cli_installed_version.as_deref(), Some("0.42.1")); + assert_eq!( + persisted.remote_headers_fingerprint.as_deref(), + Some("must-survive-success-race") + ); + Ok(()) +} + +#[test] +fn explicit_repair_preserves_concurrent_status_state_and_quarantine() -> Result<()> { + let _env_guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&["HOME"]); + let temp = tempfile::tempdir()?; + let fixture = prepare_fixture(temp.path())?; + std::env::set_var("HOME", temp.path().join("home")); + crate::npm_cli_repair::write_detected_for_test(&fixture.paths, ".codex-cqYkmGXr")?; + let mut common_env = fixture.env(); + common_env.push(("NPM_INSTALL_RESULT", Path::new("success"))); + let lock_waiting = temp.path().join("cli-install-lock.waiting"); + + let repair = spawn_process_test_child( + temp.path(), + "repair-cli", + &common_env, + &[&fixture.install_release], + )?; + wait_for_process_test_path(&fixture.install_started, "explicit repair npm install")?; + let quarantine_path = crate::npm_cli_repair::snapshot(&fixture.paths)? + .and_then(|snapshot| snapshot.quarantine_paths.into_iter().next()) + .context("repair should persist its quarantine before running npm")?; + assert!(!fixture.stale_directory.exists()); + assert!(quarantine_path.exists()); + + let mut persisted = PersistedState::load_or_default(&fixture.paths.state_file, true)?; + persisted.remote_headers_fingerprint = Some("must-survive-explicit-repair".to_string()); + persisted.save(&fixture.paths.state_file)?; + let mut status_env = common_env.clone(); + status_env.push(( + "CODEX_UPDATE_MANAGER_TEST_CLI_INSTALL_LOCK_WAITING", + &lock_waiting, + )); + let status = spawn_process_test_child(temp.path(), "cli-status", &status_env, &[])?; + wait_for_process_test_path(&lock_waiting, "status wait during explicit repair")?; + + assert_eq!( + std::fs::read_to_string(&fixture.install_log)? + .lines() + .count(), + 1 + ); + assert!(!fixture.install_overlap.exists()); + assert!(crate::npm_cli_repair::load(&fixture.paths)?.is_some()); + + std::fs::write(&fixture.install_release, b"continue")?; + repair.wait()?; + status.wait()?; + + assert_eq!( + std::fs::read_to_string(&fixture.install_log)? + .lines() + .count(), + 1 + ); + assert!(!fixture.install_overlap.exists()); + assert!(crate::npm_cli_repair::load(&fixture.paths)?.is_none()); + assert!(quarantine_path.exists()); + let persisted = PersistedState::load_or_default(&fixture.paths.state_file, true)?; + assert_eq!(persisted.cli_status, CliStatus::UpToDate); + assert_eq!(persisted.cli_installed_version.as_deref(), Some("0.42.1")); + assert_eq!( + persisted.remote_headers_fingerprint.as_deref(), + Some("must-survive-explicit-repair") + ); + Ok(()) +} + +#[test] +fn missing_cli_waiter_uses_the_repair_completed_under_the_lock() -> Result<()> { + let _env_guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&["HOME"]); + let temp = tempfile::tempdir()?; + let fixture = prepare_fixture(temp.path())?; + std::env::set_var("HOME", temp.path().join("home")); + crate::npm_cli_repair::write_detected_for_test(&fixture.paths, ".codex-cqYkmGXr")?; + std::fs::remove_file(&fixture.cli_path)?; + + let mut repair_env = fixture.env(); + repair_env.push(("NPM_INSTALL_RESULT", Path::new("success"))); + let repair = spawn_process_test_child( + temp.path(), + "repair-cli", + &repair_env, + &[&fixture.install_release], + )?; + wait_for_process_test_path(&fixture.install_started, "explicit repair npm install")?; + let views_before_waiter = std::fs::read_to_string(&fixture.view_log)?.lines().count(); + + let lock_waiting = temp.path().join("missing-cli-lock.waiting"); + let mut missing_env = fixture.env(); + missing_env.push(( + "CODEX_UPDATE_MANAGER_TEST_CLI_INSTALL_LOCK_WAITING", + &lock_waiting, + )); + missing_env.push(("NPM_VIEW_RESULT", Path::new("failure"))); + let missing = spawn_process_test_child( + temp.path(), + "cli-preflight-install-missing", + &missing_env, + &[], + )?; + wait_for_process_test_path(&lock_waiting, "missing CLI install lock wait")?; + + std::fs::write(&fixture.install_release, b"continue")?; + repair.wait()?; + missing.wait()?; + + assert_eq!( + std::fs::read_to_string(&fixture.view_log)?.lines().count(), + views_before_waiter + ); + assert_eq!( + std::fs::read_to_string(&fixture.install_log)? + .lines() + .count(), + 1 + ); + let persisted = PersistedState::load_or_default(&fixture.paths.state_file, true)?; + assert_eq!(persisted.cli_status, CliStatus::UpToDate); + assert_eq!(persisted.cli_installed_version.as_deref(), Some("0.42.1")); + assert_eq!(persisted.cli_error_message, None); + Ok(()) +} + +#[test] +fn status_loaded_before_repair_cannot_restore_its_stale_cli_snapshot() -> Result<()> { + let _env_guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&["HOME"]); + let temp = tempfile::tempdir()?; + let fixture = prepare_fixture(temp.path())?; + std::env::set_var("HOME", temp.path().join("home")); + crate::npm_cli_repair::write_detected_for_test(&fixture.paths, ".codex-cqYkmGXr")?; + + let entrypoint_loaded = temp.path().join("entrypoint.loaded"); + let entrypoint_continue = temp.path().join("entrypoint.continue"); + let mut status_env = fixture.env(); + status_env.push(( + "CODEX_UPDATE_MANAGER_TEST_ENTRYPOINT_LOADED", + &entrypoint_loaded, + )); + status_env.push(( + "CODEX_UPDATE_MANAGER_TEST_ENTRYPOINT_CONTINUE", + &entrypoint_continue, + )); + status_env.push(("NPM_VIEW_RESULT", Path::new("failure"))); + let status = spawn_process_test_child( + temp.path(), + "cli-status", + &status_env, + &[&entrypoint_continue], + )?; + wait_for_process_test_path(&entrypoint_loaded, "status initial state load")?; + + std::fs::write(&fixture.install_release, b"continue")?; + let mut repair_env = fixture.env(); + repair_env.push(("NPM_INSTALL_RESULT", Path::new("success"))); + spawn_process_test_child(temp.path(), "repair-cli", &repair_env, &[])?.wait()?; + let views_after_repair = std::fs::read_to_string(&fixture.view_log)?.lines().count(); + assert_eq!(views_after_repair, 1); + assert!(crate::npm_cli_repair::load(&fixture.paths)?.is_none()); + + std::fs::write(&entrypoint_continue, b"continue")?; + status.wait()?; + + assert_eq!( + std::fs::read_to_string(&fixture.view_log)?.lines().count(), + views_after_repair + ); + let persisted = PersistedState::load_or_default(&fixture.paths.state_file, true)?; + assert_eq!(persisted.cli_status, CliStatus::UpToDate); + assert_eq!(persisted.cli_installed_version.as_deref(), Some("0.42.1")); + assert_eq!(persisted.cli_error_message, None); + Ok(()) +} + +#[test] +fn diagnose_subprocess_is_read_only_and_explains_pending_cli_repair() -> Result<()> { + let _env_guard = crate::test_util::env_lock(); + let absent = tempfile::tempdir()?; + let child = spawn_process_test_child(absent.path(), "diagnose", &[], &[])?; + child.wait()?; + assert!(!absent.path().join("xdg-config").exists()); + assert!(!absent.path().join("xdg-state").exists()); + assert!(!absent.path().join("xdg-cache").exists()); + + let pending = tempfile::tempdir()?; + let paths = process_test_paths(pending.path()); + std::fs::create_dir_all(&paths.state_dir)?; + crate::npm_cli_repair::write_detected_for_test(&paths, ".codex-cqYkmGXr")?; + let journal_path = paths.state_dir.join("cli-repair.json"); + let before = std::fs::read(&journal_path)?; + + let mut command = std::process::Command::new(std::env::current_exe()?); + configure_process_test_command(&mut command, pending.path(), "diagnose"); + let output = command.output()?; + + anyhow::ensure!( + output.status.success(), + "diagnose subprocess failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("stale npm retirement directory")); + assert!(stdout.contains("codex-update-manager repair-cli")); + assert_eq!(std::fs::read(journal_path)?, before); + assert!(!paths.log_file.exists()); + assert!(!paths.config_file.exists()); + assert!(!paths.cache_dir.exists()); + Ok(()) +} diff --git a/updater/src/builder.rs b/updater/src/builder.rs index 1172da794..664dfa15f 100644 --- a/updater/src/builder.rs +++ b/updater/src/builder.rs @@ -122,13 +122,13 @@ pub async fn build_update_from( state.status = UpdateStatus::PreparingWorkspace; state.artifact_paths.workspace_dir = Some(workspace.workspace_dir.clone()); - state.save(&paths.state_file)?; + state.save_updater(&paths.state_file)?; copy_builder_bundle(bundle_source, &workspace.bundle_dir)?; stage_git_source_info(bundle_source, &workspace.bundle_dir)?; state.status = UpdateStatus::PatchingApp; - state.save(&paths.state_file)?; + state.save_updater(&paths.state_file)?; let feature_config = crate::config::effective_feature_config_path(config); let mut install = Command::new(workspace.bundle_dir.join("install.sh")); install @@ -158,7 +158,7 @@ pub async fn build_update_from( .context("install.sh failed during local rebuild")?; state.status = UpdateStatus::BuildingPackage; - state.save(&paths.state_file)?; + state.save_updater(&paths.state_file)?; let build_script = package_build_script(&workspace.bundle_dir); let mut package_build = Command::new(&build_script); @@ -190,7 +190,7 @@ pub async fn build_update_from( package_path: Some(package_path.clone()), rollback_package_path: state.artifact_paths.rollback_package_path.clone(), }; - state.save(&paths.state_file)?; + state.save_updater(&paths.state_file)?; info!(candidate_version, package = %package_path.display(), "local update build ready"); Ok(BuildArtifacts { @@ -700,8 +700,10 @@ mod tests { const FRESH_PATCH_BUNDLE_FILES: &[&str] = &[ "scripts/patches/descriptor.js", "scripts/patches/engine.js", + "scripts/patches/integrity-error.js", "scripts/patches/runner.js", "scripts/patches/lib/assets.js", + "scripts/patches/lib/composition-delegation.js", "scripts/patches/lib/minified-js.js", "scripts/patches/lib/settings-keys.js", "scripts/patches/impl/webview/index.js", @@ -1308,6 +1310,7 @@ fi #[test] fn fake_package_builders_emit_source_info() -> Result<()> { + let _env_guard = crate::test_util::env_lock(); let temp = tempdir()?; for (index, output) in [ FakePackageOutput::Deb, diff --git a/updater/src/cli.rs b/updater/src/cli.rs index 7800541c7..5b646ab0f 100644 --- a/updater/src/cli.rs +++ b/updater/src/cli.rs @@ -1,7 +1,7 @@ //! Command-line interface definition for the updater binary. use clap::{Parser, Subcommand}; -use std::path::PathBuf; +use std::{ffi::OsString, path::PathBuf}; #[derive(Debug, Parser)] #[command(name = "codex-update-manager")] @@ -52,6 +52,19 @@ pub enum Commands { #[arg(long)] print_path: bool, }, + RepairCli, + #[command(hide = true)] + RunNpmSupervisor { + #[arg(long)] + owner_pid: u32, + #[arg(long)] + timeout_millis: u64, + #[arg(long)] + install_lock_fd: i32, + program: PathBuf, + #[arg(last = true, allow_hyphen_values = true)] + args: Vec, + }, PromptInstallCli { #[arg(long)] cli_path: Option, diff --git a/updater/src/codex_cli.rs b/updater/src/codex_cli.rs index 082a13492..80250a31c 100644 --- a/updater/src/codex_cli.rs +++ b/updater/src/codex_cli.rs @@ -3,6 +3,7 @@ use crate::{ cli_management, config::RuntimePaths, + npm_cli_repair, state::{CliInstallChannel, CliStatus, PersistedState}, }; use anyhow::{anyhow, Context, Result}; @@ -14,8 +15,9 @@ use std::{ ffi::{OsStr, OsString}, fs, io::{Read, Write}, + os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}, os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt}, - os::unix::process::CommandExt, + os::unix::process::{CommandExt, ExitStatusExt}, path::{Path, PathBuf}, process::{Command, ExitStatus, Output, Stdio}, sync::mpsc::{self, Receiver, RecvTimeoutError}, @@ -35,6 +37,7 @@ const CLI_PREFLIGHT_VERSION_TIMEOUT: StdDuration = StdDuration::from_secs(5); const BOUNDED_COMMAND_POLL_INTERVAL: StdDuration = StdDuration::from_millis(50); const BOUNDED_COMMAND_TERMINATION_GRACE: StdDuration = StdDuration::from_millis(500); const BOUNDED_COMMAND_OUTPUT_DRAIN_TIMEOUT: StdDuration = StdDuration::from_secs(1); +const NPM_SUPERVISOR_EXIT_GRACE: StdDuration = StdDuration::from_secs(2); const BOUNDED_COMMAND_OUTPUT_LIMIT: usize = 64 * 1024; const SIGTERM: i32 = 15; const SIGKILL: i32 = 9; @@ -54,6 +57,29 @@ pub struct PreflightOutcome { pub updated: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +enum CliUpdateOutcome { + Updated(Option), + RepairRequired, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CliRepairOutcome { + pub installed_version: String, + pub quarantine_paths: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ManagedCliInstall { + cli_path: PathBuf, + installed_version: String, +} + +enum OptionalDependencyRepairOutcome { + Functional(String), + RepairRequired, +} + pub fn preflight( state: &mut PersistedState, paths: &RuntimePaths, @@ -76,16 +102,19 @@ fn preflight_with_version_timeout( allow_install_missing: bool, version_timeout: StdDuration, ) -> Result { + let mut routine_baseline = state.clone(); let requested_path = explicit_cli_path.as_deref(); let (selected_cli_path, installed_missing_cli) = match resolve_cli_path(requested_path) { Some(path) => (path, false), - None if allow_install_missing => match install_missing_cli(state, paths, requested_path) { - Ok(path) => (path, true), - Err(error) => { - persist_cli_failure(state, paths, &error)?; - return Err(error); + None if allow_install_missing => { + match install_missing_cli(state, paths, &mut routine_baseline, requested_path) { + Ok(result) => result, + Err(error) => { + persist_cli_failure(state, paths, &error, &mut routine_baseline)?; + return Err(error); + } } - }, + } None => anyhow::bail!("Codex CLI not found in PATH or known install locations"), }; let cli_path = match stable_cli_launch_path(&selected_cli_path) { @@ -97,7 +126,7 @@ fn preflight_with_version_timeout( state.cli_installed_version = None; state.cli_package_manager_latest_version = None; state.cli_last_verified_at = None; - persist_cli_failure(state, paths, &error)?; + persist_cli_failure(state, paths, &error, &mut routine_baseline)?; return Err(error); } }; @@ -118,15 +147,33 @@ fn preflight_with_version_timeout( Err(probe_error) => { let Some(missing_dependency) = missing_platform_optional_dependency(&probe_error) else { - persist_new_cli_probe_failure(installed_missing_cli, state, paths, &probe_error)?; + persist_new_cli_probe_failure( + installed_missing_cli, + state, + paths, + &probe_error, + &mut routine_baseline, + )?; return Err(probe_error); }; if managed_cli.is_some() { - persist_new_cli_probe_failure(installed_missing_cli, state, paths, &probe_error)?; + persist_new_cli_probe_failure( + installed_missing_cli, + state, + paths, + &probe_error, + &mut routine_baseline, + )?; return Err(probe_error); } let Some(npm_install) = npm_cli_install(&cli_path, &missing_dependency) else { - persist_new_cli_probe_failure(installed_missing_cli, state, paths, &probe_error)?; + persist_new_cli_probe_failure( + installed_missing_cli, + state, + paths, + &probe_error, + &mut routine_baseline, + )?; return Err(probe_error); }; @@ -141,25 +188,41 @@ fn preflight_with_version_timeout( state.cli_last_verified_at = None; state.cli_status = CliStatus::Updating; state.cli_error_message = None; - persist_state(paths, state)?; + if persist_routine_state(paths, state, &mut routine_baseline)? { + return Err(anyhow!( + "Codex CLI repair is already pending. Run `codex-update-manager diagnose` for details and repair instructions." + )); + } - let repaired_version = repair_npm_optional_dependency(&npm_install) - .and_then(|()| { - read_installed_version_bounded(&cli_path, version_timeout) - }) - .with_context(|| { - format!( - "Failed to repair npm-managed Codex CLI at {} after its version probe failed: {probe_error}", - cli_path.display() - ) - }); + let repaired_version = match repair_npm_optional_dependency( + &npm_install, + paths, + &cli_path, + version_timeout, + ) { + Ok(OptionalDependencyRepairOutcome::Functional(version)) => Ok(version), + Ok(OptionalDependencyRepairOutcome::RepairRequired) => { + set_cli_repair_required(state); + persist_routine_state(paths, state, &mut routine_baseline)?; + return Err(anyhow!( + "Codex CLI repair is already pending. Run `codex-update-manager diagnose` for details and repair instructions." + )); + } + Err(error) => Err(error), + } + .with_context(|| { + format!( + "Failed to repair npm-managed Codex CLI at {} after its version probe failed: {probe_error}", + cli_path.display() + ) + }); match repaired_version { Ok(version) => { repaired_npm_install = Some(npm_install); version } Err(error) => { - persist_cli_failure(state, paths, &error)?; + persist_cli_failure(state, paths, &error, &mut routine_baseline)?; return Err(error); } } @@ -180,7 +243,13 @@ fn preflight_with_version_timeout( .as_ref() .map(|status| status.latest_version.clone()); state.cli_last_verified_at = Some(Utc::now()); - persist_state(paths, state)?; + if persist_routine_state(paths, state, &mut routine_baseline)? { + return Ok(preflight_outcome_from_current_state( + state, + cli_path, + installed_version, + )); + } if should_skip_latest_version_check( state, @@ -198,7 +267,13 @@ fn preflight_with_version_timeout( managed_cli.as_ref(), package_manager_version_status.as_ref(), ); - persist_state(paths, state)?; + if persist_routine_state(paths, state, &mut routine_baseline)? { + return Ok(preflight_outcome_from_current_state( + state, + cli_path, + installed_version, + )); + } return Ok(preflight_outcome_from_state( cli_path, installed_version, @@ -210,7 +285,13 @@ fn preflight_with_version_timeout( state.cli_last_check_at = Some(Utc::now()); state.cli_error_message = None; state.cli_status = CliStatus::Checking; - persist_state(paths, state)?; + if persist_routine_state(paths, state, &mut routine_baseline)? { + return Ok(preflight_outcome_from_current_state( + state, + cli_path, + installed_version, + )); + } let latest_version_result = repaired_npm_install @@ -231,7 +312,13 @@ fn preflight_with_version_timeout( state.cli_error_message = Some(format!( "Could not check the latest {CLI_PACKAGE_NAME} version: {error}" )); - persist_state(paths, state)?; + if persist_routine_state(paths, state, &mut routine_baseline)? { + return Ok(preflight_outcome_from_current_state( + state, + cli_path, + installed_version, + )); + } warn!(?error, "unable to check latest Codex CLI version"); return Ok(preflight_outcome_from_state( cli_path, @@ -256,7 +343,13 @@ fn preflight_with_version_timeout( ); if managed_cli.is_some() { - persist_state(paths, state)?; + if persist_routine_state(paths, state, &mut routine_baseline)? { + return Ok(preflight_outcome_from_current_state( + state, + cli_path, + installed_version, + )); + } return Ok(preflight_outcome_from_state( cli_path, installed_version, @@ -273,7 +366,13 @@ fn preflight_with_version_timeout( "This Codex CLI appears to be installed through Homebrew at {}. Update it with Homebrew; ChatGPT Desktop will not replace it with an npm-managed install.", cli_path.display() )); - persist_state(paths, state)?; + if persist_routine_state(paths, state, &mut routine_baseline)? { + return Ok(preflight_outcome_from_current_state( + state, + cli_path, + installed_version, + )); + } return Ok(preflight_outcome_from_state( cli_path, installed_version, @@ -290,7 +389,13 @@ fn preflight_with_version_timeout( state.cli_error_message = Some(format!( "Could not check the latest {CLI_PACKAGE_NAME} version" )); - persist_state(paths, state)?; + if persist_routine_state(paths, state, &mut routine_baseline)? { + return Ok(preflight_outcome_from_current_state( + state, + cli_path, + installed_version, + )); + } return Ok(preflight_outcome_from_state( cli_path, installed_version, @@ -300,7 +405,13 @@ fn preflight_with_version_timeout( } }; if state.cli_status == CliStatus::UpToDate { - persist_state(paths, state)?; + if persist_routine_state(paths, state, &mut routine_baseline)? { + return Ok(preflight_outcome_from_current_state( + state, + cli_path, + installed_version, + )); + } return Ok(preflight_outcome_from_state( cli_path, installed_version, @@ -309,7 +420,13 @@ fn preflight_with_version_timeout( )); } if repaired { - persist_state(paths, state)?; + if persist_routine_state(paths, state, &mut routine_baseline)? { + return Ok(preflight_outcome_from_current_state( + state, + cli_path, + installed_version, + )); + } return Ok(preflight_outcome_from_state( cli_path, installed_version, @@ -318,21 +435,48 @@ fn preflight_with_version_timeout( )); } - persist_state(paths, state)?; + if persist_routine_state(paths, state, &mut routine_baseline)? { + return Ok(preflight_outcome_from_current_state( + state, + cli_path, + installed_version, + )); + } info!( installed_version, latest_version, "Codex CLI is outdated; attempting prelaunch upgrade" ); state.cli_status = CliStatus::Updating; - persist_state(paths, state)?; - if let Err(error) = update_existing_cli(&cli_install_kind, &latest_version) { - persist_cli_failure(state, paths, &error)?; - return Err(error); + if persist_routine_state(paths, state, &mut routine_baseline)? { + return Ok(preflight_outcome_from_current_state( + state, + cli_path, + installed_version, + )); } + let managed_install = + match update_existing_cli(&cli_install_kind, &latest_version, state, paths) { + Ok(CliUpdateOutcome::Updated(managed_install)) => managed_install, + Ok(CliUpdateOutcome::RepairRequired) => { + set_cli_repair_required(state); + persist_routine_state(paths, state, &mut routine_baseline)?; + return Ok(preflight_outcome_from_current_state( + state, + cli_path, + installed_version, + )); + } + Err(error) => { + persist_cli_failure(state, paths, &error, &mut routine_baseline)?; + return Err(error); + } + }; + routine_baseline = state.clone(); - let (refreshed_path, refreshed_version) = if let Some(updated_cli) = - resolve_cli_path_with_version(requested_path, &latest_version) + let (refreshed_path, refreshed_version) = if let Some(managed_install) = managed_install { + (managed_install.cli_path, managed_install.installed_version) + } else if let Some(updated_cli) = resolve_cli_path_with_version(requested_path, &latest_version) { updated_cli } else { @@ -353,13 +497,25 @@ fn preflight_with_version_timeout( ); state.cli_status = CliStatus::Failed; state.cli_error_message = Some(message.clone()); - persist_state(paths, state)?; + if persist_routine_state(paths, state, &mut routine_baseline)? { + return Ok(preflight_outcome_from_current_state( + state, + refreshed_path, + refreshed_version, + )); + } anyhow::bail!(message); } state.cli_status = CliStatus::UpToDate; state.cli_error_message = None; - persist_state(paths, state)?; + if persist_routine_state(paths, state, &mut routine_baseline)? { + return Ok(preflight_outcome_from_current_state( + state, + refreshed_path, + refreshed_version, + )); + } Ok(preflight_outcome_from_state( refreshed_path, refreshed_version, @@ -434,12 +590,13 @@ pub fn refresh_cached_status(state: &mut PersistedState, paths: &RuntimePaths) - } pub fn refresh_status(state: &mut PersistedState, paths: &RuntimePaths) -> Result<()> { + let mut routine_baseline = state.clone(); let requested_path = requested_cli_path(state); let selected_cli_path = match resolve_cli_path(requested_path.as_deref()) { Some(path) => path, None => { mark_cli_missing(state); - persist_state(paths, state)?; + persist_routine_state(paths, state, &mut routine_baseline)?; return Ok(()); } }; @@ -456,7 +613,7 @@ pub fn refresh_status(state: &mut PersistedState, paths: &RuntimePaths) -> Resul state.cli_error_message = Some(format!( "Could not read the installed {CLI_PACKAGE_NAME} version: {error:#}" )); - persist_state(paths, state)?; + persist_routine_state(paths, state, &mut routine_baseline)?; warn!(?error, "unable to trust selected Codex CLI"); return Ok(()); } @@ -493,7 +650,7 @@ pub fn refresh_status(state: &mut PersistedState, paths: &RuntimePaths) -> Resul state.cli_error_message = Some(format!( "Could not read the installed {CLI_PACKAGE_NAME} version: {error}" )); - persist_state(paths, state)?; + persist_routine_state(paths, state, &mut routine_baseline)?; warn!(?error, "unable to read installed Codex CLI version"); return Ok(()); } @@ -535,14 +692,16 @@ pub fn refresh_status(state: &mut PersistedState, paths: &RuntimePaths) -> Resul managed_cli.as_ref(), package_manager_version_status.as_ref(), ); - persist_state(paths, state)?; + persist_routine_state(paths, state, &mut routine_baseline)?; return Ok(()); } state.cli_last_check_at = Some(Utc::now()); state.cli_error_message = None; state.cli_status = CliStatus::Checking; - persist_state(paths, state)?; + if persist_routine_state(paths, state, &mut routine_baseline)? { + return Ok(()); + } match read_latest_version() { Ok(latest_version) => { @@ -591,7 +750,7 @@ pub fn refresh_status(state: &mut PersistedState, paths: &RuntimePaths) -> Resul } } - persist_state(paths, state) + persist_routine_state(paths, state, &mut routine_baseline).map(|_| ()) } pub fn reconcile_if_present(state: &mut PersistedState, paths: &RuntimePaths) -> Result { @@ -604,18 +763,47 @@ pub fn reconcile_if_present(state: &mut PersistedState, paths: &RuntimePaths) -> Ok(preflight(state, paths, requested_path, false)?.updated) } -fn persist_state(paths: &RuntimePaths, state: &PersistedState) -> Result<()> { - state.save(&paths.state_file) +fn persist_state(paths: &RuntimePaths, state: &mut PersistedState) -> Result<()> { + state.save_cli(&paths.state_file) +} + +fn persist_routine_state( + paths: &RuntimePaths, + state: &mut PersistedState, + baseline: &mut PersistedState, +) -> Result { + let _install_lock = npm_cli_repair::acquire_install_lock(paths)?; + let repair_pending = npm_cli_repair::load(paths)?.is_some(); + if repair_pending { + set_cli_repair_required(state); + state.save_cli_status(&paths.state_file)?; + *baseline = state.clone(); + return Ok(true); + } + let persisted = state.save_cli_if_unchanged(&paths.state_file, baseline)?; + if persisted { + *baseline = state.clone(); + } + Ok(!persisted) } fn persist_cli_failure( state: &mut PersistedState, paths: &RuntimePaths, error: &anyhow::Error, + baseline: &mut PersistedState, ) -> Result<()> { state.cli_status = CliStatus::Failed; state.cli_error_message = Some(format!("{error:#}")); - persist_state(paths, state) + persist_routine_state(paths, state, baseline).map(|_| ()) +} + +fn set_cli_repair_required(state: &mut PersistedState) { + state.cli_status = CliStatus::UpdateRequired; + state.cli_error_message = Some( + "A stale npm retirement directory is blocking the Codex CLI update. The existing functional CLI remains in use. Run `codex-update-manager diagnose` for details and repair instructions." + .to_string(), + ); } fn persist_new_cli_probe_failure( @@ -623,9 +811,10 @@ fn persist_new_cli_probe_failure( state: &mut PersistedState, paths: &RuntimePaths, error: &anyhow::Error, + baseline: &mut PersistedState, ) -> Result<()> { if installed_missing_cli { - persist_cli_failure(state, paths, error)?; + persist_cli_failure(state, paths, error, baseline)?; } Ok(()) } @@ -633,7 +822,7 @@ fn persist_new_cli_probe_failure( #[cfg(test)] fn persist_if_changed( paths: &RuntimePaths, - state: &PersistedState, + state: &mut PersistedState, original_state: &PersistedState, ) -> Result<()> { if state != original_state { @@ -927,6 +1116,22 @@ fn preflight_outcome_from_state( } } +fn preflight_outcome_from_current_state( + state: &PersistedState, + fallback_path: PathBuf, + fallback_version: String, +) -> PreflightOutcome { + preflight_outcome_from_state( + state.cli_path.clone().unwrap_or(fallback_path), + state + .cli_installed_version + .clone() + .unwrap_or(fallback_version), + state, + false, + ) +} + fn installed_cli_version_satisfies_latest(installed_version: &str, latest_version: &str) -> bool { if installed_version == latest_version { return true; @@ -1023,7 +1228,7 @@ fn read_latest_version_with_npm_bounded( OsString::from(CLI_PACKAGE_NAME), OsString::from("version"), ]; - let output = run_bounded_command_output(npm, path_env, None, &args, timeout, false)?; + let output = run_bounded_command_output(npm, path_env, None, &args, timeout, false, None)?; parse_latest_version_output(npm, &output) } @@ -1155,13 +1360,32 @@ fn path_is_system_managed_location(path: &Path) -> bool { .any(|root| path.starts_with(root)) } -fn repair_npm_optional_dependency(install: &NpmCliInstall) -> Result<()> { - repair_npm_optional_dependency_with_timeout(install, NPM_REPAIR_INSTALL_TIMEOUT) +fn repair_npm_optional_dependency( + install: &NpmCliInstall, + paths: &RuntimePaths, + cli_path: &Path, + version_timeout: StdDuration, +) -> Result { + let install_lock = npm_cli_repair::acquire_install_lock(paths)?; + if npm_cli_repair::load(paths)?.is_some() { + return Ok(OptionalDependencyRepairOutcome::RepairRequired); + } + if let Ok(version) = read_installed_version_bounded(cli_path, version_timeout) { + return Ok(OptionalDependencyRepairOutcome::Functional(version)); + } + repair_npm_optional_dependency_with_timeout( + install, + NPM_REPAIR_INSTALL_TIMEOUT, + Some(&install_lock), + )?; + read_installed_version_bounded(cli_path, version_timeout) + .map(OptionalDependencyRepairOutcome::Functional) } fn repair_npm_optional_dependency_with_timeout( install: &NpmCliInstall, timeout: StdDuration, + install_lock: Option<&npm_cli_repair::InstallLock>, ) -> Result<()> { let args = [ OsString::from("install"), @@ -1174,6 +1398,7 @@ fn repair_npm_optional_dependency_with_timeout( &args, timeout, true, + install_lock, )?; anyhow::ensure!( @@ -1194,7 +1419,7 @@ fn run_bounded_command( args: &[OsString], timeout: StdDuration, ) -> Result { - let output = run_bounded_command_output(program, path_env, None, args, timeout, false)?; + let output = run_bounded_command_output(program, path_env, None, args, timeout, false, None)?; if !output.status.success() { anyhow::bail!( "{} exited with {}{}", @@ -1213,11 +1438,37 @@ fn run_bounded_command_output( args: &[OsString], timeout: StdDuration, safe_umask: bool, + install_lock: Option<&npm_cli_repair::InstallLock>, ) -> Result { - let mut command = Command::new(program); + let supervised = install_lock.is_some() && !cfg!(test); + let mut command = if supervised { + let timeout_millis = u64::try_from(timeout.as_millis()) + .context("bounded npm timeout does not fit in milliseconds")?; + let mut command = Command::new("/proc/self/exe"); + command + .arg("run-npm-supervisor") + .arg("--owner-pid") + .arg(std::process::id().to_string()) + .arg("--timeout-millis") + .arg(timeout_millis.to_string()) + .arg("--install-lock-fd") + .arg( + install_lock + .expect("supervised npm commands require the install lock") + .raw_fd() + .to_string(), + ) + .arg(program) + .arg("--") + .args(args); + command + } else { + let mut command = Command::new(program); + command.args(args); + command + }; command .env("PATH", path_env) - .args(args) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -1227,6 +1478,9 @@ fn run_bounded_command_output( if safe_umask { apply_safe_child_umask(&mut command); } + if let Some(install_lock) = install_lock { + install_lock.inherit_with(&mut command); + } command.process_group(0); let mut child = command @@ -1244,34 +1498,177 @@ fn run_bounded_command_output( let stdout_rx = spawn_bounded_output_reader(stdout); let stderr_rx = spawn_bounded_output_reader(stderr); let started = Instant::now(); + let parent_timeout = if supervised { + timeout.saturating_add(NPM_SUPERVISOR_EXIT_GRACE) + } else { + timeout + }; + + loop { + if supervised { + match child_has_exited_without_reaping(&child) { + Ok(true) => { + terminate_process_group_members(process_group, child.id() as i32); + let status = child.wait().with_context(|| { + format!( + "Failed to reap npm supervisor for {} {}", + program.display(), + format_command_args(args) + ) + })?; + return Ok(collect_bounded_output( + status, + process_group, + &stdout_rx, + &stderr_rx, + )); + } + Ok(false) => {} + Err(error) => { + terminate_process_group(&mut child, process_group); + let _ = child.wait(); + anyhow::bail!( + "Failed while waiting for {} {}: {error}", + program.display(), + format_command_args(args) + ); + } + } + } else { + match child.try_wait() { + Ok(Some(status)) => { + return Ok(collect_bounded_output( + status, + process_group, + &stdout_rx, + &stderr_rx, + )); + } + Ok(None) => {} + Err(error) => { + terminate_process_group(&mut child, process_group); + let _ = child.wait(); + anyhow::bail!( + "Failed while waiting for {} {}: {error}", + program.display(), + format_command_args(args) + ); + } + } + } + + if started.elapsed() >= parent_timeout { + terminate_process_group(&mut child, process_group); + let _ = child.wait(); + let _ = receive_bounded_output(&stdout_rx, process_group); + let _ = receive_bounded_output(&stderr_rx, process_group); + anyhow::bail!( + "{} {} timed out after {} seconds", + program.display(), + format_command_args(args), + parent_timeout.as_secs_f64() + ); + } + + thread::sleep( + BOUNDED_COMMAND_POLL_INTERVAL.min(parent_timeout.saturating_sub(started.elapsed())), + ); + } +} + +pub(crate) fn run_npm_supervisor( + owner_pid: u32, + timeout_millis: u64, + install_lock_fd: RawFd, + program: &Path, + args: &[OsString], +) -> Result<()> { + anyhow::ensure!(owner_pid != 0, "npm supervisor owner PID is invalid"); + anyhow::ensure!( + program.is_absolute(), + "npm supervisor program must be an absolute path" + ); + let timeout = StdDuration::from_millis(timeout_millis); + anyhow::ensure!( + !timeout.is_zero(), + "npm supervisor timeout must be positive" + ); + anyhow::ensure!( + current_parent_pid() == owner_pid, + "npm supervisor owner exited before npm started" + ); + set_close_on_exec(install_lock_fd).context("Failed to isolate the CLI install lock")?; + + let mut command = Command::new(program); + command + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + if cfg!(test) { + // Unit tests invoke the supervisor inside the shared test runner rather + // than through the production process-group boundary. + command.process_group(0); + } + let supervisor_pid = std::process::id(); + unsafe { + command.pre_exec(move || { + if libc::prctl(libc::PR_SET_PDEATHSIG, SIGKILL) == -1 { + return Err(std::io::Error::last_os_error()); + } + if libc::getppid() as u32 != supervisor_pid { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "npm supervisor exited before npm started", + )); + } + Ok(()) + }); + } + let mut child = command + .spawn() + .with_context(|| format!("Failed to spawn supervised npm {}", program.display()))?; + let supervisor_pid = std::process::id() as i32; + let process_group = if cfg!(test) { + child.id() as i32 + } else { + supervisor_pid + }; + let started = Instant::now(); loop { match child.try_wait() { Ok(Some(status)) => { - return Ok(collect_bounded_output( - status, - process_group, - &stdout_rx, - &stderr_rx, - )); + terminate_process_group_members(process_group, supervisor_pid); + if status.success() { + return Ok(()); + } + exit_with_status(status); } Ok(None) => {} Err(error) => { - terminate_process_group(&mut child, process_group); + terminate_process_group_members(process_group, supervisor_pid); + let _ = child.kill(); let _ = child.wait(); - anyhow::bail!( - "Failed while waiting for {} {}: {error}", - program.display(), - format_command_args(args) - ); + return Err(error).with_context(|| { + format!( + "Failed while waiting for supervised npm {}", + program.display() + ) + }); } } + if current_parent_pid() != owner_pid { + terminate_process_group_members(process_group, supervisor_pid); + let _ = child.kill(); + let _ = child.wait(); + anyhow::bail!("updater parent exited while npm was running"); + } if started.elapsed() >= timeout { - terminate_process_group(&mut child, process_group); + terminate_process_group_members(process_group, supervisor_pid); + let _ = child.kill(); let _ = child.wait(); - let _ = receive_bounded_output(&stdout_rx, process_group); - let _ = receive_bounded_output(&stderr_rx, process_group); anyhow::bail!( "{} {} timed out after {} seconds", program.display(), @@ -1284,6 +1681,49 @@ fn run_bounded_command_output( } } +fn set_close_on_exec(fd: RawFd) -> Result<()> { + anyhow::ensure!(fd >= 0, "npm supervisor install lock descriptor is invalid"); + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if flags == -1 { + return Err(std::io::Error::last_os_error()) + .context("Failed to inspect the inherited install lock descriptor"); + } + if unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) } == -1 { + return Err(std::io::Error::last_os_error()) + .context("Failed to protect the inherited install lock descriptor"); + } + Ok(()) +} + +fn current_parent_pid() -> u32 { + unsafe { libc::getppid() as u32 } +} + +fn child_has_exited_without_reaping(child: &std::process::Child) -> std::io::Result { + let mut info = std::mem::MaybeUninit::::zeroed(); + let result = unsafe { + libc::waitid( + libc::P_PID, + child.id(), + info.as_mut_ptr(), + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, + ) + }; + if result == -1 { + return Err(std::io::Error::last_os_error()); + } + let info = unsafe { info.assume_init() }; + Ok(unsafe { info.si_pid() } != 0) +} + +fn exit_with_status(status: ExitStatus) -> ! { + let code = status + .code() + .or_else(|| status.signal().map(|signal| 128 + signal)) + .unwrap_or(1); + std::process::exit(code); +} + fn spawn_bounded_output_reader(mut reader: R) -> Receiver> where R: Read + Send + 'static, @@ -1340,24 +1780,118 @@ fn terminate_process_group(child: &mut std::process::Child, process_group: i32) let _ = child.kill(); } -fn signal_process_group(process_group: i32, signal: i32) { - // SAFETY: the process was spawned into a dedicated group whose id is the - // child pid. Timeout cleanup signals it before reaping the child; after a - // successful parent exit this is called only if a descendant still holds a - // captured output pipe open. - unsafe { - let _ = kill(-process_group, signal); +fn terminate_process_group_members(process_group: i32, excluded_pid: i32) { + if !signal_process_group_members(process_group, excluded_pid, SIGTERM) { + return; } -} -#[derive(Debug, Clone, PartialEq, Eq)] -enum CliInstallKind { - Standalone(StandaloneCliInstall), - Homebrew, - Npm, + let deadline = Instant::now() + BOUNDED_COMMAND_TERMINATION_GRACE; + while Instant::now() < deadline { + if !process_group_has_members(process_group, excluded_pid) { + return; + } + thread::sleep(BOUNDED_COMMAND_POLL_INTERVAL.min(deadline - Instant::now())); + } + signal_process_group(process_group, SIGKILL); } -impl CliInstallKind { +fn signal_process_group_members(process_group: i32, excluded_pid: i32, signal: i32) -> bool { + let members = match process_group_member_pidfds(process_group, excluded_pid) { + Ok(members) => members, + Err(_) => { + signal_process_group(process_group, SIGKILL); + return true; + } + }; + for member in &members { + let result = unsafe { + libc::syscall( + libc::SYS_pidfd_send_signal, + member.as_raw_fd(), + signal, + std::ptr::null::(), + 0, + ) + }; + if result == -1 && std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) { + signal_process_group(process_group, SIGKILL); + return true; + } + } + !members.is_empty() +} + +fn process_group_has_members(process_group: i32, excluded_pid: i32) -> bool { + process_group_member_pidfds(process_group, excluded_pid) + .map(|members| !members.is_empty()) + .unwrap_or(true) +} + +fn process_group_member_pidfds( + process_group: i32, + excluded_pid: i32, +) -> std::io::Result> { + let mut members = Vec::new(); + for entry in fs::read_dir("/proc")? { + let entry = entry?; + let Some(pid) = entry + .file_name() + .to_str() + .and_then(|name| name.parse::().ok()) + else { + continue; + }; + if pid == excluded_pid || process_group_for_pid(pid)? != Some(process_group) { + continue; + } + let pidfd = { + let raw_fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) as i32 }; + if raw_fd == -1 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + continue; + } + return Err(error); + } + unsafe { OwnedFd::from_raw_fd(raw_fd) } + }; + if process_group_for_pid(pid)? == Some(process_group) { + members.push(pidfd); + } + } + Ok(members) +} + +fn process_group_for_pid(pid: i32) -> std::io::Result> { + let process_group = unsafe { libc::getpgid(pid) }; + if process_group >= 0 { + return Ok(Some(process_group)); + } + let error = std::io::Error::last_os_error(); + match error.raw_os_error() { + Some(libc::ESRCH | libc::EPERM) => Ok(None), + _ => Err(error), + } +} + +fn signal_process_group(process_group: i32, signal: i32) { + // SAFETY: callers target a dedicated process group while its leader is + // alive or deliberately unreaped. The fail-closed member cleanup path may + // also terminate its own supervisor, ensuring the lock cannot be released + // while an untracked npm descendant remains. + unsafe { + let _ = kill(-process_group, signal); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CliInstallKind { + Standalone(StandaloneCliInstall), + Homebrew, + Npm, +} + +impl CliInstallKind { fn channel(&self) -> CliInstallChannel { match self { CliInstallKind::Standalone(_) => CliInstallChannel::Standalone, @@ -1379,13 +1913,21 @@ impl StandaloneCliInstall { } } -fn update_existing_cli(install_kind: &CliInstallKind, latest_version: &str) -> Result<()> { +fn update_existing_cli( + install_kind: &CliInstallKind, + latest_version: &str, + state: &mut PersistedState, + paths: &RuntimePaths, +) -> Result { match install_kind { - CliInstallKind::Standalone(install) => update_standalone_cli(install, latest_version), + CliInstallKind::Standalone(install) => { + update_standalone_cli(install, latest_version)?; + Ok(CliUpdateOutcome::Updated(None)) + } CliInstallKind::Homebrew => { anyhow::bail!("Homebrew-managed Codex CLI installs must be updated with Homebrew") } - CliInstallKind::Npm => install_latest_cli(latest_version), + CliInstallKind::Npm => install_latest_cli(latest_version, state, paths), } } @@ -2030,11 +2572,41 @@ fn resolved_program_path(path: PathBuf) -> PathBuf { fs::canonicalize(&path).unwrap_or(path) } -fn install_latest_cli(latest_version: &str) -> Result<()> { +fn install_latest_cli( + latest_version: &str, + state: &mut PersistedState, + paths: &RuntimePaths, +) -> Result { + let install_lock = npm_cli_repair::acquire_install_lock(paths)?; + install_latest_cli_locked(latest_version, state, paths, &install_lock) +} + +fn install_latest_cli_locked( + latest_version: &str, + state: &mut PersistedState, + paths: &RuntimePaths, + install_lock: &npm_cli_repair::InstallLock, +) -> Result { let (npm, path_env) = npm_program()?; let package_spec = format!("{CLI_PACKAGE_NAME}@{latest_version}"); - let local_prefix = local_npm_prefix(); + let local_prefix = npm_cli_repair::managed_prefix(); prepare_safe_npm_prefix(&local_prefix)?; + if npm_cli_repair::load(paths)?.is_some() { + set_cli_repair_required(state); + state.save_cli_status(&paths.state_file)?; + return Ok(CliUpdateOutcome::RepairRequired); + } + match current_managed_install(latest_version) { + Ok(Some(install)) => { + record_managed_install(state, latest_version, &install); + persist_state(paths, state)?; + return Ok(CliUpdateOutcome::Updated(Some(install))); + } + Ok(None) => {} + Err(error) => { + return Err(error).context("Failed to validate the existing managed Codex CLI"); + } + } let local_args = vec![ OsString::from("install"), OsString::from("-g"), @@ -2043,8 +2615,33 @@ fn install_latest_cli(latest_version: &str) -> Result<()> { local_prefix.as_os_str().to_os_string(), OsString::from(&package_spec), ]; - run_npm_command(&npm, &path_env, &local_args) - .with_context(|| format!("npm install into {} failed", local_prefix.display())) + let first_output = run_bounded_command_output( + &npm, + &path_env, + None, + &local_args, + NPM_REPAIR_INSTALL_TIMEOUT, + true, + Some(install_lock), + )?; + if first_output.status.success() { + let install = current_managed_install(latest_version)?.with_context(|| { + format!("npm completed but managed Codex CLI {latest_version} could not be resolved") + })?; + record_managed_install(state, latest_version, &install); + persist_state(paths, state)?; + return Ok(CliUpdateOutcome::Updated(Some(install))); + } + + if npm_cli_repair::detect_and_persist(paths, &local_prefix, &first_output)?.is_some() { + set_cli_repair_required(state); + state.save_cli_status(&paths.state_file)?; + return Ok(CliUpdateOutcome::RepairRequired); + } + + ensure_npm_command_success(&npm, &local_args, first_output) + .with_context(|| format!("npm install into {} failed", local_prefix.display()))?; + unreachable!("failed npm output should have returned an error") } fn prepare_safe_npm_prefix(prefix: &Path) -> Result<()> { @@ -2079,30 +2676,349 @@ fn prepare_safe_npm_prefix(prefix: &Path) -> Result<()> { Ok(()) } +pub fn repair_cli(state: &mut PersistedState, paths: &RuntimePaths) -> Result { + let install_lock = npm_cli_repair::acquire_install_lock(paths)?; + let mut journal = npm_cli_repair::load(paths)? + .context("No Codex CLI repair is pending. Run `codex-update-manager diagnose` first.")?; + let initial_snapshot = npm_cli_repair::validate_journal(&journal)?; + let (npm, path_env) = match npm_program() { + Ok(command) => command, + Err(error) => { + return Err(cli_repair_failure_error( + state, + paths, + &mut journal, + &initial_snapshot, + &format!("Failed to resolve npm for Codex CLI repair: {error:#}"), + )); + } + }; + let latest_version = + match read_latest_version_with_npm_bounded(&npm, &path_env, NPM_REPAIR_REGISTRY_TIMEOUT) { + Ok(version) => version, + Err(error) => { + return Err(cli_repair_failure_error( + state, + paths, + &mut journal, + &initial_snapshot, + &format!("Failed to resolve the latest Codex CLI version: {error:#}"), + )); + } + }; + let snapshot = match npm_cli_repair::quarantine(paths, &mut journal) { + Ok(snapshot) => snapshot, + Err(error) => { + let fallback_snapshot = npm_cli_repair::journal_snapshot(&journal) + .unwrap_or_else(|_| initial_snapshot.clone()); + return Err(cli_repair_failure_error( + state, + paths, + &mut journal, + &fallback_snapshot, + &format!("Failed to quarantine the stale npm directory: {error:#}"), + )); + } + }; + + if let Some(install) = match current_managed_install(&latest_version) { + Ok(install) => install, + Err(error) => { + warn!( + ?error, + "managed Codex CLI probe failed during explicit repair" + ); + None + } + } { + return complete_cli_repair( + state, + paths, + &mut journal, + snapshot, + &latest_version, + install, + ); + } + + let package_spec = format!("{CLI_PACKAGE_NAME}@{latest_version}"); + let args = vec![ + OsString::from("install"), + OsString::from("-g"), + OsString::from("--include=optional"), + OsString::from("--prefix"), + npm_cli_repair::managed_prefix().as_os_str().to_os_string(), + OsString::from(&package_spec), + ]; + let output = match run_bounded_command_output( + &npm, + &path_env, + None, + &args, + NPM_REPAIR_INSTALL_TIMEOUT, + true, + Some(&install_lock), + ) { + Ok(output) => output, + Err(error) => { + return Err(cli_repair_failure_error( + state, + paths, + &mut journal, + &snapshot, + &format!("{error:#}"), + )); + } + }; + if !output.status.success() { + let error = format!( + "{} {} failed with {}{}", + npm.display(), + format_command_args(&args), + output.status, + format_command_output(&output) + ); + return Err(cli_repair_failure_error( + state, + paths, + &mut journal, + &snapshot, + &error, + )); + } + + let install = match current_managed_install(&latest_version) { + Ok(Some(install)) => install, + Ok(None) => { + return Err(cli_repair_failure_error( + state, + paths, + &mut journal, + &snapshot, + &format!( + "npm completed but Codex CLI {latest_version} could not be resolved after repair" + ), + )); + } + Err(error) => { + return Err(cli_repair_failure_error( + state, + paths, + &mut journal, + &snapshot, + &format!( + "npm completed but the repaired Codex CLI could not be validated: {error:#}" + ), + )); + } + }; + + complete_cli_repair( + state, + paths, + &mut journal, + snapshot, + &latest_version, + install, + ) +} + +fn complete_cli_repair( + state: &mut PersistedState, + paths: &RuntimePaths, + journal: &mut npm_cli_repair::RepairJournal, + snapshot: npm_cli_repair::RepairSnapshot, + latest_version: &str, + install: ManagedCliInstall, +) -> Result { + record_managed_install(state, latest_version, &install); + if let Err(error) = persist_state(paths, state) { + return Err(cli_repair_failure_error( + state, + paths, + journal, + &snapshot, + &format!("Failed to persist repaired Codex CLI state: {error:#}"), + )); + } + if let Err(error) = npm_cli_repair::clear(paths) { + return Err(cli_repair_failure_error( + state, + paths, + journal, + &snapshot, + &format!("Failed to clear the completed Codex CLI repair journal: {error:#}"), + )); + } + + Ok(CliRepairOutcome { + installed_version: install.installed_version, + quarantine_paths: snapshot.quarantine_paths, + }) +} + +fn cli_repair_failure_error( + state: &mut PersistedState, + paths: &RuntimePaths, + journal: &mut npm_cli_repair::RepairJournal, + fallback_snapshot: &npm_cli_repair::RepairSnapshot, + error: &str, +) -> anyhow::Error { + let mut persistence_errors = Vec::new(); + let snapshot = match npm_cli_repair::record_failure(paths, journal, error) { + Ok(snapshot) => snapshot, + Err(persist_error) => { + persistence_errors.push(format!( + "failed to persist the Codex CLI repair journal: {persist_error:#}" + )); + fallback_snapshot.clone() + } + }; + let repair_message = repair_failure_message(error, &snapshot); + state.cli_status = CliStatus::Failed; + state.cli_error_message = Some(format!( + "{} Run `codex-update-manager diagnose` before retrying `codex-update-manager repair-cli`.", + repair_message + )); + if let Err(persist_error) = persist_state(paths, state) { + persistence_errors.push(format!( + "failed to persist updater CLI failure state: {persist_error:#}" + )); + } + if persistence_errors.is_empty() { + anyhow!(repair_message) + } else { + anyhow!("{repair_message}. {}", persistence_errors.join("; ")) + } +} + +fn repair_failure_message(error: &str, snapshot: &npm_cli_repair::RepairSnapshot) -> String { + let mut quarantine_paths = snapshot.quarantine_paths.clone(); + if let Some(path) = snapshot.planned_quarantine_path.as_ref() { + if fs::symlink_metadata(path).is_ok() && !quarantine_paths.iter().any(|item| item == path) { + quarantine_paths.push(path.clone()); + } + } + if quarantine_paths.is_empty() { + return format!("{error}. No quarantine was created by this attempt"); + } + let paths = quarantine_paths + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", "); + format!("{error}. Quarantines preserved at {paths}") +} + +fn current_managed_install(expected_version: &str) -> Result> { + let requested_path = npm_cli_repair::managed_cli_path(); + if !is_executable(&requested_path) { + return Ok(None); + } + let cli_path = canonical_cli_launch_path(&requested_path)?; + let installed_version = + read_installed_version_bounded(&cli_path, CLI_PREFLIGHT_VERSION_TIMEOUT)?; + Ok( + installed_cli_version_satisfies_latest(&installed_version, expected_version).then_some( + ManagedCliInstall { + cli_path, + installed_version, + }, + ), + ) +} + +fn record_managed_install( + state: &mut PersistedState, + latest_version: &str, + install: &ManagedCliInstall, +) { + state.cli_path = Some(install.cli_path.clone()); + state.cli_install_channel = Some(CliInstallChannel::Npm); + state.cli_installed_version = Some(install.installed_version.clone()); + state.cli_official_latest_version = Some(latest_version.to_string()); + state.cli_package_manager_latest_version = None; + state.cli_status = CliStatus::UpToDate; + state.cli_last_check_at = Some(Utc::now()); + state.cli_last_verified_at = Some(Utc::now()); + state.cli_error_message = None; +} + fn install_missing_cli( state: &mut PersistedState, paths: &RuntimePaths, + baseline: &mut PersistedState, requested_path: Option<&Path>, -) -> Result { +) -> Result<(PathBuf, bool)> { + install_missing_cli_with_registry_timeout( + state, + paths, + baseline, + requested_path, + NPM_REPAIR_REGISTRY_TIMEOUT, + ) +} + +fn install_missing_cli_with_registry_timeout( + state: &mut PersistedState, + paths: &RuntimePaths, + baseline: &mut PersistedState, + requested_path: Option<&Path>, + registry_timeout: StdDuration, +) -> Result<(PathBuf, bool)> { + let install_lock = npm_cli_repair::acquire_install_lock(paths)?; + state.reload_cli(&paths.state_file)?; + *baseline = state.clone(); + let persisted_path = state.cli_path.clone(); + if let Some(path) = + resolve_cli_path(requested_path).or_else(|| resolve_cli_path(persisted_path.as_deref())) + { + return Ok((path, false)); + } + if npm_cli_repair::load(paths)?.is_some() { + set_cli_repair_required(state); + state.save_cli_status(&paths.state_file)?; + *baseline = state.clone(); + anyhow::bail!( + "Codex CLI installation is blocked by stale npm state. Run `codex-update-manager diagnose` for details and repair instructions." + ); + } state.cli_status = CliStatus::Updating; persist_state(paths, state)?; + *baseline = state.clone(); - let latest_version = read_latest_version()?; + let (npm, path_env) = npm_program()?; + let latest_version = read_latest_version_with_npm_bounded(&npm, &path_env, registry_timeout)?; state.cli_official_latest_version = Some(latest_version.clone()); state.cli_package_manager_latest_version = None; persist_state(paths, state)?; + *baseline = state.clone(); info!( latest_version, "Codex CLI is missing; attempting automatic installation" ); - install_latest_cli(&latest_version)?; - - let cli_path = resolve_cli_path(requested_path) - .or_else(|| resolve_cli_path(None)) - .ok_or_else(|| anyhow!("Codex CLI installed but could not be found afterwards"))?; + let managed_install = match install_latest_cli_locked( + &latest_version, + state, + paths, + &install_lock, + )? { + CliUpdateOutcome::Updated(Some(install)) => install, + CliUpdateOutcome::Updated(None) => { + anyhow::bail!("Managed npm install did not return a Codex CLI path") + } + CliUpdateOutcome::RepairRequired => { + set_cli_repair_required(state); + state.save_cli_status(&paths.state_file)?; + anyhow::bail!( + "Codex CLI installation is blocked by stale npm state. Run `codex-update-manager diagnose` for details and repair instructions." + ); + } + }; + *baseline = state.clone(); - Ok(cli_path) + Ok((managed_install.cli_path, true)) } fn run_command(program: &Path, args: I) -> Result @@ -2164,6 +3080,13 @@ fn normalize_version_token(token: &str) -> Option { fn npm_program() -> Result<(PathBuf, OsString)> { let npm = find_in_path("npm", &command_path_env()).context("npm was not found in PATH")?; + let npm = if npm.is_absolute() { + npm + } else { + std::env::current_dir() + .context("Failed to resolve the current directory for npm")? + .join(npm) + }; canonical_cli_launch_path(&npm).context("npm executable is not usable")?; let toolchain_bin = npm .parent() @@ -2177,21 +3100,7 @@ fn npm_program() -> Result<(PathBuf, OsString)> { Ok((npm, path_env)) } -fn local_npm_prefix() -> PathBuf { - std::env::var_os("HOME") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".")) - .join(".codex-cli-npm") -} - -fn run_npm_command(npm: &Path, path_env: &OsString, args: &[OsString]) -> Result<()> { - let mut command = Command::new(npm); - command.env("PATH", path_env).args(args); - apply_safe_child_umask(&mut command); - let output = command - .output() - .with_context(|| format!("Failed to spawn {}", npm.display()))?; - +fn ensure_npm_command_success(npm: &Path, args: &[OsString], output: Output) -> Result<()> { anyhow::ensure!( output.status.success(), "{} {} failed with {}{}", @@ -2200,7 +3109,6 @@ fn run_npm_command(npm: &Path, path_env: &OsString, args: &[OsString]) -> Result output.status, format_command_output(&output) ); - Ok(()) } @@ -2405,9 +3313,48 @@ mod tests { test_util::{env_lock, EnvRestoreGuard}, }; use chrono::Utc; - use std::{fs, os::unix::fs::PermissionsExt, path::Path}; + use std::{ + fs, + os::fd::AsRawFd, + os::unix::{fs::PermissionsExt, process::ExitStatusExt}, + path::Path, + }; use tempfile::tempdir; + struct CurrentDirectoryGuard(PathBuf); + + impl CurrentDirectoryGuard { + fn set(path: &Path) -> Result { + let original = std::env::current_dir().context("current test directory")?; + std::env::set_current_dir(path).context("set current test directory")?; + Ok(Self(original)) + } + } + + impl Drop for CurrentDirectoryGuard { + fn drop(&mut self) { + std::env::set_current_dir(&self.0).expect("restore current test directory"); + } + } + + fn npm_enotempty_output(source: &Path, destination: &Path, legacy_prefix: bool) -> Output { + let prefix = if legacy_prefix { + "npm ERR!" + } else { + "npm error" + }; + Output { + status: ExitStatus::from_raw(217 << 8), + stdout: Vec::new(), + stderr: format!( + "{prefix} code ENOTEMPTY\n{prefix} syscall rename\n{prefix} path {}\n{prefix} dest {}\n{prefix} errno -39\n", + source.display(), + destination.display() + ) + .into_bytes(), + } + } + fn write_executable_script(path: &Path, contents: &str) -> Result<()> { let temp_root = std::env::temp_dir(); for directory in path.parent().into_iter().flat_map(Path::ancestors) { @@ -2432,6 +3379,18 @@ mod tests { Ok(()) } + fn secure_test_directory_tree(path: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(path)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Ok(()); + } + fs::set_permissions(path, fs::Permissions::from_mode(0o755))?; + for entry in fs::read_dir(path)? { + secure_test_directory_tree(&entry?.path())?; + } + Ok(()) + } + #[derive(Debug)] struct NpmCliFixture { visible_cli: PathBuf, @@ -4411,25 +5370,56 @@ exit 1 } #[test] - fn optional_dependency_repair_match_is_specific_to_linux_platform_packages() { - let linux_error = anyhow::anyhow!( - "Error: Missing optional dependency @openai/codex-linux-x64. Reinstall Codex: npm install -g @openai/codex" - ); - assert_eq!( - missing_platform_optional_dependency(&linux_error).as_deref(), - Some("@openai/codex-linux-x64") - ); - let compact_linux_error = anyhow::anyhow!( - "Error: Missing optional dependency@openai/codex-linux-arm64. Reinstall Codex" - ); - assert_eq!( - missing_platform_optional_dependency(&compact_linux_error).as_deref(), - Some("@openai/codex-linux-arm64") - ); - for message in [ - "Codex CLI configuration is invalid", - "Missing optional dependency @openai/codex-darwin-arm64", - "Missing optional dependency@openai/codex-linux-x64-evil", + fn pending_explicit_repair_blocks_optional_dependency_npm_mutation() -> Result<()> { + let _env_guard = env_lock(); + let temp = tempdir()?; + let paths = test_runtime_paths(temp.path()); + paths.ensure_dirs()?; + let prefix = temp.path().join("npm-prefix"); + let fixture = write_npm_cli_install( + &prefix, + "#!/bin/sh\necho 'Missing optional dependency @openai/codex-linux-x64. Reinstall Codex: npm install -g @openai/codex' >&2\nexit 1\n", + )?; + let npm_log = temp.path().join("npm.log"); + write_executable_script( + &fixture.npm_program, + "#!/bin/sh\necho called > \"$NPM_LOG\"\nexit 0\n", + )?; + let _restore_env = configure_cli_test_env(temp.path(), [prefix.join("bin")])?; + std::env::set_var("NPM_LOG", &npm_log); + npm_cli_repair::write_detected_for_test(&paths, ".codex-cqYkmGXr")?; + + let mut state = PersistedState::new(true); + let error = preflight(&mut state, &paths, Some(fixture.visible_cli), false) + .expect_err("pending explicit repair must block automatic npm mutation"); + + assert!(error.to_string().contains("codex-update-manager diagnose")); + assert_eq!(state.cli_status, CliStatus::UpdateRequired); + assert!(!npm_log.exists()); + assert!(npm_cli_repair::load(&paths)?.is_some()); + Ok(()) + } + + #[test] + fn optional_dependency_repair_match_is_specific_to_linux_platform_packages() { + let linux_error = anyhow::anyhow!( + "Error: Missing optional dependency @openai/codex-linux-x64. Reinstall Codex: npm install -g @openai/codex" + ); + assert_eq!( + missing_platform_optional_dependency(&linux_error).as_deref(), + Some("@openai/codex-linux-x64") + ); + let compact_linux_error = anyhow::anyhow!( + "Error: Missing optional dependency@openai/codex-linux-arm64. Reinstall Codex" + ); + assert_eq!( + missing_platform_optional_dependency(&compact_linux_error).as_deref(), + Some("@openai/codex-linux-arm64") + ); + for message in [ + "Codex CLI configuration is invalid", + "Missing optional dependency @openai/codex-darwin-arm64", + "Missing optional dependency@openai/codex-linux-x64-evil", ] { assert_eq!( missing_platform_optional_dependency(&anyhow::anyhow!(message)), @@ -4537,9 +5527,12 @@ exit 1 }; let started = Instant::now(); - let error = - repair_npm_optional_dependency_with_timeout(&install, StdDuration::from_millis(100)) - .expect_err("a hanging npm repair must time out"); + let error = repair_npm_optional_dependency_with_timeout( + &install, + StdDuration::from_millis(100), + None, + ) + .expect_err("a hanging npm repair must time out"); assert!(error.to_string().contains("timed out")); assert!(started.elapsed() < StdDuration::from_secs(3)); @@ -4548,6 +5541,148 @@ exit 1 Ok(()) } + #[test] + fn npm_supervisor_owns_timeout_and_process_group_cleanup() -> Result<()> { + let _env_guard = env_lock(); + let temp = tempdir()?; + let npm_program = temp.path().join("npm"); + let child_marker = temp.path().join("child-terminated"); + write_executable_script( + &npm_program, + r#"#!/bin/sh +sh -c 'trap '\''printf terminated > "$NPM_CHILD_MARKER"; exit 0'\'' TERM; while :; do sleep 1; done' & +wait +"#, + )?; + let _restore_env = EnvRestoreGuard::capture(&["NPM_CHILD_MARKER"]); + std::env::set_var("NPM_CHILD_MARKER", &child_marker); + let install_lock = fs::File::create(temp.path().join("install.lock"))?; + + let started = Instant::now(); + let error = run_npm_supervisor( + current_parent_pid(), + 100, + install_lock.as_raw_fd(), + &npm_program, + &[OsString::from("install")], + ) + .expect_err("the npm supervisor must enforce its own timeout"); + + assert!(error.to_string().contains("timed out")); + assert!(started.elapsed() < StdDuration::from_secs(3)); + assert_eq!(fs::read_to_string(child_marker)?, "terminated"); + Ok(()) + } + + #[test] + fn npm_supervisor_rejects_a_stale_owner_before_spawning() -> Result<()> { + let _env_guard = env_lock(); + let temp = tempdir()?; + let npm_program = temp.path().join("npm"); + let started = temp.path().join("started"); + write_executable_script( + &npm_program, + "#!/bin/sh\nprintf started > \"$NPM_STARTED\"\n", + )?; + let _restore_env = EnvRestoreGuard::capture(&["NPM_STARTED"]); + std::env::set_var("NPM_STARTED", &started); + let install_lock = fs::File::create(temp.path().join("install.lock"))?; + + let error = run_npm_supervisor( + u32::MAX, + 100, + install_lock.as_raw_fd(), + &npm_program, + &[OsString::from("install")], + ) + .expect_err("a supervisor with a stale owner must not start npm"); + + assert!(error.to_string().contains("owner exited")); + assert!(!started.exists()); + Ok(()) + } + + #[test] + fn npm_supervisor_keeps_the_install_lock_out_of_npm() -> Result<()> { + let _env_guard = env_lock(); + let temp = tempdir()?; + let npm_program = temp.path().join("npm"); + let inherited_marker = temp.path().join("lock-inherited"); + write_executable_script( + &npm_program, + "#!/bin/sh\nif [ -e \"/proc/self/fd/$NPM_INSTALL_LOCK_FD\" ]; then\n printf inherited > \"$NPM_LOCK_INHERITED_MARKER\"\n exit 88\nfi\nexit 0\n", + )?; + let install_lock = fs::File::create(temp.path().join("install.lock"))?; + let initial_flags = unsafe { libc::fcntl(install_lock.as_raw_fd(), libc::F_GETFD) }; + anyhow::ensure!( + initial_flags != -1, + "failed to inspect the test install lock descriptor" + ); + anyhow::ensure!( + unsafe { + libc::fcntl( + install_lock.as_raw_fd(), + libc::F_SETFD, + initial_flags & !libc::FD_CLOEXEC, + ) + } != -1, + "failed to make the test install lock descriptor inheritable" + ); + let _restore_env = + EnvRestoreGuard::capture(&["NPM_INSTALL_LOCK_FD", "NPM_LOCK_INHERITED_MARKER"]); + std::env::set_var("NPM_INSTALL_LOCK_FD", install_lock.as_raw_fd().to_string()); + std::env::set_var("NPM_LOCK_INHERITED_MARKER", &inherited_marker); + + run_npm_supervisor( + current_parent_pid(), + 1_000, + install_lock.as_raw_fd(), + &npm_program, + &[OsString::from("install")], + )?; + + assert!(!inherited_marker.exists()); + let final_flags = unsafe { libc::fcntl(install_lock.as_raw_fd(), libc::F_GETFD) }; + anyhow::ensure!( + final_flags != -1, + "failed to re-inspect the test install lock descriptor" + ); + assert_ne!(final_flags & libc::FD_CLOEXEC, 0); + Ok(()) + } + + #[test] + fn npm_program_absolutizes_a_relative_path_entry_without_resolving_symlinks() -> Result<()> { + let _env_guard = env_lock(); + let temp = tempdir()?; + let path_bin = temp.path().join("path-bin"); + fs::create_dir_all(&path_bin)?; + let real_bin = temp.path().join("real-bin"); + fs::create_dir_all(&real_bin)?; + write_executable_script(&real_bin.join("npm"), "#!/bin/sh\nexit 0\n")?; + write_executable_script(&real_bin.join("node"), "#!/bin/sh\nexit 0\n")?; + std::os::unix::fs::symlink(real_bin.join("npm"), path_bin.join("npm"))?; + std::os::unix::fs::symlink(real_bin.join("node"), path_bin.join("node"))?; + let _current_directory = CurrentDirectoryGuard::set(temp.path())?; + let _restore_env = EnvRestoreGuard::capture(&[ + "HOME", + "PATH", + "NVM_DIR", + "XDG_DATA_HOME", + "FNM_DIR", + "FNM_MULTISHELL_PATH", + ]); + std::env::set_var("HOME", temp.path()); + std::env::set_var("PATH", std::env::join_paths([PathBuf::from("path-bin")])?); + std::env::remove_var("NVM_DIR"); + std::env::remove_var("XDG_DATA_HOME"); + std::env::remove_var("FNM_DIR"); + std::env::remove_var("FNM_MULTISHELL_PATH"); + + assert_eq!(npm_program()?.0, path_bin.join("npm")); + Ok(()) + } + #[test] fn repaired_cli_registry_lookup_is_bounded() -> Result<()> { let _env_guard = env_lock(); @@ -4616,6 +5751,139 @@ exit 1 Ok(()) } + #[test] + fn pending_repair_blocks_missing_cli_registry_and_install_transitions() -> Result<()> { + let _env_guard = env_lock(); + let temp = tempdir()?; + let paths = test_runtime_paths(temp.path()); + paths.ensure_dirs()?; + + let bin_dir = temp.path().join("bin"); + let npm_log = temp.path().join("npm.log"); + fs::create_dir_all(&bin_dir)?; + write_executable_script( + &bin_dir.join("npm"), + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"{}\"\nexit 42\n", + npm_log.display() + ), + )?; + let _restore_env = configure_cli_test_env(temp.path(), [bin_dir])?; + npm_cli_repair::write_detected_for_test(&paths, ".codex-cqYkmGXr")?; + + let winner_path = temp.path().join("winner/codex"); + let mut winner = PersistedState::new(true); + winner.cli_path = Some(winner_path.clone()); + winner.cli_installed_version = Some("0.42.1".to_string()); + winner.remote_headers_fingerprint = Some("must-survive-pending-repair".to_string()); + winner.save(&paths.state_file)?; + + let mut state = PersistedState::new(true); + state.cli_path = Some(temp.path().join("stale/codex")); + state.cli_installed_version = Some("0.42.0".to_string()); + preflight(&mut state, &paths, None, true) + .expect_err("pending repair must block a missing CLI installation"); + + assert!(!npm_log.exists()); + assert_eq!(state.cli_status, CliStatus::UpdateRequired); + assert_eq!(state.cli_path.as_deref(), Some(winner_path.as_path())); + assert_eq!(state.cli_installed_version.as_deref(), Some("0.42.1")); + assert!(state + .cli_error_message + .as_deref() + .is_some_and(|message| message.contains("codex-update-manager diagnose"))); + let persisted = PersistedState::load_or_default(&paths.state_file, true)?; + assert_eq!(persisted.cli_status, CliStatus::UpdateRequired); + assert_eq!(persisted.cli_path.as_deref(), Some(winner_path.as_path())); + assert_eq!(persisted.cli_installed_version.as_deref(), Some("0.42.1")); + assert_eq!( + persisted.remote_headers_fingerprint.as_deref(), + Some("must-survive-pending-repair") + ); + assert_eq!(persisted.cli_error_message, state.cli_error_message); + Ok(()) + } + + #[test] + fn pending_repair_during_cli_update_preserves_newer_cli_identity() -> Result<()> { + let _env_guard = env_lock(); + let temp = tempdir()?; + let paths = test_runtime_paths(temp.path()); + paths.ensure_dirs()?; + + let bin_dir = temp.path().join("bin"); + let npm_log = temp.path().join("npm.log"); + fs::create_dir_all(&bin_dir)?; + write_executable_script( + &bin_dir.join("npm"), + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"{}\"\nexit 42\n", + npm_log.display() + ), + )?; + let _restore_env = configure_cli_test_env(temp.path(), [bin_dir])?; + npm_cli_repair::write_detected_for_test(&paths, ".codex-cqYkmGXr")?; + + let winner_path = temp.path().join("winner/codex"); + let mut winner = PersistedState::new(true); + winner.cli_path = Some(winner_path.clone()); + winner.cli_installed_version = Some("0.42.1".to_string()); + winner.remote_headers_fingerprint = Some("must-survive-update-repair".to_string()); + winner.save(&paths.state_file)?; + + let mut state = PersistedState::new(true); + state.cli_path = Some(temp.path().join("stale/codex")); + state.cli_installed_version = Some("0.42.0".to_string()); + let outcome = install_latest_cli("0.42.1", &mut state, &paths)?; + + assert!(matches!(outcome, CliUpdateOutcome::RepairRequired)); + assert!(!npm_log.exists()); + assert_eq!(state.cli_status, CliStatus::UpdateRequired); + assert_eq!(state.cli_path.as_deref(), Some(winner_path.as_path())); + assert_eq!(state.cli_installed_version.as_deref(), Some("0.42.1")); + let persisted = PersistedState::load_or_default(&paths.state_file, true)?; + assert_eq!(persisted.cli_path.as_deref(), Some(winner_path.as_path())); + assert_eq!(persisted.cli_installed_version.as_deref(), Some("0.42.1")); + assert_eq!( + persisted.remote_headers_fingerprint.as_deref(), + Some("must-survive-update-repair") + ); + Ok(()) + } + + #[test] + fn missing_cli_registry_timeout_releases_the_install_lock() -> Result<()> { + let _env_guard = env_lock(); + let temp = tempdir()?; + let paths = test_runtime_paths(temp.path()); + paths.ensure_dirs()?; + + let bin_dir = temp.path().join("bin"); + fs::create_dir_all(&bin_dir)?; + write_executable_script( + &bin_dir.join("npm"), + "#!/bin/sh\nif [ \"$1\" = \"view\" ]; then\n while :; do sleep 1; done\nfi\nexit 42\n", + )?; + let _restore_env = configure_cli_test_env(temp.path(), [bin_dir])?; + + let mut state = PersistedState::new(true); + let mut baseline = state.clone(); + let started = Instant::now(); + let error = install_missing_cli_with_registry_timeout( + &mut state, + &paths, + &mut baseline, + None, + StdDuration::from_millis(100), + ) + .expect_err("a hanging missing-CLI registry lookup must time out"); + + assert!(error.to_string().contains("timed out")); + assert!(started.elapsed() < StdDuration::from_secs(3)); + let _lock = npm_cli_repair::acquire_install_lock(&paths)?; + Ok(()) + } + #[test] fn failed_new_cli_version_probe_persists_failed_status() -> Result<()> { let _env_guard = env_lock(); @@ -4626,14 +5894,19 @@ exit 1 let bin_dir = temp.path().join("bin"); fs::create_dir_all(&bin_dir)?; - let codex_path = bin_dir.join("codex"); + let managed_codex_path = temp.path().join(".codex-cli-npm/bin/codex"); + fs::create_dir_all( + managed_codex_path + .parent() + .context("managed CLI should have a parent")?, + )?; write_executable_script( &bin_dir.join("npm"), "#!/bin/sh\nif [ \"$1\" = \"view\" ]; then\n echo '0.42.1'\n exit 0\nfi\nif [ \"$1\" = \"install\" ]; then\n printf '%s\\n' '#!/bin/sh' \"echo 'version probe failed' >&2\" 'exit 43' > \"$FAKE_CODEX_PATH\"\n /bin/chmod 0755 \"$FAKE_CODEX_PATH\"\n exit 0\nfi\nexit 1\n", )?; let _restore_env = configure_cli_test_env(temp.path(), [bin_dir])?; - std::env::set_var("FAKE_CODEX_PATH", &codex_path); + std::env::set_var("FAKE_CODEX_PATH", &managed_codex_path); let mut state = PersistedState::new(true); let error = preflight(&mut state, &paths, None, true) @@ -4673,8 +5946,15 @@ exit 1 #[test] fn reconcile_if_present_upgrades_outdated_cli() -> Result<()> { let _env_guard = env_lock(); - let _restore_fnm_env = - EnvRestoreGuard::capture(&["XDG_DATA_HOME", "FNM_DIR", "FNM_MULTISHELL_PATH"]); + let _restore_env = EnvRestoreGuard::capture(&[ + "HOME", + "PATH", + "NVM_DIR", + "XDG_DATA_HOME", + "FNM_DIR", + "FNM_MULTISHELL_PATH", + "FAKE_CODEX_PATH", + ]); let temp = tempdir()?; let paths = test_runtime_paths(temp.path()); paths.ensure_dirs()?; @@ -4683,6 +5963,12 @@ exit 1 fs::create_dir_all(&bin_dir)?; let codex_path = bin_dir.join("codex"); + let managed_codex_path = temp.path().join(".codex-cli-npm/bin/codex"); + fs::create_dir_all( + managed_codex_path + .parent() + .context("managed CLI should have a parent")?, + )?; write_executable_script( &codex_path, "#!/bin/sh\nif [ \"$1\" = \"--version\" ] || [ \"$1\" = \"version\" ]; then\n echo 'codex-cli v0.42.0'\n exit 0\nfi\nexit 1\n", @@ -4691,19 +5977,16 @@ exit 1 let npm_path = bin_dir.join("npm"); write_executable_script( &npm_path, - "#!/bin/sh\nif [ \"$1\" = \"view\" ] && [ \"$2\" = \"@openai/codex\" ] && [ \"$3\" = \"version\" ]; then\n echo '0.42.1'\n exit 0\nfi\nif [ \"$1\" = \"install\" ] && [ \"$2\" = \"-g\" ] && [ \"$3\" = \"--include=optional\" ]; then\n printf '%s\\n' '#!/bin/sh' 'if [ \"$1\" = \"--version\" ] || [ \"$1\" = \"version\" ]; then' \" echo 'codex-cli v0.42.1'\" ' exit 0' 'fi' 'exit 1' > \"$FAKE_CODEX_PATH\"\n exit 0\nfi\nexit 1\n", + "#!/bin/sh\nif [ \"$1\" = \"view\" ] && [ \"$2\" = \"@openai/codex\" ] && [ \"$3\" = \"version\" ]; then\n echo '0.42.1'\n exit 0\nfi\nif [ \"$1\" = \"install\" ] && [ \"$2\" = \"-g\" ] && [ \"$3\" = \"--include=optional\" ]; then\n printf '%s\\n' '#!/bin/sh' 'if [ \"$1\" = \"--version\" ] || [ \"$1\" = \"version\" ]; then' \" echo 'codex-cli v0.42.1'\" ' exit 0' 'fi' 'exit 1' > \"$FAKE_CODEX_PATH\"\n /bin/chmod 0755 \"$FAKE_CODEX_PATH\"\n exit 0\nfi\nexit 1\n", )?; - let original_home = std::env::var_os("HOME"); - let original_path = std::env::var_os("PATH"); - let original_nvm_dir = std::env::var_os("NVM_DIR"); std::env::set_var("HOME", temp.path()); std::env::set_var("PATH", std::env::join_paths([bin_dir.clone()])?); std::env::remove_var("NVM_DIR"); std::env::remove_var("XDG_DATA_HOME"); std::env::remove_var("FNM_DIR"); std::env::remove_var("FNM_MULTISHELL_PATH"); - std::env::set_var("FAKE_CODEX_PATH", &codex_path); + std::env::set_var("FAKE_CODEX_PATH", &managed_codex_path); assert_eq!(npm_program()?.0, npm_path); @@ -4717,35 +6000,283 @@ exit 1 let updated = reconcile_if_present(&mut state, &paths)?; - if let Some(home) = original_home { - std::env::set_var("HOME", home); - } else { - std::env::remove_var("HOME"); - } - if let Some(path) = original_path { - std::env::set_var("PATH", path); - } else { - std::env::remove_var("PATH"); - } - if let Some(nvm_dir) = original_nvm_dir { - std::env::set_var("NVM_DIR", nvm_dir); - } else { - std::env::remove_var("NVM_DIR"); - } - std::env::remove_var("FAKE_CODEX_PATH"); - assert!(updated); - assert_eq!(state.cli_path.as_deref(), Some(codex_path.as_path())); + assert_eq!( + state.cli_path.as_deref(), + Some(managed_codex_path.as_path()) + ); assert_eq!(state.cli_installed_version.as_deref(), Some("0.42.1")); assert_eq!(state.cli_official_latest_version.as_deref(), Some("0.42.1")); assert_eq!(state.cli_package_manager_latest_version, None); assert_eq!(state.cli_status, CliStatus::UpToDate); - assert_eq!(read_installed_version(&codex_path)?, "0.42.1"); + assert_eq!(read_installed_version(&managed_codex_path)?, "0.42.1"); Ok(()) } #[test] - fn preflight_accepts_user_prefix_cli_after_system_cli_upgrade() -> Result<()> { + fn reconcile_if_present_detects_stale_npm_without_mutating_it() -> Result<()> { + let _env_guard = env_lock(); + let _restore_env = EnvRestoreGuard::capture(&[ + "HOME", + "PATH", + "NVM_DIR", + "XDG_DATA_HOME", + "FNM_DIR", + "FNM_MULTISHELL_PATH", + "CODEX_CLI_PATH", + "CODEX_UPDATE_MANAGER_SKIP_SYSTEM_CLI_LOOKUP", + "FAKE_CODEX_PATH", + "NPM_ACTIVE_PACKAGE", + "NPM_INSTALL_RESULT", + "NPM_INSTALL_LOG", + "NPM_MANAGED_CLI", + "NPM_MANAGED_CLI_DIR", + "NPM_RETIREMENT_PATH", + "NPM_VIEW_RESULT", + ]); + let temp = tempdir()?; + let paths = test_runtime_paths(temp.path()); + paths.ensure_dirs()?; + + let home = temp.path().join("home"); + let bin_dir = temp.path().join("bin"); + let local_prefix = home.join(".codex-cli-npm"); + let managed_bin = local_prefix.join("bin"); + let active_package = local_prefix.join("lib/node_modules/@openai/codex"); + let retirement_path = local_prefix + .join("lib/node_modules/@openai") + .join(".codex-cqYkmGXr"); + let install_log = temp.path().join("npm-install.log"); + fs::create_dir_all(&active_package)?; + fs::write(active_package.join("package.json"), "{}\n")?; + fs::create_dir_all(&retirement_path)?; + fs::write(retirement_path.join("package.json"), "{}\n")?; + secure_test_directory_tree(&local_prefix)?; + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&managed_bin)?; + + let codex_path = managed_bin.join("codex"); + write_executable_script( + &codex_path, + "#!/bin/sh\nif [ \"$1\" = \"--version\" ] || [ \"$1\" = \"version\" ]; then\n echo 'codex-cli v0.42.0'\n exit 0\nfi\nexit 1\n", + )?; + let npm_path = bin_dir.join("npm"); + write_executable_script( + &npm_path, + r#"#!/bin/sh +if [ "$1" = "view" ] && [ "$2" = "@openai/codex" ] && [ "$3" = "version" ]; then + echo '0.42.1' + exit 0 +fi +if [ "$1" = "install" ] && [ "$2" = "-g" ] && [ "$3" = "--include=optional" ]; then + printf 'attempt\n' >> "$NPM_INSTALL_LOG" + if [ -d "$NPM_RETIREMENT_PATH" ]; then + printf '%s\n' \ + 'npm error code ENOTEMPTY' \ + 'npm error syscall rename' \ + "npm error path $NPM_ACTIVE_PACKAGE" \ + "npm error dest $NPM_RETIREMENT_PATH" \ + 'npm error errno -39' \ + "npm error ENOTEMPTY: directory not empty, rename '$NPM_ACTIVE_PACKAGE' -> '$NPM_RETIREMENT_PATH'" >&2 + exit 217 + fi + printf '%s\n' '#!/bin/sh' 'if [ "$1" = "--version" ] || [ "$1" = "version" ]; then' " echo 'codex-cli v0.42.1'" ' exit 0' 'fi' 'exit 1' > "$FAKE_CODEX_PATH" + exit 0 +fi +exit 1 +"#, + )?; + + std::env::set_var("HOME", &home); + std::env::set_var("PATH", std::env::join_paths([bin_dir, managed_bin])?); + std::env::remove_var("NVM_DIR"); + std::env::remove_var("XDG_DATA_HOME"); + std::env::remove_var("FNM_DIR"); + std::env::remove_var("FNM_MULTISHELL_PATH"); + std::env::remove_var("CODEX_CLI_PATH"); + std::env::set_var("CODEX_UPDATE_MANAGER_SKIP_SYSTEM_CLI_LOOKUP", "1"); + std::env::set_var("FAKE_CODEX_PATH", &codex_path); + std::env::set_var("NPM_ACTIVE_PACKAGE", &active_package); + std::env::set_var("NPM_INSTALL_LOG", &install_log); + std::env::set_var("NPM_RETIREMENT_PATH", &retirement_path); + + let mut state = PersistedState::new(true); + state.cli_path = Some(codex_path.clone()); + + let updated = reconcile_if_present(&mut state, &paths)?; + + assert!(!updated); + assert_eq!(state.cli_status, CliStatus::UpdateRequired); + assert_eq!(state.cli_installed_version.as_deref(), Some("0.42.0")); + assert_eq!(fs::read_to_string(&install_log)?, "attempt\n"); + assert!(retirement_path.exists()); + assert!(state + .cli_error_message + .as_deref() + .is_some_and(|message| message.contains("codex-update-manager diagnose"))); + + let updated = reconcile_if_present(&mut state, &paths)?; + assert!(!updated); + assert_eq!(fs::read_to_string(&install_log)?, "attempt\n"); + assert!(retirement_path.exists()); + + let outcome = repair_cli(&mut state, &paths)?; + assert_eq!(outcome.installed_version, "0.42.1"); + assert_eq!(outcome.quarantine_paths.len(), 1); + assert!(outcome.quarantine_paths[0].exists()); + assert!(!retirement_path.exists()); + assert_eq!(fs::read_to_string(&install_log)?, "attempt\nattempt\n"); + assert_eq!(state.cli_status, CliStatus::UpToDate); + assert!(npm_cli_repair::load(&paths)?.is_none()); + Ok(()) + } + + #[test] + fn explicit_repair_runs_npm_once_and_preserves_failed_quarantine() -> Result<()> { + let _env_guard = env_lock(); + let _restore_env = EnvRestoreGuard::capture(&[ + "HOME", + "PATH", + "NVM_DIR", + "XDG_DATA_HOME", + "FNM_DIR", + "FNM_MULTISHELL_PATH", + "CODEX_CLI_PATH", + "CODEX_UPDATE_MANAGER_SKIP_SYSTEM_CLI_LOOKUP", + "NPM_ACTIVE_PACKAGE", + "NPM_INSTALL_RESULT", + "NPM_INSTALL_LOG", + "NPM_MANAGED_CLI", + "NPM_MANAGED_CLI_DIR", + "NPM_RETIREMENT_PATH", + ]); + let temp = tempdir()?; + let paths = test_runtime_paths(temp.path()); + paths.ensure_dirs()?; + let home = temp.path().join("home"); + let bin_dir = temp.path().join("bin"); + let prefix = home.join(".codex-cli-npm"); + let source = prefix.join("lib/node_modules/@openai/codex"); + let destination = prefix + .join("lib/node_modules/@openai") + .join(".codex-cqYkmGXr"); + let managed_cli = prefix.join("bin/codex"); + let install_log = temp.path().join("npm-install.log"); + fs::create_dir_all(&source)?; + fs::create_dir_all(&destination)?; + secure_test_directory_tree(&prefix)?; + fs::set_permissions(&home, fs::Permissions::from_mode(0o755))?; + fs::create_dir_all(&bin_dir)?; + write_executable_script( + &bin_dir.join("npm"), + r#"#!/bin/sh +if [ "$1" = "view" ]; then + if [ "${NPM_VIEW_RESULT:-success}" = "failure" ]; then + printf 'registry unavailable\n' >&2 + exit 43 + fi + echo '0.42.1' + exit 0 +fi +if [ "$1" = "install" ]; then + printf 'attempt\n' >> "$NPM_INSTALL_LOG" + if [ "${NPM_INSTALL_RESULT:-failure}" = "invalid" ]; then + /bin/mkdir -p "$NPM_MANAGED_CLI_DIR" + printf '%s\n' '#!/bin/sh' 'exit 1' > "$NPM_MANAGED_CLI" + /bin/chmod 755 "$NPM_MANAGED_CLI" + exit 0 + fi + /bin/mkdir -p "$NPM_RETIREMENT_PATH" + printf 'retry failed\n' >&2 + exit 42 +fi +exit 1 +"#, + )?; + write_executable_script(&bin_dir.join("node"), "#!/bin/sh\nexit 0\n")?; + + std::env::set_var("HOME", &home); + std::env::set_var("PATH", std::env::join_paths([bin_dir])?); + std::env::remove_var("NVM_DIR"); + std::env::remove_var("XDG_DATA_HOME"); + std::env::remove_var("FNM_DIR"); + std::env::remove_var("FNM_MULTISHELL_PATH"); + std::env::remove_var("CODEX_CLI_PATH"); + std::env::set_var("CODEX_UPDATE_MANAGER_SKIP_SYSTEM_CLI_LOOKUP", "1"); + std::env::set_var("NPM_ACTIVE_PACKAGE", &source); + std::env::set_var("NPM_INSTALL_LOG", &install_log); + std::env::set_var("NPM_MANAGED_CLI", &managed_cli); + std::env::set_var( + "NPM_MANAGED_CLI_DIR", + managed_cli.parent().context("managed CLI has no parent")?, + ); + std::env::set_var("NPM_RETIREMENT_PATH", &destination); + + let mut state = PersistedState::new(true); + state.save(&paths.state_file)?; + npm_cli_repair::detect_and_persist( + &paths, + &prefix, + &npm_enotempty_output(&source, &destination, false), + )? + .context("stale npm output should be detected")?; + + let error = repair_cli(&mut state, &paths).expect_err("the retry failure must be returned"); + + assert!(format!("{error:#}").contains("retry failed")); + assert!(format!("{error:#}").contains("Quarantines preserved")); + assert_eq!(fs::read_to_string(&install_log)?, "attempt\n"); + assert!(destination.exists()); + let repair = npm_cli_repair::snapshot(&paths)?.context("repair should remain pending")?; + assert_eq!(repair.quarantine_paths.len(), 1); + assert!(repair.quarantine_paths[0].exists()); + assert!(repair + .last_error + .as_deref() + .is_some_and(|message| message.contains("retry failed"))); + + std::env::set_var("NPM_VIEW_RESULT", "failure"); + let error = + repair_cli(&mut state, &paths).expect_err("the registry failure must be returned"); + + assert!(format!("{error:#}").contains("registry unavailable")); + assert!(format!("{error:#}").contains("Quarantines preserved")); + assert_eq!(fs::read_to_string(&install_log)?, "attempt\n"); + assert!(destination.exists()); + let repair = npm_cli_repair::snapshot(&paths)?.context("repair should remain pending")?; + assert_eq!(repair.quarantine_paths.len(), 1); + assert!(repair + .last_error + .as_deref() + .is_some_and(|message| message.contains("registry unavailable"))); + std::env::remove_var("NPM_VIEW_RESULT"); + + repair_cli(&mut state, &paths).expect_err("the second retry failure must be returned"); + + assert_eq!(fs::read_to_string(install_log)?, "attempt\nattempt\n"); + assert!(destination.exists()); + let repair = npm_cli_repair::snapshot(&paths)?.context("repair should remain pending")?; + assert_eq!(repair.quarantine_paths.len(), 2); + assert!(repair.quarantine_paths.iter().all(|path| path.exists())); + + std::env::set_var("NPM_INSTALL_RESULT", "invalid"); + let error = + repair_cli(&mut state, &paths).expect_err("an invalid repaired CLI must be reported"); + + assert!(format!("{error:#}").contains("could not be validated")); + assert!(format!("{error:#}").contains("Quarantines preserved")); + assert_eq!(state.cli_status, CliStatus::Failed); + let repair = npm_cli_repair::snapshot(&paths)?.context("repair should remain pending")?; + assert_eq!(repair.quarantine_paths.len(), 3); + assert!(repair + .last_error + .as_deref() + .is_some_and(|message| message.contains("could not be validated"))); + Ok(()) + } + + #[test] + fn preflight_switches_system_cli_to_managed_prefix_after_upgrade() -> Result<()> { let _env_guard = env_lock(); let temp = tempdir()?; let paths = test_runtime_paths(temp.path()); @@ -4771,6 +6302,12 @@ exit 1 )?; let npm_path = npm_bin.join("npm"); + let managed_codex = home.join(".codex-cli-npm/bin/codex"); + fs::create_dir_all( + managed_codex + .parent() + .context("managed CLI should have a parent")?, + )?; write_executable_script( &npm_path, r#"#!/bin/sh @@ -4779,6 +6316,8 @@ if [ "$1" = "view" ] && [ "$2" = "@openai/codex" ] && [ "$3" = "version" ]; then exit 0 fi if [ "$1" = "install" ] && [ "$2" = "-g" ] && [ "$3" = "--include=optional" ]; then + printf '%s\n' '#!/bin/sh' 'echo "codex-cli v0.42.1"' > "$FAKE_CODEX_PATH" + /bin/chmod 0755 "$FAKE_CODEX_PATH" exit 0 fi exit 1 @@ -4793,6 +6332,7 @@ exit 1 "FNM_DIR", "FNM_MULTISHELL_PATH", "CODEX_CLI_PATH", + "FAKE_CODEX_PATH", ]); std::env::set_var("HOME", &home); std::env::set_var("PATH", std::env::join_paths([npm_bin, system_bin])?); @@ -4801,6 +6341,7 @@ exit 1 std::env::remove_var("FNM_DIR"); std::env::remove_var("FNM_MULTISHELL_PATH"); std::env::remove_var("CODEX_CLI_PATH"); + std::env::set_var("FAKE_CODEX_PATH", &managed_codex); let mut state = PersistedState::new(true); state.cli_path = Some(system_codex.clone()); @@ -4813,9 +6354,9 @@ exit 1 let outcome = preflight(&mut state, &paths, Some(system_codex.clone()), false)?; assert!(outcome.updated); - assert_eq!(outcome.cli_path, user_codex); + assert_eq!(outcome.cli_path, managed_codex); assert_eq!(outcome.installed_version, "0.42.1"); - assert_eq!(state.cli_path.as_deref(), Some(user_codex.as_path())); + assert_eq!(state.cli_path.as_deref(), Some(managed_codex.as_path())); assert_eq!(state.cli_installed_version.as_deref(), Some("0.42.1")); assert_eq!(state.cli_official_latest_version.as_deref(), Some("0.42.1")); assert_eq!(state.cli_package_manager_latest_version, None); diff --git a/updater/src/config.rs b/updater/src/config.rs index 7daaddd62..40b47d3fd 100644 --- a/updater/src/config.rs +++ b/updater/src/config.rs @@ -325,8 +325,16 @@ pub fn settings_wrapper_updates_override() -> Option { const FEATURE_CONFIG_FILE: &str = "linux-features.json"; const BUNDLED_FEATURE_CONFIG_FILE: &str = "features.json"; +const UPDATER_POLICY_FILE: &str = "updater-policy.json"; const FEATURE_PICKER_ON_UPDATE_SETTING_KEY: &str = "codex-linux-feature-picker-on-update"; +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UpdaterFeaturePolicy { + schema_version: u32, + auto_build_updates_setting_key: Option, +} + /// Resolves the stable per-user feature-config path /// (`//linux-features.json`), alongside `settings.json`. The /// wrapper-update feature picker writes the chosen `{"enabled":[...]}` here, and @@ -353,6 +361,71 @@ pub fn effective_feature_config_path(config: &RuntimeConfig) -> Option }) } +fn valid_feature_id(id: &str) -> bool { + !id.is_empty() + && id + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && id.as_bytes()[0].is_ascii_alphanumeric() +} + +fn valid_setting_key(key: &str) -> bool { + !key.is_empty() + && key + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +/// Loads an updater preference declared by enabled Linux features. Core does +/// not know feature ids or setting keys: it scans only enabled feature roots +/// for a versioned `updater-policy.json` declaration. Missing, malformed, or +/// conflicting declarations fail closed to the normal automatic-build path. +fn auto_build_updates_setting_key(config: &RuntimeConfig) -> Option { + let feature_config = effective_feature_config_path(config)?; + let parsed = + serde_json::from_str::(&fs::read_to_string(feature_config).ok()?) + .ok()?; + let enabled = parsed.get("enabled")?.as_array()?; + let features_root = config.builder_bundle_root.join("linux-features"); + let mut resolved = None; + + for feature_id in enabled.iter().filter_map(serde_json::Value::as_str) { + if !valid_feature_id(feature_id) { + continue; + } + let policy_path = features_root.join(feature_id).join(UPDATER_POLICY_FILE); + let Some(policy) = fs::read_to_string(&policy_path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .filter(|policy| policy.schema_version == 1) + else { + continue; + }; + let Some(key) = policy + .auto_build_updates_setting_key + .filter(|key| valid_setting_key(key)) + else { + continue; + }; + match resolved.as_deref() { + None => resolved = Some(key), + Some(existing) if existing == key => {} + Some(_) => { + warn!("conflicting enabled Linux feature updater policies; using automatic builds"); + return None; + } + } + } + + resolved +} + +/// Reads the optional automatic-build preference owned by an enabled Linux +/// feature. No enabled declaration means the updater keeps automatic builds. +pub fn settings_auto_build_updates_override(config: &RuntimeConfig) -> Option { + settings_bool_override(&auto_build_updates_setting_key(config)?) +} + /// Reads the user's "ask which features to enable on update" preference (the /// in-app Update-button feature picker). Absent ⇒ `None` ⇒ caller defaults to /// asking. @@ -567,6 +640,49 @@ app_executable_path = "/opt/codex-desktop/electron" Ok(()) } + #[test] + fn auto_build_setting_is_loaded_only_for_an_enabled_feature_policy() -> Result<()> { + let _guard = crate::test_util::env_lock(); + let _restore_env = + crate::test_util::EnvRestoreGuard::capture(&["CODEX_LINUX_SETTINGS_FILE"]); + let temp = tempdir()?; + let settings_dir = temp.path().join("settings"); + let settings_file = settings_dir.join("settings.json"); + let saved_feature_config = settings_dir.join("linux-features.json"); + let builder_root = temp.path().join("builder"); + let bundled_feature_config = builder_root.join("linux-features/features.json"); + let policy_path = + builder_root.join("linux-features/deferred-update-build/updater-policy.json"); + fs::create_dir_all(policy_path.parent().unwrap())?; + fs::create_dir_all(&settings_dir)?; + fs::write( + &policy_path, + r#"{"schemaVersion":1,"autoBuildUpdatesSettingKey":"codex-linux-auto-build-updates"}"#, + )?; + fs::write(&bundled_feature_config, r#"{"enabled":[]}"#)?; + fs::write( + &settings_file, + r#"{"codex-linux-auto-build-updates":false}"#, + )?; + std::env::set_var("CODEX_LINUX_SETTINGS_FILE", &settings_file); + + let paths = test_paths(temp.path()); + let mut config = RuntimeConfig::default_with_paths(&paths); + config.builder_bundle_root = builder_root; + + assert_eq!(settings_auto_build_updates_override(&config), None); + + fs::write( + &saved_feature_config, + r#"{"enabled":["deferred-update-build"]}"#, + )?; + assert_eq!(settings_auto_build_updates_override(&config), Some(false)); + + fs::write(&saved_feature_config, r#"{"enabled":[]}"#)?; + assert_eq!(settings_auto_build_updates_override(&config), None); + Ok(()) + } + #[test] fn loads_default_when_config_is_missing() -> Result<()> { let temp = tempdir()?; diff --git a/updater/src/diagnostics.rs b/updater/src/diagnostics.rs index 59a64f597..9ee12c921 100644 --- a/updater/src/diagnostics.rs +++ b/updater/src/diagnostics.rs @@ -2,7 +2,7 @@ use crate::{ config::{self, RuntimeConfig, RuntimePaths}, - liveness, + liveness, npm_cli_repair, state::PersistedState, }; use anyhow::Result; @@ -40,6 +40,20 @@ struct UpdateDiagnostics { last_known_good_version: Option, update_error: Option, cli_status: String, + cli_error: Option, + cli_repair: Option, +} + +#[derive(Debug, Serialize)] +struct CliRepairDiagnostics { + condition: &'static str, + detected_at: String, + phase: &'static str, + stale_directory: PathBuf, + quarantine_paths: Vec, + planned_quarantine_path: Option, + last_error: Option, + repair_command: &'static str, } #[derive(Debug, Serialize)] @@ -136,6 +150,7 @@ fn collect_with_webview( running_error: running.err().map(|error| error.to_string()), pid_file: pid_file_diagnostics(&app_pid_file), }; + let cli_repair = npm_cli_repair::snapshot(paths)?; let report_without_warnings = DiagnosticsReport { schema: "codex-update-manager/diagnostics/v1", ok: false, @@ -147,6 +162,22 @@ fn collect_with_webview( last_known_good_version: state.last_known_good_version.clone(), update_error: state.error_message.clone(), cli_status: format!("{:?}", state.cli_status), + cli_error: state.cli_error_message.clone(), + cli_repair: cli_repair.map(|repair| CliRepairDiagnostics { + condition: + "A stale npm retirement directory is blocking managed Codex CLI updates.", + detected_at: repair.detected_at.to_rfc3339(), + phase: match repair.phase { + npm_cli_repair::RepairPhase::Detected => "detected", + npm_cli_repair::RepairPhase::QuarantinePlanned => "quarantine_planned", + npm_cli_repair::RepairPhase::Quarantined => "quarantined", + }, + stale_directory: repair.stale_directory, + quarantine_paths: repair.quarantine_paths, + planned_quarantine_path: repair.planned_quarantine_path, + last_error: repair.last_error, + repair_command: "codex-update-manager repair-cli", + }), }, app, webview: WebviewDiagnostics { @@ -198,6 +229,38 @@ fn print_text(report: &DiagnosticsReport) { report.update.candidate_version.as_deref().unwrap_or("none"), report.update.update_error.as_deref().unwrap_or("none") ); + println!( + "cli: status={} error={}", + report.update.cli_status, + report.update.cli_error.as_deref().unwrap_or("none") + ); + if let Some(repair) = &report.update.cli_repair { + let quarantine_paths = repair + .quarantine_paths + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(","); + println!( + "cli_repair: condition={} phase={} stale_directory={} quarantines={} planned_quarantine={} last_error={} command={}", + repair.condition, + repair.phase, + repair.stale_directory.display(), + if quarantine_paths.is_empty() { + "none" + } else { + quarantine_paths.as_str() + }, + repair + .planned_quarantine_path + .as_deref() + .map(Path::display) + .map(|path| path.to_string()) + .unwrap_or_else(|| "none".to_string()), + repair.last_error.as_deref().unwrap_or("none"), + repair.repair_command + ); + } println!( "app: executable={} exists={} running={}", report.app.executable_path.display(), @@ -249,6 +312,11 @@ fn diagnostics_warnings(report: &DiagnosticsReport) -> Vec { if report.update.update_error.is_some() { warnings.push("updater state has an update error".to_string()); } + if report.update.cli_repair.is_some() { + warnings.push( + "Codex CLI requires explicit repair; run codex-update-manager repair-cli".to_string(), + ); + } if report.app.running && !report.webview.ok { warnings.push("app is running but webview did not respond".to_string()); } @@ -548,6 +616,8 @@ mod tests { last_known_good_version: None, update_error: None, cli_status: "Unknown".to_string(), + cli_error: None, + cli_repair: None, }, app: AppDiagnostics { executable_path: config.app_executable_path, diff --git a/updater/src/feature_picker.rs b/updater/src/feature_picker.rs index c3feb53cf..2671c834e 100644 --- a/updater/src/feature_picker.rs +++ b/updater/src/feature_picker.rs @@ -142,7 +142,7 @@ fn pick(config: &RuntimeConfig, paths: &RuntimePaths) -> Result { return Ok(PickOutcome::Skipped("invalid-selection")); } - write_feature_config(&picked)?; + write_feature_config(config, &picked)?; if dont_ask { if let Err(error) = config::write_feature_picker_on_update(false) { warn!(?error, "could not persist don't-ask-again preference"); @@ -469,15 +469,20 @@ fn show_selection_error(tool: &DialogTool, message: &str) { } /// Writes the chosen enabled set to the stable feature-config path. -fn write_feature_config(enabled: &[String]) -> Result<()> { +fn write_feature_config(config: &RuntimeConfig, enabled: &[String]) -> Result<()> { let path = config::feature_config_path().context("could not resolve feature config path")?; if let Some(dir) = path.parent() { std::fs::create_dir_all(dir) .with_context(|| format!("Failed to create {}", dir.display()))?; } - let value = serde_json::json!({ "enabled": enabled }); + let mut object = config::effective_feature_config_path(config) + .and_then(|source| std::fs::read_to_string(source).ok()) + .and_then(|content| serde_json::from_str::(&content).ok()) + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + object.insert("enabled".to_string(), serde_json::json!(enabled)); let serialized = - serde_json::to_string_pretty(&value).context("Failed to serialize feature config")?; + serde_json::to_string_pretty(&object).context("Failed to serialize feature config")?; std::fs::write(&path, format!("{serialized}\n")) .with_context(|| format!("Failed to write {}", path.display()))?; Ok(()) @@ -947,6 +952,139 @@ if (arg === "--features-json") { std::env::remove_var("DISPLAY"); } + #[test] + fn first_selection_preserves_effective_bundled_feature_config() { + let _g = env_lock(); + let root = tempdir().unwrap(); + let settings = tempdir().unwrap(); + write_fake_catalog_script(root.path()); + let config = base_config(root.path()); + let paths = runtime_paths(root.path()); + + let settings_file = settings.path().join("settings.json"); + let feature_config = settings.path().join("linux-features.json"); + let bundled_dir = root.path().join("linux-features"); + let bundled_config = bundled_dir.join("features.json"); + let preserved_settings = serde_json::json!({ + "alpha": { + "nested": { + "enabled": true + } + } + }); + let preserved_metadata = serde_json::json!({ + "owner": "bundled", + "version": 3 + }); + std::fs::create_dir_all(&bundled_dir).unwrap(); + let original_bundled = format!( + "{}\n", + serde_json::to_string_pretty(&serde_json::json!({ + "enabled": ["alpha", "private-local-feature"], + "settings": preserved_settings, + "metadata": preserved_metadata + })) + .unwrap() + ); + std::fs::write(&bundled_config, &original_bundled).unwrap(); + std::env::set_var("CODEX_LINUX_SETTINGS_FILE", &settings_file); + std::env::set_var("DISPLAY", ":99"); + std::env::remove_var("WAYLAND_DISPLAY"); + + let (_d, fake_path) = fake_dialog("zenity", "beta", 0); + let prev_path = std::env::var_os("PATH"); + let mut joined = fake_path.clone(); + if let Some(prev) = &prev_path { + joined.push(":"); + joined.push(prev); + } + std::env::set_var("PATH", &joined); + + run_pick_features(&config, &paths, false).unwrap(); + + let value: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&feature_config).unwrap()).unwrap(); + assert_eq!( + value["enabled"], + serde_json::json!(["beta", "private-local-feature"]) + ); + assert_eq!(value["settings"], preserved_settings); + assert_eq!(value["metadata"], preserved_metadata); + assert_eq!( + std::fs::read_to_string(&bundled_config).unwrap(), + original_bundled + ); + + if let Some(prev) = prev_path { + std::env::set_var("PATH", prev); + } + std::env::remove_var("CODEX_LINUX_SETTINGS_FILE"); + std::env::remove_var("DISPLAY"); + } + + #[test] + fn selection_preserves_existing_feature_settings_and_unknown_keys() { + let _g = env_lock(); + let root = tempdir().unwrap(); + let settings = tempdir().unwrap(); + write_fake_catalog_script(root.path()); + let config = base_config(root.path()); + let paths = runtime_paths(root.path()); + + let settings_file = settings.path().join("settings.json"); + let feature_config = settings.path().join("linux-features.json"); + let preserved_settings = serde_json::json!({ + "alpha": { + "nested": { + "enabled": true + } + } + }); + let preserved_metadata = serde_json::json!({ + "owner": "local", + "version": 2 + }); + std::fs::write( + &feature_config, + format!( + "{}\n", + serde_json::to_string_pretty(&serde_json::json!({ + "enabled": ["alpha"], + "settings": preserved_settings, + "metadata": preserved_metadata + })) + .unwrap() + ), + ) + .unwrap(); + std::env::set_var("CODEX_LINUX_SETTINGS_FILE", &settings_file); + std::env::set_var("DISPLAY", ":99"); + std::env::remove_var("WAYLAND_DISPLAY"); + + let (_d, fake_path) = fake_dialog("zenity", "beta\nalpha", 0); + let prev_path = std::env::var_os("PATH"); + let mut joined = fake_path.clone(); + if let Some(prev) = &prev_path { + joined.push(":"); + joined.push(prev); + } + std::env::set_var("PATH", &joined); + + run_pick_features(&config, &paths, false).unwrap(); + + let value: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&feature_config).unwrap()).unwrap(); + assert_eq!(value["enabled"], serde_json::json!(["alpha", "beta"])); + assert_eq!(value["settings"], preserved_settings); + assert_eq!(value["metadata"], preserved_metadata); + + if let Some(prev) = prev_path { + std::env::set_var("PATH", prev); + } + std::env::remove_var("CODEX_LINUX_SETTINGS_FILE"); + std::env::remove_var("DISPLAY"); + } + #[test] fn dont_ask_sentinel_writes_setting() { let _g = env_lock(); diff --git a/updater/src/main.rs b/updater/src/main.rs index bed52fcff..19ed3bba6 100644 --- a/updater/src/main.rs +++ b/updater/src/main.rs @@ -15,6 +15,7 @@ mod install_rollback; mod liveness; mod logging; mod notify; +mod npm_cli_repair; mod restart; mod rollback; mod state; diff --git a/updater/src/npm_cli_repair.rs b/updater/src/npm_cli_repair.rs new file mode 100644 index 000000000..7d6afe89a --- /dev/null +++ b/updater/src/npm_cli_repair.rs @@ -0,0 +1,1084 @@ +use crate::{ + config::RuntimePaths, + state::{atomic_write, sync_directory}, +}; +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::{ + ffi::{CString, OsStr}, + fs, + io::{Seek, SeekFrom, Write}, + os::fd::AsRawFd, + os::unix::ffi::OsStrExt, + os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt}, + os::unix::process::CommandExt, + path::{Component, Path, PathBuf}, + process::{Command, Output}, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use tracing::info; + +const JOURNAL_FILE_NAME: &str = "cli-repair.json"; +const LOCK_FILE_NAME: &str = "cli-install.lock"; +const LOCK_TIMEOUT: Duration = Duration::from_secs(120); +const LOCK_POLL_INTERVAL: Duration = Duration::from_millis(50); +const QUARANTINE_DIRECTORY_NAME: &str = ".codex-linux-quarantine"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct RepairJournal { + detected_at: DateTime, + retirement_name: String, + #[serde(default)] + quarantine_names: Vec, + stage: RepairStage, + #[serde(default)] + last_error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "stage", rename_all = "snake_case")] +enum RepairStage { + Detected, + QuarantinePlanned { quarantine_name: String }, + Quarantined, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RepairPhase { + Detected, + QuarantinePlanned, + Quarantined, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RepairSnapshot { + pub(crate) detected_at: DateTime, + pub(crate) phase: RepairPhase, + pub(crate) stale_directory: PathBuf, + pub(crate) quarantine_paths: Vec, + pub(crate) planned_quarantine_path: Option, + pub(crate) last_error: Option, +} + +#[derive(Debug)] +pub(crate) struct InstallLock { + _file: fs::File, +} + +impl InstallLock { + pub(crate) fn raw_fd(&self) -> std::os::fd::RawFd { + self._file.as_raw_fd() + } + + pub(crate) fn inherit_with(&self, command: &mut Command) { + let fd = self._file.as_raw_fd(); + unsafe { + command.pre_exec(move || { + let flags = libc::fcntl(fd, libc::F_GETFD); + if flags == -1 { + return Err(std::io::Error::last_os_error()); + } + if libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + } +} + +#[derive(Debug, Clone)] +struct ManagedNpmLayout { + retirement_name: String, + prefix: PathBuf, + scope: PathBuf, + active_package: PathBuf, + stale_directory: PathBuf, + quarantine_root: PathBuf, +} + +pub(crate) fn managed_prefix() -> PathBuf { + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) + .join(".codex-cli-npm") +} + +pub(crate) fn managed_cli_path() -> PathBuf { + managed_prefix().join("bin/codex") +} + +pub(crate) fn acquire_install_lock(paths: &RuntimePaths) -> Result { + acquire_install_lock_with_timeout(paths, LOCK_TIMEOUT) +} + +fn acquire_install_lock_with_timeout( + paths: &RuntimePaths, + timeout: Duration, +) -> Result { + let lock_path = paths.state_dir.join(LOCK_FILE_NAME); + let mut file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&lock_path) + .with_context(|| format!("Failed to open {}", lock_path.display()))?; + let metadata = file + .metadata() + .with_context(|| format!("Failed to inspect {}", lock_path.display()))?; + let euid = unsafe { libc::geteuid() }; + anyhow::ensure!( + metadata.is_file() && metadata.uid() == euid, + "CLI install lock {} is not a user-owned regular file", + lock_path.display() + ); + if metadata.permissions().mode() & 0o077 != 0 { + file.set_permissions(fs::Permissions::from_mode(0o600)) + .with_context(|| format!("Failed to secure {}", lock_path.display()))?; + } + + let started = Instant::now(); + let mut reported_wait = false; + loop { + match file.try_lock() { + Ok(()) => break, + Err(fs::TryLockError::WouldBlock) if started.elapsed() < timeout => { + if !reported_wait { + info!( + "Codex CLI install lock is busy; process {} is waiting", + std::process::id() + ); + reported_wait = true; + } + #[cfg(test)] + if let Some(path) = + std::env::var_os("CODEX_UPDATE_MANAGER_TEST_CLI_INSTALL_LOCK_WAITING") + { + let _ = fs::write(path, b"waiting"); + } + thread::sleep(LOCK_POLL_INTERVAL); + } + Err(fs::TryLockError::WouldBlock) => { + anyhow::bail!( + "Timed out waiting for another Codex CLI install after {} ms", + timeout.as_millis() + ); + } + Err(fs::TryLockError::Error(error)) => { + return Err(error) + .with_context(|| format!("Failed to lock {}", lock_path.display())); + } + } + } + + file.set_len(0) + .with_context(|| format!("Failed to truncate {}", lock_path.display()))?; + file.seek(SeekFrom::Start(0)) + .with_context(|| format!("Failed to seek {}", lock_path.display()))?; + writeln!(file, "{}", std::process::id()) + .with_context(|| format!("Failed to write {}", lock_path.display()))?; + Ok(InstallLock { _file: file }) +} + +pub(crate) fn load(paths: &RuntimePaths) -> Result> { + let journal_path = journal_path(paths); + let metadata = match fs::symlink_metadata(&journal_path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(error) + .with_context(|| format!("Failed to inspect {}", journal_path.display())); + } + }; + anyhow::ensure!( + metadata.is_file() + && !metadata.file_type().is_symlink() + && metadata.uid() == unsafe { libc::geteuid() } + && metadata.permissions().mode() & 0o022 == 0, + "Codex CLI repair journal {} is not a secure user-owned file", + journal_path.display() + ); + let contents = fs::read_to_string(&journal_path) + .with_context(|| format!("Failed to read {}", journal_path.display()))?; + serde_json::from_str(&contents) + .with_context(|| format!("Failed to parse {}", journal_path.display())) + .map(Some) +} + +pub(crate) fn snapshot(paths: &RuntimePaths) -> Result> { + load(paths)?.map(|journal| journal.snapshot()).transpose() +} + +pub(crate) fn detect_and_persist( + paths: &RuntimePaths, + prefix: &Path, + output: &Output, +) -> Result> { + let Some(journal) = detect(prefix, output) else { + return Ok(None); + }; + save(paths, &journal)?; + journal.snapshot().map(Some) +} + +fn detect(prefix: &Path, output: &Output) -> Option { + if output.status.success() || prefix != managed_prefix() { + return None; + } + + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.lines().any(|line| line.contains("ENOTEMPTY")) + || !stderr.lines().any(|line| line.contains("syscall rename")) + { + return None; + } + + let source = npm_error_path_field(&stderr, "path")?; + let destination = npm_error_path_field(&stderr, "dest")?; + let expected_source = prefix.join("lib/node_modules/@openai/codex"); + let expected_parent = expected_source.parent()?; + let retirement_name = destination.file_name()?.to_str()?; + if source != expected_source + || destination.parent() != Some(expected_parent) + || !is_retirement_directory_name(retirement_name) + { + return None; + } + + Some(RepairJournal { + detected_at: Utc::now(), + retirement_name: retirement_name.to_string(), + quarantine_names: Vec::new(), + stage: RepairStage::Detected, + last_error: None, + }) +} + +pub(crate) fn quarantine( + paths: &RuntimePaths, + journal: &mut RepairJournal, +) -> Result { + validate_journal(journal)?; + let layout = ManagedNpmLayout::new(&journal.retirement_name)?; + + if matches!(journal.stage, RepairStage::QuarantinePlanned { .. }) { + reconcile_planned_quarantine(paths, &layout, journal)?; + } + + if path_exists_without_following(&layout.stale_directory)? { + layout.validate_stale_directory()?; + layout.ensure_quarantine_root()?; + let quarantine_name = layout.allocate_quarantine_name(); + journal.stage = RepairStage::QuarantinePlanned { quarantine_name }; + save(paths, journal)?; + reconcile_planned_quarantine(paths, &layout, journal)?; + } else if matches!(journal.stage, RepairStage::Detected) { + journal.stage = RepairStage::Quarantined; + save(paths, journal)?; + } + + let snapshot = journal.snapshot()?; + for path in &snapshot.quarantine_paths { + layout.validate_quarantine_path(path)?; + } + Ok(snapshot) +} + +pub(crate) fn validate_journal(journal: &RepairJournal) -> Result { + let snapshot = journal.snapshot()?; + let layout = ManagedNpmLayout::new(&journal.retirement_name)?; + layout.validate_prefix()?; + + if matches!(journal.stage, RepairStage::Detected) { + layout.validate_active_package()?; + } else { + layout.validate_existing_install_target()?; + } + + if path_exists_without_following(&layout.stale_directory)? { + layout.validate_stale_directory()?; + } + for path in &snapshot.quarantine_paths { + layout.validate_quarantine_path(path)?; + } + if let Some(path) = snapshot.planned_quarantine_path.as_deref() { + if path_exists_without_following(path)? { + layout.validate_quarantine_path(path)?; + } + } + Ok(snapshot) +} + +pub(crate) fn journal_snapshot(journal: &RepairJournal) -> Result { + journal.snapshot() +} + +fn reconcile_planned_quarantine( + paths: &RuntimePaths, + layout: &ManagedNpmLayout, + journal: &mut RepairJournal, +) -> Result<()> { + let RepairStage::QuarantinePlanned { quarantine_name } = &journal.stage else { + return Ok(()); + }; + let quarantine_name = quarantine_name.clone(); + layout.ensure_quarantine_root()?; + let quarantine_path = layout.quarantine_path(&quarantine_name)?; + let stale_exists = path_exists_without_following(&layout.stale_directory)?; + let quarantine_exists = path_exists_without_following(&quarantine_path)?; + match (stale_exists, quarantine_exists) { + (true, false) => { + layout.validate_stale_directory()?; + rename_noreplace(&layout.stale_directory, &quarantine_path).with_context(|| { + format!( + "Failed to quarantine stale npm directory {} at {}", + layout.stale_directory.display(), + quarantine_path.display() + ) + })?; + } + (false, true) => {} + (true, true) => { + layout.validate_stale_directory()?; + } + (false, false) => { + journal.stage = RepairStage::Quarantined; + save(paths, journal)?; + return Ok(()); + } + } + layout.validate_quarantine_path(&quarantine_path)?; + if !journal + .quarantine_names + .iter() + .any(|name| name == &quarantine_name) + { + journal.quarantine_names.push(quarantine_name); + } + journal.stage = RepairStage::Quarantined; + save(paths, journal) +} + +pub(crate) fn record_failure( + paths: &RuntimePaths, + journal: &mut RepairJournal, + error: &str, +) -> Result { + journal.last_error = Some(error.to_string()); + save(paths, journal)?; + journal.snapshot() +} + +pub(crate) fn clear(paths: &RuntimePaths) -> Result<()> { + let path = journal_path(paths); + match fs::remove_file(&path) { + Ok(()) => sync_directory(&paths.state_dir), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).with_context(|| format!("Failed to remove {}", path.display())), + } +} + +fn save(paths: &RuntimePaths, journal: &RepairJournal) -> Result<()> { + let contents = serde_json::to_vec_pretty(journal)?; + atomic_write(&journal_path(paths), &contents) +} + +fn journal_path(paths: &RuntimePaths) -> PathBuf { + paths.state_dir.join(JOURNAL_FILE_NAME) +} + +#[cfg(test)] +pub(crate) fn write_detected_for_test(paths: &RuntimePaths, retirement_name: &str) -> Result<()> { + anyhow::ensure!( + is_retirement_directory_name(retirement_name), + "invalid test retirement name" + ); + save( + paths, + &RepairJournal { + detected_at: Utc::now(), + retirement_name: retirement_name.to_string(), + quarantine_names: Vec::new(), + stage: RepairStage::Detected, + last_error: None, + }, + ) +} + +impl RepairJournal { + fn snapshot(&self) -> Result { + let layout = ManagedNpmLayout::new(&self.retirement_name)?; + let (phase, planned_quarantine_path) = match &self.stage { + RepairStage::Detected => (RepairPhase::Detected, None), + RepairStage::QuarantinePlanned { quarantine_name } => ( + RepairPhase::QuarantinePlanned, + Some(layout.quarantine_path(quarantine_name)?), + ), + RepairStage::Quarantined => (RepairPhase::Quarantined, None), + }; + let quarantine_paths = self + .quarantine_names + .iter() + .map(|name| layout.quarantine_path(name)) + .collect::>>()?; + Ok(RepairSnapshot { + detected_at: self.detected_at, + phase, + stale_directory: layout.stale_directory, + quarantine_paths, + planned_quarantine_path, + last_error: self.last_error.clone(), + }) + } +} + +impl ManagedNpmLayout { + fn new(retirement_name: &str) -> Result { + anyhow::ensure!( + is_retirement_directory_name(retirement_name), + "Invalid npm retirement directory name {retirement_name}" + ); + let prefix = managed_prefix(); + let scope = prefix.join("lib/node_modules/@openai"); + Ok(Self { + retirement_name: retirement_name.to_string(), + active_package: scope.join("codex"), + stale_directory: scope.join(retirement_name), + quarantine_root: scope.join(QUARANTINE_DIRECTORY_NAME), + prefix, + scope, + }) + } + + fn validate_prefix(&self) -> Result<()> { + let home = self + .prefix + .parent() + .context("Managed npm prefix has no home directory")?; + validate_owned_directory(home)?; + validate_owned_directory(&self.prefix) + } + + fn validate_active_package(&self) -> Result<()> { + validate_managed_directory(&self.prefix, &self.active_package) + } + + fn validate_existing_install_target(&self) -> Result<()> { + validate_existing_managed_path(&self.prefix, &self.active_package) + } + + fn validate_stale_directory(&self) -> Result<()> { + validate_managed_directory(&self.prefix, &self.stale_directory) + } + + fn ensure_quarantine_root(&self) -> Result<()> { + validate_managed_directory(&self.prefix, &self.scope)?; + match fs::symlink_metadata(&self.quarantine_root) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::DirBuilder::new() + .mode(0o700) + .create(&self.quarantine_root) + .with_context(|| { + format!( + "Failed to create npm quarantine {}", + self.quarantine_root.display() + ) + })?; + sync_directory(&self.scope)?; + } + Err(error) => { + return Err(error).with_context(|| { + format!( + "Failed to inspect npm quarantine {}", + self.quarantine_root.display() + ) + }); + } + } + validate_managed_directory(&self.prefix, &self.quarantine_root) + } + + fn validate_quarantine_path(&self, path: &Path) -> Result<()> { + anyhow::ensure!( + path.parent() == Some(self.quarantine_root.as_path()), + "Persisted npm quarantine path {} is invalid", + path.display() + ); + validate_managed_directory(&self.prefix, path) + } + + fn quarantine_path(&self, name: &str) -> Result { + let mut components = Path::new(name).components(); + anyhow::ensure!( + matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none(), + "Persisted npm quarantine name {name} is invalid" + ); + let suffix = name + .strip_prefix(&format!("{}.", self.retirement_name)) + .context("Persisted npm quarantine name does not match the retirement directory")?; + let (timestamp, pid) = suffix + .split_once('.') + .context("Persisted npm quarantine name has an invalid suffix")?; + anyhow::ensure!( + !timestamp.is_empty() + && timestamp.bytes().all(|byte| byte.is_ascii_digit()) + && !pid.is_empty() + && pid.bytes().all(|byte| byte.is_ascii_digit()), + "Persisted npm quarantine name {name} has an invalid suffix" + ); + Ok(self.quarantine_root.join(name)) + } + + fn allocate_quarantine_name(&self) -> String { + let stale_name = self + .stale_directory + .file_name() + .and_then(OsStr::to_str) + .expect("validated retirement name should remain UTF-8"); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!("{stale_name}.{timestamp}.{}", std::process::id()) + } +} + +fn npm_error_path_field(stderr: &str, field: &str) -> Option { + stderr.lines().find_map(|line| { + let payload = line + .trim() + .strip_prefix("npm error ") + .or_else(|| line.trim().strip_prefix("npm ERR! "))?; + payload + .strip_prefix(field)? + .strip_prefix(char::is_whitespace) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + }) +} + +fn is_retirement_directory_name(name: &str) -> bool { + name.strip_prefix(".codex-").is_some_and(|suffix| { + suffix.len() == 8 && suffix.chars().all(|ch| ch.is_ascii_alphanumeric()) + }) +} + +fn validate_owned_directory(path: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("Failed to inspect managed npm path {}", path.display()))?; + anyhow::ensure!( + metadata.is_dir() + && !metadata.file_type().is_symlink() + && metadata.uid() == unsafe { libc::geteuid() } + && metadata.permissions().mode() & 0o022 == 0, + "Managed npm path {} is not a secure user-owned directory", + path.display() + ); + Ok(()) +} + +fn validate_managed_directory(prefix: &Path, path: &Path) -> Result<()> { + let relative = path.strip_prefix(prefix).with_context(|| { + format!( + "Managed npm path {} is outside {}", + path.display(), + prefix.display() + ) + })?; + anyhow::ensure!( + !relative.as_os_str().is_empty(), + "Managed npm operation cannot target the prefix root" + ); + + let mut current = prefix.to_path_buf(); + for component in relative.components() { + anyhow::ensure!( + matches!(component, Component::Normal(_)), + "Managed npm path {} contains an unsafe component", + path.display() + ); + current.push(component.as_os_str()); + validate_owned_directory(¤t)?; + } + Ok(()) +} + +fn validate_existing_managed_path(prefix: &Path, path: &Path) -> Result<()> { + let relative = path.strip_prefix(prefix).with_context(|| { + format!( + "Managed npm path {} is outside {}", + path.display(), + prefix.display() + ) + })?; + anyhow::ensure!( + !relative.as_os_str().is_empty(), + "Managed npm operation cannot target the prefix root" + ); + + let mut current = prefix.to_path_buf(); + for component in relative.components() { + anyhow::ensure!( + matches!(component, Component::Normal(_)), + "Managed npm path {} contains an unsafe component", + path.display() + ); + current.push(component.as_os_str()); + match fs::symlink_metadata(¤t) { + Ok(_) => validate_owned_directory(¤t)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, + Err(error) => { + return Err(error).with_context(|| { + format!("Failed to inspect managed npm path {}", current.display()) + }); + } + } + } + Ok(()) +} + +fn path_exists_without_following(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error).with_context(|| format!("Failed to inspect {}", path.display())), + } +} + +fn rename_noreplace(source: &Path, destination: &Path) -> std::io::Result<()> { + let source_parent = source.parent().map(Path::to_path_buf); + let destination_parent = destination.parent().map(Path::to_path_buf); + let source = CString::new(source.as_os_str().as_bytes()) + .map_err(|_| std::io::Error::from_raw_os_error(libc::EINVAL))?; + let destination = CString::new(destination.as_os_str().as_bytes()) + .map_err(|_| std::io::Error::from_raw_os_error(libc::EINVAL))?; + let result = unsafe { + libc::renameat2( + libc::AT_FDCWD, + source.as_ptr(), + libc::AT_FDCWD, + destination.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + if result == 0 { + if let Some(parent) = source_parent.as_deref() { + sync_directory(parent).map_err(std::io::Error::other)?; + } + if destination_parent != source_parent { + if let Some(parent) = destination_parent.as_deref() { + sync_directory(parent).map_err(std::io::Error::other)?; + } + } + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{os::unix::fs::PermissionsExt, process::ExitStatus}; + use tempfile::tempdir; + + fn test_paths(root: &Path) -> RuntimePaths { + RuntimePaths { + config_file: root.join("config/config.toml"), + state_file: root.join("state/state.json"), + log_file: root.join("state/service.log"), + cache_dir: root.join("cache"), + state_dir: root.join("state"), + config_dir: root.join("config"), + } + } + + fn output_with_prefix(source: &Path, destination: &Path, prefix: &str) -> Output { + use std::os::unix::process::ExitStatusExt; + Output { + status: ExitStatus::from_raw(217 << 8), + stdout: Vec::new(), + stderr: format!( + "{prefix} code ENOTEMPTY\n{prefix} syscall rename\n{prefix} path {}\n{prefix} dest {}\n", + source.display(), + destination.display() + ) + .into_bytes(), + } + } + + fn output(source: &Path, destination: &Path) -> Output { + output_with_prefix(source, destination, "npm error") + } + + fn secure_tree(root: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(root)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Ok(()); + } + fs::set_permissions(root, fs::Permissions::from_mode(0o755))?; + for entry in fs::read_dir(root)? { + secure_tree(&entry?.path())?; + } + Ok(()) + } + + #[test] + fn detection_persists_without_mutating_stale_directory() -> Result<()> { + let _guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&["HOME"]); + let temp = tempdir()?; + let home = temp.path().join("home"); + let paths = test_paths(temp.path()); + fs::create_dir_all(&paths.state_dir)?; + let prefix = home.join(".codex-cli-npm"); + let active = prefix.join("lib/node_modules/@openai/codex"); + let stale = prefix.join("lib/node_modules/@openai/.codex-cqYkmGXr"); + fs::create_dir_all(&active)?; + fs::create_dir_all(&stale)?; + std::env::set_var("HOME", &home); + + let snapshot = detect_and_persist(&paths, &prefix, &output(&active, &stale))? + .context("stale condition should be detected")?; + + assert_eq!(snapshot.stale_directory, stale); + assert!(stale.exists()); + assert!(journal_path(&paths).exists()); + Ok(()) + } + + #[test] + fn detection_rejects_untrusted_shapes_without_persisting() -> Result<()> { + let _guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&["HOME"]); + let temp = tempdir()?; + let home = temp.path().join("home"); + let paths = test_paths(temp.path()); + fs::create_dir_all(&paths.state_dir)?; + let prefix = home.join(".codex-cli-npm"); + let active = prefix.join("lib/node_modules/@openai/codex"); + let scope = active.parent().context("test package has no scope")?; + let outside = temp.path().join(".codex-cqYkmGXr"); + let malformed = scope.join(".codex-not-a-retirement-hash"); + let valid = scope.join(".codex-cqYkmGXr"); + let wrong_source = scope.join("another-package"); + fs::create_dir_all(&active)?; + fs::create_dir_all(&outside)?; + fs::create_dir_all(&malformed)?; + fs::create_dir_all(&valid)?; + std::env::set_var("HOME", &home); + + assert!(detect_and_persist(&paths, &prefix, &output(&active, &outside))?.is_none()); + assert!(detect_and_persist(&paths, &prefix, &output(&active, &malformed))?.is_none()); + assert!(detect_and_persist(&paths, &prefix, &output(&wrong_source, &valid))?.is_none()); + assert!(detect_and_persist( + &paths, + &temp.path().join("other-prefix"), + &output(&active, &valid) + )? + .is_none()); + assert!(!journal_path(&paths).exists()); + assert!(outside.exists()); + assert!(malformed.exists()); + assert!(valid.exists()); + Ok(()) + } + + #[test] + fn detection_accepts_legacy_npm_error_prefix_without_mutating() -> Result<()> { + let _guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&["HOME"]); + let temp = tempdir()?; + let home = temp.path().join("home"); + let paths = test_paths(temp.path()); + fs::create_dir_all(&paths.state_dir)?; + let prefix = home.join(".codex-cli-npm"); + let active = prefix.join("lib/node_modules/@openai/codex"); + let stale = prefix.join("lib/node_modules/@openai/.codex-cqYkmGXr"); + fs::create_dir_all(&active)?; + fs::create_dir_all(&stale)?; + std::env::set_var("HOME", &home); + + let snapshot = detect_and_persist( + &paths, + &prefix, + &output_with_prefix(&active, &stale, "npm ERR!"), + )? + .context("legacy npm stderr should be detected")?; + + assert_eq!(snapshot.stale_directory, stale); + assert!(stale.exists()); + Ok(()) + } + + #[test] + fn planned_quarantine_recovers_after_rename_before_stage_commit() -> Result<()> { + let _guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&["HOME"]); + let temp = tempdir()?; + let home = temp.path().join("home"); + let paths = test_paths(temp.path()); + fs::create_dir_all(&paths.state_dir)?; + let prefix = home.join(".codex-cli-npm"); + let active = prefix.join("lib/node_modules/@openai/codex"); + let stale = prefix.join("lib/node_modules/@openai/.codex-cqYkmGXr"); + fs::create_dir_all(&active)?; + fs::create_dir_all(&stale)?; + secure_tree(&home)?; + std::env::set_var("HOME", &home); + let mut journal = detect(&prefix, &output(&active, &stale)).context("missing repair")?; + let layout = ManagedNpmLayout::new(&journal.retirement_name)?; + layout.ensure_quarantine_root()?; + let quarantine_name = layout.allocate_quarantine_name(); + let quarantine_path = layout.quarantine_root.join(&quarantine_name); + journal.stage = RepairStage::QuarantinePlanned { quarantine_name }; + save(&paths, &journal)?; + let planned = snapshot(&paths)?.context("planned repair should be visible")?; + assert_eq!(planned.phase, RepairPhase::QuarantinePlanned); + assert_eq!( + planned.planned_quarantine_path.as_deref(), + Some(quarantine_path.as_path()) + ); + rename_noreplace(&stale, &quarantine_path)?; + + let mut journal = load(&paths)?.context("missing journal")?; + let snapshot = quarantine(&paths, &mut journal)?; + + assert!(matches!(journal.stage, RepairStage::Quarantined)); + assert_eq!(snapshot.quarantine_paths, vec![quarantine_path.clone()]); + assert!(quarantine_path.exists()); + Ok(()) + } + + #[test] + fn planned_quarantine_moves_a_recreated_stale_directory_again() -> Result<()> { + let _guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&["HOME"]); + let temp = tempdir()?; + let home = temp.path().join("home"); + let paths = test_paths(temp.path()); + fs::create_dir_all(&paths.state_dir)?; + let prefix = home.join(".codex-cli-npm"); + let active = prefix.join("lib/node_modules/@openai/codex"); + let stale = prefix.join("lib/node_modules/@openai/.codex-cqYkmGXr"); + fs::create_dir_all(&active)?; + fs::create_dir_all(&stale)?; + secure_tree(&home)?; + std::env::set_var("HOME", &home); + let mut journal = detect(&prefix, &output(&active, &stale)).context("missing repair")?; + let layout = ManagedNpmLayout::new(&journal.retirement_name)?; + layout.ensure_quarantine_root()?; + let quarantine_name = layout.allocate_quarantine_name(); + let first_quarantine = layout.quarantine_root.join(&quarantine_name); + fs::create_dir_all(&first_quarantine)?; + secure_tree(&first_quarantine)?; + journal.stage = RepairStage::QuarantinePlanned { quarantine_name }; + save(&paths, &journal)?; + + let snapshot = quarantine(&paths, &mut journal)?; + + assert!(matches!(journal.stage, RepairStage::Quarantined)); + assert_eq!(snapshot.quarantine_paths.len(), 2); + assert_eq!(snapshot.quarantine_paths[0], first_quarantine); + assert!(snapshot.quarantine_paths.iter().all(|path| path.exists())); + assert!(!stale.exists()); + Ok(()) + } + + #[test] + fn quarantine_handles_an_already_absent_stale_directory() -> Result<()> { + let _guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&["HOME"]); + let temp = tempdir()?; + let home = temp.path().join("home"); + let paths = test_paths(temp.path()); + fs::create_dir_all(&paths.state_dir)?; + let active = home.join(".codex-cli-npm/lib/node_modules/@openai/codex"); + fs::create_dir_all(&active)?; + secure_tree(&home)?; + std::env::set_var("HOME", &home); + write_detected_for_test(&paths, ".codex-cqYkmGXr")?; + + let mut journal = load(&paths)?.context("missing repair journal")?; + let snapshot = quarantine(&paths, &mut journal)?; + + assert!(matches!(journal.stage, RepairStage::Quarantined)); + assert!(snapshot.quarantine_paths.is_empty()); + Ok(()) + } + + #[test] + fn quarantine_rejects_a_stale_directory_symlink() -> Result<()> { + let _guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&["HOME"]); + let temp = tempdir()?; + let home = temp.path().join("home"); + let paths = test_paths(temp.path()); + fs::create_dir_all(&paths.state_dir)?; + let scope = home.join(".codex-cli-npm/lib/node_modules/@openai"); + let active = scope.join("codex"); + let stale = scope.join(".codex-cqYkmGXr"); + let target = temp.path().join("must-survive"); + fs::create_dir_all(&active)?; + fs::create_dir_all(&target)?; + std::os::unix::fs::symlink(&target, &stale)?; + secure_tree(&home)?; + std::env::set_var("HOME", &home); + write_detected_for_test(&paths, ".codex-cqYkmGXr")?; + + let mut journal = load(&paths)?.context("missing repair journal")?; + quarantine(&paths, &mut journal).expect_err("quarantine must reject a retirement symlink"); + + assert!(stale.is_symlink()); + assert!(target.exists()); + Ok(()) + } + + #[test] + fn planned_quarantine_rejects_an_untrusted_persisted_name_before_rename() -> Result<()> { + let _guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&["HOME"]); + let temp = tempdir()?; + let home = temp.path().join("home"); + let paths = test_paths(temp.path()); + fs::create_dir_all(&paths.state_dir)?; + let prefix = home.join(".codex-cli-npm"); + let scope = prefix.join("lib/node_modules/@openai"); + let active = scope.join("codex"); + let stale = scope.join(".codex-cqYkmGXr"); + let escaped = scope.join("escaped"); + fs::create_dir_all(&active)?; + fs::create_dir_all(&stale)?; + secure_tree(&home)?; + std::env::set_var("HOME", &home); + let mut journal = detect(&prefix, &output(&active, &stale)).context("missing repair")?; + journal.stage = RepairStage::QuarantinePlanned { + quarantine_name: "../escaped".to_string(), + }; + save(&paths, &journal)?; + + quarantine(&paths, &mut journal) + .expect_err("repair must reject an untrusted persisted quarantine name"); + + assert!(stale.exists()); + assert!(!escaped.exists()); + Ok(()) + } + + #[test] + fn historical_quarantine_names_are_validated_before_a_new_rename() -> Result<()> { + let _guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&["HOME"]); + let temp = tempdir()?; + let home = temp.path().join("home"); + let paths = test_paths(temp.path()); + fs::create_dir_all(&paths.state_dir)?; + let prefix = home.join(".codex-cli-npm"); + let scope = prefix.join("lib/node_modules/@openai"); + let active = scope.join("codex"); + let stale = scope.join(".codex-cqYkmGXr"); + let escaped = scope.join("escaped"); + fs::create_dir_all(&active)?; + fs::create_dir_all(&stale)?; + secure_tree(&home)?; + std::env::set_var("HOME", &home); + let mut journal = detect(&prefix, &output(&active, &stale)).context("missing repair")?; + journal.quarantine_names.push("../escaped".to_string()); + journal.stage = RepairStage::Quarantined; + save(&paths, &journal)?; + + quarantine(&paths, &mut journal) + .expect_err("repair must reject an untrusted historical quarantine name"); + + assert!(stale.exists()); + assert!(!escaped.exists()); + assert!(!scope.join(QUARANTINE_DIRECTORY_NAME).exists()); + Ok(()) + } + + #[test] + fn retry_revalidates_a_recreated_active_package() -> Result<()> { + let _guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&["HOME"]); + let temp = tempdir()?; + let home = temp.path().join("home"); + let paths = test_paths(temp.path()); + fs::create_dir_all(&paths.state_dir)?; + let scope = home.join(".codex-cli-npm/lib/node_modules/@openai"); + let active = scope.join("codex"); + let stale = scope.join(".codex-cqYkmGXr"); + let target = temp.path().join("must-survive"); + fs::create_dir_all(&scope)?; + fs::create_dir_all(&stale)?; + fs::create_dir_all(&target)?; + secure_tree(&home)?; + std::os::unix::fs::symlink(&target, &active)?; + std::env::set_var("HOME", &home); + write_detected_for_test(&paths, ".codex-cqYkmGXr")?; + let mut journal = load(&paths)?.context("missing repair journal")?; + journal.stage = RepairStage::Quarantined; + save(&paths, &journal)?; + + quarantine(&paths, &mut journal).expect_err("repair retry must reject an active symlink"); + + assert!(active.is_symlink()); + assert!(stale.exists()); + assert!(target.exists()); + Ok(()) + } + + #[test] + fn retry_revalidates_existing_install_ancestors() -> Result<()> { + let _guard = crate::test_util::env_lock(); + let _restore_env = crate::test_util::EnvRestoreGuard::capture(&["HOME"]); + let temp = tempdir()?; + let home = temp.path().join("home"); + let paths = test_paths(temp.path()); + fs::create_dir_all(&paths.state_dir)?; + let modules = home.join(".codex-cli-npm/lib/node_modules"); + let scope = modules.join("@openai"); + let target = temp.path().join("must-survive"); + fs::create_dir_all(&modules)?; + fs::create_dir_all(&target)?; + secure_tree(&home)?; + std::os::unix::fs::symlink(&target, &scope)?; + std::env::set_var("HOME", &home); + write_detected_for_test(&paths, ".codex-cqYkmGXr")?; + let mut journal = load(&paths)?.context("missing repair journal")?; + journal.stage = RepairStage::Quarantined; + save(&paths, &journal)?; + + quarantine(&paths, &mut journal).expect_err("repair retry must reject an ancestor symlink"); + + assert!(scope.is_symlink()); + assert!(target.exists()); + Ok(()) + } + + #[test] + fn install_lock_rejects_symlinks_and_times_out() -> Result<()> { + let temp = tempdir()?; + let paths = test_paths(temp.path()); + fs::create_dir_all(&paths.state_dir)?; + let first = acquire_install_lock_with_timeout(&paths, Duration::from_millis(100))?; + let error = acquire_install_lock_with_timeout(&paths, Duration::from_millis(75)) + .expect_err("second lock must time out"); + assert!(error.to_string().contains("Timed out waiting")); + drop(first); + + fs::remove_file(paths.state_dir.join(LOCK_FILE_NAME))?; + let target = temp.path().join("must-survive"); + fs::write(&target, "unchanged\n")?; + std::os::unix::fs::symlink(&target, paths.state_dir.join(LOCK_FILE_NAME))?; + acquire_install_lock_with_timeout(&paths, Duration::from_millis(100)) + .expect_err("lock must reject symlinks"); + assert_eq!(fs::read_to_string(target)?, "unchanged\n"); + Ok(()) + } +} diff --git a/updater/src/rollback.rs b/updater/src/rollback.rs index e3f31bbe4..22657e43e 100644 --- a/updater/src/rollback.rs +++ b/updater/src/rollback.rs @@ -53,7 +53,7 @@ pub async fn run( "Rollback package is missing: {}", package_path.display() )); - state.save(&paths.state_file)?; + state.save_updater(&paths.state_file)?; println!("Rollback package is missing: {}", package_path.display()); return Ok(()); } @@ -71,7 +71,7 @@ async fn trigger_rollback( state.status = UpdateStatus::Installing; state.error_message = None; - state.save(&paths.state_file)?; + state.save_updater(&paths.state_file)?; let _ = notify::send( "Rolling back ChatGPT Desktop", @@ -92,7 +92,7 @@ async fn trigger_rollback( blocked_candidate, blocked_dmg_sha256, ); - state.save(&paths.state_file)?; + state.save_updater(&paths.state_file)?; let _ = cache_cleanup::prune_unreferenced_workspaces(&config.workspace_root, state); println!( "Rolled back ChatGPT Desktop to {}.", @@ -117,7 +117,7 @@ async fn trigger_rollback( } state.mark_failed(message.clone()); - state.save(&paths.state_file)?; + state.save_updater(&paths.state_file)?; let _ = notify::send( "ChatGPT Desktop rollback failed", "The previous package could not be installed. Check the updater log for details.", diff --git a/updater/src/state.rs b/updater/src/state.rs index 6f18bd5fa..d810daa89 100644 --- a/updater/src/state.rs +++ b/updater/src/state.rs @@ -7,17 +7,21 @@ use std::{ collections::BTreeSet, fs::{self, OpenOptions}, io::Write, + os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}, path::{Path, PathBuf}, process, time::{SystemTime, UNIX_EPOCH}, }; +const STATE_LOCK_FILE_NAME: &str = "state.lock"; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] /// High-level lifecycle states for the local updater daemon. pub enum UpdateStatus { Idle, CheckingUpstream, + #[serde(alias = "update_available")] UpdateDetected, DownloadingDmg, PreparingWorkspace, @@ -77,6 +81,11 @@ pub struct PersistedState { pub installed_version: String, pub candidate_version: Option, pub status: UpdateStatus, + /// True when an enabled Linux feature deferred the candidate's package + /// build. Optional so older state files remain readable; older updater + /// binaries ignore this unknown field and retain their automatic behavior. + #[serde(default, skip_serializing_if = "is_false")] + pub deferred_build: bool, pub last_check_at: Option>, pub last_successful_check_at: Option>, pub remote_headers_fingerprint: Option, @@ -142,6 +151,7 @@ impl PersistedState { installed_version: "unknown".to_string(), candidate_version: None, status: UpdateStatus::Idle, + deferred_build: false, last_check_at: None, last_successful_check_at: None, remote_headers_fingerprint: None, @@ -187,9 +197,60 @@ impl PersistedState { } /// Persists the updater state to JSON on disk. + #[cfg(test)] pub fn save(&self, path: &Path) -> Result<()> { - let content = serde_json::to_string_pretty(self)?; - atomic_write(path, content.as_bytes())?; + let _lock = StateLock::acquire(path)?; + self.save_unlocked(path) + } + + pub fn save_updater(&self, path: &Path) -> Result<()> { + let _lock = StateLock::acquire(path)?; + let mut merged = self.clone(); + if let Some(latest) = Self::load_if_present(path)? { + merged.copy_cli_state_from(&latest); + } + merged.save_unlocked(path) + } + + pub fn save_cli(&mut self, path: &Path) -> Result<()> { + let _lock = StateLock::acquire(path)?; + let mut merged = Self::load_if_present(path)? + .unwrap_or_else(|| Self::new(self.auto_install_on_app_exit)); + merged.copy_cli_state_from(self); + merged.save_unlocked(path)?; + *self = merged; + Ok(()) + } + + pub fn reload_cli(&mut self, path: &Path) -> Result<()> { + let _lock = StateLock::acquire(path)?; + if let Some(latest) = Self::load_if_present(path)? { + self.copy_cli_state_from(&latest); + } + Ok(()) + } + + pub fn save_cli_if_unchanged(&mut self, path: &Path, expected: &Self) -> Result { + let _lock = StateLock::acquire(path)?; + let latest = Self::load_if_present(path)?.unwrap_or_else(|| expected.clone()); + if !latest.same_cli_state(expected) { + *self = latest; + return Ok(false); + } + let mut merged = latest; + merged.copy_cli_state_from(self); + merged.save_unlocked(path)?; + *self = merged; + Ok(true) + } + + pub fn save_cli_status(&mut self, path: &Path) -> Result<()> { + let _lock = StateLock::acquire(path)?; + let mut latest = Self::load_if_present(path)?.unwrap_or_else(|| self.clone()); + latest.cli_status = self.cli_status.clone(); + latest.cli_error_message = self.cli_error_message.clone(); + latest.save_unlocked(path)?; + *self = latest; Ok(()) } @@ -207,9 +268,52 @@ impl PersistedState { self.wrapper_changelog = None; self.wrapper_dev_mode = None; } + + fn load_if_present(path: &Path) -> Result> { + match fs::read_to_string(path) { + Ok(content) => serde_json::from_str::(&content) + .with_context(|| format!("Failed to parse {}", path.display())) + .map(Some), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("Failed to read {}", path.display())), + } + } + + fn save_unlocked(&self, path: &Path) -> Result<()> { + let content = serde_json::to_string_pretty(self)?; + atomic_write(path, content.as_bytes()) + } + + fn copy_cli_state_from(&mut self, source: &Self) { + self.cli_path = source.cli_path.clone(); + self.cli_install_channel = source.cli_install_channel.clone(); + self.cli_installed_version = source.cli_installed_version.clone(); + self.cli_official_latest_version = source.cli_official_latest_version.clone(); + self.cli_package_manager_latest_version = source.cli_package_manager_latest_version.clone(); + self.cli_status = source.cli_status.clone(); + self.cli_last_check_at = source.cli_last_check_at; + self.cli_last_verified_at = source.cli_last_verified_at; + self.cli_error_message = source.cli_error_message.clone(); + } + + fn same_cli_state(&self, other: &Self) -> bool { + self.cli_path == other.cli_path + && self.cli_install_channel == other.cli_install_channel + && self.cli_installed_version == other.cli_installed_version + && self.cli_official_latest_version == other.cli_official_latest_version + && self.cli_package_manager_latest_version == other.cli_package_manager_latest_version + && self.cli_status == other.cli_status + && self.cli_last_check_at == other.cli_last_check_at + && self.cli_last_verified_at == other.cli_last_verified_at + && self.cli_error_message == other.cli_error_message + } } -fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> { +fn is_false(value: &bool) -> bool { + !*value +} + +pub(crate) fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> { let parent = path .parent() .with_context(|| format!("{} has no parent directory", path.display()))?; @@ -219,6 +323,7 @@ fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> { let mut temp_file = OpenOptions::new() .write(true) .create_new(true) + .mode(0o600) .open(&temp_path) .with_context(|| format!("Failed to create {}", temp_path.display()))?; @@ -244,9 +349,56 @@ fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> { temp_path.display() ) })?; + sync_directory(parent)?; Ok(()) } +pub(crate) fn sync_directory(path: &Path) -> Result<()> { + fs::File::open(path) + .with_context(|| format!("Failed to open directory {}", path.display()))? + .sync_all() + .with_context(|| format!("Failed to sync directory {}", path.display())) +} + +struct StateLock { + _file: fs::File, +} + +impl StateLock { + fn acquire(state_path: &Path) -> Result { + let parent = state_path + .parent() + .with_context(|| format!("{} has no parent directory", state_path.display()))?; + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + let lock_path = parent.join(STATE_LOCK_FILE_NAME); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&lock_path) + .with_context(|| format!("Failed to open {}", lock_path.display()))?; + let metadata = file + .metadata() + .with_context(|| format!("Failed to inspect {}", lock_path.display()))?; + anyhow::ensure!( + metadata.is_file() && metadata.uid() == unsafe { libc::geteuid() }, + "Updater state lock {} is not a user-owned regular file", + lock_path.display() + ); + if metadata.permissions().mode() & 0o077 != 0 { + file.set_permissions(fs::Permissions::from_mode(0o600)) + .with_context(|| format!("Failed to secure {}", lock_path.display()))?; + } + file.lock() + .with_context(|| format!("Failed to lock {}", lock_path.display()))?; + Ok(Self { _file: file }) + } +} + fn atomic_temp_path(path: &Path) -> PathBuf { let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -281,6 +433,7 @@ mod tests { let mut state = PersistedState::new(false); state.installed_version = "2026.03.24+deadbeef".to_string(); state.status = UpdateStatus::WaitingForAppExit; + state.deferred_build = true; state.candidate_version = Some("2026.03.25+feedface".to_string()); state.rollback_blocked_dmg_sha256 = Some("full-rollback-dmg-sha256".to_string()); state.notified_events.insert("ready_to_install".to_string()); @@ -301,6 +454,150 @@ mod tests { ); assert!(!loaded.auto_install_on_app_exit); assert!(loaded.waiting_for_app_exit_auto_install); + assert!(loaded.deferred_build); + Ok(()) + } + + #[test] + fn legacy_deferred_status_migrates_to_update_detected() -> Result<()> { + let mut value = serde_json::to_value(PersistedState::new(true))?; + value["status"] = serde_json::Value::String("update_available".to_string()); + value + .as_object_mut() + .expect("state object") + .remove("deferred_build"); + + let loaded = serde_json::from_value::(value)?; + assert_eq!(loaded.status, UpdateStatus::UpdateDetected); + assert!(!loaded.deferred_build); + assert_eq!( + serde_json::to_value(&loaded)?["status"], + serde_json::Value::String("update_detected".to_string()) + ); + Ok(()) + } + + #[test] + fn updater_and_cli_state_transactions_preserve_each_other() -> Result<()> { + let temp = tempdir()?; + let path = temp.path().join("state.json"); + PersistedState::new(true).save(&path)?; + + let mut cli = PersistedState::load_or_default(&path, true)?; + cli.cli_status = CliStatus::UpToDate; + cli.cli_installed_version = Some("0.42.1".to_string()); + + let mut updater = PersistedState::load_or_default(&path, true)?; + updater.status = UpdateStatus::DownloadingDmg; + updater.remote_headers_fingerprint = Some("new-updater-state".to_string()); + updater.cli_prompt_dismissed_at = Some(Utc::now()); + + cli.save_cli(&path)?; + updater.save_updater(&path)?; + + let loaded = PersistedState::load_or_default(&path, true)?; + assert_eq!(loaded.cli_status, CliStatus::UpToDate); + assert_eq!(loaded.cli_installed_version.as_deref(), Some("0.42.1")); + assert_eq!(loaded.status, UpdateStatus::DownloadingDmg); + assert_eq!( + loaded.remote_headers_fingerprint.as_deref(), + Some("new-updater-state") + ); + assert!(loaded.cli_prompt_dismissed_at.is_some()); + + let mut later_cli = PersistedState::new(true); + later_cli.cli_status = CliStatus::UpdateRequired; + later_cli.cli_error_message = Some("repair required".to_string()); + later_cli.save_cli(&path)?; + + let loaded = PersistedState::load_or_default(&path, true)?; + assert_eq!(loaded.cli_status, CliStatus::UpdateRequired); + assert_eq!( + loaded.remote_headers_fingerprint.as_deref(), + Some("new-updater-state") + ); + assert!(loaded.cli_prompt_dismissed_at.is_some()); + Ok(()) + } + + #[test] + fn guarded_cli_state_write_reloads_a_newer_cli_result() -> Result<()> { + let temp = tempdir()?; + let path = temp.path().join("state.json"); + PersistedState::new(true).save(&path)?; + + let baseline = PersistedState::load_or_default(&path, true)?; + let mut stale = baseline.clone(); + stale.cli_status = CliStatus::Unknown; + stale.cli_installed_version = Some("0.42.0".to_string()); + + let mut newer = baseline.clone(); + newer.cli_status = CliStatus::UpToDate; + newer.cli_installed_version = Some("0.42.1".to_string()); + newer.save_cli(&path)?; + newer.remote_headers_fingerprint = Some("must-survive".to_string()); + newer.save_updater(&path)?; + + assert!(!stale.save_cli_if_unchanged(&path, &baseline)?); + assert_eq!(stale.cli_status, CliStatus::UpToDate); + assert_eq!(stale.cli_installed_version.as_deref(), Some("0.42.1")); + assert_eq!( + stale.remote_headers_fingerprint.as_deref(), + Some("must-survive") + ); + let loaded = PersistedState::load_or_default(&path, true)?; + assert_eq!(loaded.cli_status, CliStatus::UpToDate); + assert_eq!(loaded.cli_installed_version.as_deref(), Some("0.42.1")); + Ok(()) + } + + #[test] + fn cli_status_write_preserves_the_latest_cli_identity() -> Result<()> { + let temp = tempdir()?; + let path = temp.path().join("state.json"); + let mut latest = PersistedState::new(true); + latest.cli_path = Some(PathBuf::from("/new/codex")); + latest.cli_installed_version = Some("0.42.1".to_string()); + latest.cli_official_latest_version = Some("0.42.1".to_string()); + latest.cli_last_verified_at = Some(Utc::now()); + latest.cli_status = CliStatus::UpToDate; + latest.remote_headers_fingerprint = Some("must-survive".to_string()); + latest.save(&path)?; + + let mut stale = PersistedState::new(true); + stale.cli_path = Some(PathBuf::from("/old/codex")); + stale.cli_installed_version = Some("0.42.0".to_string()); + stale.cli_official_latest_version = None; + stale.cli_status = CliStatus::UpdateRequired; + stale.cli_error_message = Some("repair required".to_string()); + stale.save_cli_status(&path)?; + + assert_eq!(stale.cli_path, Some(PathBuf::from("/new/codex"))); + assert_eq!(stale.cli_installed_version.as_deref(), Some("0.42.1")); + assert_eq!(stale.cli_official_latest_version.as_deref(), Some("0.42.1")); + assert!(stale.cli_last_verified_at.is_some()); + assert_eq!(stale.cli_status, CliStatus::UpdateRequired); + assert_eq!(stale.cli_error_message.as_deref(), Some("repair required")); + assert_eq!( + stale.remote_headers_fingerprint.as_deref(), + Some("must-survive") + ); + Ok(()) + } + + #[test] + fn state_lock_rejects_a_symlink() -> Result<()> { + let temp = tempdir()?; + let path = temp.path().join("state.json"); + let target = temp.path().join("must-survive"); + fs::write(&target, "unchanged\n")?; + std::os::unix::fs::symlink(&target, temp.path().join(STATE_LOCK_FILE_NAME))?; + + PersistedState::new(true) + .save(&path) + .expect_err("state lock must reject symlinks"); + + assert_eq!(fs::read_to_string(target)?, "unchanged\n"); Ok(()) } diff --git a/updater/src/wrapper.rs b/updater/src/wrapper.rs index 845906dbe..63670d976 100644 --- a/updater/src/wrapper.rs +++ b/updater/src/wrapper.rs @@ -62,6 +62,9 @@ pub struct WrapperUpdate { pub enum WrapperDetectionState { /// Installed commit matches the tracked head. Aligned, + /// The tracked head is newer, but only repository documentation or + /// metadata changed and rebuilding the installed app would be redundant. + NoRebuildNeeded, /// A genuinely newer tracked commit is available. UpdateAvailable, /// Installed build appears to be local/ahead; applying would downgrade it. @@ -336,6 +339,36 @@ fn commit_is_ancestor(repo: &Path, ancestor: &str, descendant: &str) -> Option bool { + let range = format!("{installed}..{candidate}"); + let status = git_status( + repo, + &[ + "diff", + "--quiet", + "--exit-code", + &range, + "--", + ".", + ":(exclude).github/**", + ":(exclude)docs/**", + ":(exclude)AGENTS.md", + ":(exclude)CHANGELOG.md", + ":(exclude)CONTRIBUTING.md", + ":(exclude)README.md", + ], + ); + + match status.and_then(|status| status.code()) { + Some(0) => false, + Some(1) | None => true, + Some(_) => true, + } +} + /// Reads `CHANGELOG.md` at a specific commit from the object store (the /// candidate's changelog, which reflects the new version's entries). fn changelog_at_commit(repo: &Path, commit: &str) -> Option { @@ -441,6 +474,10 @@ pub fn detect_wrapper_update_state_for_installed( Some(false) | None => return Ok((DevMode, None)), } + if !has_rebuild_relevant_changes(repo, &installed.commit, &candidate_commit) { + return Ok((NoRebuildNeeded, None)); + } + let installed_version = installed .version .clone() @@ -681,6 +718,56 @@ exit 0 ); } + #[test] + fn documentation_only_changes_do_not_trigger_wrapper_update() { + let _g = env_lock(); + let origin = tempdir().unwrap(); + init_repo(origin.path()); + + let clone = tempdir().unwrap(); + let clone_path = clone.path().join("checkout"); + git_clone(origin.path(), &clone_path); + + std::fs::create_dir_all(origin.path().join("docs")).unwrap(); + std::fs::write( + origin.path().join("docs/updater.md"), + "Documentation only\n", + ) + .unwrap(); + git(origin.path(), &["add", "-A"]); + git( + origin.path(), + &["commit", "-q", "-m", "docs: update updater guide"], + ); + + assert_eq!( + detect_wrapper_update(&clone_path, "origin", "main").unwrap(), + None + ); + let installed = installed_wrapper(&clone_path).expect("installed"); + let (state, update) = + detect_wrapper_update_state_for_installed(&clone_path, &installed, "origin", "main") + .unwrap(); + assert_eq!(state, WrapperDetectionState::NoRebuildNeeded); + assert_eq!(update, None); + + std::fs::write( + origin.path().join("updater/Cargo.toml"), + "[package]\nname = \"codex-update-manager\"\nversion = \"0.8.2\"\n", + ) + .unwrap(); + git(origin.path(), &["add", "-A"]); + git( + origin.path(), + &["commit", "-q", "-m", "fix: update wrapper"], + ); + + let update = detect_wrapper_update(&clone_path, "origin", "main") + .unwrap() + .expect("code change after documentation commits should trigger an update"); + assert_eq!(update.candidate_version.as_deref(), Some("0.8.2")); + } + #[test] fn up_to_date_clone_reports_no_update() { let _g = env_lock(); diff --git a/updater/src/wrapper_apply.rs b/updater/src/wrapper_apply.rs index 12ba12b1e..7e4473a6d 100644 --- a/updater/src/wrapper_apply.rs +++ b/updater/src/wrapper_apply.rs @@ -109,7 +109,7 @@ pub async fn run_apply_wrapper_update( state.artifact_paths.package_path = None; refresh_installed_wrapper_state(config, state); state.clear_wrapper_update_candidate(); - state.save(&paths.state_file)?; + state.save_updater(&paths.state_file)?; let _ = notify::send( "ChatGPT Desktop for Linux updated", "The newer Linux wrapper build has been installed.", @@ -415,7 +415,7 @@ async fn apply_packaged( } state.installed_version = install::installed_package_version(); - let _ = state.save(&paths.state_file); + let _ = state.save_updater(&paths.state_file); Ok(()) } @@ -527,7 +527,7 @@ async fn cached_or_downloaded_dmg( .await .context("Failed to download upstream DMG for wrapper rebuild")?; state.artifact_paths.dmg_path = Some(downloaded.path.clone()); - state.save(&paths.state_file)?; + state.save_updater(&paths.state_file)?; Ok(CachedDmg { path: downloaded.path, _lease: downloaded.lease, diff --git a/updater/tests/cli_repair_concurrency.rs b/updater/tests/cli_repair_concurrency.rs new file mode 100644 index 000000000..5eeaeebca --- /dev/null +++ b/updater/tests/cli_repair_concurrency.rs @@ -0,0 +1,776 @@ +use anyhow::{Context, Result}; +use serde_json::{json, Value}; +use std::{ + fs, + os::unix::fs::{symlink, PermissionsExt}, + path::{Path, PathBuf}, + process::{Child, Command, Output, Stdio}, + thread, + time::{Duration, Instant}, +}; +use tempfile::TempDir; + +const WAIT_TIMEOUT: Duration = Duration::from_secs(15); + +struct Fixture { + _temp: TempDir, + root: PathBuf, + home: PathBuf, + config_home: PathBuf, + state_home: PathBuf, + cache_home: PathBuf, + cli_path: PathBuf, + active_package: PathBuf, + stale_directory: PathBuf, + latest_version: PathBuf, + install_mode: PathBuf, + install_log: PathBuf, + install_started: PathBuf, + install_release: PathBuf, + install_overlap: PathBuf, + install_owner: PathBuf, + background_process_group: PathBuf, + npm_supervisor_pid: PathBuf, + view_started: PathBuf, + view_release: PathBuf, +} + +impl Fixture { + fn new() -> Result { + let temp = tempfile::tempdir()?; + let root = temp.path().to_path_buf(); + let home = root.join("home"); + let config_home = root.join("xdg-config"); + let state_home = root.join("xdg-state"); + let cache_home = root.join("xdg-cache"); + let prefix = home.join(".codex-cli-npm"); + let package_root = prefix.join("lib/node_modules/@openai/codex"); + let cli_entrypoint = package_root.join("bin/codex.js"); + let cli_path = prefix.join("bin/codex"); + let stale_directory = prefix + .join("lib/node_modules/@openai") + .join(".codex-cqYkmGXr"); + let npm_path = prefix.join("bin/npm"); + let node_path = prefix.join("bin/node"); + let latest_version = root.join("npm-latest-version"); + let install_mode = root.join("npm-install-mode"); + let install_log = root.join("npm-install.log"); + let install_started = root.join("npm-install.started"); + let install_release = root.join("npm-install.release"); + let install_overlap = root.join("npm-install.overlap"); + let install_owner = root.join("npm-install.owner"); + let background_process_group = root.join("npm-background-process-group"); + let npm_supervisor_pid = root.join("npm-supervisor.pid"); + let view_started = root.join("npm-view.started"); + let view_release = root.join("npm-view.release"); + let app_executable = root.join("app/electron"); + + fs::create_dir_all(cli_entrypoint.parent().context("CLI entrypoint parent")?)?; + fs::create_dir_all(cli_path.parent().context("CLI path parent")?)?; + fs::create_dir_all(config_home.join("codex-update-manager"))?; + fs::create_dir_all(state_home.join("codex-update-manager"))?; + fs::create_dir_all(cache_home.join("codex-update-manager"))?; + fs::create_dir_all(app_executable.parent().context("app executable parent")?)?; + + write_executable(&cli_entrypoint, &cli_script("0.42.0"))?; + symlink( + Path::new("../lib/node_modules/@openai/codex/bin/codex.js"), + &cli_path, + )?; + write_executable(&node_path, "#!/bin/sh\nexit 0\n")?; + write_executable( + &npm_path, + r#"#!/bin/sh + if [ "$1" = "view" ]; then + if [ "${NPM_VIEW_MODE:-success}" = "delayed-failure" ]; then + /bin/touch "$NPM_VIEW_STARTED" + while [ ! -e "$NPM_VIEW_RELEASE" ]; do + /bin/sleep 0.01 + done + printf 'registry unavailable\n' >&2 + exit 43 + fi + /bin/cat "$NPM_LATEST_VERSION" + exit 0 +fi +if [ "$1" = "install" ]; then + printf '%s\n' "$$" >> "$NPM_INSTALL_LOG" + printf '%s\n' "$PPID" > "$NPM_SUPERVISOR_PID" + previous_group="$(/bin/cat "$NPM_BACKGROUND_PROCESS_GROUP" 2>/dev/null || true)" + if [ -n "$previous_group" ] && /bin/kill -0 -- "-$previous_group" 2>/dev/null; then + /bin/touch "$NPM_INSTALL_OVERLAP" + exit 99 + fi + if ! /bin/mkdir "$NPM_INSTALL_OWNER" 2>/dev/null; then + owner_pid="$(/bin/cat "$NPM_INSTALL_OWNER/pid" 2>/dev/null || true)" + if [ -n "$owner_pid" ] && /bin/kill -0 "$owner_pid" 2>/dev/null; then + /bin/touch "$NPM_INSTALL_OVERLAP" + exit 99 + fi + /bin/rm -f "$NPM_INSTALL_OWNER/pid" + /bin/rmdir "$NPM_INSTALL_OWNER" + /bin/mkdir "$NPM_INSTALL_OWNER" + fi + if [ -d "$NPM_INSTALL_OWNER" ]; then + printf '%s\n' "$$" > "$NPM_INSTALL_OWNER/pid" + trap '/bin/rm -f "$NPM_INSTALL_OWNER/pid"; /bin/rmdir "$NPM_INSTALL_OWNER"' EXIT + /bin/touch "$NPM_INSTALL_STARTED" + if [ "$(/bin/cat "$NPM_INSTALL_MODE")" = "background-descendant" ] || + [ "$(/bin/cat "$NPM_INSTALL_MODE")" = "background-descendant-hang" ]; then + /bin/sh -c 'trap "exit 0" TERM; while :; do /bin/sleep 1; done' \ + >/dev/null 2>&1 & + printf '%s\n' "$PPID" > "$NPM_BACKGROUND_PROCESS_GROUP" + if [ "$(/bin/cat "$NPM_INSTALL_MODE")" = "background-descendant" ]; then + exit 0 + fi + fi + while [ ! -e "$NPM_INSTALL_RELEASE" ]; do + /bin/sleep 0.01 + done + fi + if [ "$(/bin/cat "$NPM_INSTALL_MODE")" = "stale" ]; then + /bin/mkdir -p "$NPM_RETIREMENT_PATH" + printf '%s\n' \ + 'npm error code ENOTEMPTY' \ + 'npm error syscall rename' \ + "npm error path $NPM_ACTIVE_PACKAGE" \ + "npm error dest $NPM_RETIREMENT_PATH" >&2 + exit 217 + fi + printf '%s\n' '#!/bin/sh' "echo 'codex-cli v0.42.1'" > "$NPM_MANAGED_CLI" + /bin/chmod 755 "$NPM_MANAGED_CLI" + exit 0 +fi +exit 1 +"#, + )?; + fs::write(&latest_version, b"0.42.0\n")?; + fs::write(&install_mode, b"success\n")?; + write_executable(&app_executable, "#!/bin/sh\nexit 0\n")?; + fs::write( + package_root.join("package.json"), + serde_json::to_vec_pretty(&json!({ + "name": "@openai/codex", + "bin": {"codex": "bin/codex.js"}, + "optionalDependencies": { + "@openai/codex-linux-x64": "0.42.1" + } + }))?, + )?; + secure_directory_tree(&home)?; + + let config = format!( + "dmg_url = \"http://127.0.0.1:9/Codex.dmg\"\n\ + initial_check_delay_seconds = 3600\n\ + check_interval_hours = 24\n\ + auto_install_on_app_exit = false\n\ + notifications = false\n\ + workspace_root = \"{}\"\n\ + builder_bundle_root = \"{}\"\n\ + app_executable_path = \"{}\"\n\ + enable_wrapper_updates = false\n\ + wrapper_remote = \"\"\n\ + wrapper_branch = \"main\"\n", + toml_path(&cache_home.join("codex-update-manager")), + toml_path(&root), + toml_path(&app_executable), + ); + fs::write(config_home.join("codex-update-manager/config.toml"), config)?; + + let fixture = Self { + _temp: temp, + root, + home, + config_home, + state_home, + cache_home, + cli_path, + active_package: package_root, + stale_directory, + latest_version, + install_mode, + install_log, + install_started, + install_release, + install_overlap, + install_owner, + background_process_group, + npm_supervisor_pid, + view_started, + view_release, + }; + let output = fixture + .command() + .args(["cli-preflight", "--cli-path"]) + .arg(&fixture.cli_path) + .output()?; + ensure_success("fixture CLI preflight", &output)?; + fixture.update_state(|state| { + state["remote_headers_fingerprint"] = json!("must-survive-cli-race"); + state["cli_last_check_at"] = Value::Null; + state["cli_official_latest_version"] = Value::Null; + })?; + fs::write(&fixture.latest_version, b"0.42.1\n")?; + fs::write(&fixture.install_mode, b"stale\n")?; + Ok(fixture) + } + + fn command(&self) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_codex-update-manager")); + command + .env("HOME", &self.home) + .env("XDG_CONFIG_HOME", &self.config_home) + .env("XDG_STATE_HOME", &self.state_home) + .env("XDG_CACHE_HOME", &self.cache_home) + .env( + "CODEX_LINUX_SETTINGS_FILE", + self.root.join("missing-settings.json"), + ) + .env("CODEX_CLI_PATH", &self.cli_path) + .env( + "PATH", + format!( + "{}:/usr/bin:/bin", + self.cli_path + .parent() + .expect("CLI path should have a parent") + .display() + ), + ) + .env("NPM_ACTIVE_PACKAGE", &self.active_package) + .env("NPM_RETIREMENT_PATH", &self.stale_directory) + .env("NPM_LATEST_VERSION", &self.latest_version) + .env("NPM_INSTALL_MODE", &self.install_mode) + .env("NPM_INSTALL_LOG", &self.install_log) + .env("NPM_MANAGED_CLI", &self.cli_path) + .env("NPM_INSTALL_STARTED", &self.install_started) + .env("NPM_INSTALL_RELEASE", &self.install_release) + .env("NPM_INSTALL_OVERLAP", &self.install_overlap) + .env("NPM_INSTALL_OWNER", &self.install_owner) + .env( + "NPM_BACKGROUND_PROCESS_GROUP", + &self.background_process_group, + ) + .env("NPM_SUPERVISOR_PID", &self.npm_supervisor_pid) + .env("NPM_VIEW_STARTED", &self.view_started) + .env("NPM_VIEW_RELEASE", &self.view_release) + .env_remove("FNM_DIR") + .env_remove("FNM_MULTISHELL_PATH") + .env_remove("HOMEBREW_PREFIX") + .env_remove("NVM_DIR") + .env_remove("XDG_DATA_HOME") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command + } + + fn state_path(&self) -> PathBuf { + self.state_home.join("codex-update-manager/state.json") + } + + fn log_path(&self) -> PathBuf { + self.state_home.join("codex-update-manager/service.log") + } + + fn update_state(&self, update: impl FnOnce(&mut Value)) -> Result<()> { + let path = self.state_path(); + let mut state: Value = serde_json::from_slice(&fs::read(&path)?)?; + update(&mut state); + fs::write(path, serde_json::to_vec_pretty(&state)?)?; + Ok(()) + } + + fn state(&self) -> Result { + Ok(serde_json::from_slice(&fs::read(self.state_path())?)?) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + if let Ok(process_group) = fs::read_to_string(&self.background_process_group) { + if let Ok(process_group) = process_group.trim().parse::() { + unsafe { + libc::kill(-process_group, libc::SIGKILL); + } + } + } + let pid_path = self.install_owner.join("pid"); + let Ok(pid) = fs::read_to_string(pid_path) else { + return; + }; + let Ok(pid) = pid.trim().parse::() else { + return; + }; + unsafe { + libc::kill(-pid, libc::SIGKILL); + libc::kill(pid, libc::SIGKILL); + } + } +} + +struct ManagedChild { + child: Option, + name: &'static str, +} + +impl ManagedChild { + fn spawn(mut command: Command, name: &'static str) -> Result { + let child = command + .spawn() + .with_context(|| format!("failed to spawn {name}"))?; + Ok(Self { + child: Some(child), + name, + }) + } + + fn assert_running(&mut self) -> Result<()> { + anyhow::ensure!( + self.child + .as_mut() + .context("child already consumed")? + .try_wait()? + .is_none(), + "{} exited before the install lock was released", + self.name + ); + Ok(()) + } + + fn pid(&self) -> Result { + Ok(self.child.as_ref().context("child already consumed")?.id()) + } + + fn kill_parent_only(&mut self) -> Result<()> { + let child = self.child.as_mut().context("child already consumed")?; + child.kill()?; + child.wait()?; + self.child.take(); + Ok(()) + } + + fn wait(mut self) -> Result { + let deadline = Instant::now() + WAIT_TIMEOUT; + loop { + if self + .child + .as_mut() + .context("child already consumed")? + .try_wait()? + .is_some() + { + let child = self.child.take().context("child already consumed")?; + return child.wait_with_output().map_err(Into::into); + } + anyhow::ensure!( + Instant::now() < deadline, + "{} did not exit before the timeout", + self.name + ); + thread::sleep(Duration::from_millis(20)); + } + } + + fn terminate(&mut self) -> Result<()> { + let Some(child) = self.child.as_mut() else { + return Ok(()); + }; + child.kill()?; + child.wait()?; + self.child.take(); + Ok(()) + } +} + +impl Drop for ManagedChild { + fn drop(&mut self) { + if let Some(child) = self.child.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +#[test] +fn actual_cli_preflight_status_and_daemon_share_the_npm_install_lock() -> Result<()> { + let fixture = Fixture::new()?; + + let mut preflight_command = fixture.command(); + preflight_command + .args(["cli-preflight", "--cli-path"]) + .arg(&fixture.cli_path); + let mut preflight = ManagedChild::spawn(preflight_command, "cli-preflight")?; + wait_for_path(&fixture.install_started, "first npm install")?; + + fixture.update_state(expire_cli_registry_check)?; + let mut status_command = fixture.command(); + status_command.args(["status", "--json"]); + let mut status = ManagedChild::spawn(status_command, "status")?; + wait_for_text( + &fixture.log_path(), + &format!("process {} is waiting", status.pid()?), + "status install-lock wait", + )?; + status.assert_running()?; + + fixture.update_state(expire_cli_registry_check)?; + let mut daemon_command = fixture.command(); + daemon_command.arg("daemon"); + let mut daemon = ManagedChild::spawn(daemon_command, "daemon")?; + + wait_for_text( + &fixture.log_path(), + &format!("process {} is waiting", daemon.pid()?), + "daemon install-lock wait", + )?; + preflight.assert_running()?; + status.assert_running()?; + daemon.assert_running()?; + assert_eq!(install_count(&fixture.install_log)?, 1); + assert!(!fixture.install_overlap.exists()); + + fs::write(&fixture.install_release, b"continue")?; + ensure_success("cli-preflight", &preflight.wait()?)?; + ensure_success("status", &status.wait()?)?; + wait_for_state(&fixture, |state| { + state["cli_status"] == "update_required" && state["cli_installed_version"] == "0.42.0" + })?; + daemon.terminate()?; + + assert_eq!(install_count(&fixture.install_log)?, 1); + assert!(!fixture.install_overlap.exists()); + assert!(fixture.stale_directory.exists()); + assert!(fixture + .state_home + .join("codex-update-manager/cli-repair.json") + .exists()); + let state = fixture.state()?; + assert_eq!(state["remote_headers_fingerprint"], "must-survive-cli-race"); + assert_eq!(state["cli_status"], "update_required"); + assert_eq!(state["cli_installed_version"], "0.42.0"); + assert!(state["cli_error_message"] + .as_str() + .is_some_and(|message| message.contains("codex-update-manager diagnose"))); + Ok(()) +} + +#[test] +fn late_registry_failure_cannot_overwrite_a_completed_repair() -> Result<()> { + let fixture = Fixture::new()?; + + let mut status_command = fixture.command(); + status_command + .args(["status", "--json"]) + .env("NPM_VIEW_MODE", "delayed-failure"); + let status = ManagedChild::spawn(status_command, "delayed status")?; + wait_for_path(&fixture.view_started, "delayed npm registry lookup")?; + + fixture.update_state(expire_cli_registry_check)?; + fs::write(&fixture.install_release, b"continue")?; + let mut preflight_command = fixture.command(); + preflight_command + .args(["cli-preflight", "--cli-path"]) + .arg(&fixture.cli_path); + ensure_success( + "stale-detecting cli-preflight", + &preflight_command.output()?, + )?; + wait_for_state(&fixture, |state| { + state["cli_status"] == "update_required" + && state["cli_error_message"] + .as_str() + .is_some_and(|message| message.contains("codex-update-manager diagnose")) + })?; + + fs::write(&fixture.install_mode, b"success\n")?; + let mut repair_command = fixture.command(); + repair_command.arg("repair-cli"); + ensure_success("explicit repair", &repair_command.output()?)?; + wait_for_state(&fixture, |state| { + state["cli_status"] == "up_to_date" && state["cli_installed_version"] == "0.42.1" + })?; + assert!(!fixture + .state_home + .join("codex-update-manager/cli-repair.json") + .exists()); + + fs::write(&fixture.view_release, b"continue")?; + ensure_success("delayed status", &status.wait()?)?; + + let state = fixture.state()?; + assert_eq!(state["cli_status"], "up_to_date"); + assert_eq!(state["cli_installed_version"], "0.42.1"); + assert!(state["cli_error_message"].is_null()); + assert!(!fixture + .state_home + .join("codex-update-manager/cli-repair.json") + .exists()); + assert_eq!(install_count(&fixture.install_log)?, 2); + Ok(()) +} + +#[test] +fn orphaned_npm_process_group_is_bounded_and_releases_the_install_lock() -> Result<()> { + let fixture = Fixture::new()?; + + let mut preflight_command = fixture.command(); + preflight_command + .args(["cli-preflight", "--cli-path"]) + .arg(&fixture.cli_path); + let mut preflight = ManagedChild::spawn(preflight_command, "cli-preflight")?; + wait_for_path(&fixture.install_started, "first npm install")?; + let orphan_process_group = + wait_for_nonempty_file(&fixture.npm_supervisor_pid, "npm supervisor pid")? + .trim() + .parse::()?; + preflight.kill_parent_only()?; + wait_for_process_group_exit(orphan_process_group, "orphaned npm install")?; + fs::remove_file(&fixture.install_started)?; + + fixture.update_state(expire_cli_registry_check)?; + let mut status_command = fixture.command(); + status_command.args(["status", "--json"]); + let mut status = ManagedChild::spawn(status_command, "status")?; + wait_for_path(&fixture.install_started, "replacement npm install")?; + status.assert_running()?; + assert_eq!(install_count(&fixture.install_log)?, 2); + assert!(!fixture.install_overlap.exists()); + + fs::write(&fixture.install_release, b"continue")?; + ensure_success("status", &status.wait()?)?; + + assert!(!fixture.install_overlap.exists()); + Ok(()) +} + +#[test] +fn completed_npm_leader_cleans_background_group_and_releases_the_install_lock() -> Result<()> { + let fixture = Fixture::new()?; + fs::write(&fixture.install_mode, b"background-descendant\n")?; + + let mut first_status_command = fixture.command(); + first_status_command.args(["status", "--json"]); + let first_status = ManagedChild::spawn(first_status_command, "first status")?; + wait_for_path( + &fixture.background_process_group, + "background npm process group", + )?; + let background_process_group = wait_for_nonempty_file( + &fixture.background_process_group, + "background npm process group value", + )? + .trim() + .parse::()?; + let first_output = first_status.wait()?; + assert!(!first_output.status.success()); + assert!(String::from_utf8_lossy(&first_output.stderr) + .contains("npm completed but managed Codex CLI 0.42.1 could not be resolved")); + wait_for_process_group_exit(background_process_group, "background npm descendant")?; + fs::remove_file(&fixture.background_process_group)?; + + fs::remove_file(&fixture.install_started)?; + fixture.update_state(expire_cli_registry_check)?; + fs::write(&fixture.install_mode, b"success\n")?; + let mut replacement_status_command = fixture.command(); + replacement_status_command.args(["status", "--json"]); + let mut replacement_status = + ManagedChild::spawn(replacement_status_command, "replacement status")?; + wait_for_path(&fixture.install_started, "replacement npm install")?; + replacement_status.assert_running()?; + assert_eq!(install_count(&fixture.install_log)?, 2); + assert!(!fixture.install_overlap.exists()); + + fs::write(&fixture.install_release, b"continue")?; + ensure_success("replacement status", &replacement_status.wait()?)?; + + assert!(!fixture.install_overlap.exists()); + Ok(()) +} + +#[test] +fn killed_npm_supervisor_cleans_descendants_before_lock_release() -> Result<()> { + let fixture = Fixture::new()?; + fs::write(&fixture.install_mode, b"background-descendant-hang\n")?; + + let mut preflight_command = fixture.command(); + preflight_command + .args(["cli-preflight", "--cli-path"]) + .arg(&fixture.cli_path); + let preflight = ManagedChild::spawn(preflight_command, "cli-preflight")?; + wait_for_path(&fixture.install_started, "first npm install")?; + wait_for_path( + &fixture.background_process_group, + "background npm process group", + )?; + wait_for_path(&fixture.npm_supervisor_pid, "npm supervisor pid")?; + + let background_process_group = wait_for_nonempty_file( + &fixture.background_process_group, + "background npm process group value", + )? + .trim() + .parse::()?; + let supervisor_pid = + wait_for_nonempty_file(&fixture.npm_supervisor_pid, "npm supervisor pid value")? + .trim() + .parse::()?; + anyhow::ensure!( + Command::new("/bin/kill") + .args(["-0", "--", &format!("-{background_process_group}")]) + .status()? + .success(), + "overlap oracle did not recognize live npm group {background_process_group}" + ); + anyhow::ensure!( + unsafe { libc::kill(supervisor_pid, libc::SIGKILL) } == 0, + "failed to kill npm supervisor {supervisor_pid}" + ); + + fs::remove_file(&fixture.install_started)?; + fixture.update_state(expire_cli_registry_check)?; + fs::write(&fixture.install_mode, b"success\n")?; + let mut status_command = fixture.command(); + status_command.args(["status", "--json"]); + let status = ManagedChild::spawn(status_command, "status")?; + + wait_for_process_group_exit(background_process_group, "npm group after supervisor death")?; + fs::remove_file(&fixture.background_process_group)?; + let first = preflight.wait()?; + anyhow::ensure!( + !first.status.success(), + "cli-preflight unexpectedly succeeded after its npm supervisor was killed" + ); + wait_for_path(&fixture.install_started, "replacement npm install")?; + assert_eq!(install_count(&fixture.install_log)?, 2); + assert!(!fixture.install_overlap.exists()); + + fs::write(&fixture.install_release, b"continue")?; + ensure_success("status", &status.wait()?)?; + assert!(!fixture.install_overlap.exists()); + Ok(()) +} + +fn cli_script(version: &str) -> String { + format!("#!/bin/sh\necho 'codex-cli v{version}'\n") +} + +fn expire_cli_registry_check(state: &mut Value) { + state["cli_last_check_at"] = Value::Null; + state["cli_official_latest_version"] = Value::Null; +} + +fn write_executable(path: &Path, contents: &str) -> Result<()> { + fs::write(path, contents)?; + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions)?; + Ok(()) +} + +fn secure_directory_tree(path: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(path)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Ok(()); + } + fs::set_permissions(path, fs::Permissions::from_mode(0o755))?; + for entry in fs::read_dir(path)? { + secure_directory_tree(&entry?.path())?; + } + Ok(()) +} + +fn toml_path(path: &Path) -> String { + path.display() + .to_string() + .replace('\\', "\\\\") + .replace('"', "\\\"") +} + +fn wait_for_path(path: &Path, description: &str) -> Result<()> { + let deadline = Instant::now() + WAIT_TIMEOUT; + while !path.exists() { + anyhow::ensure!( + Instant::now() < deadline, + "timed out waiting for {description}: {}", + path.display() + ); + thread::sleep(Duration::from_millis(20)); + } + Ok(()) +} + +fn wait_for_nonempty_file(path: &Path, description: &str) -> Result { + let deadline = Instant::now() + WAIT_TIMEOUT; + loop { + if let Ok(value) = fs::read_to_string(path) { + if !value.trim().is_empty() { + return Ok(value); + } + } + anyhow::ensure!( + Instant::now() < deadline, + "timed out waiting for {description} at {}", + path.display() + ); + thread::sleep(Duration::from_millis(20)); + } +} + +fn wait_for_process_group_exit(process_group: i32, description: &str) -> Result<()> { + let deadline = Instant::now() + WAIT_TIMEOUT; + loop { + let exists = unsafe { libc::kill(-process_group, 0) } == 0; + if !exists { + return Ok(()); + } + anyhow::ensure!( + Instant::now() < deadline, + "timed out waiting for {description} process group {process_group} to exit" + ); + thread::sleep(Duration::from_millis(20)); + } +} + +fn wait_for_state(fixture: &Fixture, predicate: impl Fn(&Value) -> bool) -> Result<()> { + let deadline = Instant::now() + WAIT_TIMEOUT; + loop { + if fixture.state().is_ok_and(|state| predicate(&state)) { + return Ok(()); + } + anyhow::ensure!( + Instant::now() < deadline, + "timed out waiting for persisted CLI state" + ); + thread::sleep(Duration::from_millis(20)); + } +} + +fn wait_for_text(path: &Path, needle: &str, description: &str) -> Result<()> { + let deadline = Instant::now() + WAIT_TIMEOUT; + loop { + if fs::read_to_string(path).is_ok_and(|contents| contents.contains(needle)) { + return Ok(()); + } + anyhow::ensure!( + Instant::now() < deadline, + "timed out waiting for {description}: {}", + path.display() + ); + thread::sleep(Duration::from_millis(20)); + } +} + +fn install_count(path: &Path) -> Result { + Ok(fs::read_to_string(path)?.lines().count()) +} + +fn ensure_success(name: &str, output: &Output) -> Result<()> { + anyhow::ensure!( + output.status.success(), + "{name} failed with {}: stdout={} stderr={}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) +}