From 307d2c3bd2e5d7dba425cf539e6b5fba90729325 Mon Sep 17 00:00:00 2001 From: Anthony Spangenberg Date: Fri, 24 Jul 2026 14:19:34 -0700 Subject: [PATCH 001/112] feat: add Codex Micro Linux support --- .github/workflows/ci.yml | 97 +++ README.md | 1 + docs/nix.md | 6 + flake.nix | 42 ++ linux-features/codex-micro/README.md | 107 ++++ linux-features/codex-micro/feature.json | 42 ++ .../codex-micro/native-artifacts.json | 27 + linux-features/codex-micro/native-binding.js | 524 +++++++++++++++ linux-features/codex-micro/patch.js | 146 +++++ .../resources/70-codex-micro.rules | 5 + linux-features/codex-micro/test.js | 596 ++++++++++++++++++ nix/linux-features-test.nix | 59 ++ nix/linux-features.nix | 1 + nix/nixos-module.nix | 7 + scripts/ci/container-entrypoint.sh | 88 ++- scripts/ci/upstream-dmg-acceptance.test.js | 1 + scripts/ci/watchdog-linux-features.json | 1 + 17 files changed, 1747 insertions(+), 3 deletions(-) create mode 100644 linux-features/codex-micro/README.md create mode 100644 linux-features/codex-micro/feature.json create mode 100644 linux-features/codex-micro/native-artifacts.json create mode 100644 linux-features/codex-micro/native-binding.js create mode 100644 linux-features/codex-micro/patch.js create mode 100644 linux-features/codex-micro/resources/70-codex-micro.rules create mode 100644 linux-features/codex-micro/test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60dc3937e..94934a909 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,6 +143,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 @@ -363,6 +397,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 +495,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 +530,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 +538,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/README.md b/README.md index 99d0735f7..5eae393f8 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,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) | 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/flake.nix b/flake.nix index 9efcd7e56..1b4082222 100644 --- a/flake.nix +++ b/flake.nix @@ -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="; @@ -345,6 +351,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 +523,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 +576,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 +627,7 @@ PY else normalizeLinuxFeaturesConfig linuxFeaturesConfigOverride; normalizedLinuxFeatureIds = effectiveLinuxFeaturesConfig.enabled; + codexMicroEnabled = builtins.elem "codex-micro" normalizedLinuxFeatureIds; featureArgs = { inherit enableComputerUseUi; linuxFeatureIds = normalizedLinuxFeatureIds; @@ -658,6 +675,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 diff --git a/linux-features/codex-micro/README.md b/linux-features/codex-micro/README.md new file mode 100644 index 000000000..7de207b3a --- /dev/null +++ b/linux-features/codex-micro/README.md @@ -0,0 +1,107 @@ +# 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 two 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. + +The feature is disabled by default. + +## 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`. Reload the rules after the first +install, then reconnect USB or Bluetooth: + +```bash +sudo udevadm control --reload-rules +``` + +AppImage, source, Home Manager, and direct flake installs cannot change host +udev policy. Install the tracked rule once: + +```bash +sudo install -Dm0644 \ + linux-features/codex-micro/resources/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. Home Manager and direct flake users must use the manual rule procedure +above. + +## 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..c78cc139f --- /dev/null +++ b/linux-features/codex-micro/native-binding.js @@ -0,0 +1,524 @@ +#!/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", + ); + requirePackageDirectory(deviceKitDir, "@worklouder/device-kit-oai", "Work Louder device-kit-oai"); + + const workLouderKitDir = path.join( + deviceKitDir, + "node_modules", + "@worklouder", + "wl-device-kit", + ); + 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); + if (digest(existing, "sha256", "hex") === prebuild.sha256) { + 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..0d303fb3b --- /dev/null +++ b/linux-features/codex-micro/patch.js @@ -0,0 +1,146 @@ +"use strict"; + +const childProcess = require("node:child_process"); +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 FEATURE_GATE_WARNING = "useFeatureGate hook failed to find a valid StatsigClient"; +const JS_IDENT = "[A-Za-z_$][\\w$]*"; + +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) { + return typeof source === "string" + && source.includes(CODEX_MICRO_GATE_ID) + && source.includes(CODEX_MICRO_ROUTE); +} + +function matchesCodexMicroFeatureGateContract(source) { + if (typeof source !== "string") { + return false; + } + if (source.includes(CODEX_MICRO_GATE_MARKER)) { + return true; + } + return hasCodexMicroCallsite(source) + && source.includes(FEATURE_GATE_WARNING) + && exportedFeatureGateHook(source) != null; +} + +function applyCodexMicroFeatureGatePatch(source) { + if (typeof source !== "string" || source.includes(CODEX_MICRO_GATE_MARKER)) { + return source; + } + if (!hasCodexMicroCallsite(source)) { + 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; + } + + 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 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_ROUTE, + applyCodexMicroFeatureGatePatch, + exportedFeatureGateHook, + hasCodexMicroCallsite, + matchesCodexMicroFeatureGateContract, + descriptors: [ + 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..95f832744 --- /dev/null +++ b/linux-features/codex-micro/test.js @@ -0,0 +1,596 @@ +#!/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_ROUTE, + applyCodexMicroFeatureGatePatch, + descriptors, + exportedFeatureGateHook, + matchesCodexMicroFeatureGateContract, +} = 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=kh(\`${CODEX_MICRO_GATE_ID}\`);`, + `const microRoute=\`${CODEX_MICRO_ROUTE}\`;`, + "export{zh as c,Lh as flt,Rh as rlt};", + ].join(""); +} + +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("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 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("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 in the bundled dependency chain", (t) => { + const fixture = createBundledFixture(t); + const scopedModules = path.join( + fixture.extractedDir, + "node_modules", + "@worklouder", + ); + const outside = path.join(path.dirname(fixture.extractedDir), "outside-worklouder"); + fs.renameSync(scopedModules, outside); + 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/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..6d750c36c 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" 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/scripts/ci/container-entrypoint.sh b/scripts/ci/container-entrypoint.sh index ef577be73..829f52a62 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() { diff --git a/scripts/ci/upstream-dmg-acceptance.test.js b/scripts/ci/upstream-dmg-acceptance.test.js index 8a7dcc46b..1aefabaec 100644 --- a/scripts/ci/upstream-dmg-acceptance.test.js +++ b/scripts/ci/upstream-dmg-acceptance.test.js @@ -217,6 +217,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/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", From d8938989766cf362ee3c018bcba92fee8296b3b5 Mon Sep 17 00:00:00 2001 From: Anthony Spangenberg Date: Sat, 25 Jul 2026 00:59:06 -0700 Subject: [PATCH 002/112] fix: reject symlinked Micro package ancestors early --- linux-features/codex-micro/native-binding.js | 2 + linux-features/codex-micro/test.js | 50 ++++++++++++++------ 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/linux-features/codex-micro/native-binding.js b/linux-features/codex-micro/native-binding.js index c78cc139f..8f6ac8564 100644 --- a/linux-features/codex-micro/native-binding.js +++ b/linux-features/codex-micro/native-binding.js @@ -52,6 +52,7 @@ function discoverBundledNodeHid(extractedDir) { "@worklouder", "device-kit-oai", ); + assertNoSymbolicLinkAncestors(extractedRoot, deviceKitDir); requirePackageDirectory(deviceKitDir, "@worklouder/device-kit-oai", "Work Louder device-kit-oai"); const workLouderKitDir = path.join( @@ -60,6 +61,7 @@ function discoverBundledNodeHid(extractedDir) { "@worklouder", "wl-device-kit", ); + assertNoSymbolicLinkAncestors(extractedRoot, workLouderKitDir); requirePackageDirectory( workLouderKitDir, "@worklouder/wl-device-kit", diff --git a/linux-features/codex-micro/test.js b/linux-features/codex-micro/test.js index 95f832744..6f43d41d1 100644 --- a/linux-features/codex-micro/test.js +++ b/linux-features/codex-micro/test.js @@ -547,21 +547,43 @@ test("the nested discovery path cannot be substituted with a hoisted node-hid", ); }); -test("discovery rejects symlinked ancestors in the bundled dependency chain", (t) => { - const fixture = createBundledFixture(t); - const scopedModules = path.join( - fixture.extractedDir, - "node_modules", - "@worklouder", - ); - const outside = path.join(path.dirname(fixture.extractedDir), "outside-worklouder"); - fs.renameSync(scopedModules, outside); - fs.symlinkSync(outside, scopedModules, "dir"); +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, - ); + assert.throws( + () => discoverBundledNodeHid(fixture.extractedDir), + /path must not contain symlinks/i, + ); + } }); test("native binding staging rejects valid existing bindings behind symlinked parents", async (t) => { From de018911ba711b3a163e3681923d9e3f20c5ff40 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sat, 25 Jul 2026 10:57:20 +0300 Subject: [PATCH 003/112] Fix Codex Micro path safety and setup docs --- linux-features/codex-micro/README.md | 49 +++++++++++++++++++++++++--- linux-features/codex-micro/test.js | 21 ++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/linux-features/codex-micro/README.md b/linux-features/codex-micro/README.md index 7de207b3a..a299e06a0 100644 --- a/linux-features/codex-micro/README.md +++ b/linux-features/codex-micro/README.md @@ -59,12 +59,52 @@ install, then reconnect USB or Bluetooth: sudo udevadm control --reload-rules ``` -AppImage, source, Home Manager, and direct flake installs cannot change host -udev policy. Install the tracked rule once: +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 \ - linux-features/codex-micro/resources/70-codex-micro.rules \ + "$codex_package_root/lib/udev/rules.d/70-codex-micro.rules" \ /etc/udev/rules.d/70-codex-micro.rules sudo udevadm control --reload-rules ``` @@ -75,8 +115,7 @@ 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. Home Manager and direct flake users must use the manual rule procedure -above. +module. Other install modes require the matching manual procedure above. ## Bluetooth diff --git a/linux-features/codex-micro/test.js b/linux-features/codex-micro/test.js index 6f43d41d1..2b597a7f5 100644 --- a/linux-features/codex-micro/test.js +++ b/linux-features/codex-micro/test.js @@ -586,6 +586,27 @@ test("discovery rejects symlinked ancestors before reading package metadata", (t } }); +test("discovery rejects symlinked package ancestors before reading metadata", (t) => { + const fixture = createBundledFixture(t); + const scopedModules = path.join( + fixture.extractedDir, + "node_modules", + "@worklouder", + ); + const outside = path.join(path.dirname(fixture.extractedDir), "outside-worklouder"); + fs.renameSync(scopedModules, outside); + fs.writeFileSync( + path.join(outside, "device-kit-oai", "package.json"), + "{ metadata outside the extracted tree must not be read", + ); + 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 }); From 62a2b04e55d74f02e01ef18790086e9ea8d7bf73 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sat, 25 Jul 2026 11:04:01 +0300 Subject: [PATCH 004/112] Make Codex Micro drift checks fail closed --- linux-features/codex-micro/README.md | 10 ++++- linux-features/codex-micro/native-binding.js | 25 +++++++----- linux-features/codex-micro/patch.js | 18 +++++---- linux-features/codex-micro/test.js | 42 +++++++++++++++++++- 4 files changed, 74 insertions(+), 21 deletions(-) diff --git a/linux-features/codex-micro/README.md b/linux-features/codex-micro/README.md index a299e06a0..78acd5878 100644 --- a/linux-features/codex-micro/README.md +++ b/linux-features/codex-micro/README.md @@ -52,8 +52,10 @@ 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`. Reload the rules after the first -install, then reconnect USB or Bluetooth: +`/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 @@ -116,6 +118,10 @@ same vendor HID channel on the Bluetooth HID bus. Both rules use `uaccess` and 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 diff --git a/linux-features/codex-micro/native-binding.js b/linux-features/codex-micro/native-binding.js index 8f6ac8564..edf4ad4f3 100644 --- a/linux-features/codex-micro/native-binding.js +++ b/linux-features/codex-micro/native-binding.js @@ -443,17 +443,22 @@ async function stageCodexMicroNativeBinding(options) { throw new Error(`Existing node-hid native binding is unsafe: ${targetPath}`); } const existing = fs.readFileSync(targetPath); - if (digest(existing, "sha256", "hex") === prebuild.sha256) { - validateBinding(existing, arch, prebuild.sha256); - return { - changed: false, - alreadyApplied: true, - version: artifactManifest.version, - targetPath, - source: "existing-prebuild", - integrity: artifactManifest.integrity, - }; + 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; diff --git a/linux-features/codex-micro/patch.js b/linux-features/codex-micro/patch.js index 0d303fb3b..f59539418 100644 --- a/linux-features/codex-micro/patch.js +++ b/linux-features/codex-micro/patch.js @@ -51,9 +51,10 @@ function exportedFeatureGateHook(source) { return exportedCandidates.length === 1 ? exportedCandidates[0] : null; } -function hasCodexMicroCallsite(source) { +function hasCodexMicroCallsite(source, hookName) { return typeof source === "string" - && source.includes(CODEX_MICRO_GATE_ID) + && typeof hookName === "string" + && source.includes(`${hookName}(\`${CODEX_MICRO_GATE_ID}\`)`) && source.includes(CODEX_MICRO_ROUTE); } @@ -64,18 +65,16 @@ function matchesCodexMicroFeatureGateContract(source) { if (source.includes(CODEX_MICRO_GATE_MARKER)) { return true; } - return hasCodexMicroCallsite(source) - && source.includes(FEATURE_GATE_WARNING) - && exportedFeatureGateHook(source) != null; + 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; } - if (!hasCodexMicroCallsite(source)) { - return source; - } const hook = exportedFeatureGateHook(source); if (hook == null) { @@ -87,6 +86,9 @@ function applyCodexMicroFeatureGatePatch(source) { } return source; } + if (!hasCodexMicroCallsite(source, hook.hookName)) { + return source; + } const replacement = `function ${hook.hookName}(${hook.argumentName}){return ` + diff --git a/linux-features/codex-micro/test.js b/linux-features/codex-micro/test.js index 2b597a7f5..f852c3fbb 100644 --- a/linux-features/codex-micro/test.js +++ b/linux-features/codex-micro/test.js @@ -202,7 +202,7 @@ function currentFeatureGateFixture() { "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=kh(\`${CODEX_MICRO_GATE_ID}\`);`, + `const microGate=Rh(\`${CODEX_MICRO_GATE_ID}\`);`, `const microRoute=\`${CODEX_MICRO_ROUTE}\`;`, "export{zh as c,Lh as flt,Rh as rlt};", ].join(""); @@ -249,6 +249,21 @@ test("both the Codex Micro gate id and route are required", () => { 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 gate patch targets only the current app-initial bundle shape", () => { const descriptor = descriptors.find(({ id }) => id === "webview-feature-gate"); assert.ok(descriptor); @@ -329,6 +344,31 @@ test("an already verified binding is idempotent and performs no package fetch", 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 }, From 0f619389b8ac84be6440b286090af69cf2489b13 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sat, 25 Jul 2026 11:11:28 +0300 Subject: [PATCH 005/112] Tighten Codex Micro gate matching --- linux-features/codex-micro/patch.js | 12 ++++++++---- linux-features/codex-micro/test.js | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/linux-features/codex-micro/patch.js b/linux-features/codex-micro/patch.js index f59539418..c02f34245 100644 --- a/linux-features/codex-micro/patch.js +++ b/linux-features/codex-micro/patch.js @@ -52,10 +52,14 @@ function exportedFeatureGateHook(source) { } function hasCodexMicroCallsite(source, hookName) { - return typeof source === "string" - && typeof hookName === "string" - && source.includes(`${hookName}(\`${CODEX_MICRO_GATE_ID}\`)`) - && source.includes(CODEX_MICRO_ROUTE); + 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) { diff --git a/linux-features/codex-micro/test.js b/linux-features/codex-micro/test.js index f852c3fbb..9a5709843 100644 --- a/linux-features/codex-micro/test.js +++ b/linux-features/codex-micro/test.js @@ -264,6 +264,28 @@ test("Codex Micro gate drift cannot redirect the patch to an unrelated exported 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); From 2e6a9cea09b73704eb098d634038879e4a09bdf9 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sat, 25 Jul 2026 11:15:46 +0300 Subject: [PATCH 006/112] Deduplicate Codex Micro symlink coverage --- linux-features/codex-micro/test.js | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/linux-features/codex-micro/test.js b/linux-features/codex-micro/test.js index 9a5709843..3c5d0bcf6 100644 --- a/linux-features/codex-micro/test.js +++ b/linux-features/codex-micro/test.js @@ -648,27 +648,6 @@ test("discovery rejects symlinked ancestors before reading package metadata", (t } }); -test("discovery rejects symlinked package ancestors before reading metadata", (t) => { - const fixture = createBundledFixture(t); - const scopedModules = path.join( - fixture.extractedDir, - "node_modules", - "@worklouder", - ); - const outside = path.join(path.dirname(fixture.extractedDir), "outside-worklouder"); - fs.renameSync(scopedModules, outside); - fs.writeFileSync( - path.join(outside, "device-kit-oai", "package.json"), - "{ metadata outside the extracted tree must not be read", - ); - 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 }); From a61fb284d8337ea4642e33044a1d2ac811cf9950 Mon Sep 17 00:00:00 2001 From: Daniel Castrillon <112592276+danielcadev@users.noreply.github.com> Date: Sat, 25 Jul 2026 06:07:32 -0500 Subject: [PATCH 007/112] chore(nix): update tar to 7.5.22 Signed-off-by: Daniel Castrillon <112592276+danielcadev@users.noreply.github.com> --- nix/native-modules/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nix/native-modules/package-lock.json b/nix/native-modules/package-lock.json index d5056c688..026979dd9 100644 --- a/nix/native-modules/package-lock.json +++ b/nix/native-modules/package-lock.json @@ -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", From a7cc6040c3bf43623f6f7fa880f4e3f46d6764b4 Mon Sep 17 00:00:00 2001 From: Kamil Beda Date: Sat, 25 Jul 2026 20:41:49 +0200 Subject: [PATCH 008/112] fix(computer-use): recognize Arch ydotool CLI --- computer-use-linux/src/ydotool.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/computer-use-linux/src/ydotool.rs b/computer-use-linux/src/ydotool.rs index bb784159d..95fc35fe3 100644 --- a/computer-use-linux/src/ydotool.rs +++ b/computer-use-linux/src/ydotool.rs @@ -38,7 +38,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) @@ -80,6 +84,13 @@ 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 rejects_unknown_cli_shape() { assert_eq!(classify_help("Usage: ydotool "), None); From 4b0d5f6ebabfc1b4d7e384f01fb767c9e7dbecdd Mon Sep 17 00:00:00 2001 From: Kamil Beda Date: Sat, 25 Jul 2026 21:10:43 +0200 Subject: [PATCH 009/112] fix(computer-use): probe required ydotool semantics --- computer-use-linux/src/ydotool.rs | 131 +++++++++++++++++++++++++++++- docs/linux-computer-use.md | 8 +- 2 files changed, 131 insertions(+), 8 deletions(-) diff --git a/computer-use-linux/src/ydotool.rs b/computer-use-linux/src/ydotool.rs index 95fc35fe3..5927e365c 100644 --- a/computer-use-linux/src/ydotool.rs +++ b/computer-use-linux/src/ydotool.rs @@ -1,4 +1,11 @@ -use std::{process::Command, sync::OnceLock}; +use std::{ + env, fs, + os::unix::net::UnixDatagram, + path::{Path, PathBuf}, + process::{self, Command, Stdio}, + sync::OnceLock, + time::{SystemTime, UNIX_EPOCH}, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum CliGeneration { @@ -6,7 +13,39 @@ 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 UNSUPPORTED_MESSAGE: &str = "unsupported ydotool CLI; Computer Use requires ydotool 1.0.2 or newer with raw key events, wheel movement, stdin typing, and absolute mouse movement"; + +struct ProbeSocket { + _socket: UnixDatagram, + path: PathBuf, +} + +impl ProbeSocket { + fn bind() -> Result { + let runtime_dir = env::var_os("XDG_RUNTIME_DIR") + .ok_or_else(|| "XDG_RUNTIME_DIR is unavailable for safe ydotool probing".to_string())?; + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("failed to create ydotool probe nonce: {error}"))? + .as_nanos(); + let path = PathBuf::from(runtime_dir).join(format!( + ".codex-ydotool-probe-{}-{nonce}.socket", + process::id() + )); + let socket = UnixDatagram::bind(&path) + .map_err(|error| format!("failed to bind isolated ydotool probe socket: {error}"))?; + Ok(Self { + _socket: socket, + path, + }) + } +} + +impl Drop for ProbeSocket { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} pub(crate) fn ensure_supported() -> Result { static RESULT: OnceLock> = OnceLock::new(); @@ -24,12 +63,71 @@ fn probe() -> Result { 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().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.2 or newer".to_string()) +} + +fn probe_raw_semantics() -> Result<(), String> { + let socket = ProbeSocket::bind()?; + let wheel = run_probe_command( + &socket.path, + &["mousemove", "--wheel", "--", "0", "0"], + None, + )?; + let type_from_stdin = run_probe_command( + &socket.path, + &["type", "--file", "-"], + Some(Path::new("/proc/self/fd")), + )?; + + if raw_semantic_probes_succeeded( + wheel.status.success(), + &wheel.stderr, + type_from_stdin.status.success(), + &type_from_stdin.stderr, + ) { + Ok(()) + } else { + Err(UNSUPPORTED_MESSAGE.to_string()) + } +} + +fn run_probe_command( + socket_path: &Path, + args: &[&str], + current_dir: Option<&Path>, +) -> Result { + let mut command = Command::new("ydotool"); + command + .args(args) + .env("YDOTOOL_SOCKET", socket_path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(current_dir) = current_dir { + command.current_dir(current_dir); + } + command + .output() + .map_err(|error| format!("failed to run ydotool capability probe: {error}")) +} + +fn raw_semantic_probes_succeeded( + wheel_success: bool, + wheel_stderr: &[u8], + type_success: bool, + type_stderr: &[u8], +) -> bool { + wheel_success + && cli_error(wheel_stderr).is_none() + && type_success + && cli_error(type_stderr).is_none() } pub(crate) fn classify_help(help: &str) -> Option { @@ -91,6 +189,31 @@ mod tests { assert_eq!(classify_help(help), Some(CliGeneration::RawEvents)); } + #[test] + fn rejects_raw_cli_without_wheel_semantics() { + assert!(!raw_semantic_probes_succeeded( + true, + b"mousemove: unrecognized option '--wheel'\n", + true, + b"", + )); + } + + #[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"")); + } + #[test] fn rejects_unknown_cli_shape() { assert_eq!(classify_help("Usage: ydotool "), None); diff --git a/docs/linux-computer-use.md b/docs/linux-computer-use.md index c424bcda9..e9340a2fe 100644 --- a/docs/linux-computer-use.md +++ b/docs/linux-computer-use.md @@ -17,10 +17,10 @@ It supports: ## 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.2 or newer when you need the fallback input path. Earlier +releases lack wheel movement or functional stdin typing required by the +backend. The Computer Use readiness report detects and rejects incompatible +CLIs instead of sending unsafe input commands. ```bash # Debian / Ubuntu From 2b921dc04b193624e4b3770733073a43d7fb997a Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sun, 26 Jul 2026 12:00:43 +0300 Subject: [PATCH 010/112] Preserve acceptance source provenance on early failures --- scripts/ci/upstream-dmg-acceptance.test.js | 25 ++++++++++++++++++++++ scripts/lib/upstream-dmg-acceptance.js | 5 +++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/scripts/ci/upstream-dmg-acceptance.test.js b/scripts/ci/upstream-dmg-acceptance.test.js index 1aefabaec..2f8ce0a95 100644 --- a/scripts/ci/upstream-dmg-acceptance.test.js +++ b/scripts/ci/upstream-dmg-acceptance.test.js @@ -149,6 +149,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); 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, From 4842dc7cb6d211e35d373142e240444937257b36 Mon Sep 17 00:00:00 2001 From: YoDDV <116607353+Yo-DDV@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:26:16 +0200 Subject: [PATCH 011/112] Preserve ydotool probing without XDG runtime --- computer-use-linux/src/ydotool.rs | 267 +++++++++++++++++++++++++++--- 1 file changed, 244 insertions(+), 23 deletions(-) diff --git a/computer-use-linux/src/ydotool.rs b/computer-use-linux/src/ydotool.rs index 5927e365c..65f29de29 100644 --- a/computer-use-linux/src/ydotool.rs +++ b/computer-use-linux/src/ydotool.rs @@ -1,10 +1,15 @@ use std::{ - env, fs, - os::unix::net::UnixDatagram, + env, + ffi::OsStr, + fs, io, + os::unix::{ + ffi::OsStrExt, + fs::{DirBuilderExt, MetadataExt, PermissionsExt}, + net::UnixDatagram, + }, path::{Path, PathBuf}, process::{self, Command, Stdio}, sync::OnceLock, - time::{SystemTime, UNIX_EPOCH}, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -13,37 +18,95 @@ pub(crate) enum CliGeneration { LegacyNamed, } +const MAX_UNIX_SOCKET_PATH_BYTES: usize = 107; +const PROBE_DIRECTORY_ATTEMPTS: usize = 8; const UNSUPPORTED_MESSAGE: &str = "unsupported ydotool CLI; Computer Use requires ydotool 1.0.2 or newer with raw key events, wheel movement, stdin typing, and absolute mouse movement"; struct ProbeSocket { _socket: UnixDatagram, + directory: PathBuf, path: PathBuf, } impl ProbeSocket { - fn bind() -> Result { - let runtime_dir = env::var_os("XDG_RUNTIME_DIR") - .ok_or_else(|| "XDG_RUNTIME_DIR is unavailable for safe ydotool probing".to_string())?; - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|error| format!("failed to create ydotool probe nonce: {error}"))? - .as_nanos(); - let path = PathBuf::from(runtime_dir).join(format!( - ".codex-ydotool-probe-{}-{nonce}.socket", + 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() )); - let socket = UnixDatagram::bind(&path) - .map_err(|error| format!("failed to bind isolated ydotool probe socket: {error}"))?; - Ok(Self { - _socket: socket, - path, - }) + 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); } } @@ -53,9 +116,22 @@ pub(crate) fn ensure_supported() -> Result { } fn probe() -> Result { + let runtime_dir = env::var_os("XDG_RUNTIME_DIR"); + probe_with( + Path::new("ydotool"), + runtime_dir.as_deref(), + &env::temp_dir(), + ) +} + +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") + let output = Command::new(ydotool_path) .arg(argument) .output() .map_err(|error| format!("failed to run ydotool: {error}"))?; @@ -64,7 +140,8 @@ fn probe() -> Result { if let Some(generation) = classify_help(&output_text) { return match generation { CliGeneration::RawEvents => { - probe_raw_semantics().map(|()| "compatible raw-event CLI detected".to_string()) + probe_raw_semantics(ydotool_path, runtime_dir, temp_dir) + .map(|()| "compatible raw-event CLI detected".to_string()) } CliGeneration::LegacyNamed => Err(UNSUPPORTED_MESSAGE.to_string()), }; @@ -73,14 +150,20 @@ fn probe() -> Result { Err("unrecognized ydotool CLI; Computer Use requires ydotool 1.0.2 or newer".to_string()) } -fn probe_raw_semantics() -> Result<(), String> { - let socket = ProbeSocket::bind()?; +fn probe_raw_semantics( + ydotool_path: &Path, + runtime_dir: Option<&OsStr>, + temp_dir: &Path, +) -> Result<(), String> { + let socket = ProbeSocket::bind_with(runtime_dir, temp_dir)?; let wheel = run_probe_command( + ydotool_path, &socket.path, &["mousemove", "--wheel", "--", "0", "0"], None, )?; let type_from_stdin = run_probe_command( + ydotool_path, &socket.path, &["type", "--file", "-"], Some(Path::new("/proc/self/fd")), @@ -99,11 +182,12 @@ fn probe_raw_semantics() -> Result<(), String> { } fn run_probe_command( + ydotool_path: &Path, socket_path: &Path, args: &[&str], current_dir: Option<&Path>, ) -> Result { - let mut command = Command::new("ydotool"); + let mut command = Command::new(ydotool_path); command .args(args) .env("YDOTOOL_SOCKET", socket_path) @@ -118,6 +202,55 @@ fn run_probe_command( .map_err(|error| format!("failed to run ydotool capability probe: {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( wheel_success: bool, wheel_stderr: &[u8], @@ -168,6 +301,70 @@ pub(crate) fn cli_error(stderr: &[u8]) -> Option { mod tests { use super::*; + 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' + ;; + mousemove) + test -S "$YDOTOOL_SOCKET" && + test "$2" = '--wheel' && + test "$3" = '--' && + test "$4" = '0' && + test "$5" = '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() { let help = "Usage: ydotool \nAvailable commands:\n type\n recorder\n mousemove\n key\n click\n"; @@ -189,6 +386,30 @@ mod tests { 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 + ); + } + #[test] fn rejects_raw_cli_without_wheel_semantics() { assert!(!raw_semantic_probes_succeeded( From 8c6a945d9b5acbabd0b34f28809a066b179c0fad Mon Sep 17 00:00:00 2001 From: Tanguy De Taxis <19763394+TanguyDeTaxis@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:33:35 +0200 Subject: [PATCH 012/112] test(launcher): add resident window-reopen behavior harness (#1147) * test(launcher): add window reopen behavior harness * test(launcher): close resident reopen review gaps * test(launcher): prove resident mutation sensitivity --------- Co-authored-by: Yo-DDV <116607353+Yo-DDV@users.noreply.github.com> Co-authored-by: Gary Lysenko --- tests/launcher_window_reopen_behavior.sh | 475 +++++++++++++++++++++++ tests/scripts_smoke.sh | 107 +++++ 2 files changed, 582 insertions(+) create mode 100755 tests/launcher_window_reopen_behavior.sh diff --git a/tests/launcher_window_reopen_behavior.sh b/tests/launcher_window_reopen_behavior.sh new file mode 100755 index 000000000..7757bfbb8 --- /dev/null +++ b/tests/launcher_window_reopen_behavior.sh @@ -0,0 +1,475 @@ +#!/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" +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" +APP_LOG="$HOME_DIR/.cache/codex-desktop/launcher.log" +LAUNCHER_PID="" +SECOND_LAUNCHER_PID="" +SOCKET_PID="" +DECOY_PID="" +FIRST_ELECTRON_PID="" +FINAL_ELECTRON_PID="" +HANDOFF_STATUS="not-attempted" +TIMEOUT_STATUS="false" +ERROR_STATUS="false" + +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 + if [ "${arg0:-}" = "$APP_DIR/electron" ]; then + count=$((count + 1)) + fi + 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 "$APP_DIR/start.sh" || cleanup_failed=1 + stop_owned_process_bounded "$SOCKET_PID" argv "$SOCKET_PATH" || cleanup_failed=1 + stop_owned_process_bounded "$webview_pid" argv "$APP_DIR/.codex-linux/webview-server.py" || cleanup_failed=1 + 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 + if [ "${arg0:-}" = "$APP_DIR/electron" ]; then + IFS= read -r -d '' revalidated_arg0 < "$cmdline" 2>/dev/null || true + if [ "${revalidated_arg0:-}" = "$APP_DIR/electron" ]; then + stop_owned_process_bounded "$pid" arg0 "$APP_DIR/electron" || cleanup_failed=1 + fi + fi + 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=$! + +COMMON_ENV=( + env -i + "PATH=$PATH" + "HOME=$HOME_DIR" + "XDG_RUNTIME_DIR=$RUNTIME_DIR" + "CODEX_CLI_PATH=$(command -v true)" + "CODEX_WEBVIEW_PORT=$PORT" +) + +"${COMMON_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)" + +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 [ "${CODEX_TEST_FORCE_RESIDENT_REPLACEMENT:-0}" = "1" ]; then + "${COMMON_ENV[@]}" "$APP_DIR/start.sh" --new-chat > "$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[@]}" "$APP_DIR/start.sh" --new-chat > "$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" <<'PY' \ + || fail "reopen handoff did not preserve the --new-chat argument" +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as result: + assert json.load(result)["argv"] == ["--new-chat"] +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 + +record_result "preserved" +printf '%s\n' "launcher window-reopen behavior test passed" diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index 76607b9f3..7d63f2acd 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -10702,6 +10702,112 @@ 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 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_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" @@ -10850,6 +10956,7 @@ 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 From bc7b92cf38c74b49dbff568257542eabbc62cf55 Mon Sep 17 00:00:00 2001 From: Dmytro Mitin Date: Sun, 26 Jul 2026 20:33:31 +0300 Subject: [PATCH 013/112] Fix Computer Use plugin details for 26.721 (#1159) --- scripts/patch-linux-window-ui.test.js | 246 +++++++++++++----- .../webview/computer-use-ui/patch.js | 12 +- scripts/patches/impl/computer-use.js | 37 ++- tests/scripts_smoke.sh | 46 ++-- 4 files changed, 239 insertions(+), 102 deletions(-) diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index 9e4f250b3..cc61786c2 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -131,6 +131,7 @@ const { const { applyExtractedAppPatchDescriptors, applyMainBundlePatchDescriptors, + applyWebviewAssetPatchDescriptors, discoverCorePatchDescriptors, normalizePatchDescriptors, } = require("./patches/engine.js"); @@ -1100,22 +1101,28 @@ test("default core patch descriptors are grouped and unique", () => { ); 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", @@ -8966,6 +8973,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], @@ -8990,7 +8998,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, @@ -9013,6 +9023,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", @@ -9186,16 +9199,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`/); }); @@ -9213,22 +9251,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", () => { @@ -9246,6 +9321,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) { @@ -9948,7 +10069,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-")); @@ -9969,7 +10090,7 @@ test("patchExtractedApp scans current Computer Use settings bundles when UI is e ].join(""), ); fs.writeFileSync( - path.join(assetsDir, "computer-use-settings-DsM_pz8i.js"), + path.join(assetsDir, "computer-use-settings-BzkBOuLk.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]})}" + @@ -9977,59 +10098,46 @@ test("patchExtractedApp scans current Computer Use settings bundles when UI is e "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", @@ -10038,13 +10146,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", 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/impl/computer-use.js b/scripts/patches/impl/computer-use.js index a432566d1..844440114 100644 --- a/scripts/patches/impl/computer-use.js +++ b/scripts/patches/impl/computer-use.js @@ -508,13 +508,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 +544,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 +599,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 +804,6 @@ module.exports = { applyLinuxComputerUseRendererAvailabilityPatch, isComputerUseUiEnabled, linuxComputerUseCursorBridgeRuntimeSource, + matchesLinuxComputerUseHostPlatformContract, + matchesLinuxComputerUseInstallFlowContract, }; diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index 7d63f2acd..115f65219 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -9778,13 +9778,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" @@ -9802,19 +9800,15 @@ 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} -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" \ @@ -9823,14 +9817,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" \ @@ -9839,20 +9832,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 \ @@ -9862,8 +9854,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() { From 49afa21eed586b97dc8407198d86c1536a9c2306 Mon Sep 17 00:00:00 2001 From: kortylokai-web <263109108+kortylokai-web@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:32:26 +0200 Subject: [PATCH 014/112] fix(lifecycle): prevent windowless warm-start survivors --- docs/windowless-warm-start-fix-report.md | 127 +++++++ scripts/patch-linux-window-ui.test.js | 350 +++++++++++++++++- .../impl/main-process/quit-lifecycle.js | 152 +++++++- tests/scripts_smoke.sh | 19 +- 4 files changed, 619 insertions(+), 29 deletions(-) create mode 100644 docs/windowless-warm-start-fix-report.md diff --git a/docs/windowless-warm-start-fix-report.md b/docs/windowless-warm-start-fix-report.md new file mode 100644 index 000000000..1d1e9873c --- /dev/null +++ b/docs/windowless-warm-start-fix-report.md @@ -0,0 +1,127 @@ +# 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 stops Codex Micro and flushes tracing; +- a full branch also flushes global state and 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 drain to three seconds. +3. Rejected `Promise.allSettled()` results and deadline expiry are logged. +4. Context disposal remains bounded by the upstream five-second limit and + cannot suppress shared-disposable cleanup. +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 and 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/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index cc61786c2..995f56d23 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -1389,10 +1389,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([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`;", @@ -2640,7 +2738,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, @@ -2648,13 +2746,255 @@ 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.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, + /Promise\.resolve\(\)\.then\(\(\)=>U5\(h,N5\)\)\.catch\(e=>\{try\{console\.warn\(`WARN: Linux quit context cleanup failed`,e\)\}catch\{\}\}\)/, + ); + 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, + /Promise\.race\(\[Promise\.resolve\(\)\.then\(e\)\.then\(codexLinuxLogQuitDrainResults\),new Promise\(\(_,e\)=>setTimeout\(\(\)=>e\(Error\(`Linux quit drain timed out`\)\),typeof codexLinuxExplicitQuitDrainTimeoutMs===`number`\?codexLinuxExplicitQuitDrainTimeoutMs:3e3\)\)\]\)\.catch\(e=>\{try\{console\.warn\(`WARN: Linux quit drain cleanup failed`,e\)\}catch\{\}\}\)\.then\(codexLinuxFinalizeQuit\)/, + ); + assert.match( + patched, + /codexLinuxRunQuitDrain=e=>\{if\(process\.platform===`linux`\)\{/, + ); + assert.equal( + (patched.match(/codexLinuxRunQuitDrain\(\(\)=>\{/g) ?? []).length, + 2, + ); + assert.equal( + (patched.match(/\.then\(codexLinuxLogQuitDrainResults\)/g) ?? []).length, + 1, + ); assert.doesNotMatch(patched, /\\`number\\`/); - assert.match(patched, /codexLinuxDrainPromise\.finally\(codexLinuxFinalizeQuit\)\}\)\(\)/); + assert.match(patched, /e\(\)\.then\(t\)\}/); + assert.doesNotMatch(patched, /Promise\.allSettled\([^;]+\.then\(t\)/); assert.doesNotThrow(() => new Function(patched)); }); +test("Linux will-quit reaches app.exit exactly once when a disposer throws", async () => { + const state = await runPatchedLinuxWillQuit({ + disposablesDispose() { + throw new Error("disposer 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 disposables cleanup failed Error: disposer failed", + ]); +}); + +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 drain cleanup failed Error: Linux quit drain timed out", + ]); + resolveGlobalState(); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(state.exitCalls, 1); + assert.equal(state.quitCalls, 0); +}); + +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.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("Linux will-quit reaches app.exit when pre-drain disposal throws synchronously", async () => { + const state = await runPatchedLinuxWillQuit({ + lifecycleDisposeThrowsAt: 1, + }); + + 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("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( + "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("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( diff --git a/scripts/patches/impl/main-process/quit-lifecycle.js b/scripts/patches/impl/main-process/quit-lifecycle.js index 19dca2366..eb61cb0f6 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,141 @@ 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})\\(\\),(?${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.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 applyLinuxWillQuitDrainTimeoutPatch(currentSource) { + const linuxQuitDrainGuard = "process.platform===`linux`"; + const appliedMarkers = [ + "codexLinuxLogQuitDrainResults=e=>{", + "codexLinuxFinalizeQuit=()=>{", + "codexLinuxRunQuitDrain=e=>{if(process.platform===`linux`){Promise.race([Promise.resolve().then(e)", + "Linux quit drain timed out", + "WARN: Linux quit drain cleanup failed", + "WARN: Linux quit context cleanup failed", + "WARN: Linux quit disposables cleanup failed", + ]; + const appliedFinalizerStart = currentSource.indexOf(appliedMarkers[0]); + const appliedFinalizerEnd = currentSource.indexOf( + ",codexLinuxRunQuitDrain=", + appliedFinalizerStart, + ); + const hasAppliedFinalizerPostcondition = + appliedFinalizerStart !== -1 && + appliedFinalizerEnd > appliedFinalizerStart && + /finally\{[A-Za-z_$][\w$]*\.app\.exit\(0\)\}/.test( + currentSource.slice(appliedFinalizerStart, appliedFinalizerEnd), + ); if ( - !patchedAny && - !patchedSource.includes("codexLinuxDrainPromise=Promise.all(") && - patchedSource.includes("n.app.on(`will-quit`,") && - patchedSource.includes(".flush()") + appliedMarkers.every((marker) => currentSource.includes(marker)) && + hasAppliedFinalizerPostcondition ) { - console.warn("WARN: Could not find will-quit drain sequence — skipping Linux explicit quit drain timeout patch"); + return currentSource; } - return patchedSource; + 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=()=>{Promise.resolve().then(()=>${finalizer.contextDispose}(${finalizer.contextArg},${finalizer.contextTimeout})).catch(e=>{try{console.warn(\`WARN: Linux quit context cleanup failed\`,e)}catch{}}).then(()=>{try{${finalizer.disposables}.dispose()}catch(e){try{console.warn(\`WARN: Linux quit disposables cleanup failed\`,e)}catch{}}finally{${finalizer.electron}.app.exit(0)}})},codexLinuxRunQuitDrain=e=>{if(${linuxQuitDrainGuard}){Promise.race([Promise.resolve().then(e).then(codexLinuxLogQuitDrainResults),new Promise((_,e)=>setTimeout(()=>e(Error(\`Linux quit drain timed out\`)),typeof codexLinuxExplicitQuitDrainTimeoutMs===\`number\`?codexLinuxExplicitQuitDrainTimeoutMs:3e3))]).catch(e=>{try{console.warn(\`WARN: Linux quit drain 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.stop}(),${reduced.trace}()]).then(${outer.upstreamFinalize})`, + `codexLinuxRunQuitDrain(()=>{${reduced.hotkey}.dispose(),${reduced.dictation}.dispose();return Promise.allSettled([${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})`, + `codexLinuxRunQuitDrain(()=>{${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/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index 115f65219..f3c1d2379 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -8980,7 +8980,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([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" @@ -8992,9 +8992,18 @@ 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 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" 'codexLinuxRunQuitDrain(()=>{' '2' + assert_contains "$extracted/.vite/build/main-test.js" 'Promise.resolve().then(e).then(codexLinuxLogQuitDrainResults),new Promise' 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 drain 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' @@ -9098,7 +9107,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" 'codexLinuxRunQuitDrain(()=>{' '2' } test_keybinds_settings_tab_patch_smoke() { From 71be795f923b3a53bd7b66aec11425d53878fcb8 Mon Sep 17 00:00:00 2001 From: Yanis Falaki <2005yanisf@gmail.com> Date: Mon, 27 Jul 2026 01:11:47 -0400 Subject: [PATCH 015/112] Add per-connection SSH command wrappers (#1161) --- README.md | 1 + linux-features/ssh-command-wrapper/README.md | 59 ++++ .../ssh-command-wrapper/feature.json | 9 + linux-features/ssh-command-wrapper/patch.js | 315 ++++++++++++++++++ linux-features/ssh-command-wrapper/test.js | 170 ++++++++++ 5 files changed, 554 insertions(+) create mode 100644 linux-features/ssh-command-wrapper/README.md create mode 100644 linux-features/ssh-command-wrapper/feature.json create mode 100644 linux-features/ssh-command-wrapper/patch.js create mode 100644 linux-features/ssh-command-wrapper/test.js diff --git a/README.md b/README.md index 5eae393f8..79bd4b471 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,7 @@ 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) | diff --git a/linux-features/ssh-command-wrapper/README.md b/linux-features/ssh-command-wrapper/README.md new file mode 100644 index 000000000..c8eac59fa --- /dev/null +++ b/linux-features/ssh-command-wrapper/README.md @@ -0,0 +1,59 @@ +# 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 input characters 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. 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..944ea7d95 --- /dev/null +++ b/linux-features/ssh-command-wrapper/patch.js @@ -0,0 +1,315 @@ +"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("; + +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); + if (args.length > MAX_WRAPPER_ARGS) { + throw wrapperError(`Remote command wrapper must have at most ${MAX_WRAPPER_ARGS} arguments`); + } + if (args.length > 0 && args[0].length === 0) { + throw wrapperError("Remote command wrapper executable cannot be empty"); + } + return 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"); + } + 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 replaceRequired(source, needle, replacement, label) { + if (!source.includes(needle)) { + console.warn(`WARN: Could not find ${label} - skipping SSH command-wrapper patch`); + return null; + } + return source.replace(needle, replacement); +} + +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)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) { + if (source.includes(MAIN_MARKER)) return source; + + let patched = source; + const helperNeedle = "function Gx("; + const helperIndex = patched.indexOf(helperNeedle); + if (helperIndex < 0) { + console.warn("WARN: Could not find SSH login-shell helper - skipping SSH command-wrapper patch"); + return source; + } + patched = `${patched.slice(0, helperIndex)}${mainHelperSource()}${patched.slice(helperIndex)}`; + + const replacements = [ + [ + "Gx(e,s)]", + `codexLinuxSshWrapRemoteCommand(Gx(e,s),this.options.sshConnection.${WRAPPER_PROPERTY})]`, + "SSH management command", + ], + [ + "Gx(t,i)]", + `codexLinuxSshWrapRemoteCommand(Gx(t,i),this.options.sshConnection.${WRAPPER_PROPERTY})]`, + "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", + ], + ]; + + for (const [needle, replacement, label] of replacements) { + const next = replaceRequired(patched, needle, replacement, label); + if (next == null) return source; + patched = next; + } + return patched; +} + +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)throw Error(`invalid`);return t}", + "function codexLinuxFormatSshCommandWrapper(e){return Array.isArray(e)?e.map(e=>typeof e==`string`&&e.length>0&&/^[A-Za-z0-9_@%+=:,./-]+$/u.test(e)?e:`'${String(e??``).replaceAll(`'`,`'\\\\''`)}'`).join(` `):``}", + ].join(""); +} + +function applyWebviewPatch(source) { + if (source.includes(WEBVIEW_MARKER)) return source; + if (!source.includes("function Pi(){return{displayName:") || !source.includes("function Bi(e){")) { + console.warn("WARN: Could not find remote-connections settings editor - skipping SSH command-wrapper patch"); + return source; + } + + let patched = source.replace("function Pi(){", `${webviewHelperSource()}function Pi(){`); + 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();", + `let r=[],i=e.displayName.trim();try{codexLinuxParseSshCommandWrapper(e.${WRAPPER_TEXT_PROPERTY})}catch{r.push(\`invalidSshCommandWrapper\`)}`, + "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){", + "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`});", + "wrapper validation message", + ], + ]; + for (const [needle, replacement, label] of replacements) { + const next = replaceRequired(patched, needle, replacement, label); + if (next == null) return source; + patched = next; + } + return patched; +} + +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..55d106715 --- /dev/null +++ b/linux-features/ssh-command-wrapper/test.js @@ -0,0 +1,170 @@ +#!/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 { + MAX_WRAPPER_ARGS, + applyMainBundlePatch, + applyWebviewPatch, + descriptors, + formatCommandWrapper, + parseCommandWrapper, + validateCommandWrapperArgs, + wrapRemoteCommand, +} = require("./patch.js"); + +const mainFixture = [ + "function Gx(e,t){return e+t}", + "function management(){return[...x,Gx(e,s)]}", + "function proxy(){return[...x,Gx(t,i)]}", + "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();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`other`:return null}}", +].join(""); + +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("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 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("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("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", + ], + ); + }); +}); From c6d76231f0623c3ef0b18c7e9158697c96bdcf9f Mon Sep 17 00:00:00 2001 From: Yanis Falaki Date: Mon, 27 Jul 2026 03:35:40 -0400 Subject: [PATCH 016/112] Harden SSH command-wrapper patching (#1162) * fix(ssh-command-wrapper): reject partial bundle patches * fix(ssh-command-wrapper): preserve editor round trips * ci(ssh-command-wrapper): gate current DMG patches * test(nix): cover SSH wrapper feature profile * fix(ci): read transactional SSH patch report * fix(ssh-command-wrapper): validate complete helper bodies --- linux-features/ssh-command-wrapper/README.md | 8 +- linux-features/ssh-command-wrapper/patch.js | 173 +++++++++++++------ linux-features/ssh-command-wrapper/test.js | 126 +++++++++++++- nix/linux-features.nix | 1 + 4 files changed, 248 insertions(+), 60 deletions(-) diff --git a/linux-features/ssh-command-wrapper/README.md b/linux-features/ssh-command-wrapper/README.md index c8eac59fa..51a5a1675 100644 --- a/linux-features/ssh-command-wrapper/README.md +++ b/linux-features/ssh-command-wrapper/README.md @@ -21,8 +21,9 @@ 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 input characters and 64 arguments. Wrapper -failures use the existing SSH connection error path and abort setup. +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. @@ -57,3 +58,6 @@ 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/patch.js b/linux-features/ssh-command-wrapper/patch.js index 944ea7d95..68d8cbd26 100644 --- a/linux-features/ssh-command-wrapper/patch.js +++ b/linux-features/ssh-command-wrapper/patch.js @@ -6,6 +6,15 @@ 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); @@ -81,13 +90,7 @@ function parseCommandWrapper(input) { } if (quote != null) throw wrapperError("Remote command wrapper has an unterminated quote"); if (started) args.push(value); - if (args.length > MAX_WRAPPER_ARGS) { - throw wrapperError(`Remote command wrapper must have at most ${MAX_WRAPPER_ARGS} arguments`); - } - if (args.length > 0 && args[0].length === 0) { - throw wrapperError("Remote command wrapper executable cannot be empty"); - } - return args; + return validateCommandWrapperArgs(args); } function quoteShellArg(value) { @@ -120,6 +123,13 @@ function validateCommandWrapperArgs(value) { 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; } @@ -129,43 +139,102 @@ function wrapRemoteCommand(command, args) { return `exec ${valid.map(quoteShellArg).join(" ")} ${quoteShellArg(command)}`; } -function replaceRequired(source, needle, replacement, label) { - if (!source.includes(needle)) { - console.warn(`WARN: Could not find ${label} - skipping SSH command-wrapper patch`); +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)throw Error(`Invalid SSH remote command wrapper`);return n}", + "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) { - if (source.includes(MAIN_MARKER)) return source; - - let patched = source; - const helperNeedle = "function Gx("; - const helperIndex = patched.indexOf(helperNeedle); - if (helperIndex < 0) { - console.warn("WARN: Could not find SSH login-shell helper - skipping SSH command-wrapper patch"); - return source; - } - patched = `${patched.slice(0, helperIndex)}${mainHelperSource()}${patched.slice(helperIndex)}`; - const replacements = [ [ - "Gx(e,s)]", - `codexLinuxSshWrapRemoteCommand(Gx(e,s),this.options.sshConnection.${WRAPPER_PROPERTY})]`, + "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", ], [ - "Gx(t,i)]", - `codexLinuxSshWrapRemoteCommand(Gx(t,i),this.options.sshConnection.${WRAPPER_PROPERTY})]`, + "(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", ], [ @@ -214,30 +283,24 @@ function applyMainBundlePatch(source) { "SSH host configuration", ], ]; - - for (const [needle, replacement, label] of replacements) { - const next = replaceRequired(patched, needle, replacement, label); - if (next == null) return source; - patched = next; - } - return patched; + 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)throw Error(`invalid`);return t}", - "function codexLinuxFormatSshCommandWrapper(e){return Array.isArray(e)?e.map(e=>typeof e==`string`&&e.length>0&&/^[A-Za-z0-9_@%+=:,./-]+$/u.test(e)?e:`'${String(e??``).replaceAll(`'`,`'\\\\''`)}'`).join(` `):``}", + "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) { - if (source.includes(WEBVIEW_MARKER)) return source; - if (!source.includes("function Pi(){return{displayName:") || !source.includes("function Bi(e){")) { - console.warn("WARN: Could not find remote-connections settings editor - skipping SSH command-wrapper patch"); - return source; - } - - let patched = source.replace("function Pi(){", `${webviewHelperSource()}function Pi(){`); const replacements = [ [ "authMode:`none`,identity:``}}", @@ -260,27 +323,29 @@ function applyWebviewPatch(source) { "alias connection save", ], [ - "let r=[],i=e.displayName.trim();", - `let r=[],i=e.displayName.trim();try{codexLinuxParseSshCommandWrapper(e.${WRAPPER_TEXT_PROPERTY})}catch{r.push(\`invalidSshCommandWrapper\`)}`, + "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})})]`, + "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){", - "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`});", + "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", ], ]; - for (const [needle, replacement, label] of replacements) { - const next = replaceRequired(patched, needle, replacement, label); - if (next == null) return source; - patched = next; - } - return patched; + return applyCompletePatch(source, { + label: "webview bundle", + helperAnchor: "function Pi(){", + helperMarkers: WEBVIEW_HELPER_MARKERS, + requiredAnchors: ["function Pi(){", "function Bi(e){"], + helperSource: webviewHelperSource, + replacements, + }); } module.exports = { diff --git a/linux-features/ssh-command-wrapper/test.js b/linux-features/ssh-command-wrapper/test.js index 55d106715..48aee64f6 100644 --- a/linux-features/ssh-command-wrapper/test.js +++ b/linux-features/ssh-command-wrapper/test.js @@ -9,6 +9,13 @@ 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, @@ -20,10 +27,13 @@ const { 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(){return[...x,Gx(e,s)]}", - "function proxy(){return[...x,Gx(t,i)]}", + `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})", @@ -39,11 +49,22 @@ 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();return r}", + "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`other`:return null}}", + "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-")); @@ -99,6 +120,22 @@ test("round trips quoted argv and preserves an empty wrapper", () => { ); }); +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"]); @@ -118,6 +155,16 @@ test("patches all main-process transport and persistence paths idempotently", () 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; @@ -130,6 +177,32 @@ test("main-process patch fails soft and byte-identical on drift", () => { 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); @@ -140,6 +213,51 @@ test("patches the SSH connection editor for manual hosts and aliases", () => { 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]), diff --git a/nix/linux-features.nix b/nix/linux-features.nix index 6d750c36c..12991609e 100644 --- a/nix/linux-features.nix +++ b/nix/linux-features.nix @@ -15,6 +15,7 @@ let "remote-control-ui" "remote-mobile-control" "shallow-repository-watches" + "ssh-command-wrapper" "ui-tweaks" ]; From 1fae9a48ee90cc638ec238179b9ab68786a64210 Mon Sep 17 00:00:00 2001 From: pinguuss Date: Tue, 28 Jul 2026 10:39:06 -0400 Subject: [PATCH 017/112] Stabilize Dock settings contract matching --- linux-features/ui-tweaks/patches/dock-icon.js | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/linux-features/ui-tweaks/patches/dock-icon.js b/linux-features/ui-tweaks/patches/dock-icon.js index 2bddf6588..256e5fbb8 100644 --- a/linux-features/ui-tweaks/patches/dock-icon.js +++ b/linux-features/ui-tweaks/patches/dock-icon.js @@ -101,24 +101,48 @@ 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; + +function settingsGateMatches(source, pattern) { + pattern.lastIndex = 0; + return [...source.matchAll(pattern)]; +} + +function dockIconSettingsContract(source) { + const currentMatches = settingsGateMatches(source, currentSettingsGatePattern); + const patchedMatches = settingsGateMatches(source, patchedSettingsGatePattern); + if (currentMatches.length === 1 && patchedMatches.length === 0) { + return "current"; + } + if (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 +180,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, From a8d0abb967aab2c2e9fc03ba2f501576925615f4 Mon Sep 17 00:00:00 2001 From: pinguuss Date: Tue, 28 Jul 2026 10:39:11 -0400 Subject: [PATCH 018/112] Test Dock settings contract ambiguity --- linux-features/ui-tweaks/dock-icon.test.js | 36 ++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/linux-features/ui-tweaks/dock-icon.test.js b/linux-features/ui-tweaks/dock-icon.test.js index bd300ebad..0c52615cd 100644 --- a/linux-features/ui-tweaks/dock-icon.test.js +++ b/linux-features/ui-tweaks/dock-icon.test.js @@ -50,7 +50,7 @@ const currentTraySource = 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}", @@ -242,8 +242,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 +269,20 @@ 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); + for (const source of [ + currentSettingsSource + currentSettingsSource, + currentSettingsSource + 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)); From 1193364266deab74490e35d5e05a6d61ab029a75 Mon Sep 17 00:00:00 2001 From: pinguuss Date: Tue, 28 Jul 2026 11:04:44 -0400 Subject: [PATCH 019/112] Reject mixed Dock settings contracts --- linux-features/ui-tweaks/dock-icon.test.js | 4 ++++ linux-features/ui-tweaks/patches/dock-icon.js | 17 +++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/linux-features/ui-tweaks/dock-icon.test.js b/linux-features/ui-tweaks/dock-icon.test.js index 0c52615cd..38515919d 100644 --- a/linux-features/ui-tweaks/dock-icon.test.js +++ b/linux-features/ui-tweaks/dock-icon.test.js @@ -271,9 +271,13 @@ test("settings drift remains byte-identical", () => { 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); diff --git a/linux-features/ui-tweaks/patches/dock-icon.js b/linux-features/ui-tweaks/patches/dock-icon.js index 256e5fbb8..820bb2cf5 100644 --- a/linux-features/ui-tweaks/patches/dock-icon.js +++ b/linux-features/ui-tweaks/patches/dock-icon.js @@ -105,6 +105,7 @@ 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; @@ -112,12 +113,24 @@ function settingsGateMatches(source, pattern) { } function dockIconSettingsContract(source) { + if (typeof source !== "string") { + return "drifted"; + } const currentMatches = settingsGateMatches(source, currentSettingsGatePattern); const patchedMatches = settingsGateMatches(source, patchedSettingsGatePattern); - if (currentMatches.length === 1 && patchedMatches.length === 0) { + const rowAnchors = settingsGateMatches(source, settingsRowAnchorPattern); + if ( + rowAnchors.length === 1 && + currentMatches.length === 1 && + patchedMatches.length === 0 + ) { return "current"; } - if (currentMatches.length === 0 && patchedMatches.length === 1) { + if ( + rowAnchors.length === 1 && + currentMatches.length === 0 && + patchedMatches.length === 1 + ) { return "patched"; } return "drifted"; From 14989e8fad340f2e72077bb098fd9c8491c6427d Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Tue, 28 Jul 2026 20:54:31 +0300 Subject: [PATCH 020/112] HEROX-1165: Fix current DMG optional feature patch drift --- linux-features/agent-workspace/patch.js | 156 +++++++++++----------- linux-features/agent-workspace/test.js | 152 ++++++++++++--------- linux-features/conversation-mode/patch.js | 27 ++-- linux-features/conversation-mode/test.js | 65 ++++----- linux-features/read-aloud/patch.js | 2 +- linux-features/read-aloud/test.js | 15 ++- 6 files changed, 215 insertions(+), 202 deletions(-) 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 80b555655..80938f978 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(), ); } @@ -1588,31 +1595,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", () => { @@ -1632,8 +1653,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(), @@ -1672,7 +1694,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 }); } @@ -1698,7 +1720,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 }); } @@ -1721,27 +1743,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 }); @@ -1754,7 +1778,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/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/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, From 0186de6966d7493bfe1a1abaef3deb2182a436e9 Mon Sep 17 00:00:00 2001 From: wangJie Date: Wed, 29 Jul 2026 14:04:50 +0800 Subject: [PATCH 021/112] Fix Linux desktop settings navigation grouping Add the current settings-page Personal group patch with idempotence, drift, and smoke coverage.\n\nFixes #1168 --- scripts/patch-linux-window-ui.test.js | 75 ++++++++++++++++++++++- scripts/patches/impl/keybinds-settings.js | 57 +++++++++++++++++ tests/scripts_smoke.sh | 5 ++ 3 files changed, 136 insertions(+), 1 deletion(-) diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index 995f56d23..16c3df7c0 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -46,6 +46,7 @@ const { linuxDesktopSettingsAsset, applyLinuxDesktopSettingsIconPatch, applyLinuxDesktopSettingsIndexPatch, + applyLinuxDesktopSettingsNavigationGroupPatch, applyLinuxShortcutPhysicalKeyFallbackPatch, applyLinuxDesktopSettingsSectionsPatch, applyLinuxDesktopSettingsSharedPatch, @@ -1767,6 +1768,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", [ @@ -1872,7 +1881,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(""), ); @@ -6380,6 +6389,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); @@ -6473,6 +6490,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 { diff --git a/scripts/patches/impl/keybinds-settings.js b/scripts/patches/impl/keybinds-settings.js index ea00d6fe8..81d7fd0b5 100644 --- a/scripts/patches/impl/keybinds-settings.js +++ b/scripts/patches/impl/keybinds-settings.js @@ -632,6 +632,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)) { @@ -801,6 +833,7 @@ function patchKeybindsSettingsAssets(extractedDir) { isSettingsSectionsMetadataBundleSource, applyLinuxDesktopSettingsSectionsPatch, ), + ...collectLinuxDesktopNavigationGroupPatch(extractedDir), ...collectLinuxDesktopVisibilityPatch(extractedDir), ...collectOptionalMatchingAssetPatches( extractedDir, @@ -920,6 +953,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 +1273,7 @@ module.exports = { applyKeybindsSettingsSharedPatch, applyLinuxDesktopSettingsIndexPatch, applyLinuxDesktopSettingsIconPatch, + applyLinuxDesktopSettingsNavigationGroupPatch, applyLinuxDesktopSettingsRoutePatch, applyLinuxDesktopSettingsSectionsPatch, applyLinuxDesktopSettingsSharedPatch, diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index f3c1d2379..d621b8777 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -9129,6 +9129,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}; @@ -9161,6 +9164,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' @@ -9174,6 +9178,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' } From df41b3e42bbf49d3289e286e196aaab918a5c5ff Mon Sep 17 00:00:00 2001 From: Caio Faheina <69549574+PinguuSS@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:37:39 -0400 Subject: [PATCH 022/112] Recover stale npm Codex CLI upgrades (#1166) * Recover stale npm CLI upgrades * Serialize managed npm CLI installs * Bump updater for npm recovery * Serialize PATH-dependent builder test * Make stale CLI repair explicit * Harden explicit stale CLI repair * Deduplicate CLI repair completion * Bound orphaned npm process groups * Prevent npm descendants from retaining the install lock * Harden npm supervisor failure cleanup * Fail closed on npm process discovery * Make npm group cleanup hidepid safe * Fix npm overlap group probe --------- Co-authored-by: Gary Lysenko --- CHANGELOG.md | 14 + Cargo.lock | 2 +- docs/updater.md | 56 + updater/Cargo.toml | 2 +- updater/src/app.rs | 86 +- .../src/app/tests/cli_repair_process_tests.rs | 408 ++++ updater/src/builder.rs | 9 +- updater/src/cli.rs | 15 +- updater/src/codex_cli.rs | 1885 +++++++++++++++-- updater/src/diagnostics.rs | 72 +- updater/src/main.rs | 1 + updater/src/npm_cli_repair.rs | 1084 ++++++++++ updater/src/rollback.rs | 8 +- updater/src/state.rs | 271 ++- updater/src/wrapper_apply.rs | 6 +- updater/tests/cli_repair_concurrency.rs | 776 +++++++ 16 files changed, 4502 insertions(+), 193 deletions(-) create mode 100644 updater/src/app/tests/cli_repair_process_tests.rs create mode 100644 updater/src/npm_cli_repair.rs create mode 100644 updater/tests/cli_repair_concurrency.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 18e24f160..47c822065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- 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..b48439b80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -560,7 +560,7 @@ dependencies = [ [[package]] name = "codex-update-manager" -version = "0.10.3" +version = "0.10.4" dependencies = [ "anyhow", "chrono", diff --git a/docs/updater.md b/docs/updater.md index dad86a9fa..a216bebf2 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -22,6 +22,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,6 +166,9 @@ 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 diff --git a/updater/Cargo.toml b/updater/Cargo.toml index ca28eb01a..c94e597e4 100644 --- a/updater/Cargo.toml +++ b/updater/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-update-manager" -version = "0.10.3" +version = "0.10.4" edition = "2021" [dependencies] diff --git a/updater/src/app.rs b/updater/src/app.rs index c4dbc9dd9..8d41546a0 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( @@ -872,6 +893,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), @@ -1187,7 +1230,7 @@ async fn run_check_cycle_with_options( 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)?; maybe_notify( state, @@ -2182,6 +2225,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"), @@ -2966,7 +3011,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 +3211,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 +4639,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..992e98aab 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 { @@ -1308,6 +1308,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/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/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..26583e694 100644 --- a/updater/src/state.rs +++ b/updater/src/state.rs @@ -7,11 +7,14 @@ 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. @@ -187,9 +190,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 +261,48 @@ 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<()> { +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 +312,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 +338,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) @@ -304,6 +445,130 @@ mod tests { 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(()) + } + #[test] fn loads_legacy_state_without_cli_fields() -> Result<()> { let temp = tempdir()?; 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(()) +} From 9148083e6aacc82cc70dd1b49bf46c617fca1199 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:10:00 +0300 Subject: [PATCH 023/112] fix(nix): refresh upstream Nix pins for 26.721.81911 (#1172) Refreshed Codex.dmg SRI hash to sha256-ezci5PWGQgKx3Wnm5gYvL4xDiNIVCRUDxz4ZV7TL+Xo= and synced codexVersion / electronVersion / native-module pins to the current upstream DMG. Verified all ChatGPT Desktop Nix package outputs against the refreshed DMG. Source-Main-SHA: df41b3e42bbf49d3289e286e196aaab918a5c5ff Upstream-DMG-SHA256: 7b3722e4f5864202b1dd69e6e6062f2f8c4388d215091503c73e1957b4cbf97a Co-authored-by: codex-dmg-hash-bot --- flake.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 1b4082222..7b0710548 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-ezci5PWGQgKx3Wnm5gYvL4xDiNIVCRUDxz4ZV7TL+Xo="; }; - codexVersion = "26.721.41059"; + codexVersion = "26.721.81911"; electronVersion = "42.3.0"; electronPlatform = { From 65bc0b7ab2bc946b9a55b2e514c9950a2fb54ba5 Mon Sep 17 00:00:00 2001 From: Morami UwU <140313878+MatsumotoMorami@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:18:55 +0800 Subject: [PATCH 024/112] fix(remote-mobile-control): retarget current gate bridge Patch the 26.721 dual-gate renderer contract, surface bridge drift in patch reports, and preserve saved auto-connect choices for other hosts. Co-authored-by: GuillaumeCisco --- CHANGELOG.md | 4 + .../remote-mobile-control/README.md | 2 +- linux-features/remote-mobile-control/patch.js | 21 +++- linux-features/remote-mobile-control/test.js | 118 ++++++++++++++++-- 4 files changed, 126 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47c822065..cbdcfdc42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- 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 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..10d12679e 100644 --- a/linux-features/remote-mobile-control/patch.js +++ b/linux-features/remote-mobile-control/patch.js @@ -1254,9 +1254,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 +1278,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 +1300,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) => diff --git a/linux-features/remote-mobile-control/test.js b/linux-features/remote-mobile-control/test.js index 1178c35ca..5555ad68a 100644 --- a/linux-features/remote-mobile-control/test.js +++ b/linux-features/remote-mobile-control/test.js @@ -413,8 +413,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(""); } @@ -2591,7 +2591,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), @@ -2640,6 +2641,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, @@ -2718,7 +2732,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(); } }, @@ -2738,7 +2752,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(""); @@ -2750,6 +2764,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); @@ -2812,16 +2894,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() {} }, @@ -2838,7 +2921,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" }, ], }); } @@ -2851,7 +2934,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"); @@ -2859,9 +2942,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 () => { @@ -3716,6 +3804,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 }); } From ecbe128148afb6c2e6c87b7e56024f8c34c50fab Mon Sep 17 00:00:00 2001 From: mohit Date: Wed, 29 Jul 2026 17:25:13 +0530 Subject: [PATCH 025/112] Fix latest-DMG Arch bootstrap failures (#1173) * project-group-last-updated-sort: follow current sidebar bundle symbols * packaging/linux: avoid copying staging ownership --- .../project-group-last-updated-sort/patch.js | 11 +++++----- .../project-group-last-updated-sort/test.js | 20 +++++++++---------- packaging/linux/PKGBUILD.template | 2 +- tests/scripts_smoke.sh | 1 + 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/linux-features/project-group-last-updated-sort/patch.js b/linux-features/project-group-last-updated-sort/patch.js index 9b12427e4..ffd841979 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 h2o({groups:e,items:t,projectOrder:n}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return iZi(e.map((e,t)=>({group:e,index:t,recencyAt:y2o(e,r)})).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 h2o({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:iZi(codexLinuxRecencySortedGroups,n))(e.map((e,t)=>({group:e,index:t,recencyAt:y2o(e,r)})).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)})"; + "T=h2o({groups:m2o({groups:C,items:s}),items:s,projectOrder:ap(t,zl.PROJECT_ORDER)})"; const patchedGroupSorterCall = - "T=Fe({groups:Pe({groups:S,items:c}),items:c,projectOrder:f(t,o.PROJECT_ORDER),sortMode:t(C).projectSortMode})"; + "T=h2o({groups:m2o({groups:C,items:s}),items:s,projectOrder:ap(t,zl.PROJECT_ORDER),sortMode:t(Ez).projectSortMode})"; 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..dc47b019d 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 iZi(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 y2o(e,t){let n=e.projectUpdatedAt??0;for(let r of e.threadKeys)n=Math.max(n,t.get(r)??0);return n}", + "function h2o({groups:e,items:t,projectOrder:n}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return iZi(e.map((e,t)=>({group:e,index:t,recencyAt:y2o(e,r)})).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)});", + "T=h2o({groups:m2o({groups:C,items:s}),items:s,projectOrder:ap(t,zl.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=h2o`, 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:ap(t,zl.PROJECT_ORDER),sortMode:t(Ez).projectSortMode", ), ); }); 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 h2o({groups:e,items:t,projectOrder:n})", + "function h2o({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:ap(t,zl.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-BHB6SClA.js", ); fs.mkdirSync(assetsDir, { recursive: true }); fs.writeFileSync(assetPath, currentProjectSource); 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/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index d621b8777..d59441977 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -1429,6 +1429,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" From 0049083680c68cced9063d878a7fb4e0f476313c Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Wed, 29 Jul 2026 17:38:16 +0300 Subject: [PATCH 026/112] HEROX-1175: Complete Linux Sparkle shim contract Add neutral implementations for the current AppView state getters and Sparkle query parameters so Linux RPC registration remains callable. Cover the complete view-state contract with a focused regression test. --- scripts/lib/linux-update-bridge-patch.js | 2 +- scripts/patch-linux-window-ui.test.js | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/lib/linux-update-bridge-patch.js b/scripts/lib/linux-update-bridge-patch.js index 2d80b1dea..087e3d69b 100644 --- a/scripts/lib/linux-update-bridge-patch.js +++ b/scripts/lib/linux-update-bridge-patch.js @@ -32,7 +32,7 @@ function buildBridgeSource({ childProcessVar, fsVar, pathVar }) { } 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);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:()=>{},setSparkleQueryParams:()=>{},getDownloadProgressPercent:()=>null,getDownloadedUpdateAppBrand:()=>null,getIsUpdateReady:()=>s&&t,getUpdateLifecycleState:()=>s?n:\`idle\`,getInstallProgressPercent:()=>r,getRelaunchNotice:()=>null,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) { diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index 16c3df7c0..01b87ace1 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -7150,6 +7150,18 @@ test("adds Linux package updater to current bootstrap updater wiring", () => { assert.doesNotMatch(patched, /codexLinuxRunUpdateManager\(\[`status`,`--json`\]\)/); }); +test("implements the current Sparkle AppView 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, /setSparkleQueryParams:\(\)=>\{\}/); +}); + test("fails soft when the current updater callback bridge drifts", () => { for (const source of [ currentBootstrapUpdaterBundleFixture().replace( From 2e17d47a8093ae34f1e72ee96e105c22c91b24bb Mon Sep 17 00:00:00 2001 From: Morami UwU <140313878+MatsumotoMorami@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:09:54 +0800 Subject: [PATCH 027/112] fix(updater): complete Linux Sparkle menu contract --- scripts/lib/linux-update-bridge-patch.js | 2 +- scripts/patch-linux-window-ui.test.js | 96 +++++++++++++++++++++++- 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/scripts/lib/linux-update-bridge-patch.js b/scripts/lib/linux-update-bridge-patch.js index 087e3d69b..663881702 100644 --- a/scripts/lib/linux-update-bridge-patch.js +++ b/scripts/lib/linux-update-bridge-patch.js @@ -32,7 +32,7 @@ function buildBridgeSource({ childProcessVar, fsVar, pathVar }) { } 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:()=>{},setSparkleQueryParams:()=>{},getDownloadProgressPercent:()=>null,getDownloadedUpdateAppBrand:()=>null,getIsUpdateReady:()=>s&&t,getUpdateLifecycleState:()=>s?n:\`idle\`,getInstallProgressPercent:()=>r,getRelaunchNotice:()=>null,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);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:()=>{},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) { diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index 01b87ace1..6ad4cab3a 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -1952,6 +1952,10 @@ function currentBootstrapUpdaterBundleFixture() { ].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`);", @@ -7150,7 +7154,7 @@ test("adds Linux package updater to current bootstrap updater wiring", () => { assert.doesNotMatch(patched, /codexLinuxRunUpdateManager\(\[`status`,`--json`\]\)/); }); -test("implements the current Sparkle AppView and RPC contract on Linux", () => { +test("implements the current Sparkle AppView, menu, and RPC contract on Linux", () => { const patched = applyLinuxAppUpdaterBridgePatch(currentBootstrapUpdaterBundleFixture()); assert.match(patched, /getDownloadProgressPercent:\(\)=>null/); @@ -7159,9 +7163,99 @@ test("implements the current Sparkle AppView and RPC contract on Linux", () => { 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:\(\)=>\{\}/); }); +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( From 5b74648bff603a5284e07b96b512359d7cb2fc04 Mon Sep 17 00:00:00 2001 From: Morami UwU <140313878+MatsumotoMorami@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:38:59 +0800 Subject: [PATCH 028/112] fix(window): require managed context-menu suppression --- scripts/patch-linux-window-ui.test.js | 380 +++++++++++++++++- .../main-process/window-shell/patch.js | 8 + scripts/patches/impl/main-process/window.js | 250 +++++++++++- tests/scripts_smoke.sh | 65 +++ 4 files changed, 665 insertions(+), 38 deletions(-) diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index 6ad4cab3a..020a49e39 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -71,6 +71,7 @@ const { const { applyLinuxAppReloadShortcutsPatch, applyLinuxApplicationMenuPatch, + applyLinuxManagedWindowSystemContextMenuPatch, applyLinuxMenuPatch, applyLinuxNativeTitlebarPatch, applyLinuxOpaqueBackgroundPatch, @@ -982,6 +983,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", @@ -1135,6 +1137,7 @@ test("default core patch descriptors are grouped and unique", () => { ); for (const id of [ "linux-window-options", + "linux-managed-window-system-context-menu", "linux-native-titlebar", "linux-opaque-background", "linux-avatar-overlay-mouse-passthrough", @@ -3349,6 +3352,335 @@ test("removes native title tooltip from the thread side panel toolbar action", ( 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{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 canonicalLinuxMenuSnippet(windowAlias, eventAlias = "e") { + return ( + `process.platform===\`linux\`&&(${windowAlias}.on(\`system-context-menu\`,` + + `${eventAlias}=>${eventAlias}.preventDefault()),${windowAlias}.removeMenu()),` + + `process.platform===\`win32\`&&${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 required managed-window patch must not claim success by patching only the popup", + ); + assert.match(managedPatched, new RegExp(escapeRegExp(canonicalLinuxMenuSnippet("N")))); + + const fullyPatched = applyLinuxMenuPatch(managedPatched); + assert.equal((fullyPatched.match(/system-context-menu/g) ?? []).length, 2); + 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(canonicalLinuxMenuSnippet("M"))), + ); +}); + +test("recognizes an equivalent managed-window preventDefault listener", () => { + const source = managedWindowMenuFixture( + canonicalLinuxMenuSnippet("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( + canonicalLinuxMenuSnippet("N") + + "N.on(`system-context-menu`,event=>event.preventDefault()),", + ); + assert.throws( + () => applyLinuxManagedWindowSystemContextMenuPatch(duplicate), + /non-canonical or duplicate system-context-menu listener/, + ); + + const duplicateMenuTarget = managedWindowMenuFixture( + canonicalLinuxMenuSnippet("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/, + ); + + const ambiguous = + managedWindowMenuFixture(windowsAndLinuxMenuSnippet("N"), { + className: "FirstWindowManager", + }) + + managedWindowMenuFixture(windowsAndLinuxMenuSnippet("M"), { + className: "SecondWindowManager", + windowAlias: "M", + }); + assert.throws( + () => applyLinuxManagedWindowSystemContextMenuPatch(ambiguous), + /Found 2 managed BrowserWindow candidates/, + ); +}); + +test("records managed-window menu drift as a required patch failure", () => { + 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 result = applyMainBundlePatchDescriptors( + source, + [descriptor], + {}, + report, + ); + + assert.equal(result.patchedSource, source); + const entry = report.patches.find( + (patch) => patch.name === descriptor.id, + ); + assert.equal(entry?.status, "failed-required"); + assert.match(entry?.reason ?? "", /Could not identify the managed BrowserWindow/); + assert.deepEqual(entry?.strategies, [ + { group: "linux-managed-window-menu", strategy: "none" }, + ]); +}); + +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", + }, + ]); + + const secondReport = createPatchReport(); + const second = applyMainBundlePatchDescriptors( + first.patchedSource, + [descriptor], + {}, + secondReport, + ); + 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 managed-window menu behavior platform-specific at runtime", async () => { + const source = applyLinuxManagedWindowSystemContextMenuPatch( + managedWindowMenuFixture(windowsAndLinuxMenuSnippet("N")), + ); + + for (const expected of [ + { platform: "linux", removeMenuCalls: 1, preventDefaultCalls: 1 }, + { platform: "win32", removeMenuCalls: 1, preventDefaultCalls: 0 }, + { platform: "darwin", removeMenuCalls: 0, preventDefaultCalls: 0 }, + ]) { + class BrowserWindow extends EventEmitter { + constructor() { + super(); + this.removeMenuCalls = 0; + } + + removeMenu() { + this.removeMenuCalls += 1; + } + } + + const context = vm.createContext({ + electron: { BrowserWindow }, + process: { platform: expected.platform }, + }); + vm.runInContext( + `${source};globalThis.ManagedWindowManager=WindowManager;`, + context, + ); + const window = await new context.ManagedWindowManager().createWindow(); + let preventDefaultCalls = 0; + window.emit("system-context-menu", { + preventDefault() { + preventDefaultCalls += 1; + }, + }); + assert.equal( + window.removeMenuCalls, + expected.removeMenuCalls, + expected.platform, + ); + assert.equal( + preventDefaultCalls, + expected.preventDefaultCalls, + expected.platform, + ); + } +}); + +test("upgrades the current combined Linux and Windows removeMenu shape", () => { + const source = + "(process.platform===`win32`||process.platform===`linux`)&&k.removeMenu(),"; + const patched = applyPatchTwice(applyLinuxMenuPatch, source); + + assert.equal(patched, canonicalLinuxMenuSnippet("k")); +}); + test("removes the Linux menu next to Windows removeMenu calls", () => { const source = "process.platform===`win32`&&k.removeMenu(),"; const patched = applyPatchTwice(applyLinuxMenuPatch, source); @@ -3375,40 +3707,50 @@ test("patches remaining Windows menu snippets when another copy is already Linux ); }); -test("upgrades legacy Linux menu snippets to remove the menu", () => { +test("recognizes the Linux system context menu suppression snippet as already applied", () => { const source = - "process.platform===`linux`&&(k.setMenuBarVisibility(!1),k.removeMenu?.()),process.platform===`win32`&&k.removeMenu(),"; + "process.platform===`linux`&&(k.on(`system-context-menu`,e=>e.preventDefault()),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(),", - ); - assert.doesNotMatch(patched, /setMenuBarVisibility/); + assert.equal(patched, source); + assert.equal((patched.match(/system-context-menu/g) ?? []).length, 1); }); -test("upgrades old Linux removeMenu snippets to suppress system context menus", () => { - const source = - "process.platform===`linux`&&k.removeMenu(),process.platform===`win32`&&k.removeMenu(),"; - - const patched = applyPatchTwice(applyLinuxMenuPatch, source); +test("upgrades a half-patched bundle without duplicating the popup listener", () => { + const source = managedWindowMenuFixture( + windowsAndLinuxMenuSnippet("N"), + { + popupSnippet: browserCommentPopupMenuSnippet( + canonicalLinuxMenuSnippet("e"), + ), + }, + ); + const patched = applyLinuxMenuPatch( + applyLinuxManagedWindowSystemContextMenuPatch(source), + ); + assert.equal((patched.match(/system-context-menu/g) ?? []).length, 2); assert.equal( + applyLinuxMenuPatch( + applyLinuxManagedWindowSystemContextMenuPatch(patched), + ), patched, - "process.platform===`linux`&&(k.on(`system-context-menu`,e=>e.preventDefault()),k.removeMenu()),process.platform===`win32`&&k.removeMenu(),", ); - 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(),"; - - const patched = applyPatchTwice(applyLinuxMenuPatch, source); + managedWindowMenuFixture(windowsAndLinuxMenuSnippet("N")) + + unrelated; - 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", () => { 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..c749e9f4d 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: "required-upstream", + apply: applyLinuxManagedWindowSystemContextMenuPatch, + }), mainBundlePatch({ id: "linux-menu", phase: "main-bundle", diff --git a/scripts/patches/impl/main-process/window.js b/scripts/patches/impl/main-process/window.js index 5b7a67f80..64989be7e 100644 --- a/scripts/patches/impl/main-process/window.js +++ b/scripts/patches/impl/main-process/window.js @@ -4,6 +4,9 @@ const { escapeRegExp, findMatchingBrace, } = require("../../lib/minified-js.js"); +const { + recordStrategy, +} = require("../../strategy-telemetry.js"); const LINUX_TITLEBAR_OVERLAY_HEIGHT = 30; const LINUX_TITLEBAR_OVERLAY_HELPER = "codexLinuxTitleBarOverlay"; @@ -268,35 +271,243 @@ function applyLinuxNativeTitlebarPatch(currentSource) { ); } +const MINIFIED_IDENTIFIER = "[A-Za-z_$][\\w$]*"; +const LINUX_MANAGED_WINDOW_MENU_STRATEGY = "linux-managed-window-menu"; + +function linuxSystemContextMenuPatchFor(windowAlias) { + return ( + `process.platform===\`linux\`&&(${windowAlias}.on(\`system-context-menu\`,` + + `e=>e.preventDefault()),${windowAlias}.removeMenu()),` + + `process.platform===\`win32\`&&${windowAlias}.removeMenu(),` + ); +} + +function semanticLinuxSystemContextMenuRegex(windowAlias, flags = "") { + const escapedWindowAlias = escapeRegExp(windowAlias); + return new RegExp( + `process\\.platform===\`linux\`&&\\(${escapedWindowAlias}\\.on\\(` + + `\`system-context-menu\`,(${MINIFIED_IDENTIFIER})=>\\1\\.preventDefault\\(\\)\\),` + + `${escapedWindowAlias}\\.removeMenu\\(\\)\\),process\\.platform===\`win32\`&&` + + `${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 required 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\\{[^}]*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}),`, "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 = + semanticLinuxSystemContextMenuRegex(windowAlias, "g"); + const semanticMatches = [...candidate.text.matchAll(semanticPatchRegex)]; + + if (semanticMatches.length === 1 && listenerCount === 1) { + if (removeMenuCallCount !== 2) { + 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) + + linuxSystemContextMenuPatchFor(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 || + !semanticLinuxSystemContextMenuRegex(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 patchedSource = currentSource.replace( + /\(process\.platform===`win32`\|\|process\.platform===`linux`\)&&([A-Za-z_$][\w$]*)\.removeMenu\(\),/g, + (_match, windowVar) => linuxSystemContextMenuPatchFor(windowVar), + ); 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 linuxPatch = linuxSystemContextMenuPatchFor(windowVar); + const linuxPrefixRegex = new RegExp( + `${semanticLinuxSystemContextMenuRegex(windowVar).source}$`, + ); + 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 = /process\.platform===`linux`&&\(([A-Za-z_$][\w$]*)\.on\(`system-context-menu`,([A-Za-z_$][\w$]*)=>\2\.preventDefault\(\)\),\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 +837,7 @@ function applyLinuxOpaqueBackgroundPatch(currentSource) { module.exports = { applyLinuxAppReloadShortcutsPatch, applyLinuxApplicationMenuPatch, + applyLinuxManagedWindowSystemContextMenuPatch, applyLinuxMenuPatch, applyLinuxNativeTitlebarPatch, applyLinuxOpaqueBackgroundPatch, diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index d59441977..c83f79309 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -8813,6 +8813,70 @@ 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{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' + assert_contains \ + "$extracted/.vite/build/main-test.js" \ + 'process.platform===`linux`&&(N.on(`system-context-menu`,e=>e.preventDefault()),N.removeMenu()),process.platform===`win32`&&N.removeMenu(),' + assert_contains \ + "$extracted/.vite/build/main-test.js" \ + 'process.platform===`linux`&&(e.on(`system-context-menu`,e=>e.preventDefault()),e.removeMenu()),process.platform===`win32`&&e.removeMenu(),' + 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 ( + 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" @@ -10972,6 +11036,7 @@ main() { 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 From b77f345437c6c6f82ce7578a4a6ee6020974caac Mon Sep 17 00:00:00 2001 From: Caio Faheina <69549574+PinguuSS@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:24:48 -0400 Subject: [PATCH 029/112] Fix current Linux open target command lookup (#1176) * Retarget current open target command lookup * Reject partial open target command drift * Document current open target fix * Remove obsolete open target diagnostic --- CHANGELOG.md | 4 + linux-features/open-target-discovery/patch.js | 64 +++++++--- linux-features/open-target-discovery/test.js | 109 +++++++++++++++++- 3 files changed, 156 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbdcfdc42..f979a3f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- 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. - 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 diff --git a/linux-features/open-target-discovery/patch.js b/linux-features/open-target-discovery/patch.js index a7b75cb53..c884bc520 100644 --- a/linux-features/open-target-discovery/patch.js +++ b/linux-features/open-target-discovery/patch.js @@ -595,7 +595,6 @@ function applyOpenInTargetRegistryCommandPatch(currentSource, { warnOnMissing = warnOnMissing && ( currentSource.includes("get-target-command") || - currentSource.includes("getOpenInTargetCommand") || currentSource.includes("allAvailableTargets") ) ) { @@ -618,30 +617,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 +806,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..e4046133c 100644 --- a/linux-features/open-target-discovery/test.js +++ b/linux-features/open-target-discovery/test.js @@ -51,7 +51,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 = @@ -1109,11 +1109,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 +1124,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 +1492,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 +1504,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 }); } From c9c327539b386d00e209017652515b21183c6ef9 Mon Sep 17 00:00:00 2001 From: Morami UwU <140313878+MatsumotoMorami@users.noreply.github.com> Date: Thu, 30 Jul 2026 06:36:13 +0800 Subject: [PATCH 030/112] fix(window): scope context menu suppression to GNOME X11 --- scripts/patch-linux-window-ui.test.js | 267 ++++++++++++++++-- .../main-process/window-shell/patch.js | 2 +- scripts/patches/impl/main-process/window.js | 125 +++++++- tests/scripts_smoke.sh | 47 ++- 4 files changed, 400 insertions(+), 41 deletions(-) diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index 020a49e39..b022a9c06 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -1098,6 +1098,14 @@ 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", @@ -1137,7 +1145,6 @@ test("default core patch descriptors are grouped and unique", () => { ); for (const id of [ "linux-window-options", - "linux-managed-window-system-context-menu", "linux-native-titlebar", "linux-opaque-background", "linux-avatar-overlay-mouse-passthrough", @@ -3380,7 +3387,28 @@ function windowsAndLinuxMenuSnippet(windowAlias) { ); } +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 legacyAllLinuxMenuSnippet(windowAlias, eventAlias = "e") { return ( `process.platform===\`linux\`&&(${windowAlias}.on(\`system-context-menu\`,` + `${eventAlias}=>${eventAlias}.preventDefault()),${windowAlias}.removeMenu()),` + @@ -3388,6 +3416,14 @@ function canonicalLinuxMenuSnippet(windowAlias, eventAlias = "e") { ); } +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({});" + @@ -3416,12 +3452,23 @@ test("patches the managed WindowManager window before the browser-comment popup" assert.equal( (managedPatched.slice(popupStart).match(/system-context-menu/g) ?? []).length, 0, - "the required managed-window patch must not claim success by patching only the popup", + "the managed-window patch must not claim success by patching only the popup", + ); + assert.match( + managedPatched, + new RegExp(escapeRegExp(scopedManagedLinuxMenuSnippet("N"))), ); - assert.match(managedPatched, new RegExp(escapeRegExp(canonicalLinuxMenuSnippet("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), @@ -3443,13 +3490,13 @@ test("patches the current WindowManager contract across minified aliases", () => assert.match( patched, - new RegExp(escapeRegExp(canonicalLinuxMenuSnippet("M"))), + new RegExp(escapeRegExp(scopedManagedLinuxMenuSnippet("M"))), ); }); test("recognizes an equivalent managed-window preventDefault listener", () => { const source = managedWindowMenuFixture( - canonicalLinuxMenuSnippet("N", "event"), + scopedManagedLinuxMenuSnippet("N", "event"), ); assert.equal( @@ -3458,6 +3505,28 @@ test("recognizes an equivalent managed-window preventDefault listener", () => { ); }); +test("migrates the legacy all-Linux managed-window listener to GNOME/X11", () => { + const source = managedWindowMenuFixture( + legacyAllLinuxMenuSnippet("N", "event"), + ); + const patched = applyPatchTwice( + applyLinuxManagedWindowSystemContextMenuPatch, + source, + ); + + assert.match( + patched, + new RegExp( + escapeRegExp(scopedManagedLinuxMenuSnippet("N")), + ), + ); + assert.doesNotMatch( + patched, + new RegExp(escapeRegExp(legacyAllLinuxMenuSnippet("N", "event"))), + ); + assert.equal((patched.match(/system-context-menu/g) ?? []).length, 1); +}); + 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())," + @@ -3469,7 +3538,7 @@ test("rejects malformed or duplicate managed-window system context menu listener ); const duplicate = managedWindowMenuFixture( - canonicalLinuxMenuSnippet("N") + + scopedManagedLinuxMenuSnippet("N") + "N.on(`system-context-menu`,event=>event.preventDefault()),", ); assert.throws( @@ -3478,7 +3547,7 @@ test("rejects malformed or duplicate managed-window system context menu listener ); const duplicateMenuTarget = managedWindowMenuFixture( - canonicalLinuxMenuSnippet("N") + + scopedManagedLinuxMenuSnippet("N") + "process.platform===`win32`&&N.removeMenu(),", ); assert.throws( @@ -3548,7 +3617,7 @@ test("fails loudly when the managed window is missing or ambiguous", () => { ); }); -test("records managed-window menu drift as a required patch failure", () => { +test("records managed-window menu drift as optional without blocking candidates", () => { const descriptor = corePatchDescriptors().find( (candidate) => candidate.id === "linux-managed-window-system-context-menu", @@ -3567,14 +3636,73 @@ test("records managed-window menu drift as a required patch failure", () => { ); assert.equal(result.patchedSource, source); + assert.deepEqual(result.requiredCoreWarnings, []); const entry = report.patches.find( (patch) => patch.name === descriptor.id, ); - assert.equal(entry?.status, "failed-required"); + 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 the generic fallback GNOME/X11-scoped when the managed matcher drifts", () => { + 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"), + "return N}}", + ].join(""); + const report = createPatchReport(); + + const result = applyMainBundlePatchDescriptors( + source, + descriptors, + {}, + report, + ); + + assert.deepEqual(result.requiredCoreWarnings, []); + assert.match( + result.patchedSource, + new RegExp(escapeRegExp(canonicalLinuxMenuSnippet("N"))), + ); + assert.doesNotMatch( + result.patchedSource, + /process\.platform===`linux`&&\(N\.on\(`system-context-menu`/, + ); + assert.equal( + report.patches.find( + (patch) => + patch.name === "linux-managed-window-system-context-menu", + )?.status, + "skipped-optional", + ); + assert.equal( + report.patches.find((patch) => patch.name === "linux-menu")?.status, + "applied", + ); + assert.deepEqual(criticalFailuresFromReport(report), []); }); test("reports managed-window patch strategy and idempotence", () => { @@ -3624,15 +3752,83 @@ test("reports managed-window patch strategy and idempotence", () => { ]); }); -test("keeps managed-window menu behavior platform-specific at runtime", async () => { +test("suppresses the managed-window system menu only on GNOME/X11", async () => { const source = applyLinuxManagedWindowSystemContextMenuPatch( managedWindowMenuFixture(windowsAndLinuxMenuSnippet("N")), ); for (const expected of [ - { platform: "linux", removeMenuCalls: 1, preventDefaultCalls: 1 }, - { platform: "win32", removeMenuCalls: 1, preventDefaultCalls: 0 }, - { platform: "darwin", removeMenuCalls: 0, preventDefaultCalls: 0 }, + { + 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() { @@ -3647,13 +3843,21 @@ test("keeps managed-window menu behavior platform-specific at runtime", async () const context = vm.createContext({ electron: { BrowserWindow }, - process: { platform: expected.platform }, + 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() { @@ -3663,12 +3867,12 @@ test("keeps managed-window menu behavior platform-specific at runtime", async () assert.equal( window.removeMenuCalls, expected.removeMenuCalls, - expected.platform, + expected.name, ); assert.equal( preventDefaultCalls, expected.preventDefaultCalls, - expected.platform, + expected.name, ); } }); @@ -3685,16 +3889,14 @@ 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); @@ -3703,13 +3905,16 @@ 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\(\),\}/, + new RegExp( + escapeRegExp( + `function createSecondWindow(){${canonicalLinuxMenuSnippet("k")}}`, + ), + ), ); }); test("recognizes the Linux system context menu suppression snippet as already applied", () => { - const source = - "process.platform===`linux`&&(k.on(`system-context-menu`,e=>e.preventDefault()),k.removeMenu()),process.platform===`win32`&&k.removeMenu(),"; + const source = canonicalLinuxMenuSnippet("k"); const patched = applyPatchTwice(applyLinuxMenuPatch, source); @@ -3717,6 +3922,18 @@ test("recognizes the Linux system context menu suppression snippet as already ap assert.equal((patched.match(/system-context-menu/g) ?? []).length, 1); }); +test("migrates legacy generic system-menu suppression to GNOME/X11", () => { + const source = legacyAllLinuxMenuSnippet("k", "event"); + const patched = applyPatchTwice(applyLinuxMenuPatch, source); + + assert.equal(patched, canonicalLinuxMenuSnippet("k")); + assert.doesNotMatch( + patched, + new RegExp(escapeRegExp(source)), + ); + assert.equal((patched.match(/system-context-menu/g) ?? []).length, 1); +}); + test("upgrades a half-patched bundle without duplicating the popup listener", () => { const source = managedWindowMenuFixture( windowsAndLinuxMenuSnippet("N"), @@ -10763,7 +10980,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( 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 c749e9f4d..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 @@ -62,7 +62,7 @@ module.exports = [ id: "linux-managed-window-system-context-menu", phase: "main-bundle", order: 59, - ciPolicy: "required-upstream", + ciPolicy: "optional", apply: applyLinuxManagedWindowSystemContextMenuPatch, }), mainBundlePatch({ diff --git a/scripts/patches/impl/main-process/window.js b/scripts/patches/impl/main-process/window.js index 64989be7e..67e4df38b 100644 --- a/scripts/patches/impl/main-process/window.js +++ b/scripts/patches/impl/main-process/window.js @@ -273,16 +273,46 @@ function applyLinuxNativeTitlebarPatch(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 ( - `process.platform===\`linux\`&&(${windowAlias}.on(\`system-context-menu\`,` + - `e=>e.preventDefault()),${windowAlias}.removeMenu()),` + + 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 semanticLegacyLinuxSystemContextMenuRegex(windowAlias, flags = "") { const escapedWindowAlias = escapeRegExp(windowAlias); return new RegExp( `process\\.platform===\`linux\`&&\\(${escapedWindowAlias}\\.on\\(` + @@ -293,6 +323,27 @@ function semanticLinuxSystemContextMenuRegex(windowAlias, flags = "") { ); } +function legacyLinuxSystemContextMenuRegex(flags = "") { + return new RegExp( + `process\\.platform===\`linux\`&&\\((${MINIFIED_IDENTIFIER})\\.on\\(` + + `\`system-context-menu\`,(${MINIFIED_IDENTIFIER})=>\\2\\.preventDefault\\(\\)\\),` + + `\\1\\.removeMenu\\(\\)\\),process\\.platform===\`win32\`&&` + + `\\1\\.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( @@ -404,11 +455,16 @@ function applyLinuxManagedWindowSystemContextMenuPatch(currentSource) { ), ].length; const semanticPatchRegex = - semanticLinuxSystemContextMenuRegex(windowAlias, "g"); + semanticLinuxManagedWindowSystemContextMenuRegex(windowAlias, "g"); const semanticMatches = [...candidate.text.matchAll(semanticPatchRegex)]; + const legacySemanticMatches = [ + ...candidate.text.matchAll( + semanticLegacyLinuxSystemContextMenuRegex(windowAlias, "g"), + ), + ]; if (semanticMatches.length === 1 && listenerCount === 1) { - if (removeMenuCallCount !== 2) { + if (removeMenuCallCount !== 1) { recordStrategy(LINUX_MANAGED_WINDOW_MENU_STRATEGY, "ambiguous"); throw new Error( `Found multiple menu targets for managed BrowserWindow '${windowAlias}'`, @@ -417,6 +473,34 @@ function applyLinuxManagedWindowSystemContextMenuPatch(currentSource) { recordStrategy(LINUX_MANAGED_WINDOW_MENU_STRATEGY, "already-applied"); return currentSource; } + if ( + legacySemanticMatches.length === 1 && + semanticMatches.length === 0 && + listenerCount === 1 && + removeMenuCallCount === 2 + ) { + const legacyMatch = legacySemanticMatches[0]; + const patchedMethod = + candidate.text.slice(0, legacyMatch.index) + + linuxManagedWindowSystemContextMenuPatchFor(windowAlias) + + candidate.text.slice(legacyMatch.index + legacyMatch[0].length); + if ( + !semanticLinuxManagedWindowSystemContextMenuRegex(windowAlias).test( + patchedMethod, + ) + ) { + recordStrategy(LINUX_MANAGED_WINDOW_MENU_STRATEGY, "validation-failed"); + throw new Error( + `Failed to validate the scoped system-context-menu migration for managed BrowserWindow '${windowAlias}'`, + ); + } + recordStrategy(LINUX_MANAGED_WINDOW_MENU_STRATEGY, "legacy-unscoped"); + return ( + currentSource.slice(0, candidate.start) + + patchedMethod + + currentSource.slice(candidate.end) + ); + } if (listenerCount > 0 || semanticMatches.length > 0) { recordStrategy(LINUX_MANAGED_WINDOW_MENU_STRATEGY, "non-canonical-listener"); throw new Error( @@ -451,7 +535,7 @@ function applyLinuxManagedWindowSystemContextMenuPatch(currentSource) { const targetMatch = targetMatches[0]; const patchedMethod = candidate.text.slice(0, targetMatch.index) + - linuxSystemContextMenuPatchFor(windowAlias) + + linuxManagedWindowSystemContextMenuPatchFor(windowAlias) + candidate.text.slice(targetMatch.index + targetMatch[0].length); const patchedListenerCount = [ ...patchedMethod.matchAll( @@ -463,7 +547,9 @@ function applyLinuxManagedWindowSystemContextMenuPatch(currentSource) { ].length; if ( patchedListenerCount !== 1 || - !semanticLinuxSystemContextMenuRegex(windowAlias).test(patchedMethod) + !semanticLinuxManagedWindowSystemContextMenuRegex(windowAlias).test( + patchedMethod, + ) ) { recordStrategy(LINUX_MANAGED_WINDOW_MENU_STRATEGY, "validation-failed"); throw new Error( @@ -482,8 +568,25 @@ function applyLinuxManagedWindowSystemContextMenuPatch(currentSource) { function applyLinuxMenuPatch(currentSource) { const menuRegex = /process\.platform===`win32`&&([A-Za-z_$][\w$]*)\.removeMenu\(\),/g; let patchedSource = currentSource.replace( + legacyLinuxSystemContextMenuRegex("g"), + (_match, windowVar) => + linuxSystemContextMenuPatchFor(windowVar), + ); + patchedSource = patchedSource.replace( /\(process\.platform===`win32`\|\|process\.platform===`linux`\)&&([A-Za-z_$][\w$]*)\.removeMenu\(\),/g, - (_match, windowVar) => linuxSystemContextMenuPatchFor(windowVar), + (match, windowVar, offset, source) => { + const scopedListener = + linuxGnomeX11SystemContextMenuListenerFor(windowVar); + if ( + source.slice( + Math.max(0, offset - scopedListener.length), + offset, + ) === scopedListener + ) { + return match; + } + return linuxSystemContextMenuPatchFor(windowVar); + }, ); let patchedAny = patchedSource !== currentSource; patchedSource = patchedSource.replace(menuRegex, (match, windowVar, offset, source) => { @@ -507,7 +610,13 @@ function applyLinuxMenuPatch(currentSource) { }); 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$]*)=>\2\.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"); } diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index c83f79309..3d6026848 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -8800,7 +8800,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' @@ -8835,12 +8848,29 @@ test_linux_titlebar_context_menu_patch_smoke() { "$extracted/.vite/build/main-test.js" \ 'system-context-menu' \ '2' - assert_contains \ - "$extracted/.vite/build/main-test.js" \ - 'process.platform===`linux`&&(N.on(`system-context-menu`,e=>e.preventDefault()),N.removeMenu()),process.platform===`win32`&&N.removeMenu(),' - assert_contains \ - "$extracted/.vite/build/main-test.js" \ - 'process.platform===`linux`&&(e.on(`system-context-menu`,e=>e.preventDefault()),e.removeMenu()),process.platform===`win32`&&e.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())&&" + + "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")); @@ -8850,6 +8880,9 @@ const entry = report.patches.find( 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" }]) From b48075d553475ba7674ea609fea806dc66913638 Mon Sep 17 00:00:00 2001 From: Roee Jukin Date: Thu, 30 Jul 2026 13:30:07 +0300 Subject: [PATCH 031/112] fix Codex Micro hot-plug discovery (#1186) --- linux-features/codex-micro/README.md | 20 ++- linux-features/codex-micro/patch.js | 188 +++++++++++++++++++++ linux-features/codex-micro/test.js | 236 +++++++++++++++++++++++++++ 3 files changed, 442 insertions(+), 2 deletions(-) diff --git a/linux-features/codex-micro/README.md b/linux-features/codex-micro/README.md index 78acd5878..a870cd311 100644 --- a/linux-features/codex-micro/README.md +++ b/linux-features/codex-micro/README.md @@ -1,15 +1,31 @@ # 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 two narrowly scoped +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. + 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 diff --git a/linux-features/codex-micro/patch.js b/linux-features/codex-micro/patch.js index c02f34245..ec92edae5 100644 --- a/linux-features/codex-micro/patch.js +++ b/linux-features/codex-micro/patch.js @@ -1,6 +1,7 @@ "use strict"; const childProcess = require("node:child_process"); +const fs = require("node:fs"); const path = require("node:path"); const { @@ -11,8 +12,16 @@ const { 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 = + /^codex-micro-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, "\\$&"); @@ -101,6 +110,161 @@ function applyCodexMicroFeatureGatePatch(source) { 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], { @@ -114,12 +278,36 @@ function stageNativeBinding(extractedDir) { 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, diff --git a/linux-features/codex-micro/test.js b/linux-features/codex-micro/test.js index 3c5d0bcf6..6f0dfe02d 100644 --- a/linux-features/codex-micro/test.js +++ b/linux-features/codex-micro/test.js @@ -18,11 +18,15 @@ const { 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, @@ -208,6 +212,121 @@ function currentFeatureGateFixture() { ].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); @@ -233,6 +352,123 @@ test("Codex Micro locally enables only its current upstream feature gate", () => 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, "codex-micro-service-fixture.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/codex-micro-service-fixture.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}\`);`, "") From 201ab4c2bc81948a61fa2bfc63538be9daf49b93 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Fri, 31 Jul 2026 00:16:09 +0300 Subject: [PATCH 032/112] Fix upstream DMG drift (#1188) watchdog-v2-action: commit-source --- linux-features/codex-micro/patch.js | 2 +- linux-features/codex-micro/test.js | 4 +- .../patch.js | 81 ++-- .../directory-only-working-tree-watch/test.js | 31 +- linux-features/open-target-discovery/patch.js | 49 +-- linux-features/open-target-discovery/test.js | 9 +- linux-features/remote-mobile-control/patch.js | 390 ++++++------------ linux-features/remote-mobile-control/test.js | 38 +- .../ui-tweaks/patches/sidebar-project-name.js | 4 +- linux-features/ui-tweaks/test.js | 2 +- 10 files changed, 209 insertions(+), 401 deletions(-) diff --git a/linux-features/codex-micro/patch.js b/linux-features/codex-micro/patch.js index ec92edae5..1c7143cca 100644 --- a/linux-features/codex-micro/patch.js +++ b/linux-features/codex-micro/patch.js @@ -16,7 +16,7 @@ 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 = - /^codex-micro-service-[A-Za-z0-9_-]+\.js$/; + /^service-[A-Za-z0-9_-]+\.js$/; const WATCH_TOPOLOGY_FUNCTION = new RegExp( `function (${JS_IDENT})\\((${JS_IDENT})\\)\\{return ` + `(${JS_IDENT})\\(\\)\\.watch\\(\\2\\)\\}`, diff --git a/linux-features/codex-micro/test.js b/linux-features/codex-micro/test.js index 6f0dfe02d..706fe2497 100644 --- a/linux-features/codex-micro/test.js +++ b/linux-features/codex-micro/test.js @@ -450,7 +450,7 @@ test("Codex Micro service patch rejects unrelated topology watchers", () => { 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, "codex-micro-service-fixture.js"); + const servicePath = path.join(buildDir, "service-current.js"); writeFile(servicePath, currentCodexMicroServiceFixture()); writeFile(path.join(buildDir, "unrelated.js"), "const unrelated=true;"); @@ -461,7 +461,7 @@ test("Codex Micro service discovery patches exactly one current bundle", (t) => const result = patchCodexMicroService(root); assert.equal(result.changed, 1); - assert.equal(result.target, ".vite/build/codex-micro-service-fixture.js"); + 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); diff --git a/linux-features/directory-only-working-tree-watch/patch.js b/linux-features/directory-only-working-tree-watch/patch.js index 41b1b1696..ec6775bcb 100644 --- a/linux-features/directory-only-working-tree-watch/patch.js +++ b/linux-features/directory-only-working-tree-watch/patch.js @@ -2186,75 +2186,74 @@ function patchWorkerSource(source, settings) { return { source: helper + withBranch, matched: 1, changed: 1, reason: null }; } -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; + const targets = []; 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) }); + targets.push({ bundlePath, 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; - } - - 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`, - }; + if (matches > 0) { + targets.push({ bundlePath, result: patchWorkerSource(source, settings) }); } - const target = alreadyPatched[0]; - return { target: target.bundlePath, result: target.result, reason: target.result.reason }; } - if (rawMatchCount !== 1 || rawMatches.length !== 1) { + const targetNames = targets.map(({ bundlePath }) => path.basename(bundlePath)); + const hasWorker = targetNames.filter((name) => name === "worker.js").length === 1; + const srcCount = targetNames.filter((name) => + /^src-[A-Za-z0-9_-]+\.js$/u.test(name), + ).length; + if ( + targets.length !== 2 || + !hasWorker || + srcCount !== 1 || + targets.some(({ result }) => result.matched !== 1) + ) { return { - target: null, - result: null, - reason: `Found ${rawMatchCount} local startFileWatch implementations across ${bundlePaths.length} build bundles`, + targets: [], + reason: + `Found ${targets.length} current local startFileWatch bundles ` + + `(${targetNames.join(", ") || "none"}) across ${bundlePaths.length} build bundles`, }; } - const target = rawMatches[0]; - const result = patchWorkerSource(target.source, settings); - return { target: target.bundlePath, result, reason: result.reason }; + return { targets, reason: null }; } 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 }; + } + + for (const { bundlePath, result } of discovery.targets) { + 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"); + 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 +2265,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"; }, }, ]; @@ -2281,7 +2280,7 @@ module.exports = { LOCAL_FILE_WATCH_METHOD, 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..ad9eae3e7 100644 --- a/linux-features/directory-only-working-tree-watch/test.js +++ b/linux-features/directory-only-working-tree-watch/test.js @@ -175,37 +175,42 @@ 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"); fs.mkdirSync(buildDir, { recursive: true }); - fs.writeFileSync(workerPath, "var gitWorker={startFileWatch(){}};"); + fs.writeFileSync(workerPath, localWorkerSource()); fs.writeFileSync(localHostPath, localWorkerSource()); 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-current.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)); + } 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("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 +222,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 current local startFileWatch bundles/); }); }); diff --git a/linux-features/open-target-discovery/patch.js b/linux-features/open-target-discovery/patch.js index c884bc520..68f59a7b8 100644 --- a/linux-features/open-target-discovery/patch.js +++ b/linux-features/open-target-discovery/patch.js @@ -331,7 +331,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 +349,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 +373,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 +421,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 +442,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; } diff --git a/linux-features/open-target-discovery/test.js b/linux-features/open-target-discovery/test.js index e4046133c..1800d6377 100644 --- a/linux-features/open-target-discovery/test.js +++ b/linux-features/open-target-discovery/test.js @@ -37,7 +37,7 @@ const fileManagerBundle = 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`);" + @@ -194,13 +194,11 @@ 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\(\)/); }); @@ -1560,7 +1558,7 @@ test("open-target discovery reports current command lookup drift as an enabled f }); }); -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}}", @@ -1568,4 +1566,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/remote-mobile-control/patch.js b/linux-features/remote-mobile-control/patch.js index 10d12679e..40846841e 100644 --- a/linux-features/remote-mobile-control/patch.js +++ b/linux-features/remote-mobile-control/patch.js @@ -21,15 +21,12 @@ const REMOTE_CONTROL_SETTINGS_VISIBILITY_NEEDLE = 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_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"; @@ -369,85 +366,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}`; } @@ -528,28 +490,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 +512,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,35 +620,34 @@ 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); - } - 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); diff --git a/linux-features/remote-mobile-control/test.js b/linux-features/remote-mobile-control/test.js index 5555ad68a..8c7b3d75e 100644 --- a/linux-features/remote-mobile-control/test.js +++ b/linux-features/remote-mobile-control/test.js @@ -216,18 +216,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(""); } @@ -271,7 +262,6 @@ function syntheticSettingsBundle() { "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(""); @@ -279,9 +269,9 @@ function syntheticSettingsBundle() { 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(""); } @@ -1453,17 +1443,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); @@ -1564,7 +1555,7 @@ test("Linux mobile setup dialog copy does not refer to Mac-only setup", () => { }); test("Linux remote-control settings UX patch keeps outbound tab visible and removes Mac copy", () => { - const source = syntheticSettingsBundle(); + const source = syntheticSettingsBundle() + syntheticSshInstallSettingsBundle(); const patched = applyLinuxRemoteControlSettingsUxPatch(source); assert.notEqual(patched, source); @@ -1573,7 +1564,6 @@ test("Linux remote-control settings UX patch keeps outbound tab visible and remo 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/); @@ -3652,7 +3642,7 @@ test("remote mobile control feature participates in ASAR patching and reports", assert.match(patchedAppServerLaunchFile, /codexLinuxRemoteMobileAppServerArgs/); 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, /codexLinuxRemoteControlResetMobileSetupAfterRevoke/); 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..b2a0664f8 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(""); } From efe491761d9075341fe79f564631a6dd9aafd291 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:41:37 +0300 Subject: [PATCH 033/112] fix(nix): refresh upstream Nix pins for 26.727.40816 (#1189) Refreshed Codex.dmg SRI hash to sha256-+5OiOcgRx2Oc9FqQ/zbCYvoCkGQBQM0S2j/cYLYiVa4= and synced codexVersion / electronVersion / native-module pins to the current upstream DMG. Verified all ChatGPT Desktop Nix package outputs against the refreshed DMG. Source-Main-SHA: 201ab4c2bc81948a61fa2bfc63538be9daf49b93 Upstream-DMG-SHA256: fb93a239c811c7639cf45a90ff36c262fa0290640140cd12da3fdc60b62255ae Co-authored-by: codex-dmg-hash-bot --- flake.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 7b0710548..9e5fe705e 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-ezci5PWGQgKx3Wnm5gYvL4xDiNIVCRUDxz4ZV7TL+Xo="; + hash = "sha256-+5OiOcgRx2Oc9FqQ/zbCYvoCkGQBQM0S2j/cYLYiVa4="; }; - codexVersion = "26.721.81911"; + codexVersion = "26.727.40816"; electronVersion = "42.3.0"; electronPlatform = { From 689fc5b138c2e9b33a64e4b6d3d66410f173930f Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Fri, 31 Jul 2026 14:53:11 +0300 Subject: [PATCH 034/112] Fix optional upstream DMG drift (#1191) * Fix optional upstream DMG drift optional-drift-watchdog-action: source-commit * Fix optional upstream DMG drift optional-drift-watchdog-action: source-commit --- scripts/patch-linux-window-ui.test.js | 169 ++++++------------ .../browser-use-attach-recovery/patch.js | 11 -- scripts/patches/impl/launch-actions.js | 67 ++----- scripts/patches/impl/main-process/tray.js | 11 +- scripts/patches/impl/webview/index.js | 102 ++--------- tests/scripts_smoke.sh | 15 +- 6 files changed, 95 insertions(+), 280 deletions(-) diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index 6ad4cab3a..9eca7610f 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -177,7 +177,6 @@ const { applyLinuxAppSunsetPatch, applyLinuxBrowserUseAvailabilityPatch, applyLinuxBrowserUseExternalAvailabilityPatch, - applyLinuxBrowserUseHiddenHostOwnershipPatch, applyLinuxBrowserUseWebviewHostRecoveryPatch, applyLinuxBrowserUseWebviewRemountStorePatch, applyLinuxBrowserUseNonLocalNavigationPatch, @@ -995,7 +994,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", @@ -1244,7 +1242,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, ); @@ -1620,24 +1618,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(""); } @@ -1648,7 +1647,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" }, }, ); @@ -3739,30 +3738,29 @@ test("patches current webview opaque window default bundle shapes", () => { test("patches the current comment 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 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})}", ].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\(pt&&N\?\.annotation\.anchor\.kind===`element`\)\{let t=fs\(N\.annotation\.anchor\);Lt=void 0,It=js/, ); - 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\?\?fs/); }); 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()}"; + const source = "if(pt&&N?.annotation.anchor.kind===`element`){renderDriftedAnchor()}"; const { value, warnings } = captureWarns(() => applyBrowserAnnotationScreenshotPatch(source), ); @@ -4758,7 +4756,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\(\)/); @@ -4775,7 +4773,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/); @@ -4787,7 +4785,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( @@ -4809,14 +4807,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/, ); }); @@ -5182,9 +5180,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, { @@ -5212,43 +5209,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()); + const settingsFile = path.join(tempRoot, "config", "codex-desktop", "settings.json"); + const source = exactDmgSettingsPersistenceBundleFixture(); + const patchedOnce = applyLinuxSettingsPersistencePatch(source); + const patchedTwice = applyLinuxSettingsPersistencePatch(patchedOnce); - assert.match(patched, /process\.env\.CODEX_LINUX_SETTINGS_FILE/); - assert.doesNotMatch(patched, /join\(e,`codex-desktop`,`settings\.json`\)/); + assert.equal(patchedTwice, patchedOnce); runSettingsPersistence( - patched, - { - CODEX_LINUX_APP_ID: "codex-cua-lab", - XDG_CONFIG_HOME: xdgConfig, - }, - "codex-linux-prompt-window-enabled", - false, - ); - - assert.equal( - JSON.parse(fs.readFileSync(path.join(xdgConfig, "codex-cua-lab", "settings.json"), "utf8"))["codex-linux-prompt-window-enabled"], + patchedTwice, + { CODEX_LINUX_SETTINGS_FILE: settingsFile }, + "codex-linux-system-tray-enabled", false, ); runSettingsPersistence( - patched, - { - CODEX_LINUX_APP_ID: "codex-cua-lab", - XDG_CONFIG_HOME: xdgConfig, - }, - "codex-linux-read-aloud-enabled", + patchedTwice, + { CODEX_LINUX_SETTINGS_FILE: settingsFile }, + "codex-linux-warm-start-enabled", true, ); - assert.equal( - JSON.parse(fs.readFileSync(path.join(xdgConfig, "codex-cua-lab", "settings.json"), "utf8"))["codex-linux-read-aloud-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 }); } @@ -5261,10 +5247,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\}\)/, ); }); @@ -5272,7 +5257,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); @@ -5280,7 +5265,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\}\)/, ); }); @@ -6810,7 +6795,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); @@ -8849,11 +8834,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( @@ -8877,7 +8862,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, @@ -8893,11 +8878,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/); @@ -8990,14 +8971,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( @@ -9096,13 +9069,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, @@ -9119,14 +9088,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", () => { @@ -9141,11 +9102,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, @@ -9160,7 +9116,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, @@ -9183,10 +9138,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) { @@ -9244,24 +9195,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"]), }), ), @@ -9270,7 +9211,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, @@ -9279,7 +9219,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"]), }), ), 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/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/tray.js b/scripts/patches/impl/main-process/tray.js index 394bf1cf7..7f1077482 100644 --- a/scripts/patches/impl/main-process/tray.js +++ b/scripts/patches/impl/main-process/tray.js @@ -189,12 +189,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 +209,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 +237,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/webview/index.js b/scripts/patches/impl/webview/index.js index fa88f3d0d..e16e5adf7 100644 --- a/scripts/patches/impl/webview/index.js +++ b/scripts/patches/impl/webview/index.js @@ -702,7 +702,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 +734,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 +758,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 +797,6 @@ function applyLinuxBrowserUseWebviewRemountStorePatch(currentSource) { removeTabMatch == null || removeConversationTabsMatch == null || releaseBrowserUseTabMatch == null || - siblingDeactivateMatch == null || reassociateKeysMatch == null || reassociateStateMatch == null || disposeAllMatch == null || @@ -815,13 +813,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 +841,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 +860,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,68 +991,6 @@ 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", - ); - return currentSource; - } - - 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; - } - if ( - componentStartIndex === -1 || - componentCloseIndex === -1 || - parsedTabIdsMatch == null || - guardMatch == null - ) { - console.warn( - "WARN: Could not find hidden Browser Use host ownership guard — skipping Linux inactive-route host 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)}` - ); -} - function applyLinuxBrowserUseExternalAvailabilityPatch(currentSource) { const externalFeatureNeedle = "featureName:`browser_use_external`"; const statsigNeedle = "410065390"; @@ -1246,7 +1175,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 +1200,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}})`; }, ); } @@ -1701,13 +1630,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( @@ -1716,7 +1645,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,`, @@ -2281,7 +2210,6 @@ module.exports = { applyLinuxChatSearchHydrationPatch, applyLinuxBrowserUseAvailabilityPatch, applyLinuxBrowserUseExternalAvailabilityPatch, - applyLinuxBrowserUseHiddenHostOwnershipPatch, applyLinuxBrowserUseNonLocalNavigationPatch, applyLinuxBrowserUseWebviewHostRecoveryPatch, applyLinuxBrowserUseWebviewRemountStorePatch, diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index d59441977..28a942905 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -9227,19 +9227,18 @@ 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})} +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/comment-preload.js" 'let t=fs(N.annotation.anchor);Lt=void 0,It=js' + assert_contains "$extracted/.vite/build/comment-preload.js" 'selectionIndexOffset:1' + assert_not_contains "$extracted/.vite/build/comment-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/comment-preload.js" 'let t=fs(N.annotation.anchor)' '1' + assert_occurrence_count "$extracted/.vite/build/comment-preload.js" 'selectionIndexOffset:1' '1' } test_linux_single_instance_patch_smoke() { From dec2ddb1d628bb8741c90510c30de9f224ad15d5 Mon Sep 17 00:00:00 2001 From: Shotaro Nakamura <79000684+nakasyou@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:46:41 +0900 Subject: [PATCH 035/112] Fix shallow repository watches for current DMG (#1192) --- CHANGELOG.md | 3 + .../shallow-repository-watches/README.md | 5 +- .../shallow-repository-watches/patch.js | 171 +++++++++++------- .../shallow-repository-watches/test.js | 97 +++++++++- 4 files changed, 201 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f979a3f7c..da354876e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- 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. - 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 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 }); } From 0f3ffbfc4260e3ab5b79900b3df6235ced953f0e Mon Sep 17 00:00:00 2001 From: Caio Faheina <69549574+PinguuSS@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:47:12 -0400 Subject: [PATCH 036/112] Keep current feature composition idempotent on second pass (#1184) * Stabilize external open feature composition * Keep native titlebar composition idempotent * Make Chrome runtime composition idempotent * Cover second-pass feature composition * Keep frameless safe-area composition stable * Preserve generated settings extensions * Harden current DMG composition validation * Harden patch composition integrity * Fail closed when Chrome runtime rollback loses bytes * Resolve thermo review maintainability findings * Reject incomplete patch completion states * Harden delegated patch and integrity contracts * Validate feature composition delegation plans * Reject incomplete composed patch consumers * Document fatal patch integrity failures * ci: rerun checks * Keep open target discovery idempotent on 26.727 * refactor: require core owners for frameless composition * Fix composed patch completion invariants * Make patch integrity failures unconditionally fatal --------- Co-authored-by: Gary Lysenko --- AGENTS.md | 13 +- CHANGELOG.md | 5 + docs/architecture.md | 10 +- docs/linux-features-architecture.md | 10 + docs/upstream-dmg-intelligence.md | 16 +- linux-features/frameless-titlebar/patch.js | 224 ++++++- linux-features/frameless-titlebar/test.js | 269 +++++++- linux-features/open-target-discovery/patch.js | 16 +- linux-features/open-target-discovery/test.js | 17 + linux-features/record-and-replay/patch.js | 30 +- linux-features/record-and-replay/test.js | 46 +- scripts/ci/upstream-dmg-acceptance.test.js | 18 + scripts/dev/upstream-dmg-intel.test.js | 65 ++ scripts/lib/asar-patch.sh | 1 + scripts/lib/patch-report.js | 19 +- scripts/lib/upstream-dmg-intel.js | 8 +- scripts/patch-linux-window-ui.js | 18 +- scripts/patch-linux-window-ui.test.js | 448 +++---------- scripts/patches/composition.test.js | 627 ++++++++++++++++++ scripts/patches/core/README.md | 6 + .../browser-integrations/patch.js | 28 +- scripts/patches/descriptor.js | 27 + scripts/patches/descriptor.test.js | 16 + scripts/patches/engine.js | 36 +- scripts/patches/impl/chrome-plugin.js | 609 +++++++++++------ scripts/patches/impl/chrome-plugin.test.js | 406 ++++++++++++ scripts/patches/impl/keybinds-settings.js | 98 ++- .../patches/impl/keybinds-settings.test.js | 157 +++++ scripts/patches/impl/main-process/browser.js | 72 +- .../patches/impl/main-process/browser.test.js | 103 ++- scripts/patches/impl/main-process/window.js | 160 ++++- scripts/patches/impl/webview/index.js | 115 +++- scripts/patches/integrity-error.js | 19 + scripts/patches/lib/composition-delegation.js | 98 +++ .../lib/composition-delegation.test.js | 100 +++ scripts/patches/lib/minified-js.js | 2 +- scripts/patches/lib/minified-js.test.js | 6 + scripts/patches/runner.js | 84 ++- scripts/patches/runner.test.js | 114 ++++ scripts/patches/test-fixtures/current-dmg.js | 156 +++++ tests/scripts_smoke.sh | 2 + updater/src/builder.rs | 2 + 42 files changed, 3613 insertions(+), 663 deletions(-) create mode 100644 scripts/patches/composition.test.js create mode 100644 scripts/patches/impl/chrome-plugin.test.js create mode 100644 scripts/patches/impl/keybinds-settings.test.js create mode 100644 scripts/patches/integrity-error.js create mode 100644 scripts/patches/lib/composition-delegation.js create mode 100644 scripts/patches/lib/composition-delegation.test.js create mode 100644 scripts/patches/test-fixtures/current-dmg.js 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 da354876e..c18c6f02a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). 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 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-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/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/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 68f59a7b8..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) { diff --git a/linux-features/open-target-discovery/test.js b/linux-features/open-target-discovery/test.js index 1800d6377..57d25b735 100644 --- a/linux-features/open-target-discovery/test.js +++ b/linux-features/open-target-discovery/test.js @@ -34,6 +34,8 @@ 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 = @@ -202,6 +204,21 @@ test("open-target discovery upgrades file manager and terminal support and adds 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"); 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/scripts/ci/upstream-dmg-acceptance.test.js b/scripts/ci/upstream-dmg-acceptance.test.js index 2f8ce0a95..9b1933ab8 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"]; 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/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/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/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 9eca7610f..208ac02c8 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. @@ -1523,31 +1527,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}"; } @@ -1555,44 +1534,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}}"; } @@ -1669,13 +1610,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). @@ -3073,12 +3007,29 @@ test("patches remaining explicit quit handlers when another copy is already patc ); }); +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); @@ -3100,6 +3051,8 @@ test("uses a module-scoped Linux native titlebar helper when aliases shadow Elec "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), @@ -3123,6 +3076,7 @@ test("updates the Linux native titlebar overlay when nativeTheme changes", () => "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)}}", ].join(""); const patched = applyPatchTwice(applyLinuxNativeTitlebarPatch, source); @@ -3139,6 +3093,26 @@ test("updates the Linux native titlebar overlay when nativeTheme changes", () => 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`}", @@ -3146,7 +3120,7 @@ test("redirects the renamed Linux-aware titlebar overlay sync away from the tran "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)))", + nativeTitlebarZoomFixture(), ].join(""); const { value: patched, warnings } = captureWarns(() => applyPatchTwice(applyLinuxNativeTitlebarPatch, source), @@ -3172,29 +3146,6 @@ test("redirects the renamed Linux-aware titlebar overlay sync away from the tran assert.deepEqual(warnings, []); }); - -test("updates every Linux zoom titlebar overlay refresh call site", () => { - 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)))", - "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.doesNotMatch( - patched, - /\(process\.platform===`win32`\|\|process\.platform===`linux`\)&&\(this\.windowZooms\.set\([^)]+\),[A-Za-z_$][\w$]*\.setTitleBarOverlay\(b2\([^)]+\)\)\)/, - ); -}); - function windowControlsSafeAreaFixture(firstInset = 0, secondInset = 0) { return [ `var l=Object.freeze({default:Object.freeze({left:0,right:0}),mac:Object.freeze({legacy:Object.freeze({left:66+c,right:0}),modern:Object.freeze({left:76+c,right:0})}),applicationMenu:Object.freeze({left:0,right:${firstInset}})});`, @@ -3280,6 +3231,25 @@ test("patches remaining Linux header safe-area padding when the menu inset is al 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})});", @@ -3290,9 +3260,10 @@ test("warns when the Linux window-controls safe area cannot follow the current h applyLinuxWindowControlsSafeAreaPatch(source), ); - assert.match(value, /applicationMenu:Object\.freeze\(\{left:0,right:138\}\)/); + 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", ]); }); @@ -7729,105 +7700,16 @@ test("fails closed when bundled plugin reconcile insertion order drifts", () => 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", - }, - }, - }, - ); - - 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", - }); -}); - 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/, @@ -7840,29 +7722,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") { @@ -7881,149 +7748,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:", @@ -9859,7 +9604,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) { @@ -9945,7 +9690,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)); @@ -9960,7 +9705,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", ]); }); @@ -10796,6 +10541,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", @@ -10807,6 +10553,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 }); @@ -11459,12 +11208,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" }, @@ -11472,6 +11223,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-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/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/chrome-plugin.js b/scripts/patches/impl/chrome-plugin.js index 212a3cbbb..d63c27c85 100644 --- a/scripts/patches/impl/chrome-plugin.js +++ b/scripts/patches/impl/chrome-plugin.js @@ -2,15 +2,167 @@ 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_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, +]; +const LINUX_CHROME_PLUGIN_APP_SERVER_SOURCE_PATH_HELPER = + "function codexLinuxChromePluginAppServerSourcePath(e){return e.codexCliPath}"; +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 && + 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\}\)\}`, + ), + ); +} + +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 +211,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 +317,174 @@ 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; + } + + return hasCompleteCurrentChromeAppServerRuntimePatch(patchedSource) + ? patchedSource + : null; +} + +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 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 patchLinuxChromeNativeHostRuntimeAssets(extractedDir) { +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 +492,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 }; + } + + 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 }); + } - matched += 1; - const patched = applyLinuxChromeNativeHostRuntimePatch(source); - if (patched !== source) { - fs.writeFileSync(filePath, patched, "utf8"); - changed += 1; + 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..7c1c09607 --- /dev/null +++ b/scripts/patches/impl/chrome-plugin.test.js @@ -0,0 +1,406 @@ +"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")], + ]); +} + +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/); + + 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("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/keybinds-settings.js b/scripts/patches/impl/keybinds-settings.js index 81d7fd0b5..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, @@ -803,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)) { @@ -814,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. @@ -852,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, diff --git a/scripts/patches/impl/keybinds-settings.test.js b/scripts/patches/impl/keybinds-settings.test.js new file mode 100644 index 000000000..ed2d0a6f7 --- /dev/null +++ b/scripts/patches/impl/keybinds-settings.test.js @@ -0,0 +1,157 @@ +"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 { + 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 wrapper updater 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 }); + 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"/, + ); + + 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.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/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/window.js b/scripts/patches/impl/main-process/window.js index 5b7a67f80..cda260c6f 100644 --- a/scripts/patches/impl/main-process/window.js +++ b/scripts/patches/impl/main-process/window.js @@ -4,9 +4,14 @@ const { escapeRegExp, findMatchingBrace, } = require("../../lib/minified-js.js"); +const { + patchDelegationState, +} = require("../../lib/composition-delegation.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 +166,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 +320,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 +386,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,11 +394,22 @@ 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; } function applyLinuxMenuPatch(currentSource) { diff --git a/scripts/patches/impl/webview/index.js b/scripts/patches/impl/webview/index.js index e16e5adf7..ab273edf6 100644 --- a/scripts/patches/impl/webview/index.js +++ b/scripts/patches/impl/webview/index.js @@ -9,12 +9,19 @@ const { escapeRegExp, findMatchingBrace, } = require("../../lib/minified-js.js"); +const { + patchDelegationState, +} = require("../../lib/composition-delegation.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 +191,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 +315,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 ( 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..c98f8e848 100644 --- a/scripts/patches/runner.js +++ b/scripts/patches/runner.js @@ -24,6 +24,8 @@ const { applyExtractedAppPatchDescriptors, applyMainBundlePatchDescriptors, applyWebviewAssetPatchDescriptors, + descriptorAppliesTo, + descriptorEnabled, discoverCorePatchDescriptors, normalizePatchDescriptors, } = require("./engine.js"); @@ -75,15 +77,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 +119,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 +206,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,8 +232,11 @@ 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) { @@ -255,6 +332,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..994e0e228 --- /dev/null +++ b/scripts/patches/test-fixtures/current-dmg.js @@ -0,0 +1,156 @@ +"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})}", + "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/scripts_smoke.sh b/tests/scripts_smoke.sh index 28a942905..f77f62fb3 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -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" diff --git a/updater/src/builder.rs b/updater/src/builder.rs index 992e98aab..664dfa15f 100644 --- a/updater/src/builder.rs +++ b/updater/src/builder.rs @@ -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", From 5f2db131149b023d764905459a0b0f48b1d87666 Mon Sep 17 00:00:00 2001 From: pinguuss Date: Fri, 31 Jul 2026 11:26:43 -0400 Subject: [PATCH 037/112] test: cover current Dock icon main contract --- linux-features/ui-tweaks/dock-icon.test.js | 25 ++++++++++++---------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/linux-features/ui-tweaks/dock-icon.test.js b/linux-features/ui-tweaks/dock-icon.test.js index 38515919d..3411a68fe 100644 --- a/linux-features/ui-tweaks/dock-icon.test.js +++ b/linux-features/ui-tweaks/dock-icon.test.js @@ -28,24 +28,24 @@ 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=_S(`${F_(e,t)}.png`),i=_S(n.dark),a=_S(n.light);return r==null||i==null||a==null?null:{appDefault:r,codexDark:i,codexLight:a}}", + "function _S(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.Ec.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),Gce({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)}});", + "let ee=null,I=new xwe({onWindowRegistered:e=>{ee?.registerWindow(e),C?.(e)}});", "return{updateDockIcon:F,windowManager:I}}", ].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(t.defaultIcon));if(!W9)return n.destroy(),null;return n}"; const currentMainSource = currentAppInfoSource + currentRuntimeSource + currentTraySource; @@ -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\.Ec\.ChatGPT/); + assert.doesNotMatch(patched, /n\.Ml\.ChatGPT/); assert.match(patched, /process\.platform!==`darwin`&&process\.platform!==`linux`/); assert.match( patched, @@ -197,7 +200,7 @@ 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 _S(e){if(e==null)return null", "E=e=>{if(!l.app.isPackaged)return null", "P=t=>{if(t===`app-default`", "F=()=>{if(!v)return", From 373c2ae8cdd13d996914d1e4d55ab4dec7318aa7 Mon Sep 17 00:00:00 2001 From: pinguuss Date: Fri, 31 Jul 2026 11:26:48 -0400 Subject: [PATCH 038/112] fix: retarget Dock icon to current main bundle --- CHANGELOG.md | 2 ++ linux-features/ui-tweaks/patches/dock-icon.js | 16 ++++++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c18c6f02a..f6cdb6481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- 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. diff --git a/linux-features/ui-tweaks/patches/dock-icon.js b/linux-features/ui-tweaks/patches/dock-icon.js index 820bb2cf5..01024ae44 100644 --- a/linux-features/ui-tweaks/patches/dock-icon.js +++ b/linux-features/ui-tweaks/patches/dock-icon.js @@ -4,21 +4,21 @@ 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 _S(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 _S(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.Ec.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.Ec.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),Gce({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),Gce({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 = @@ -28,9 +28,9 @@ const currentWindowRegistration = const patchedWindowRegistration = "onWindowRegistered:e=>{ee?.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(t.defaultIcon));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));if(!W9)return"; const currentMainContracts = [ currentPreviewGate, From afb0aef0fd89b497d5a81f4b125c8b31d4607241 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Fri, 31 Jul 2026 18:52:46 +0300 Subject: [PATCH 039/112] Clarify managed-window patch ownership --- scripts/patches/impl/main-process/window.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/patches/impl/main-process/window.js b/scripts/patches/impl/main-process/window.js index 959b2dc4f..2569b5598 100644 --- a/scripts/patches/impl/main-process/window.js +++ b/scripts/patches/impl/main-process/window.js @@ -484,7 +484,7 @@ function managedWindowRemoveMenuCallRegex(windowAlias, flags = "") { } // The current bundle also creates a browser-comment popup inside createWindow. -// Tie the required patch to the BrowserWindow that the WindowManager registers, +// 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( From 94bea84222984fb327690ca0d81ef75b44945c80 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Fri, 31 Jul 2026 20:55:33 +0300 Subject: [PATCH 040/112] fix(computer-use): harden input and compositor handling --- CHANGELOG.md | 9 + Cargo.lock | 8 +- README.md | 4 +- computer-use-linux/Cargo.toml | 3 +- .../src/bin/codex-computer-use-cosmic.rs | 302 ++- computer-use-linux/src/command_runner.rs | 638 +++++ computer-use-linux/src/cosmic_helper.rs | 90 +- computer-use-linux/src/diagnostics.rs | 338 ++- computer-use-linux/src/lib.rs | 1 + computer-use-linux/src/main.rs | 1 + computer-use-linux/src/remote_desktop.rs | 1477 +++++++++++- computer-use-linux/src/server.rs | 2053 +++++++++++++---- .../src/windowing/backends/cosmic.rs | 12 +- .../src/windowing/backends/hyprland.rs | 178 +- .../src/windowing/backends/i3.rs | 43 +- .../src/windowing/backends/kwin.rs | 84 +- .../src/windowing/backends/mod.rs | 1 + .../src/windowing/backends/niri.rs | 22 +- .../src/windowing/backends/x11.rs | 566 +++++ computer-use-linux/src/windowing/mod.rs | 3 +- computer-use-linux/src/windowing/registry.rs | 129 +- computer-use-linux/src/windowing/target.rs | 92 +- computer-use-linux/src/ydotool.rs | 525 ++++- docs/linux-computer-use.md | 28 +- docs/troubleshooting.md | 4 +- .../x11-ewmh-computer-use/README.md | 6 +- 26 files changed, 5896 insertions(+), 721 deletions(-) create mode 100644 computer-use-linux/src/command_runner.rs create mode 100644 computer-use-linux/src/windowing/backends/x11.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f6cdb6481..71ac8cb0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added +- The embedded Computer Use backend is synchronized to standalone v0.4.3 as + `0.4.3-linux-alpha1`, including generic X11/EWMH window control, X11 + `xdotool` keyboard/text 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 @@ -40,6 +44,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - 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. +- 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 diff --git a/Cargo.lock b/Cargo.lock index b48439b80..592997e16 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.3-linux-alpha1" dependencies = [ "anyhow", "atspi", @@ -512,6 +512,7 @@ dependencies = [ "tokio-tungstenite", "wayland-client", "wayland-protocols", + "wayland-protocols-wlr", "xkeysym", "zbus", ] @@ -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", ] diff --git a/README.md b/README.md index 79bd4b471..47e810d2f 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,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) | @@ -235,7 +235,7 @@ workarounds. | 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 diff --git a/computer-use-linux/Cargo.toml b/computer-use-linux/Cargo.toml index ca1434592..ceca019d3 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.3-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/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..54003f64a 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(); @@ -594,7 +652,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 +663,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 +697,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 +719,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 +746,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 +766,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 +785,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 +881,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 +944,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 +1199,7 @@ mod tests { ydotoold, ydotool_socket, uinput, + xdotool: Check::fail("missing xdotool"), } } @@ -1176,6 +1284,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 +1564,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 +1666,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 +1679,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..66504d251 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,17 @@ 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(Debug, Clone)] @@ -58,6 +76,43 @@ 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>, + valid: Arc, +} + +struct KeyboardReleaseGuard { + connection: Connection, + session_handle: OwnedObjectPath, + pressed: Vec, + input_guard: Option>, + valid: Arc, +} + +struct PortalSessionCleanup { + connection: Connection, + session_handle: OwnedObjectPath, + armed: bool, +} + #[derive(Debug, Clone, Copy)] pub enum PointerButton { Left, @@ -84,6 +139,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 +151,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 +171,350 @@ 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, +} + +#[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) + .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, }) } @@ -144,10 +540,14 @@ pub async fn click( button: PointerButton, click_count: u32, ) -> 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); 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 +563,7 @@ pub async fn click( POINTER_BUTTON_RELEASED, ) .await?; + release_guard.disarm(); } Ok(()) } @@ -173,20 +574,106 @@ 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( @@ -196,8 +683,12 @@ pub async fn drag( end_x: i32, end_y: i32, ) -> 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); 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 +697,7 @@ pub async fn drag( start_y, ) .await?; + release_guard.arm(BTN_LEFT); notify_pointer_button( &proxy, &session.session_handle, @@ -214,7 +706,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,18 +715,25 @@ pub async fn drag( BTN_LEFT, POINTER_BUTTON_RELEASED, ) - .await + .await?; + release_guard.disarm(); + Ok(()) } pub async fn type_text_with_keysyms( session: &PortalKeyboardSession, keysyms: &[i32], ) -> 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); 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(()) @@ -246,20 +744,236 @@ pub async fn press_keycode_chord( modifiers: &[i32], keycode: i32, ) -> 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); 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<()>) -> Self { + Self { + connection: session.connection.clone(), + session_handle: session.session_handle.clone(), + button: None, + input_guard: Some(input_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 Some(button) = self.button else { + return; + }; + self.valid.store(false, Ordering::Release); + 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, 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; + drop(input_guard); + }); + } + } +} + +impl KeyboardReleaseGuard { + fn new(session: &PortalKeyboardSession, input_guard: OwnedMutexGuard<()>) -> Self { + Self { + connection: session.connection.clone(), + session_handle: session.session_handle.clone(), + pressed: Vec::new(), + input_guard: Some(input_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(); + 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); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(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; + drop(input_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 +983,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 +1186,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 +1342,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 +1359,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 +1379,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 +1396,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 +1620,468 @@ 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 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 + ); + } } diff --git a/computer-use-linux/src/server.rs b/computer-use-linux/src/server.rs index f48b07464..bbbd68aa5 100644 --- a/computer-use-linux/src/server.rs +++ b/computer-use-linux/src/server.rs @@ -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, }; 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 = @@ -530,6 +619,8 @@ impl ComputerUseLinux { )] async fn click(&self, Parameters(mut params): Parameters) -> Json { let received = Some(serde_json::json!(params.clone())); + let _input_guard = self.input_operation_lock.lock().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 +660,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 +684,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) { @@ -669,10 +779,19 @@ impl ComputerUseLinux { )); } 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), ) @@ -690,34 +809,59 @@ 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), + ) + .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(_) => {} } @@ -827,6 +971,8 @@ impl ComputerUseLinux { )] async fn scroll(&self, Parameters(mut params): Parameters) -> Json { let received = Some(serde_json::json!(params.clone())); + let _input_guard = self.input_operation_lock.lock().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 +1012,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 +1036,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 +1055,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 +1078,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 +1117,20 @@ 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(), + )); + }; + match portal_scroll(&session, portal_target_point, direction, units).await { Ok(()) => { return Json(with_notes( pointer_action_result(ActionOutput { @@ -949,12 +1143,30 @@ 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", 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(), + )); + }; + match portal_scroll(&session, portal_target_point, direction, units).await { Ok(()) => { return Json(with_notes( pointer_action_result(ActionOutput { @@ -968,7 +1180,15 @@ 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", error, received, + )), + off_screen_note.clone(), + )); + } } } Ok(None) => {} @@ -1015,6 +1235,7 @@ impl ComputerUseLinux { )] async fn drag(&self, Parameters(params): Parameters) -> Json { let received = Some(serde_json::json!(params)); + let _input_guard = self.input_operation_lock.lock().await; // Preferred backend: the uinput absolute pointer (accurate landing). if self.ensure_abs_pointer().await { let abs_pointer = Arc::clone(&self.abs_pointer); @@ -1046,15 +1267,24 @@ impl ComputerUseLinux { } } if let Some(session) = self.cached_portal_pointer_session() { - match portal_drag( - &session, - params.start_x, - params.start_y, - params.end_x, - params.end_y, - ) - .await - { + 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, start_x, start_y, end_x, end_y).await { Ok(()) => { return Json(pointer_action_result(ActionOutput { ok: true, @@ -1064,30 +1294,52 @@ 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).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(_) => {} } @@ -1106,7 +1358,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 +1371,7 @@ impl ComputerUseLinux { Parameters(params): Parameters, ) -> Json { let received = Some(serde_json::json!(params.clone())); + let _input_guard = self.input_operation_lock.lock().await; let focus = match self.focus_target_for_input(¶ms.window_target()).await { Ok(focus) => focus, Err(message) => { @@ -1131,6 +1384,50 @@ impl ComputerUseLinux { }); } }; + let Some((chord_modifiers, chord_key)) = key_chord(¶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_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)).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) => {} + Err(_) => {} + } + } let Some(key_events) = key_sequence(¶ms.key) else { return Json(ActionOutput { ok: false, @@ -1140,8 +1437,35 @@ impl ComputerUseLinux { received, }); }; - let mut args = vec!["key".to_string()]; - args.extend(key_events); + 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 result = run_xdotool_or_fallback(Path::new("xdotool"), &xdotool_args, || { + run_ydotool(&ydotool_args) + }) + .await; + 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 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() { @@ -1166,6 +1490,7 @@ impl ComputerUseLinux { Parameters(params): Parameters, ) -> Json { let received = Some(serde_json::json!(params.clone())); + let _input_guard = self.input_operation_lock.lock().await; let focus = match self.focus_target_for_input(¶ms.window_target()).await { Ok(focus) => focus, Err(message) => { @@ -1197,7 +1522,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( @@ -1214,7 +1539,7 @@ impl ComputerUseLinux { Err(_) => {} } } - if self.should_prefer_portal_keyboard_backend() { + if self.should_prefer_portal_keyboard_backend().await { 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 { @@ -1231,7 +1556,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:#}")), @@ -1245,6 +1570,30 @@ impl ComputerUseLinux { } } } + if self.should_prefer_xdotool_keyboard() { + let args = xdotool_type_args(¶ms.text); + let result = run_xdotool_or_fallback(Path::new("xdotool"), &args, || { + run_ydotool_type_text(¶ms.text) + }) + .await; + 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 result = run_ydotool_type_text(¶ms.text) .await .map(|output| vec![output]); @@ -1258,7 +1607,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 +1621,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 +1643,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.3-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 +2254,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 +2269,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 +2284,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 +2307,56 @@ 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_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 +2366,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 +2437,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,20 +2471,166 @@ impl ComputerUseLinux { } } - async fn resolve_accessibility_app_filter( + async fn resolve_screenshot_window( &self, - params: &GetAppStateParams, - window_context: Option<&WindowInfo>, - ) -> Option { - if let Some(explicit) = trimmed_nonempty(params.app_name_or_bundle_identifier.as_deref()) { - return Some(explicit.to_string()); + 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) + } - let target_pid = window_context.and_then(|window| window.pid).or(params.pid); - let candidates = accessibility_filter_candidates(window_context); + 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 + } - if let Some(target_pid) = target_pid { - if let Ok(apps) = list_accessible_apps(200).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:#}" + ) + })?, + ) + } else if window.backend == GNOME_SHELL_INTROSPECT_BACKEND { + crate::windowing::backends::gnome::extension_monitor_layout() + .await + .ok() + } 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 + ) { + 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, + window_context: Option<&WindowInfo>, + ) -> Option { + if let Some(explicit) = trimmed_nonempty(params.app_name_or_bundle_identifier.as_deref()) { + return Some(explicit.to_string()); + } + + let target_pid = window_context.and_then(|window| window.pid).or(params.pid); + let candidates = accessibility_filter_candidates(window_context); + + if let Some(target_pid) = target_pid { + if let Ok(apps) = list_accessible_apps(200).await { if let Some(object_ref) = select_accessibility_object_ref(&apps, target_pid, &candidates) { @@ -2131,6 +2684,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 +2823,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 +2833,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 +2841,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 +2855,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 +2875,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 +2887,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 +3535,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 +3550,184 @@ 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 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: &[crate::windowing::backends::gnome::MonitorInfo], + capture_width: u32, + capture_height: u32, +) -> Result<(i32, i32, u32, u32)> { + let mut monitors = monitors + .iter() + .filter(|monitor| monitor.width > 0 && monitor.height > 0); + let first = monitors + .next() + .ok_or_else(|| anyhow::anyhow!("GNOME returned no usable monitor geometry"))?; + let (mut min_x, mut min_y) = (i64::from(first.x), i64::from(first.y)); + let (mut max_x, mut max_y) = ( + i64::from(first.x) + i64::from(first.width), + i64::from(first.y) + i64::from(first.height), + ); + for monitor in monitors { + min_x = min_x.min(i64::from(monitor.x)); + min_y = min_y.min(i64::from(monitor.y)); + max_x = max_x.max(i64::from(monitor.x) + i64::from(monitor.width)); + max_y = max_y.max(i64::from(monitor.y) + i64::from(monitor.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 +3741,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 +3756,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 +3775,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,6 +3862,34 @@ fn action_result( } } +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 != "." @@ -3460,114 +4202,56 @@ async fn run_ydotool_sequence( } 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; @@ -3712,6 +4396,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 +4576,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 +4626,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 +4644,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 +4653,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 +4661,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 +4678,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 +4841,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, @@ -4103,6 +4961,49 @@ mod tests { 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()) } @@ -4148,6 +5049,179 @@ mod tests { 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 = [crate::windowing::backends::gnome::MonitorInfo { + index: 0, + x: 0, + y: 0, + width: 1920, + height: 1200, + primary: true, + scale: 4.0 / 3.0, + }]; + + 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 gnome_window_crop_accounts_for_negative_monitor_origins() { + let bounds = WindowBounds { + x: Some(-900), + y: Some(100), + width: 400, + height: 300, + }; + let monitors = [ + crate::windowing::backends::gnome::MonitorInfo { + index: 0, + x: -1000, + y: 0, + width: 1000, + height: 800, + primary: false, + scale: 1.0, + }, + crate::windowing::backends::gnome::MonitorInfo { + index: 1, + x: 0, + y: 0, + width: 1200, + height: 800, + primary: true, + scale: 1.0, + }, + ]; + + assert_eq!( + logical_window_crop_rect(&bounds, &monitors, 2200, 800).unwrap(), + (100, 100, 400, 300) + ); + } + + #[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()); + } + + #[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(" "))); + } + + #[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 window_crop_happens_before_screenshot_payload_resize() { let (cropped, width, height) = crop_png(&solid_png(400, 200), 50, 20, 200, 100).unwrap(); @@ -4204,60 +5278,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)); + fn relative_click_coordinates_use_capture_space_rect() { let mut params = ClickParams { x: Some(7), y: Some(9), @@ -4265,37 +5287,21 @@ mod tests { ..Default::default() }; - apply_window_relative_click_coordinates(&mut params, &focus).unwrap(); + apply_window_relative_click_coordinates(&mut params, (133, 267, 1067, 800)).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))); - let mut params = ClickParams { - x: Some(7), - y: Some(9), - relative: Some(true), - ..Default::default() - }; - - let error = apply_window_relative_click_coordinates(&mut params, &focus).unwrap_err(); - - 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 +5309,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 +5317,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))); @@ -4844,6 +5849,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 +5873,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 +5888,172 @@ 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 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"); + } + + #[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 +6088,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 +6107,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 +6118,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 +6384,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 +6404,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 +6424,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 +6445,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..e66b17d5a 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,57 @@ 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(&clients_json), + } +} + +fn parse_hyprland_clients_with_monitors( + clients_json: &str, + monitors_json: &[u8], +) -> Result> { + let monitors: Vec = serde_json::from_slice(monitors_json) + .context("failed to parse hyprctl monitors -j output")?; + let monitors = monitors + .into_iter() + .map(|monitor| (monitor.id, monitor)) + .collect::>(); + let mut clients: Vec = + serde_json::from_str(clients_json).context("failed to parse hyprctl clients -j output")?; + for client in &mut clients { + let Some(monitor) = client.monitor.and_then(|id| monitors.get(&id)) else { + continue; + }; + if let Some(at) = client.at.as_mut() { + at[0] = scale_i32(at[0] - monitor.x, monitor.scale); + at[1] = scale_i32(at[1] - monitor.y, monitor.scale); + } + if let Some(size) = client.size.as_mut() { + size[0] = scale_u32(size[0], monitor.scale); + size[1] = scale_u32(size[1], monitor.scale); + } + } + windows_from_hyprland_clients(clients) } 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 scale_i32(value: i32, scale: f64) -> i32 { + (f64::from(value) * scale).round() as i32 +} +fn scale_u32(value: u32, scale: f64) -> u32 { + (f64::from(value) * scale).round() 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 +129,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 +189,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 +285,62 @@ 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(1741), Some(97))); + assert_eq!((bounds.width, bounds.height), (1676, 2023)); + } + + #[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 +378,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 +393,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..bbcfba871 100644 --- a/computer-use-linux/src/windowing/backends/kwin.rs +++ b/computer-use-linux/src/windowing/backends/kwin.rs @@ -74,7 +74,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 +111,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 +143,6 @@ where ) .await .context("KWin loadScript failed")?; - loaded_script = true; let _: () = scripting_proxy .call("start", &()) @@ -162,8 +164,73 @@ where .context("KWin temporary script did not return data before timeout")? } .await; + cleanup.run().await; + result +} + +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, + } + } - if loaded_script { + 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 +243,8 @@ where .call("unloadScript", &(plugin_name.as_str())) .await; } - } + }) + .await; let _: Result = connection .object_server() .remove::(callback_object_path.as_str()) @@ -184,8 +252,6 @@ where if let Some(script_path) = script_path { let _ = fs::remove_file(script_path); } - - result } struct KwinWindowCallback { 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..b4ab78bce 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::{ @@ -58,6 +58,7 @@ mod tests { HYPRLAND_BACKEND, NIRI_BACKEND, I3_BACKEND, + X11_BACKEND, ] ); } 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..406e761d6 100644 --- a/computer-use-linux/src/windowing/target.rs +++ b/computer-use-linux/src/windowing/target.rs @@ -1,9 +1,10 @@ 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_TIMEOUT: Duration = Duration::from_secs(1); const FOCUS_VERIFY_DELAY: Duration = Duration::from_millis(50); pub async fn list_windows() -> Result> { @@ -58,7 +59,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 +70,45 @@ 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_TIMEOUT, || { + registry::focused_window_for_backend(&requested_window.backend) + }) + .await +} + +async fn wait_for_focused_window_with( + requested_window: &WindowInfo, + verify_timeout: Duration, + mut query: F, +) -> Option +where + F: FnMut() -> Fut, + Fut: Future>>, +{ + let deadline = Instant::now() + verify_timeout; 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 { + match timeout_at(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 +394,42 @@ 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_TIMEOUT >= Duration::from_secs(1)); + } + + #[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), || 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)); + } +} diff --git a/computer-use-linux/src/ydotool.rs b/computer-use-linux/src/ydotool.rs index 65f29de29..465f10bca 100644 --- a/computer-use-linux/src/ydotool.rs +++ b/computer-use-linux/src/ydotool.rs @@ -1,15 +1,17 @@ +use crate::command_runner; use std::{ env, - ffi::OsStr, + ffi::{CString, OsStr, OsString}, fs, io, os::unix::{ - ffi::OsStrExt, + ffi::{OsStrExt, OsStringExt}, fs::{DirBuilderExt, MetadataExt, PermissionsExt}, net::UnixDatagram, }, path::{Path, PathBuf}, process::{self, Command, Stdio}, - sync::OnceLock, + sync::{Mutex, OnceLock}, + time::{Duration, Instant}, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -20,7 +22,9 @@ pub(crate) enum CliGeneration { const MAX_UNIX_SOCKET_PATH_BYTES: usize = 107; const PROBE_DIRECTORY_ATTEMPTS: usize = 8; -const UNSUPPORTED_MESSAGE: &str = "unsupported ydotool CLI; Computer Use requires ydotool 1.0.2 or newer with raw key events, wheel movement, stdin typing, and absolute mouse movement"; +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"; struct ProbeSocket { _socket: UnixDatagram, @@ -28,6 +32,39 @@ struct ProbeSocket { path: PathBuf, } +#[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() }; @@ -110,18 +147,133 @@ impl Drop for ProbeSocket { } } -pub(crate) fn ensure_supported() -> Result { - static RESULT: OnceLock> = OnceLock::new(); - RESULT.get_or_init(probe).clone() +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 probe() -> Result { +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"); - probe_with( - Path::new("ydotool"), - runtime_dir.as_deref(), - &env::temp_dir(), - ) + let detail = probe_with(&executable, runtime_dir.as_deref(), &env::temp_dir())?; + Ok(SupportedYdotool { executable, detail }) } fn probe_with( @@ -131,10 +283,9 @@ fn probe_with( ) -> Result { let mut output_text = String::new(); for argument in ["help", "--help"] { - let output = Command::new(ydotool_path) - .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) { @@ -147,7 +298,7 @@ fn probe_with( }; } } - Err("unrecognized ydotool CLI; Computer Use requires ydotool 1.0.2 or newer".to_string()) + Err("unrecognized ydotool CLI; Computer Use requires ydotool 1.0.3 or newer".to_string()) } fn probe_raw_semantics( @@ -155,51 +306,84 @@ fn probe_raw_semantics( runtime_dir: Option<&OsStr>, temp_dir: &Path, ) -> Result<(), String> { - let socket = ProbeSocket::bind_with(runtime_dir, temp_dir)?; + 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, - &socket.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, - &socket.path, + runtime_dir, + temp_dir, &["type", "--file", "-"], Some(Path::new("/proc/self/fd")), )?; - - if raw_semantic_probes_succeeded( - wheel.status.success(), - &wheel.stderr, - type_from_stdin.status.success(), - &type_from_stdin.stderr, - ) { - Ok(()) - } else { - Err(UNSUPPORTED_MESSAGE.to_string()) - } + require_semantic_probe(&type_from_stdin) } fn run_probe_command( ydotool_path: &Path, - socket_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) + .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() - .map_err(|error| format!("failed to run ydotool capability probe: {error}")) + 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 { @@ -251,16 +435,18 @@ 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( - wheel_success: bool, - wheel_stderr: &[u8], - type_success: bool, - type_stderr: &[u8], -) -> bool { - wheel_success - && cli_error(wheel_stderr).is_none() - && type_success - && cli_error(type_stderr).is_none() +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 { @@ -300,6 +486,11 @@ 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); @@ -340,13 +531,25 @@ case "$1" in ' 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 "$3" = '--' && - test "$4" = '0' && - test "$5" = '0' + { 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" && @@ -408,31 +611,212 @@ esac .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"mousemove: unrecognized option '--wheel'\n", - true, - b"", - )); + 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", - )); + 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"")); + assert!(raw_semantic_probes_succeeded(&[ + (true, b""), + (true, b""), + (true, b""), + (true, b""), + (true, b""), + ])); } #[test] @@ -452,4 +836,17 @@ esac 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/linux-computer-use.md b/docs/linux-computer-use.md index e9340a2fe..677fb29fa 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.2 or newer when you need the fallback input path. Earlier -releases lack wheel movement or functional stdin typing required by the -backend. The Computer Use readiness report detects and rejects incompatible -CLIs 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,15 @@ sudo usermod -a -G input "$USER" Then log out and back in. +On X11, install `xdotool` for layout-correct XTEST keyboard/text input 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. + 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 +77,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/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/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 From 1a93139a54321b710f56f40cd612d8c4facfd83b Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Fri, 31 Jul 2026 22:12:09 +0300 Subject: [PATCH 041/112] chore(computer-use): sync standalone v0.4.4 metadata --- CHANGELOG.md | 4 ++-- Cargo.lock | 2 +- computer-use-linux/Cargo.toml | 2 +- computer-use-linux/src/server.rs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71ac8cb0d..33b7e1e80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added -- The embedded Computer Use backend is synchronized to standalone v0.4.3 as - `0.4.3-linux-alpha1`, including generic X11/EWMH window control, X11 +- The embedded Computer Use backend is synchronized to standalone v0.4.4 as + `0.4.4-linux-alpha1`, including generic X11/EWMH window control, X11 `xdotool` keyboard/text 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 diff --git a/Cargo.lock b/Cargo.lock index 592997e16..15ea96a59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -490,7 +490,7 @@ dependencies = [ [[package]] name = "codex-computer-use-linux" -version = "0.4.3-linux-alpha1" +version = "0.4.4-linux-alpha1" dependencies = [ "anyhow", "atspi", diff --git a/computer-use-linux/Cargo.toml b/computer-use-linux/Cargo.toml index ceca019d3..abb790240 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.4.3-linux-alpha1" +version = "0.4.4-linux-alpha1" edition = "2021" [[bin]] diff --git a/computer-use-linux/src/server.rs b/computer-use-linux/src/server.rs index bbbd68aa5..3bf869870 100644 --- a/computer-use-linux/src/server.rs +++ b/computer-use-linux/src/server.rs @@ -1657,7 +1657,7 @@ impl ComputerUseLinux { // 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.4.3-linux-alpha1", + version = "0.4.4-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 {} From cc239f8d87a2096d659666cfdf9a14ad412bcc0b Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Sat, 1 Aug 2026 02:19:13 +0300 Subject: [PATCH 042/112] fix(computer-use): complete input safety and compositor mapping --- computer-use-linux/src/remote_desktop.rs | 23 +- computer-use-linux/src/server.rs | 288 ++++++++++++++---- .../src/windowing/backends/hyprland.rs | 195 ++++++++++-- .../src/windowing/backends/kwin.rs | 56 ++++ computer-use-linux/src/windowing/mod.rs | 9 +- 5 files changed, 482 insertions(+), 89 deletions(-) diff --git a/computer-use-linux/src/remote_desktop.rs b/computer-use-linux/src/remote_desktop.rs index 66504d251..18236dcab 100644 --- a/computer-use-linux/src/remote_desktop.rs +++ b/computer-use-linux/src/remote_desktop.rs @@ -364,6 +364,8 @@ struct KscreenOutput { scale: f64, connected: bool, enabled: bool, + #[serde(default, rename = "replicationSource")] + replication_source: Option, } #[derive(serde::Deserialize)] @@ -383,7 +385,9 @@ fn parse_kscreen_monitor_layout(json: &[u8]) -> Option> { let layout = config .outputs .into_iter() - .filter(|output| output.connected && output.enabled) + .filter(|output| { + output.connected && output.enabled && output.replication_source.unwrap_or(0) == 0 + }) .map(|output| { if !output.scale.is_finite() || output.scale <= 0.0 @@ -1751,6 +1755,23 @@ mod tests { ); } + #[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( diff --git a/computer-use-linux/src/server.rs b/computer-use-linux/src/server.rs index 3bf869870..9110b41f2 100644 --- a/computer-use-linux/src/server.rs +++ b/computer-use-linux/src/server.rs @@ -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_EXTENSION_BACKEND, GNOME_SHELL_INTROSPECT_BACKEND, + GNOME_SHELL_EXTENSION_BACKEND, GNOME_SHELL_INTROSPECT_BACKEND, KWIN_BACKEND, }; use crate::ydotool; use anyhow::Result; @@ -619,7 +619,7 @@ impl ComputerUseLinux { )] async fn click(&self, Parameters(mut params): Parameters) -> Json { let received = Some(serde_json::json!(params.clone())); - let _input_guard = self.input_operation_lock.lock().await; + let 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. @@ -866,7 +866,7 @@ impl ComputerUseLinux { Err(_) => {} } } - let result = run_ydotool_sequence(&[ + let commands = vec![ absolute_mousemove_args(x, y), vec![ "click".to_string(), @@ -874,8 +874,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, @@ -971,7 +975,7 @@ impl ComputerUseLinux { )] async fn scroll(&self, Parameters(mut params): Parameters) -> Json { let received = Some(serde_json::json!(params.clone())); - let _input_guard = self.input_operation_lock.lock().await; + 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 @@ -1216,7 +1220,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, @@ -1235,7 +1243,7 @@ impl ComputerUseLinux { )] async fn drag(&self, Parameters(params): Parameters) -> Json { let received = Some(serde_json::json!(params)); - let _input_guard = self.input_operation_lock.lock().await; + let 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); @@ -1344,13 +1352,11 @@ impl ComputerUseLinux { Err(_) => {} } } - 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, ))) @@ -1371,7 +1377,7 @@ impl ComputerUseLinux { Parameters(params): Parameters, ) -> Json { let received = Some(serde_json::json!(params.clone())); - let _input_guard = self.input_operation_lock.lock().await; + let input_guard = 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) => { @@ -1442,10 +1448,14 @@ impl ComputerUseLinux { 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 result = run_xdotool_or_fallback(Path::new("xdotool"), &xdotool_args, || { - run_ydotool(&ydotool_args) + 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); @@ -1466,7 +1476,11 @@ impl ComputerUseLinux { } } let args = ydotool_key_args(key_events, !chord_modifiers.is_empty()); - let result = run_ydotool(&args).await.map(|output| vec![output]); + 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; @@ -1490,7 +1504,7 @@ impl ComputerUseLinux { Parameters(params): Parameters, ) -> Json { let received = Some(serde_json::json!(params.clone())); - let _input_guard = self.input_operation_lock.lock().await; + let input_guard = 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) => { @@ -1572,10 +1586,15 @@ impl ComputerUseLinux { } if self.should_prefer_xdotool_keyboard() { let args = xdotool_type_args(¶ms.text); - let result = run_xdotool_or_fallback(Path::new("xdotool"), &args, || { - run_ydotool_type_text(¶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); @@ -1594,9 +1613,14 @@ impl ComputerUseLinux { } return Json(output); } - let result = run_ydotool_type_text(¶ms.text) - .await - .map(|output| vec![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; @@ -2549,12 +2573,31 @@ impl ComputerUseLinux { 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 }; @@ -2582,7 +2625,7 @@ impl ComputerUseLinux { .unwrap_or(&focus.requested_window); if !matches!( window.backend.as_str(), - GNOME_SHELL_EXTENSION_BACKEND | GNOME_SHELL_INTROSPECT_BACKEND + GNOME_SHELL_EXTENSION_BACKEND | GNOME_SHELL_INTROSPECT_BACKEND | KWIN_BACKEND ) { let full_capture_rect = window .bounds @@ -3640,26 +3683,26 @@ fn map_coordinate_between_rects( fn logical_window_crop_rect( bounds: &crate::windowing::WindowBounds, - monitors: &[crate::windowing::backends::gnome::MonitorInfo], + monitors: &[(i32, i32, i32, i32)], capture_width: u32, capture_height: u32, ) -> Result<(i32, i32, u32, u32)> { let mut monitors = monitors .iter() - .filter(|monitor| monitor.width > 0 && monitor.height > 0); + .filter(|(_, _, width, height)| *width > 0 && *height > 0); let first = monitors .next() - .ok_or_else(|| anyhow::anyhow!("GNOME returned no usable monitor geometry"))?; - let (mut min_x, mut min_y) = (i64::from(first.x), i64::from(first.y)); + .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.x) + i64::from(first.width), - i64::from(first.y) + i64::from(first.height), + i64::from(first.0) + i64::from(first.2), + i64::from(first.1) + i64::from(first.3), ); - for monitor in monitors { - min_x = min_x.min(i64::from(monitor.x)); - min_y = min_y.min(i64::from(monitor.y)); - max_x = max_x.max(i64::from(monitor.x) + i64::from(monitor.width)); - max_y = max_y.max(i64::from(monitor.y) + i64::from(monitor.height)); + 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; @@ -4188,6 +4231,25 @@ fn wheel_mousemove_args(dx: i32, dy: i32) -> Vec { ] } +async fn run_cancellation_safe_input( + input_guard: tokio::sync::OwnedMutexGuard<()>, + operation: F, +) -> ( + Option>, + std::result::Result, +) +where + 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 { (input_guard, operation.await) }).await { + Ok((input_guard, result)) => (Some(input_guard), result), + Err(error) => (None, Err(format!("stateful input task failed: {error}"))), + } +} + async fn run_ydotool_sequence( commands: &[Vec], ) -> std::result::Result, String> { @@ -4201,6 +4263,46 @@ 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 { let support = ydotool::ensure_supported_async().await?; let mut command = TokioCommand::new(&support.executable); @@ -5126,15 +5228,7 @@ mod tests { width: 1357, height: 1144, }; - let monitors = [crate::windowing::backends::gnome::MonitorInfo { - index: 0, - x: 0, - y: 0, - width: 1920, - height: 1200, - primary: true, - scale: 4.0 / 3.0, - }]; + let monitors = [(0, 0, 1920, 1200)]; assert_eq!( logical_window_crop_rect(&bounds, &monitors, 2560, 1600).unwrap(), @@ -5159,6 +5253,48 @@ mod tests { 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))); + } + #[test] fn gnome_window_crop_accounts_for_negative_monitor_origins() { let bounds = WindowBounds { @@ -5167,26 +5303,7 @@ mod tests { width: 400, height: 300, }; - let monitors = [ - crate::windowing::backends::gnome::MonitorInfo { - index: 0, - x: -1000, - y: 0, - width: 1000, - height: 800, - primary: false, - scale: 1.0, - }, - crate::windowing::backends::gnome::MonitorInfo { - index: 1, - x: 0, - y: 0, - width: 1200, - height: 800, - primary: true, - scale: 1.0, - }, - ]; + let monitors = [(-1000, 0, 1000, 800), (0, 0, 1200, 800)]; assert_eq!( logical_window_crop_rect(&bounds, &monitors, 2200, 800).unwrap(), @@ -6043,6 +6160,47 @@ mod tests { 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"); + } + #[test] fn xdotool_key_spec_rejects_everything_key_chord_rejects() { for key in ["NotAKey", "", "ctrl+", "ctrl+NotAKey", "f13", "hyper+a"] { diff --git a/computer-use-linux/src/windowing/backends/hyprland.rs b/computer-use-linux/src/windowing/backends/hyprland.rs index e66b17d5a..5baa68640 100644 --- a/computer-use-linux/src/windowing/backends/hyprland.rs +++ b/computer-use-linux/src/windowing/backends/hyprland.rs @@ -72,7 +72,7 @@ pub async fn list_windows() -> Result> { 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(&clients_json), + None => parse_hyprland_clients_without_bounds(&clients_json), } } @@ -80,42 +80,131 @@ fn parse_hyprland_clients_with_monitors( clients_json: &str, monitors_json: &[u8], ) -> Result> { - let monitors: Vec = serde_json::from_slice(monitors_json) - .context("failed to parse hyprctl monitors -j output")?; - let monitors = monitors - .into_iter() - .map(|monitor| (monitor.id, monitor)) - .collect::>(); 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 { - let Some(monitor) = client.monitor.and_then(|id| monitors.get(&id)) else { + if !client + .monitor + .is_some_and(|monitor_id| monitor_ids.contains(&monitor_id)) + { + client.at = None; + client.size = None; continue; - }; - if let Some(at) = client.at.as_mut() { - at[0] = scale_i32(at[0] - monitor.x, monitor.scale); - at[1] = scale_i32(at[1] - monitor.y, monitor.scale); - } - if let Some(size) = client.size.as_mut() { - size[0] = scale_u32(size[0], monitor.scale); - size[1] = scale_u32(size[1], monitor.scale); } + 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 scale_i32(value: i32, scale: f64) -> i32 { - (f64::from(value) * scale).round() as i32 +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 scale_u32(value: u32, scale: f64) -> u32 { - (f64::from(value) * scale).round() as u32 +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> { @@ -306,8 +395,70 @@ mod tests { 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(1741), Some(97))); - assert_eq!((bounds.width, bounds.height), (1676, 2023)); + 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] diff --git a/computer-use-linux/src/windowing/backends/kwin.rs b/computer-use-linux/src/windowing/backends/kwin.rs index bbcfba871..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") @@ -357,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) {{ @@ -429,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 }})); }})(); @@ -667,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/mod.rs b/computer-use-linux/src/windowing/mod.rs index b4ab78bce..1acce11db 100644 --- a/computer-use-linux/src/windowing/mod.rs +++ b/computer-use-linux/src/windowing/mod.rs @@ -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::{ @@ -749,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}", @@ -792,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] @@ -820,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] From 9825ca69e486ca9e64922190b6ec8ecbf623dbf2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:14:21 +0300 Subject: [PATCH 043/112] fix(nix): refresh upstream Nix pins for 26.727.51351 (#1195) Refreshed Codex.dmg SRI hash to sha256-RewAag8/D6AEtv1NbVUpl5oFNh+ZXKLFHeOjsE3u4SM= and synced codexVersion / electronVersion / native-module pins to the current upstream DMG. Verified all ChatGPT Desktop Nix package outputs against the refreshed DMG. Source-Main-SHA: d3c7baa851af452547ff5da369a1e0101ceedca3 Upstream-DMG-SHA256: 45ec006a0f3f0fa004b6fd4d6d5529979a05361f995ca2c51de3a3b04deee123 Co-authored-by: codex-dmg-hash-bot --- flake.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 9e5fe705e..a636bdddd 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-+5OiOcgRx2Oc9FqQ/zbCYvoCkGQBQM0S2j/cYLYiVa4="; + hash = "sha256-RewAag8/D6AEtv1NbVUpl5oFNh+ZXKLFHeOjsE3u4SM="; }; - codexVersion = "26.727.40816"; + codexVersion = "26.727.51351"; electronVersion = "42.3.0"; electronPlatform = { From c566ca1df46ab187a6f4e04a3ab8f8423cae3af9 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Sat, 1 Aug 2026 10:31:31 +0300 Subject: [PATCH 044/112] chore(computer-use): sync standalone v0.4.5 metadata --- CHANGELOG.md | 4 ++-- Cargo.lock | 2 +- computer-use-linux/Cargo.toml | 2 +- computer-use-linux/src/server.rs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33b7e1e80..6bf4ef436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added -- The embedded Computer Use backend is synchronized to standalone v0.4.4 as - `0.4.4-linux-alpha1`, including generic X11/EWMH window control, X11 +- The embedded Computer Use backend is synchronized to standalone v0.4.5 as + `0.4.5-linux-alpha1`, including generic X11/EWMH window control, X11 `xdotool` keyboard/text 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 diff --git a/Cargo.lock b/Cargo.lock index 15ea96a59..8130ee1db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -490,7 +490,7 @@ dependencies = [ [[package]] name = "codex-computer-use-linux" -version = "0.4.4-linux-alpha1" +version = "0.4.5-linux-alpha1" dependencies = [ "anyhow", "atspi", diff --git a/computer-use-linux/Cargo.toml b/computer-use-linux/Cargo.toml index abb790240..481474632 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.4.4-linux-alpha1" +version = "0.4.5-linux-alpha1" edition = "2021" [[bin]] diff --git a/computer-use-linux/src/server.rs b/computer-use-linux/src/server.rs index 9110b41f2..7dd6ef8fb 100644 --- a/computer-use-linux/src/server.rs +++ b/computer-use-linux/src/server.rs @@ -1681,7 +1681,7 @@ impl ComputerUseLinux { // 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.4.4-linux-alpha1", + version = "0.4.5-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 {} From 89b0651fee3184bc22bc65bdbf502c707957a749 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sat, 1 Aug 2026 12:35:23 +0300 Subject: [PATCH 045/112] Fix Chrome plugin install cache collision --- launcher/start.sh.template | 19 ++++++++++++------- tests/scripts_smoke.sh | 25 ++++++++++++++++++++----- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/launcher/start.sh.template b/launcher/start.sh.template index 501cb0062..f904c50ac 100644 --- a/launcher/start.sh.template +++ b/launcher/start.sh.template @@ -1261,7 +1261,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" @@ -1270,8 +1275,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 @@ -1279,8 +1284,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 @@ -1323,8 +1328,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." diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index ae8720f88..7a48ff0c5 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -6467,6 +6467,7 @@ 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', @@ -6541,7 +6542,7 @@ 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" +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; } @@ -6594,8 +6595,10 @@ 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" @@ -6605,8 +6608,8 @@ grep -qx trusted-module "$cache_plugin/scripts/node_modules/classic-level.mjs" 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 @@ -6619,6 +6622,18 @@ if find "$cache_plugin" ! -type l -perm /022 -print -quit | grep -q .; then 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. +official_cache="$CODEX_HOME/plugins/cache/openai-bundled/chrome" +mkdir -p "$official_cache" +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 From 7fb9c811cb80106f043dec7e56536e88e2210457 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sat, 1 Aug 2026 13:03:41 +0300 Subject: [PATCH 046/112] Update Chrome extension metadata handling --- launcher/start.sh.template | 21 +++++++-------------- tests/scripts_smoke.sh | 11 ++++++----- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/launcher/start.sh.template b/launcher/start.sh.template index f904c50ac..6cfef9953 100644 --- a/launcher/start.sh.template +++ b/launcher/start.sh.template @@ -1143,23 +1143,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: @@ -1309,7 +1302,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 diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index 7a48ff0c5..e154a0276 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -6345,7 +6345,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" @@ -8449,8 +8450,8 @@ 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; @@ -8738,8 +8739,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" From 65913445a374fbf777671d1ded1710aec3c70ef3 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sat, 1 Aug 2026 14:20:38 +0300 Subject: [PATCH 047/112] Harden Chrome plugin cache before host registration --- scripts/patches/impl/chrome-plugin.js | 30 ++++++++++++ scripts/patches/impl/chrome-plugin.test.js | 49 ++++++++++++++++++++ scripts/patches/test-fixtures/current-dmg.js | 2 + 3 files changed, 81 insertions(+) diff --git a/scripts/patches/impl/chrome-plugin.js b/scripts/patches/impl/chrome-plugin.js index d63c27c85..c59abb062 100644 --- a/scripts/patches/impl/chrome-plugin.js +++ b/scripts/patches/impl/chrome-plugin.js @@ -31,13 +31,18 @@ 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_CACHE_TRUST_MARKER = + "/*codexLinuxChromePluginCacheTrust*/"; 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_CACHE_TRUST_MARKER, ]; const LINUX_CHROME_PLUGIN_APP_SERVER_SOURCE_PATH_HELPER = "function codexLinuxChromePluginAppServerSourcePath(e){return e.codexCliPath}"; +const LINUX_CHROME_PLUGIN_CACHE_TRUST_HELPER = + "async function codexLinuxTrustChromePluginCache(e,t){if(process.platform!==`linux`)return;let n=require(`node:path`),r=require(`node:fs/promises`),i=process.geteuid?.();if(!Number.isInteger(i))throw Error(`Linux Chrome plugin cache owner is unavailable`);let a=await r.realpath(e),o=await r.realpath(t),s=n.join(o,`plugins`,`cache`),c=n.relative(s,a);if(c===``||c===`..`||c.startsWith(`..${n.sep}`)||n.isAbsolute(c))throw Error(`Linux Chrome plugin cache path is outside CODEX_HOME`);let l=async e=>{let t=await r.lstat(e);if(t.isSymbolicLink()||!t.isDirectory()&&!t.isFile()||t.uid!==i)throw Error(`Linux Chrome plugin cache is not trusted`);await r.chmod(e,t.mode&~18);if(t.isDirectory())for(let t of await r.readdir(e))await l(n.join(e,t))};for(let e=a;;){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);if(e===o)break;let s=n.dirname(e);if(s===e)throw Error(`Linux Chrome plugin cache path is outside CODEX_HOME`);e=s}await l(a)}"; 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 = @@ -81,6 +86,7 @@ function hasCompleteModernChromeNativeHostRuntimePatch(source) { 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_CACHE_TRUST_HELPER) === 1 && LINUX_CHROME_NATIVE_HOST_RUNTIME_APP_SERVER_MARKERS.every((marker) => markerCount(source, marker) === 1 ) && @@ -101,6 +107,12 @@ function hasCompleteCurrentChromeAppServerRuntimePatch(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`\/\*codexLinuxChromePluginCacheTrust\*\/async function ${IDENTIFIER_PATTERN}\((?${IDENTIFIER_PATTERN})\)\{await codexLinuxTrustChromePluginCache\(\k\.pluginRoot,\k\.codexHome\);let ${IDENTIFIER_PATTERN}=\[\.\.\.new Set\(\[\.\.\.\k\.extensionIds,\.\.\.${IDENTIFIER_PATTERN}\(\k\.nativeHostName\)\]\)\],`, + ), ); } @@ -364,11 +376,29 @@ function applyCurrentChromeAppServerRuntimePatches(currentSource, helper) { return null; } + patchedSource = applyLinuxChromePluginCacheTrustPatch(patchedSource); + if (patchedSource == null) { + return null; + } + return hasCompleteCurrentChromeAppServerRuntimePatch(patchedSource) ? patchedSource : null; } +function applyLinuxChromePluginCacheTrustPatch(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_CACHE_TRUST_HELPER}${LINUX_CHROME_PLUGIN_CACHE_TRUST_MARKER}async function ${functionName}(${configVar}){await codexLinuxTrustChromePluginCache(${configVar}.pluginRoot,${configVar}.codexHome);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) { diff --git a/scripts/patches/impl/chrome-plugin.test.js b/scripts/patches/impl/chrome-plugin.test.js index 7c1c09607..39f779d24 100644 --- a/scripts/patches/impl/chrome-plugin.test.js +++ b/scripts/patches/impl/chrome-plugin.test.js @@ -77,6 +77,7 @@ test("patches the complete current Chrome runtime asset set transactionally", as /codexLinuxChromeNativeHostRuntimeEnv\(`CODEX_CLI_PATH`\)/, ); assert.match(srcPatched, /codexLinuxChromePluginAppServerSourcePath/); + assert.match(srcPatched, /codexLinuxTrustChromePluginCache/); const files = new Set([ "/home/josh/.local/bin/codex", @@ -125,6 +126,54 @@ test("patches the complete current Chrome runtime asset set transactionally", as } }); +test("hardens the installed Chrome plugin cache before native-host registration", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-chrome-cache-trust-")); + try { + const codexHome = path.join(root, "codex-home"); + const cacheRoot = path.join(codexHome, "plugins", "cache", "openai-bundled", "chrome"); + const versionRoot = path.join(cacheRoot, "26.test"); + const nestedDir = path.join(versionRoot, "extension-host", "linux", "x64"); + const hostPath = path.join(nestedDir, "extension-host"); + fs.mkdirSync(nestedDir, { recursive: true }); + fs.writeFileSync(hostPath, "host\n"); + fs.symlinkSync(versionRoot, path.join(cacheRoot, "latest")); + for (const target of [ + codexHome, + path.join(codexHome, "plugins"), + path.join(codexHome, "plugins", "cache"), + path.join(codexHome, "plugins", "cache", "openai-bundled"), + cacheRoot, + versionRoot, + path.join(versionRoot, "extension-host"), + path.join(versionRoot, "extension-host", "linux"), + nestedDir, + ]) { + fs.chmodSync(target, 0o775); + } + fs.chmodSync(hostPath, 0o775); + + const patched = applyLinuxChromeNativeHostRuntimePatch( + currentChromePluginAppServerSourceBundleFixture(), + ); + await vm.runInNewContext( + `${patched};cq({codexHome:${JSON.stringify(codexHome)},extensionIds:[],nativeHostName:"com.openai.codexextension",pluginRoot:${JSON.stringify(path.join(cacheRoot, "latest"))}});`, + { + process: { + geteuid: () => process.geteuid(), + platform: "linux", + }, + require, + }, + ); + + for (const target of [codexHome, cacheRoot, versionRoot, nestedDir, hostPath]) { + assert.equal(fs.statSync(target).mode & 0o022, 0, target); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + test("rejects mixed or partial current Chrome runtime asset sets without writes", () => { const mixed = createCurrentChromeNativeHostRuntimeAssetsFixture(); try { diff --git a/scripts/patches/test-fixtures/current-dmg.js b/scripts/patches/test-fixtures/current-dmg.js index 994e0e228..8531efed5 100644 --- a/scripts/patches/test-fixtures/current-dmg.js +++ b/scripts/patches/test-fixtures/current-dmg.js @@ -21,6 +21,8 @@ function currentChromePluginAppServerSourceBundleFixture() { "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{extensionIds:t,target:n,extensionHostPath:r}}", + "function nb(){return[]}function Aq(){return{platform:`linux`,architecture:`x64`,filename:`extension-host`}}async function kq(e){return e.pluginRoot}", "function yq(e){return null}function GN(e){return null}function WN(e){return null}function KN(e){return []}", ].join(""); } From 2056f2a43f70ca427c7da572f8853a4bf7a20bc6 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sat, 1 Aug 2026 14:32:15 +0300 Subject: [PATCH 048/112] Align Chrome native host with app-server registry --- launcher/start.sh.template | 41 ++++++++++++++++++++++++++++++ tests/scripts_smoke.sh | 52 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/launcher/start.sh.template b/launcher/start.sh.template index 6cfef9953..714a60d61 100644 --- a/launcher/start.sh.template +++ b/launcher/start.sh.template @@ -1110,6 +1110,25 @@ write_chrome_native_host_launcher() { ' *) exit 1 ;;' \ 'esac' \ 'host_path="$cache_root/latest/extension-host/linux/$extension_arch/extension-host"' \ + 'codex_home="$(cd -- "$cache_root/../../../.." && pwd -P)"' \ + 'installed_cache_root="$codex_home/plugins/cache/openai-bundled/chrome"' \ + 'installed_plugin="$(readlink -f -- "$installed_cache_root/latest" 2>/dev/null || true)"' \ + 'case "$installed_plugin" in' \ + ' "$installed_cache_root"/*)' \ + ' installed_host="$installed_plugin/extension-host/linux/$extension_arch/extension-host"' \ + ' installed_trusted=1' \ + ' for trusted_path in "$codex_home" "$codex_home/plugins" "$codex_home/plugins/cache" "$codex_home/plugins/cache/openai-bundled" "$installed_cache_root" "$installed_plugin"; do' \ + ' [ -d "$trusted_path" ] && [ ! -L "$trusted_path" ] || { installed_trusted=0; break; }' \ + ' if find "$trusted_path" -maxdepth 0 -perm /022 -print -quit 2>/dev/null | grep -q .; then' \ + ' installed_trusted=0' \ + ' break' \ + ' fi' \ + ' done' \ + ' if [ "$installed_trusted" -eq 1 ] && [ -x "$installed_host" ] && ! find "$installed_plugin" -xdev \( -type l -o -perm /022 \) -print -quit 2>/dev/null | grep -q .; then' \ + ' host_path="$installed_host"' \ + ' fi' \ + ' ;;' \ + 'esac' \ 'exec "$host_path" "$@"' > "$tmp_path"; then rm -f -- "$tmp_path" return 1 @@ -1223,6 +1242,8 @@ sync_chrome_bundled_plugin_cache() { local marketplace_plugins_dir local marketplace_plugin_link local host_path + local official_cache_root + local official_cache_plugin local needs_copy=1 local cache_was_untrusted=0 @@ -1352,6 +1373,26 @@ sync_chrome_bundled_plugin_cache() { fi replace_symlink "$version" "$cache_root/latest" + # app-server records the extension host from its official install cache in + # chrome-native-hosts-v2.json. Keep that existing cache trusted so the + # native-host launcher can execute the same inode as the recorded entry. + official_cache_root="$codex_home/plugins/cache/openai-bundled/chrome" + official_cache_plugin="$(readlink -f -- "$official_cache_root/latest" 2>/dev/null || true)" + case "$official_cache_plugin" in + "$official_cache_root"/*) + if ! make_path_owner_trusted \ + "$codex_home" \ + "$codex_home/plugins" \ + "$codex_home/plugins/cache" \ + "$codex_home/plugins/cache/openai-bundled" \ + "$official_cache_root" \ + "$official_cache_plugin" || \ + ! make_tree_owner_trusted "$official_cache_plugin"; then + echo "Installed Chrome plugin cache could not be hardened; native host will use the Linux fallback runtime." + fi + ;; + esac + marketplace_root="$codex_home/.tmp/bundled-marketplaces/openai-bundled" marketplace_plugins_dir="$marketplace_root/.agents/plugins" marketplace_plugin_link="$marketplace_root/plugins/chrome" diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index e154a0276..739a724ce 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -6474,6 +6474,7 @@ for (const required of [ 'cache_was_untrusted=1', 'make_tree_owner_trusted "$tmp_plugin"', 'make_tree_owner_trusted "$cache_plugin"', + 'make_tree_owner_trusted "$official_cache_plugin"', 'write_chrome_native_host_manifests "$host_path" "$cache_root/latest"', ]) { if (!chromeBody.includes(required)) { @@ -6603,6 +6604,25 @@ chmod 775 "$CODEX_HOME" "$CODEX_HOME/plugins" \ 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" +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 "$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" @@ -6621,6 +6641,20 @@ 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 +for trusted_path in \ + "$CODEX_HOME/plugins/cache" \ + "$CODEX_HOME/plugins/cache/openai-bundled" \ + "$official_cache" \ + "$official_plugin"; do + if find "$trusted_path" -maxdepth 0 ! -type l -perm /022 -print -quit | grep -q .; then + echo "Installed Chrome cache remained group/world writable: $trusted_path" >&2 + exit 1 + fi +done +if find "$official_plugin" ! -type l -perm /022 -print -quit | grep -q .; then + echo "Installed Chrome plugin tree remained group/world writable" >&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" @@ -6629,8 +6663,6 @@ 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. -official_cache="$CODEX_HOME/plugins/cache/openai-bundled/chrome" -mkdir -p "$official_cache" printf '%s\n' stale > "$official_cache/stale" rm -rf "$official_cache" test -f "$marketplace_plugin/.codex-plugin/plugin.json" @@ -6851,6 +6883,22 @@ 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" + +# Once app-server has installed and registered its cache, the wrapper must +# execute that exact host instead of the Linux fallback copy. The v2 manifest +# identifies the executable by inode. +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="$(STUB_UNAME_MACHINE=x86_64 PATH="$stub_bin:$PATH" "$native_host_path")" +test "$official_output" = OFFICIAL ''' ) PY From cef67b8c06fb411af3c685cb76022536dc88b3f8 Mon Sep 17 00:00:00 2001 From: Muhammad Waleed <114993336+walid-baharwal@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:51:48 +0500 Subject: [PATCH 049/112] fix: restore AppImage window after remount (#1196) --- launcher/start.sh.template | 80 +++++++++++++--- packaging/appimage/codex-desktop.desktop | 2 +- tests/launcher_warm_start_recovery.sh | 39 ++++++-- tests/launcher_window_reopen_behavior.sh | 111 +++++++++++++++++++---- tests/scripts_smoke.sh | 92 +++++++++++++++++-- 5 files changed, 276 insertions(+), 48 deletions(-) diff --git a/launcher/start.sh.template b/launcher/start.sh.template index 501cb0062..4bd39570d 100644 --- a/launcher/start.sh.template +++ b/launcher/start.sh.template @@ -319,6 +319,7 @@ Launches the $CODEX_LINUX_APP_DISPLAY_NAME app. 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 + --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 @@ -2275,7 +2276,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 @@ -2320,15 +2321,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 ;; @@ -2346,7 +2384,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 @@ -2431,7 +2469,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() { @@ -2450,9 +2488,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 @@ -2478,13 +2519,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' @@ -2496,9 +2538,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) @@ -2532,11 +2575,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"), @@ -2641,6 +2692,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' @@ -3064,7 +3116,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 } @@ -4337,7 +4389,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/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/tests/launcher_warm_start_recovery.sh b/tests/launcher_warm_start_recovery.sh index 65af2b787..a7024dd7f 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 @@ -200,8 +215,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 @@ -213,7 +234,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 @@ -256,7 +277,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() { @@ -278,4 +299,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 index 7757bfbb8..d28898f1a 100755 --- a/tests/launcher_window_reopen_behavior.sh +++ b/tests/launcher_window_reopen_behavior.sh @@ -53,6 +53,9 @@ 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" @@ -60,17 +63,30 @@ 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 @@ -81,9 +97,11 @@ count_test_main_processes() { pid="${cmdline#/proc/}" pid="${pid%/cmdline}" IFS= read -r -d '' arg0 < "$cmdline" 2>/dev/null || true - if [ "${arg0:-}" = "$APP_DIR/electron" ]; then - count=$((count + 1)) - fi + case "${arg0:-}" in + "$APP_DIR/electron"|"$REMOUNT_APP_DIR/electron"|"$FOREIGN_APP_DIR/electron") + count=$((count + 1)) + ;; + esac arg0="" done printf '%s\n' "$count" @@ -180,21 +198,27 @@ cleanup() { 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 "$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 - stop_owned_process_bounded "$webview_pid" argv "$APP_DIR/.codex-linux/webview-server.py" || 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 - if [ "${arg0:-}" = "$APP_DIR/electron" ]; then - IFS= read -r -d '' revalidated_arg0 < "$cmdline" 2>/dev/null || true - if [ "${revalidated_arg0:-}" = "$APP_DIR/electron" ]; then - stop_owned_process_bounded "$pid" arg0 "$APP_DIR/electron" || cleanup_failed=1 - fi - fi + 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 @@ -357,6 +381,12 @@ 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" @@ -365,12 +395,21 @@ COMMON_ENV=( "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[@]}" "$APP_DIR/start.sh" > "$FIRST_LOG" 2>&1 & +"${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 @@ -401,8 +440,36 @@ 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[@]}" "$APP_DIR/start.sh" --new-chat > "$SECOND_LOG" 2>&1 & + "${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 @@ -430,7 +497,8 @@ if [ "${CODEX_TEST_FORCE_RESIDENT_REPLACEMENT:-0}" = "1" ]; then fi set +e -timeout 8s "${COMMON_ENV[@]}" "$APP_DIR/start.sh" --new-chat > "$SECOND_LOG" 2>&1 +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 @@ -452,13 +520,13 @@ kill -0 "$FIRST_ELECTRON_PID" 2>/dev/null \ || fail "runtime marker no longer identifies the healthy resident" [ "$HANDOFF_STATUS" = "acknowledged" ] \ || fail "controlled resident did not acknowledge the reopen handoff" -python3 - "$HANDOFF_RESULT" <<'PY' \ - || fail "reopen handoff did not preserve the --new-chat argument" +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"] == ["--new-chat"] + 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" @@ -471,5 +539,10 @@ if grep -Eqi 'notify-send|zenity|could not safely|failed to' "$SECOND_LOG" "$APP fail "reopen handoff emitted a user-visible error" fi -record_result "preserved" -printf '%s\n' "launcher window-reopen behavior test passed" +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 ae8720f88..6c6cff3b4 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -1538,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" @@ -1594,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 @@ -1603,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" @@ -5466,6 +5468,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" @@ -5518,6 +5521,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] @@ -5570,6 +5577,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: @@ -5582,8 +5591,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): @@ -5600,7 +5611,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: @@ -5611,7 +5622,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") @@ -5629,6 +5640,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: @@ -5797,8 +5822,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") @@ -9478,6 +9503,11 @@ function makeWindow(id) { }, focus() { state.windowCalls.push(`${id}:focus`); + if (state.focusError) { + const error = state.focusError; + state.focusError = null; + throw error; + } }, }; } @@ -9518,6 +9548,7 @@ function resetCalls() { state.ensureHostWindowCalls = []; state.createFreshLocalWindowCalls = []; state.focusCalls = []; + state.focusError = null; state.windowCalls = []; state.errors = []; state.ieCalls = 0; @@ -9702,6 +9733,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"]); @@ -9750,6 +9789,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"]); @@ -9806,6 +9860,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; @@ -10801,6 +10861,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 \ @@ -10811,6 +10872,7 @@ test_launcher_warm_start_recovery() { 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" @@ -10844,6 +10906,22 @@ test_launcher_window_reopen_behavior() { 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" \ From bef6f1b41f4b15b73992e896537ea55297dd2f76 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sat, 1 Aug 2026 15:26:42 +0300 Subject: [PATCH 050/112] Harden Chrome plugin runtime registration --- launcher/start.sh.template | 41 ----- scripts/patches/impl/chrome-plugin.js | 20 +-- scripts/patches/impl/chrome-plugin.test.js | 176 ++++++++++++++++----- tests/scripts_smoke.sh | 22 +-- 4 files changed, 154 insertions(+), 105 deletions(-) diff --git a/launcher/start.sh.template b/launcher/start.sh.template index 714a60d61..6cfef9953 100644 --- a/launcher/start.sh.template +++ b/launcher/start.sh.template @@ -1110,25 +1110,6 @@ write_chrome_native_host_launcher() { ' *) exit 1 ;;' \ 'esac' \ 'host_path="$cache_root/latest/extension-host/linux/$extension_arch/extension-host"' \ - 'codex_home="$(cd -- "$cache_root/../../../.." && pwd -P)"' \ - 'installed_cache_root="$codex_home/plugins/cache/openai-bundled/chrome"' \ - 'installed_plugin="$(readlink -f -- "$installed_cache_root/latest" 2>/dev/null || true)"' \ - 'case "$installed_plugin" in' \ - ' "$installed_cache_root"/*)' \ - ' installed_host="$installed_plugin/extension-host/linux/$extension_arch/extension-host"' \ - ' installed_trusted=1' \ - ' for trusted_path in "$codex_home" "$codex_home/plugins" "$codex_home/plugins/cache" "$codex_home/plugins/cache/openai-bundled" "$installed_cache_root" "$installed_plugin"; do' \ - ' [ -d "$trusted_path" ] && [ ! -L "$trusted_path" ] || { installed_trusted=0; break; }' \ - ' if find "$trusted_path" -maxdepth 0 -perm /022 -print -quit 2>/dev/null | grep -q .; then' \ - ' installed_trusted=0' \ - ' break' \ - ' fi' \ - ' done' \ - ' if [ "$installed_trusted" -eq 1 ] && [ -x "$installed_host" ] && ! find "$installed_plugin" -xdev \( -type l -o -perm /022 \) -print -quit 2>/dev/null | grep -q .; then' \ - ' host_path="$installed_host"' \ - ' fi' \ - ' ;;' \ - 'esac' \ 'exec "$host_path" "$@"' > "$tmp_path"; then rm -f -- "$tmp_path" return 1 @@ -1242,8 +1223,6 @@ sync_chrome_bundled_plugin_cache() { local marketplace_plugins_dir local marketplace_plugin_link local host_path - local official_cache_root - local official_cache_plugin local needs_copy=1 local cache_was_untrusted=0 @@ -1373,26 +1352,6 @@ sync_chrome_bundled_plugin_cache() { fi replace_symlink "$version" "$cache_root/latest" - # app-server records the extension host from its official install cache in - # chrome-native-hosts-v2.json. Keep that existing cache trusted so the - # native-host launcher can execute the same inode as the recorded entry. - official_cache_root="$codex_home/plugins/cache/openai-bundled/chrome" - official_cache_plugin="$(readlink -f -- "$official_cache_root/latest" 2>/dev/null || true)" - case "$official_cache_plugin" in - "$official_cache_root"/*) - if ! make_path_owner_trusted \ - "$codex_home" \ - "$codex_home/plugins" \ - "$codex_home/plugins/cache" \ - "$codex_home/plugins/cache/openai-bundled" \ - "$official_cache_root" \ - "$official_cache_plugin" || \ - ! make_tree_owner_trusted "$official_cache_plugin"; then - echo "Installed Chrome plugin cache could not be hardened; native host will use the Linux fallback runtime." - fi - ;; - esac - marketplace_root="$codex_home/.tmp/bundled-marketplaces/openai-bundled" marketplace_plugins_dir="$marketplace_root/.agents/plugins" marketplace_plugin_link="$marketplace_root/plugins/chrome" diff --git a/scripts/patches/impl/chrome-plugin.js b/scripts/patches/impl/chrome-plugin.js index c59abb062..c35e439c6 100644 --- a/scripts/patches/impl/chrome-plugin.js +++ b/scripts/patches/impl/chrome-plugin.js @@ -31,18 +31,18 @@ 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_CACHE_TRUST_MARKER = - "/*codexLinuxChromePluginCacheTrust*/"; +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_CACHE_TRUST_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_CACHE_TRUST_HELPER = - "async function codexLinuxTrustChromePluginCache(e,t){if(process.platform!==`linux`)return;let n=require(`node:path`),r=require(`node:fs/promises`),i=process.geteuid?.();if(!Number.isInteger(i))throw Error(`Linux Chrome plugin cache owner is unavailable`);let a=await r.realpath(e),o=await r.realpath(t),s=n.join(o,`plugins`,`cache`),c=n.relative(s,a);if(c===``||c===`..`||c.startsWith(`..${n.sep}`)||n.isAbsolute(c))throw Error(`Linux Chrome plugin cache path is outside CODEX_HOME`);let l=async e=>{let t=await r.lstat(e);if(t.isSymbolicLink()||!t.isDirectory()&&!t.isFile()||t.uid!==i)throw Error(`Linux Chrome plugin cache is not trusted`);await r.chmod(e,t.mode&~18);if(t.isDirectory())for(let t of await r.readdir(e))await l(n.join(e,t))};for(let e=a;;){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);if(e===o)break;let s=n.dirname(e);if(s===e)throw Error(`Linux Chrome plugin cache path is outside CODEX_HOME`);e=s}await l(a)}"; +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=await r.realpath(e.codexHome),o=await r.realpath(e.pluginRoot),s=n.join(a,`plugins`,`cache`),c=n.relative(s,o);if(c===``||c===`..`||c.startsWith(`..${n.sep}`)||n.isAbsolute(c))throw Error(`Linux Chrome plugin cache path is outside CODEX_HOME`);let l=c.split(n.sep);if(l.length!==3||l[0]!==`openai-bundled`||l[1]!==`chrome`)return e;let u=n.join(a,`plugins`,`linux-runtime-cache`,l[0],l[1]),d=n.join(u,`latest`),f=await r.realpath(d),p=n.relative(u,f);if(p!==l[2]||n.isAbsolute(p))throw Error(`Linux Chrome plugin runtime version does not match installed plugin`);let m=await r.lstat(d);if(!m.isSymbolicLink()||m.uid!==i)throw Error(`Linux Chrome plugin runtime link is not trusted`);let h=[a,n.join(a,`plugins`),n.join(a,`plugins`,`linux-runtime-cache`),n.join(a,`plugins`,`linux-runtime-cache`,l[0]),u,f];for(let e of h){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 g=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 g(n.join(e,t))};await g(f);let _=JSON.parse(await r.readFile(n.join(f,`scripts`,`extension-ids.json`),`utf8`)).extensionIds;if(!Array.isArray(_)||_.length===0||_.some(e=>typeof e!==`string`||!/^[a-p]{32}$/.test(e)))throw Error(`Linux Chrome plugin runtime extension IDs are invalid`);return{...e,pluginRoot:d,extensionIds:[...new Set(_)]}}"; 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 = @@ -86,7 +86,7 @@ function hasCompleteModernChromeNativeHostRuntimePatch(source) { 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_CACHE_TRUST_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 ) && @@ -111,7 +111,7 @@ function hasCompleteCurrentChromeAppServerRuntimePatch(source) { matchesExactlyOnce( source, new RegExp( - String.raw`\/\*codexLinuxChromePluginCacheTrust\*\/async function ${IDENTIFIER_PATTERN}\((?${IDENTIFIER_PATTERN})\)\{await codexLinuxTrustChromePluginCache\(\k\.pluginRoot,\k\.codexHome\);let ${IDENTIFIER_PATTERN}=\[\.\.\.new Set\(\[\.\.\.\k\.extensionIds,\.\.\.${IDENTIFIER_PATTERN}\(\k\.nativeHostName\)\]\)\],`, + String.raw`\/\*codexLinuxChromePluginRuntimeConfig\*\/async function ${IDENTIFIER_PATTERN}\((?${IDENTIFIER_PATTERN})\)\{\k=await codexLinuxChromePluginRuntimeConfig\(\k\);let ${IDENTIFIER_PATTERN}=\[\.\.\.new Set\(\[\.\.\.\k\.extensionIds,\.\.\.${IDENTIFIER_PATTERN}\(\k\.nativeHostName\)\]\)\],`, ), ); } @@ -376,7 +376,7 @@ function applyCurrentChromeAppServerRuntimePatches(currentSource, helper) { return null; } - patchedSource = applyLinuxChromePluginCacheTrustPatch(patchedSource); + patchedSource = applyLinuxChromePluginRuntimeConfigPatch(patchedSource); if (patchedSource == null) { return null; } @@ -386,7 +386,7 @@ function applyCurrentChromeAppServerRuntimePatches(currentSource, helper) { : null; } -function applyLinuxChromePluginCacheTrustPatch(currentSource) { +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); @@ -395,7 +395,7 @@ function applyLinuxChromePluginCacheTrustPatch(currentSource) { } const [originalPrefix, functionName, configVar, extensionIdsVar, bundledIdsFn] = match; const replacement = - `${LINUX_CHROME_PLUGIN_CACHE_TRUST_HELPER}${LINUX_CHROME_PLUGIN_CACHE_TRUST_MARKER}async function ${functionName}(${configVar}){await codexLinuxTrustChromePluginCache(${configVar}.pluginRoot,${configVar}.codexHome);let ${extensionIdsVar}=[...new Set([...${configVar}.extensionIds,...${bundledIdsFn}(${configVar}.nativeHostName)])],`; + `${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); } diff --git a/scripts/patches/impl/chrome-plugin.test.js b/scripts/patches/impl/chrome-plugin.test.js index 39f779d24..9a205154a 100644 --- a/scripts/patches/impl/chrome-plugin.test.js +++ b/scripts/patches/impl/chrome-plugin.test.js @@ -57,6 +57,94 @@ function assetSources(candidate) { ]); } +const CHROME_EXTENSION_ID = "hehggadaopoacecdllhhajmbjkdcmajg"; + +function createChromeRuntimeCaches(root) { + 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", + "x64", + "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", + "x64", + "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.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); + + return { + codexHome, + installedHost, + installedLatest: path.join(installedRoot, "latest"), + runtimeHost, + runtimeLatest: path.join(runtimeRoot, "latest"), + runtimeVersion, + }; +} + +function registerChromeRuntime(patched, fixture, geteuid = () => process.geteuid()) { + 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", + }, + require, + }, + ); +} + test("patches the complete current Chrome runtime asset set transactionally", async () => { const candidate = createCurrentChromeNativeHostRuntimeAssetsFixture(); try { @@ -77,7 +165,7 @@ test("patches the complete current Chrome runtime asset set transactionally", as /codexLinuxChromeNativeHostRuntimeEnv\(`CODEX_CLI_PATH`\)/, ); assert.match(srcPatched, /codexLinuxChromePluginAppServerSourcePath/); - assert.match(srcPatched, /codexLinuxTrustChromePluginCache/); + assert.match(srcPatched, /codexLinuxChromePluginRuntimeConfig/); const files = new Set([ "/home/josh/.local/bin/codex", @@ -126,49 +214,63 @@ test("patches the complete current Chrome runtime asset set transactionally", as } }); -test("hardens the installed Chrome plugin cache before native-host registration", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-chrome-cache-trust-")); +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 codexHome = path.join(root, "codex-home"); - const cacheRoot = path.join(codexHome, "plugins", "cache", "openai-bundled", "chrome"); - const versionRoot = path.join(cacheRoot, "26.test"); - const nestedDir = path.join(versionRoot, "extension-host", "linux", "x64"); - const hostPath = path.join(nestedDir, "extension-host"); - fs.mkdirSync(nestedDir, { recursive: true }); - fs.writeFileSync(hostPath, "host\n"); - fs.symlinkSync(versionRoot, path.join(cacheRoot, "latest")); - for (const target of [ - codexHome, - path.join(codexHome, "plugins"), - path.join(codexHome, "plugins", "cache"), - path.join(codexHome, "plugins", "cache", "openai-bundled"), - cacheRoot, - versionRoot, - path.join(versionRoot, "extension-host"), - path.join(versionRoot, "extension-host", "linux"), - nestedDir, - ]) { - fs.chmodSync(target, 0o775); - } - fs.chmodSync(hostPath, 0o775); + const fixture = createChromeRuntimeCaches(root); + const patched = applyLinuxChromeNativeHostRuntimePatch( + currentChromePluginAppServerSourceBundleFixture(), + ); + const result = await registerChromeRuntime(patched, fixture); + assert.equal(result.extensionHostPath, fixture.runtimeLatest); + 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("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 vm.runInNewContext( - `${patched};cq({codexHome:${JSON.stringify(codexHome)},extensionIds:[],nativeHostName:"com.openai.codexextension",pluginRoot:${JSON.stringify(path.join(cacheRoot, "latest"))}});`, - { - process: { - geteuid: () => process.geteuid(), - platform: "linux", - }, - require, - }, + + await assert.rejects( + registerChromeRuntime(patched, fixture), + /Linux Chrome plugin runtime is not trusted/, ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); - for (const target of [codexHome, cacheRoot, versionRoot, nestedDir, hostPath]) { - assert.equal(fs.statSync(target).mode & 0o022, 0, target); - } +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 }); } diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index 739a724ce..d1f3b6d59 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -6474,7 +6474,6 @@ for (const required of [ 'cache_was_untrusted=1', 'make_tree_owner_trusted "$tmp_plugin"', 'make_tree_owner_trusted "$cache_plugin"', - 'make_tree_owner_trusted "$official_cache_plugin"', 'write_chrome_native_host_manifests "$host_path" "$cache_root/latest"', ]) { if (!chromeBody.includes(required)) { @@ -6641,18 +6640,8 @@ 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 -for trusted_path in \ - "$CODEX_HOME/plugins/cache" \ - "$CODEX_HOME/plugins/cache/openai-bundled" \ - "$official_cache" \ - "$official_plugin"; do - if find "$trusted_path" -maxdepth 0 ! -type l -perm /022 -print -quit | grep -q .; then - echo "Installed Chrome cache remained group/world writable: $trusted_path" >&2 - exit 1 - fi -done -if find "$official_plugin" ! -type l -perm /022 -print -quit | grep -q .; then - echo "Installed Chrome plugin tree remained group/world writable" >&2 +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" @@ -6884,9 +6873,8 @@ proxy_output="$(env -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \ test "$proxy_output" = 'ARCH=x64' test ! -e "$probe_called_file" -# Once app-server has installed and registered its cache, the wrapper must -# execute that exact host instead of the Linux fallback copy. The v2 manifest -# identifies the executable by inode. +# 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")" @@ -6898,7 +6886,7 @@ 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="$(STUB_UNAME_MACHINE=x86_64 PATH="$stub_bin:$PATH" "$native_host_path")" -test "$official_output" = OFFICIAL +test "$official_output" = 'ARCH=x64' ''' ) PY From fa1559102c89bc65b9135dee9e8d12d66d58e439 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sat, 1 Aug 2026 15:38:19 +0300 Subject: [PATCH 051/112] Preserve Chrome runtime registry cleanup --- scripts/patches/impl/chrome-plugin.js | 2 +- scripts/patches/impl/chrome-plugin.test.js | 32 +++++++++++++++++++- scripts/patches/test-fixtures/current-dmg.js | 4 +-- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/scripts/patches/impl/chrome-plugin.js b/scripts/patches/impl/chrome-plugin.js index c35e439c6..be9bfc7a2 100644 --- a/scripts/patches/impl/chrome-plugin.js +++ b/scripts/patches/impl/chrome-plugin.js @@ -42,7 +42,7 @@ const LINUX_CHROME_NATIVE_HOST_RUNTIME_APP_SERVER_MARKERS = [ 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=await r.realpath(e.codexHome),o=await r.realpath(e.pluginRoot),s=n.join(a,`plugins`,`cache`),c=n.relative(s,o);if(c===``||c===`..`||c.startsWith(`..${n.sep}`)||n.isAbsolute(c))throw Error(`Linux Chrome plugin cache path is outside CODEX_HOME`);let l=c.split(n.sep);if(l.length!==3||l[0]!==`openai-bundled`||l[1]!==`chrome`)return e;let u=n.join(a,`plugins`,`linux-runtime-cache`,l[0],l[1]),d=n.join(u,`latest`),f=await r.realpath(d),p=n.relative(u,f);if(p!==l[2]||n.isAbsolute(p))throw Error(`Linux Chrome plugin runtime version does not match installed plugin`);let m=await r.lstat(d);if(!m.isSymbolicLink()||m.uid!==i)throw Error(`Linux Chrome plugin runtime link is not trusted`);let h=[a,n.join(a,`plugins`),n.join(a,`plugins`,`linux-runtime-cache`),n.join(a,`plugins`,`linux-runtime-cache`,l[0]),u,f];for(let e of h){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 g=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 g(n.join(e,t))};await g(f);let _=JSON.parse(await r.readFile(n.join(f,`scripts`,`extension-ids.json`),`utf8`)).extensionIds;if(!Array.isArray(_)||_.length===0||_.some(e=>typeof e!==`string`||!/^[a-p]{32}$/.test(e)))throw Error(`Linux Chrome plugin runtime extension IDs are invalid`);return{...e,pluginRoot:d,extensionIds:[...new Set(_)]}}"; + "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=await r.realpath(e.codexHome),o=await r.realpath(e.pluginRoot),s=n.join(a,`plugins`,`cache`),c=n.relative(s,o);if(c===``||c===`..`||c.startsWith(`..${n.sep}`)||n.isAbsolute(c))throw Error(`Linux Chrome plugin cache path is outside CODEX_HOME`);let l=c.split(n.sep);if(l.length!==3||l[0]!==`openai-bundled`||l[1]!==`chrome`)return e;let u=n.dirname(o),d=n.join(a,`plugins`,`linux-runtime-cache`,l[0],l[1]),f=n.join(d,`latest`),p=await r.realpath(f),m=n.relative(d,p);if(m!==l[2]||n.isAbsolute(m))throw Error(`Linux Chrome plugin runtime version does not match installed plugin`);let h=await r.lstat(f);if(!h.isSymbolicLink()||h.uid!==i)throw Error(`Linux Chrome plugin runtime link is not trusted`);let g=[a,n.join(a,`plugins`),n.join(a,`plugins`,`linux-runtime-cache`),n.join(a,`plugins`,`linux-runtime-cache`,l[0]),d,p];for(let e of g){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 _=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 _(n.join(e,t))};await _(p);let v=await r.lstat(n.join(p,`extension-host`,`linux`,process.arch,`extension-host`));if(!v.isFile()||(v.mode&73)===0)throw Error(`Linux Chrome plugin runtime host is not executable`);let y=JSON.parse(await r.readFile(n.join(p,`scripts`,`extension-ids.json`),`utf8`)).extensionIds;if(!Array.isArray(y)||y.length===0||y.some(e=>typeof e!==`string`||!/^[a-p]{32}$/.test(e)))throw Error(`Linux Chrome plugin runtime extension IDs are invalid`);let b=[a,n.join(a,`plugins`),s,n.join(s,l[0]),u];for(let e of b){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 x=n.join(u,`.codex-linux-runtime`),w=`${x}.tmp-${require(`node:crypto`).randomUUID()}`;try{await r.symlink(p,w,`dir`),await r.rm(x,{force:!0,recursive:!0}),await r.rename(w,x)}finally{await r.rm(w,{force:!0})}if(await r.realpath(x)!==p)throw Error(`Linux Chrome plugin runtime bridge is not trusted`);return{...e,pluginRoot:x,extensionIds:[...new Set(y)]}}"; 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 = diff --git a/scripts/patches/impl/chrome-plugin.test.js b/scripts/patches/impl/chrome-plugin.test.js index 9a205154a..193656fce 100644 --- a/scripts/patches/impl/chrome-plugin.test.js +++ b/scripts/patches/impl/chrome-plugin.test.js @@ -103,6 +103,7 @@ function createChromeRuntimeCaches(root) { 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 [ @@ -121,11 +122,14 @@ function createChromeRuntimeCaches(root) { } fs.chmodSync(runtimeHost, 0o755); fs.chmodSync(path.join(runtimeScripts, "extension-ids.json"), 0o644); + fs.chmodSync(path.join(runtimeScripts, "browser-client.mjs"), 0o644); return { codexHome, installedHost, installedLatest: path.join(installedRoot, "latest"), + installedRoot, + installedVersion, runtimeHost, runtimeLatest: path.join(runtimeRoot, "latest"), runtimeVersion, @@ -139,6 +143,7 @@ function registerChromeRuntime(patched, fixture, geteuid = () => process.geteuid process: { geteuid, platform: "linux", + arch: process.arch, }, require, }, @@ -221,9 +226,34 @@ test("registers the trusted Linux runtime cache instead of the installed cache", const patched = applyLinuxChromeNativeHostRuntimePatch( currentChromePluginAppServerSourceBundleFixture(), ); + const bridgeRoot = path.join(fixture.installedRoot, ".codex-linux-runtime"); + fs.symlinkSync(fixture.installedHost, bridgeRoot); const result = await registerChromeRuntime(patched, fixture); - assert.equal(result.extensionHostPath, fixture.runtimeLatest); + const bridgeHost = path.join( + bridgeRoot, + "extension-host", + "linux", + "x64", + "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], diff --git a/scripts/patches/test-fixtures/current-dmg.js b/scripts/patches/test-fixtures/current-dmg.js index 8531efed5..9aeaebe78 100644 --- a/scripts/patches/test-fixtures/current-dmg.js +++ b/scripts/patches/test-fixtures/current-dmg.js @@ -21,8 +21,8 @@ function currentChromePluginAppServerSourceBundleFixture() { "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{extensionIds:t,target:n,extensionHostPath:r}}", - "function nb(){return[]}function Aq(){return{platform:`linux`,architecture:`x64`,filename:`extension-host`}}async function kq(e){return e.pluginRoot}", + "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:`linux`,architecture:`x64`,filename:`extension-host`}}async function kq(e){return i.join(e.pluginRoot,`extension-host`,`linux`,`x64`,`extension-host`)}", "function yq(e){return null}function GN(e){return null}function WN(e){return null}function KN(e){return []}", ].join(""); } From 4f4bb87e7d0225acb572b93f4c760bfe188c7160 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sat, 1 Aug 2026 15:53:22 +0300 Subject: [PATCH 052/112] Make Chrome runtime bridge replacement atomic --- scripts/patches/impl/chrome-plugin.js | 2 +- scripts/patches/impl/chrome-plugin.test.js | 151 ++++++++++++++++++- scripts/patches/test-fixtures/current-dmg.js | 2 +- 3 files changed, 146 insertions(+), 9 deletions(-) diff --git a/scripts/patches/impl/chrome-plugin.js b/scripts/patches/impl/chrome-plugin.js index be9bfc7a2..0d26a9ab2 100644 --- a/scripts/patches/impl/chrome-plugin.js +++ b/scripts/patches/impl/chrome-plugin.js @@ -42,7 +42,7 @@ const LINUX_CHROME_NATIVE_HOST_RUNTIME_APP_SERVER_MARKERS = [ 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=await r.realpath(e.codexHome),o=await r.realpath(e.pluginRoot),s=n.join(a,`plugins`,`cache`),c=n.relative(s,o);if(c===``||c===`..`||c.startsWith(`..${n.sep}`)||n.isAbsolute(c))throw Error(`Linux Chrome plugin cache path is outside CODEX_HOME`);let l=c.split(n.sep);if(l.length!==3||l[0]!==`openai-bundled`||l[1]!==`chrome`)return e;let u=n.dirname(o),d=n.join(a,`plugins`,`linux-runtime-cache`,l[0],l[1]),f=n.join(d,`latest`),p=await r.realpath(f),m=n.relative(d,p);if(m!==l[2]||n.isAbsolute(m))throw Error(`Linux Chrome plugin runtime version does not match installed plugin`);let h=await r.lstat(f);if(!h.isSymbolicLink()||h.uid!==i)throw Error(`Linux Chrome plugin runtime link is not trusted`);let g=[a,n.join(a,`plugins`),n.join(a,`plugins`,`linux-runtime-cache`),n.join(a,`plugins`,`linux-runtime-cache`,l[0]),d,p];for(let e of g){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 _=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 _(n.join(e,t))};await _(p);let v=await r.lstat(n.join(p,`extension-host`,`linux`,process.arch,`extension-host`));if(!v.isFile()||(v.mode&73)===0)throw Error(`Linux Chrome plugin runtime host is not executable`);let y=JSON.parse(await r.readFile(n.join(p,`scripts`,`extension-ids.json`),`utf8`)).extensionIds;if(!Array.isArray(y)||y.length===0||y.some(e=>typeof e!==`string`||!/^[a-p]{32}$/.test(e)))throw Error(`Linux Chrome plugin runtime extension IDs are invalid`);let b=[a,n.join(a,`plugins`),s,n.join(s,l[0]),u];for(let e of b){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 x=n.join(u,`.codex-linux-runtime`),w=`${x}.tmp-${require(`node:crypto`).randomUUID()}`;try{await r.symlink(p,w,`dir`),await r.rm(x,{force:!0,recursive:!0}),await r.rename(w,x)}finally{await r.rm(w,{force:!0})}if(await r.realpath(x)!==p)throw Error(`Linux Chrome plugin runtime bridge is not trusted`);return{...e,pluginRoot:x,extensionIds:[...new Set(y)]}}"; + "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 = diff --git a/scripts/patches/impl/chrome-plugin.test.js b/scripts/patches/impl/chrome-plugin.test.js index 193656fce..1d855086c 100644 --- a/scripts/patches/impl/chrome-plugin.test.js +++ b/scripts/patches/impl/chrome-plugin.test.js @@ -59,7 +59,7 @@ function assetSources(candidate) { const CHROME_EXTENSION_ID = "hehggadaopoacecdllhhajmbjkdcmajg"; -function createChromeRuntimeCaches(root) { +function createChromeRuntimeCaches(root, arch = process.arch) { const codexHome = path.join(root, "codex-home"); const installedRoot = path.join( codexHome, @@ -73,7 +73,7 @@ function createChromeRuntimeCaches(root) { installedVersion, "extension-host", "linux", - "x64", + arch, "extension-host", ); fs.mkdirSync(path.dirname(installedHost), { recursive: true }); @@ -92,7 +92,7 @@ function createChromeRuntimeCaches(root) { runtimeVersion, "extension-host", "linux", - "x64", + arch, "extension-host", ); const runtimeScripts = path.join(runtimeVersion, "scripts"); @@ -125,6 +125,7 @@ function createChromeRuntimeCaches(root) { fs.chmodSync(path.join(runtimeScripts, "browser-client.mjs"), 0o644); return { + arch, codexHome, installedHost, installedLatest: path.join(installedRoot, "latest"), @@ -136,20 +137,35 @@ function createChromeRuntimeCaches(root) { }; } -function registerChromeRuntime(patched, fixture, geteuid = () => process.geteuid()) { +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: process.arch, + arch, }, - require, + 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 { @@ -234,7 +250,7 @@ test("registers the trusted Linux runtime cache instead of the installed cache", bridgeRoot, "extension-host", "linux", - "x64", + fixture.arch, "extension-host", ); assert.equal(result.extensionHostPath, bridgeHost); @@ -265,6 +281,127 @@ test("registers the trusted Linux runtime cache instead of the installed cache", } }); +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 { diff --git a/scripts/patches/test-fixtures/current-dmg.js b/scripts/patches/test-fixtures/current-dmg.js index 9aeaebe78..cc4fd6399 100644 --- a/scripts/patches/test-fixtures/current-dmg.js +++ b/scripts/patches/test-fixtures/current-dmg.js @@ -22,7 +22,7 @@ function currentChromePluginAppServerSourceBundleFixture() { "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:`linux`,architecture:`x64`,filename:`extension-host`}}async function kq(e){return i.join(e.pluginRoot,`extension-host`,`linux`,`x64`,`extension-host`)}", + "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(""); } From e6572ae0279d2427a8d91e3d8da38f9e6f0dce0d Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sat, 1 Aug 2026 16:14:32 +0300 Subject: [PATCH 053/112] Stabilize Chrome runtime smoke assertion --- tests/scripts_smoke.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index d1f3b6d59..963de4916 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -6885,7 +6885,9 @@ 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="$(STUB_UNAME_MACHINE=x86_64 PATH="$stub_bin:$PATH" "$native_host_path")" +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' ''' ) From 6941c58eee4cb0daaef84f166ef8747d93bbd2b2 Mon Sep 17 00:00:00 2001 From: moon <3232825542@qq.com> Date: Sat, 1 Aug 2026 21:06:13 +0800 Subject: [PATCH 054/112] fix: use launched desktop entry for approval notification icons --- notification-actions-linux/src/main.rs | 43 ++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/notification-actions-linux/src/main.rs b/notification-actions-linux/src/main.rs index bb5c15055..5ff5a2970 100644 --- a/notification-actions-linux/src/main.rs +++ b/notification-actions-linux/src/main.rs @@ -1,10 +1,13 @@ -use std::{collections::HashMap, io::Write}; +use std::{collections::HashMap, io::Write, path::Path}; 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,19 @@ fn validate_request(request: &ShowRequest) -> Result<()> { Ok(()) } +fn launched_desktop_entry_id() -> Option { + let desktop_file = std::env::var_os("GIO_LAUNCHED_DESKTOP_FILE")?; + desktop_entry_id(Path::new(&desktop_file)) +} + +fn desktop_entry_id(desktop_file: &Path) -> Option { + desktop_file + .file_name()? + .to_str()? + .strip_suffix(".desktop") + .map(str::to_owned) +} + fn notification_actions(actions: &[String]) -> Vec { let mut pairs = Vec::with_capacity(2 + actions.len() * 2); pairs.push("default".to_owned()); @@ -255,6 +277,21 @@ mod tests { assert_eq!(action_index("action-nope", 2), None); } + #[test] + fn derives_desktop_entry_id_from_desktop_file_path() { + assert_eq!( + desktop_entry_id(Path::new( + "/home/user/.local/share/applications/chatgpt.desktop" + )), + Some("chatgpt".to_owned()) + ); + assert_eq!( + desktop_entry_id(Path::new("/usr/share/applications/codex-desktop.desktop")), + Some("codex-desktop".to_owned()) + ); + assert_eq!(desktop_entry_id(Path::new("/tmp/not-a-desktop-file")), None); + } + #[test] fn rejects_empty_or_excessive_actions() { assert!(validate_request(&request(&[])).is_err()); From 2918e2c98338a9e6b505e95f50283dd912089860 Mon Sep 17 00:00:00 2001 From: moon <3232825542@qq.com> Date: Sun, 2 Aug 2026 04:22:04 +0800 Subject: [PATCH 055/112] fix: validate notification desktop entry launch PID --- launcher/start.sh.template | 27 +++++++++++++++++++++++ notification-actions-linux/src/main.rs | 30 ++++---------------------- tests/scripts_smoke.sh | 25 +++++++++++++++++++++ 3 files changed, 56 insertions(+), 26 deletions(-) diff --git a/launcher/start.sh.template b/launcher/start.sh.template index 4bd39570d..1e5638b96 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" diff --git a/notification-actions-linux/src/main.rs b/notification-actions-linux/src/main.rs index 5ff5a2970..ee852f43d 100644 --- a/notification-actions-linux/src/main.rs +++ b/notification-actions-linux/src/main.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, io::Write, path::Path}; +use std::{collections::HashMap, io::Write}; use anyhow::{bail, Context, Result}; use futures_util::StreamExt; @@ -207,16 +207,9 @@ fn validate_request(request: &ShowRequest) -> Result<()> { } fn launched_desktop_entry_id() -> Option { - let desktop_file = std::env::var_os("GIO_LAUNCHED_DESKTOP_FILE")?; - desktop_entry_id(Path::new(&desktop_file)) -} - -fn desktop_entry_id(desktop_file: &Path) -> Option { - desktop_file - .file_name()? - .to_str()? - .strip_suffix(".desktop") - .map(str::to_owned) + std::env::var("CODEX_LINUX_LAUNCHED_DESKTOP_ENTRY") + .ok() + .filter(|desktop_entry| !desktop_entry.is_empty()) } fn notification_actions(actions: &[String]) -> Vec { @@ -277,21 +270,6 @@ mod tests { assert_eq!(action_index("action-nope", 2), None); } - #[test] - fn derives_desktop_entry_id_from_desktop_file_path() { - assert_eq!( - desktop_entry_id(Path::new( - "/home/user/.local/share/applications/chatgpt.desktop" - )), - Some("chatgpt".to_owned()) - ); - assert_eq!( - desktop_entry_id(Path::new("/usr/share/applications/codex-desktop.desktop")), - Some("codex-desktop".to_owned()) - ); - assert_eq!(desktop_entry_id(Path::new("/tmp/not-a-desktop-file")), None); - } - #[test] fn rejects_empty_or_excessive_actions() { assert!(validate_request(&request(&[])).is_err()); diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index 6c6cff3b4..519b881d9 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -4942,6 +4942,30 @@ EOF "$BASH_BIN" "$probe" || fail "Expected launcher to preserve all LD_LIBRARY_PATH states" } +test_launcher_captures_desktop_entry_for_current_process_only() { + info "Checking launcher validates the GIO desktop-entry PID" + local probe="$TMP_DIR/launcher-desktop-entry-pid-probe.sh" + + awk ' + /^capture_launched_desktop_entry\(\) \{/ { capture = 1 } + capture { print } + capture && /^}/ { exit } + ' "$REPO_DIR/launcher/start.sh.template" > "$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" @@ -11134,6 +11158,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 From 75398cf958b72d3a96882906ec9195ea4279dc31 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sun, 2 Aug 2026 00:28:01 +0300 Subject: [PATCH 056/112] fix: bound Linux quit cleanup lifetime --- docs/windowless-warm-start-fix-report.md | 9 ++-- scripts/patch-linux-window-ui.test.js | 42 ++++++++++++++++--- .../impl/main-process/quit-lifecycle.js | 13 +++--- tests/scripts_smoke.sh | 10 +++-- 4 files changed, 55 insertions(+), 19 deletions(-) diff --git a/docs/windowless-warm-start-fix-report.md b/docs/windowless-warm-start-fix-report.md index 1d1e9873c..39c3fa17a 100644 --- a/docs/windowless-warm-start-fix-report.md +++ b/docs/windowless-warm-start-fix-report.md @@ -68,10 +68,11 @@ 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 drain to three seconds. +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 remains bounded by the upstream five-second limit and - cannot suppress shared-disposable cleanup. +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 @@ -106,7 +107,7 @@ Automated regression coverage exercises: - both current upstream drain branches; - synchronous lifecycle-disposer and drain-setup failures; - asynchronous drain rejection and deadline expiry; -- context-disposal and shared-disposable failures; +- 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; diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index 1b5f7d92c..ba4cdb0ce 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -2709,7 +2709,7 @@ test("adds a bounded will-quit drain fallback on Linux", () => { assert.doesNotMatch(patched, /codexLinuxQuitFinalized/); assert.match( patched, - /Promise\.resolve\(\)\.then\(\(\)=>U5\(h,N5\)\)\.catch\(e=>\{try\{console\.warn\(`WARN: Linux quit context cleanup failed`,e\)\}catch\{\}\}\)/, + /\.then\(\(\)=>Promise\.resolve\(\)\.then\(\(\)=>U5\(h,N5\)\)\.catch\(e=>\{try\{console\.warn\(`WARN: Linux quit context cleanup failed`,e\)\}catch\{\}\}\)\)/, ); assert.match( patched, @@ -2717,14 +2717,14 @@ test("adds a bounded will-quit drain fallback on Linux", () => { ); assert.match( patched, - /Promise\.race\(\[Promise\.resolve\(\)\.then\(e\)\.then\(codexLinuxLogQuitDrainResults\),new Promise\(\(_,e\)=>setTimeout\(\(\)=>e\(Error\(`Linux quit drain timed out`\)\),typeof codexLinuxExplicitQuitDrainTimeoutMs===`number`\?codexLinuxExplicitQuitDrainTimeoutMs:3e3\)\)\]\)\.catch\(e=>\{try\{console\.warn\(`WARN: Linux quit drain cleanup failed`,e\)\}catch\{\}\}\)\.then\(codexLinuxFinalizeQuit\)/, + /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\)/, ); assert.match( patched, - /codexLinuxRunQuitDrain=e=>\{if\(process\.platform===`linux`\)\{/, + /codexLinuxRunQuitCleanup=e=>\{if\(process\.platform===`linux`\)\{/, ); assert.equal( - (patched.match(/codexLinuxRunQuitDrain\(\(\)=>\{/g) ?? []).length, + (patched.match(/codexLinuxRunQuitCleanup\(\(\)=>\{/g) ?? []).length, 2, ); assert.equal( @@ -2768,7 +2768,7 @@ test("Linux will-quit reaches app.exit after the drain deadline", async () => { assert.deepEqual(state.exitCodes, [0]); assert.equal(state.quitCalls, 0); assert.deepEqual(state.warnings, [ - "WARN: Linux quit drain cleanup failed Error: Linux quit drain timed out", + "WARN: Linux quit cleanup failed Error: Linux quit cleanup timed out", ]); resolveGlobalState(); await new Promise((resolve) => setTimeout(resolve, 10)); @@ -2776,6 +2776,21 @@ test("Linux will-quit reaches app.exit after the drain deadline", async () => { assert.equal(state.quitCalls, 0); }); +test("Linux will-quit bounds context disposal inside the quit deadline", async () => { + const stalledContextDispose = new Promise(() => {}); + const state = await runPatchedLinuxWillQuit({ + contextDispose: () => stalledContextDispose, + 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", + ]); +}); + 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")), @@ -2931,6 +2946,23 @@ test("missing, renamed, or ambiguous will-quit targets fail the required lifecyc } }); +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("a non-exiting Linux finalizer is not accepted as already applied", () => { const previousBrokenPatch = applyLinuxWillQuitDrainTimeoutPatch( willQuitDrainBundleFixture(), diff --git a/scripts/patches/impl/main-process/quit-lifecycle.js b/scripts/patches/impl/main-process/quit-lifecycle.js index eb61cb0f6..3acc04784 100644 --- a/scripts/patches/impl/main-process/quit-lifecycle.js +++ b/scripts/patches/impl/main-process/quit-lifecycle.js @@ -117,15 +117,16 @@ function applyLinuxWillQuitDrainTimeoutPatch(currentSource) { const appliedMarkers = [ "codexLinuxLogQuitDrainResults=e=>{", "codexLinuxFinalizeQuit=()=>{", - "codexLinuxRunQuitDrain=e=>{if(process.platform===`linux`){Promise.race([Promise.resolve().then(e)", - "Linux quit drain timed out", + "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( - ",codexLinuxRunQuitDrain=", + ",codexLinuxRunQuitCleanup=", appliedFinalizerStart, ); const hasAppliedFinalizerPostcondition = @@ -151,18 +152,18 @@ function applyLinuxWillQuitDrainTimeoutPatch(currentSource) { 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=()=>{Promise.resolve().then(()=>${finalizer.contextDispose}(${finalizer.contextArg},${finalizer.contextTimeout})).catch(e=>{try{console.warn(\`WARN: Linux quit context cleanup failed\`,e)}catch{}}).then(()=>{try{${finalizer.disposables}.dispose()}catch(e){try{console.warn(\`WARN: Linux quit disposables cleanup failed\`,e)}catch{}}finally{${finalizer.electron}.app.exit(0)}})},codexLinuxRunQuitDrain=e=>{if(${linuxQuitDrainGuard}){Promise.race([Promise.resolve().then(e).then(codexLinuxLogQuitDrainResults),new Promise((_,e)=>setTimeout(()=>e(Error(\`Linux quit drain timed out\`)),typeof codexLinuxExplicitQuitDrainTimeoutMs===\`number\`?codexLinuxExplicitQuitDrainTimeoutMs:3e3))]).catch(e=>{try{console.warn(\`WARN: Linux quit drain cleanup failed\`,e)}catch{}}).then(codexLinuxFinalizeQuit);return}e().then(${outer.upstreamFinalize})}`; + `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.stop}(),${reduced.trace}()]).then(${outer.upstreamFinalize})`, - `codexLinuxRunQuitDrain(()=>{${reduced.hotkey}.dispose(),${reduced.dictation}.dispose();return Promise.allSettled([${reduced.stop}(),${reduced.trace}()])})`, + `codexLinuxRunQuitCleanup(()=>{${reduced.hotkey}.dispose(),${reduced.dictation}.dispose();return Promise.allSettled([${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})`, - `codexLinuxRunQuitDrain(()=>{${full.hotkey}.dispose(),${full.dictation}.dispose();return Promise.allSettled([${full.globalState}.flush(),${full.settings}.flush(),${full.stop}(),${full.trace}()])})`, + `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)}`; diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index eb2e60385..abd3e5782 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -9200,13 +9200,15 @@ JS 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" 'codexLinuxRunQuitDrain(()=>{' '2' - assert_contains "$extracted/.vite/build/main-test.js" 'Promise.resolve().then(e).then(codexLinuxLogQuitDrainResults),new Promise' + 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(Error(`Linux quit drain timed out`)),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' @@ -9312,7 +9314,7 @@ NODE assert_occurrence_count "$extracted/.vite/build/main-test.js" 'typeof codexLinuxShouldBypassQuitPrompt===`function`&&codexLinuxShouldBypassQuitPrompt()' '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" 'codexLinuxRunQuitDrain(()=>{' '2' + assert_occurrence_count "$extracted/.vite/build/main-test.js" 'codexLinuxRunQuitCleanup(()=>{' '2' } test_keybinds_settings_tab_patch_smoke() { From 3439e76c82e9c1fe39429693e7f3731c493220d2 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sun, 2 Aug 2026 00:29:02 +0300 Subject: [PATCH 057/112] test: verify stalled quit cleanup still finalizes --- scripts/patch-linux-window-ui.test.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index ba4cdb0ce..d919a412d 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -2778,11 +2778,21 @@ test("Linux will-quit reaches app.exit after the drain deadline", async () => { 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: () => stalledContextDispose, + 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); From 06a84a889ed4df1be11b719779df892df0088b1c Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sun, 2 Aug 2026 00:34:49 +0300 Subject: [PATCH 058/112] test: harden Linux quit patch verification --- scripts/patch-linux-window-ui.test.js | 73 +++++++++++++++++++ .../impl/main-process/quit-lifecycle.js | 38 ++++++++++ 2 files changed, 111 insertions(+) diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index d919a412d..6ed5243a5 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -2801,6 +2801,56 @@ test("Linux will-quit bounds context disposal inside the quit deadline", async ( ]); }); +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.equal(contextDisposeCalls, 1); + assert.equal(disposablesCalls, 1); + assert.equal(globalStateFlushCalls, 0); + 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("Linux will-quit logs rejected asynchronous drain work before exit", async () => { const state = await runPatchedLinuxWillQuit({ globalStateFlush: () => Promise.reject(new Error("global-state flush rejected")), @@ -2973,6 +3023,29 @@ test("recognizes the bounded Linux quit cleanup as already applied", () => { 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("a non-exiting Linux finalizer is not accepted as already applied", () => { const previousBrokenPatch = applyLinuxWillQuitDrainTimeoutPatch( willQuitDrainBundleFixture(), diff --git a/scripts/patches/impl/main-process/quit-lifecycle.js b/scripts/patches/impl/main-process/quit-lifecycle.js index 3acc04784..c7afc8875 100644 --- a/scripts/patches/impl/main-process/quit-lifecycle.js +++ b/scripts/patches/impl/main-process/quit-lifecycle.js @@ -112,6 +112,40 @@ function currentWillQuitDrainCandidates(currentSource) { 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 ( + closeBrace === -1 || + appliedFinalizerStart <= openBrace || + appliedFinalizerStart >= closeBrace + ) { + return false; + } + + const handlerBody = currentSource.slice(openBrace + 1, closeBrace); + return ( + handlerBody.match(/codexLinuxRunQuitCleanup\(\(\)=>\{/g) ?? [] + ).length === 2; +} + function applyLinuxWillQuitDrainTimeoutPatch(currentSource) { const linuxQuitDrainGuard = "process.platform===`linux`"; const appliedMarkers = [ @@ -134,6 +168,10 @@ function applyLinuxWillQuitDrainTimeoutPatch(currentSource) { 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)) && From e639b10a22fd1eadd088e732fa41c53400de2ed9 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sun, 2 Aug 2026 00:35:46 +0300 Subject: [PATCH 059/112] fix: verify both Linux quit cleanup branches --- .../impl/main-process/quit-lifecycle.js | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/scripts/patches/impl/main-process/quit-lifecycle.js b/scripts/patches/impl/main-process/quit-lifecycle.js index c7afc8875..330f136ac 100644 --- a/scripts/patches/impl/main-process/quit-lifecycle.js +++ b/scripts/patches/impl/main-process/quit-lifecycle.js @@ -141,9 +141,25 @@ function hasAppliedWillQuitCleanupPostcondition(currentSource, appliedFinalizerS } const handlerBody = currentSource.slice(openBrace + 1, closeBrace); - return ( - handlerBody.match(/codexLinuxRunQuitCleanup\(\(\)=>\{/g) ?? [] - ).length === 2; + const reducedBranchStart = handlerBody.indexOf( + ".shouldSkipDrainBeforeQuit()){", + ); + const reducedBranchEnd = handlerBody.indexOf( + ";return}", + reducedBranchStart, + ); + if (reducedBranchStart === -1 || reducedBranchEnd === -1) { + return false; + } + + const cleanupCall = /codexLinuxRunQuitCleanup\(\(\)=>\{/g; + const reducedCalls = + handlerBody + .slice(reducedBranchStart, reducedBranchEnd) + .match(cleanupCall)?.length ?? 0; + const fullCalls = + handlerBody.slice(reducedBranchEnd + 8).match(cleanupCall)?.length ?? 0; + return reducedCalls === 1 && fullCalls === 1; } function applyLinuxWillQuitDrainTimeoutPatch(currentSource) { From c3966053056ab0c75e7bbf93567d6c37f4f815a3 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sun, 2 Aug 2026 00:36:56 +0300 Subject: [PATCH 060/112] fix: validate Linux quit cleanup structure --- scripts/patch-linux-window-ui.test.js | 33 +++++++++++++++++ .../impl/main-process/quit-lifecycle.js | 36 +++++++++++++++---- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index 6ed5243a5..c323d4933 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -3046,6 +3046,39 @@ test("does not accept a partial Linux quit cleanup call-site patch", () => { 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([p(),m()])})", + "codexLinuxRunQuitCleanup(()=>Promise.resolve())", + ), + patched.replace( + "codexLinuxRunQuitCleanup(()=>{c.dispose(),u.dispose();return Promise.allSettled([d.flush(),f.flush(),p(),m()])})", + "codexLinuxRunQuitCleanup(()=>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(), diff --git a/scripts/patches/impl/main-process/quit-lifecycle.js b/scripts/patches/impl/main-process/quit-lifecycle.js index 330f136ac..21a8e5171 100644 --- a/scripts/patches/impl/main-process/quit-lifecycle.js +++ b/scripts/patches/impl/main-process/quit-lifecycle.js @@ -152,14 +152,36 @@ function hasAppliedWillQuitCleanupPostcondition(currentSource, appliedFinalizerS return false; } + const identifier = "[A-Za-z_$][\\w$]*"; const cleanupCall = /codexLinuxRunQuitCleanup\(\(\)=>\{/g; - const reducedCalls = - handlerBody - .slice(reducedBranchStart, reducedBranchEnd) - .match(cleanupCall)?.length ?? 0; - const fullCalls = - handlerBody.slice(reducedBranchEnd + 8).match(cleanupCall)?.length ?? 0; - return reducedCalls === 1 && fullCalls === 1; + 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})\\(\\),(?${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.stop === fullMatch.groups.stop && + reducedMatch.groups.trace === fullMatch.groups.trace + ); } function applyLinuxWillQuitDrainTimeoutPatch(currentSource) { From db690bba25e7656899b5e18c329964314caff670 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sun, 2 Aug 2026 00:38:03 +0300 Subject: [PATCH 061/112] test: preserve damaged quit helper shape --- scripts/patch-linux-window-ui.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index c323d4933..711afa0ef 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -3053,11 +3053,11 @@ test("does not accept damaged Linux quit cleanup factory bodies", () => { const sources = [ patched.replace( "codexLinuxRunQuitCleanup(()=>{c.dispose(),u.dispose();return Promise.allSettled([p(),m()])})", - "codexLinuxRunQuitCleanup(()=>Promise.resolve())", + "codexLinuxRunQuitCleanup(()=>{return Promise.resolve()})", ), patched.replace( "codexLinuxRunQuitCleanup(()=>{c.dispose(),u.dispose();return Promise.allSettled([d.flush(),f.flush(),p(),m()])})", - "codexLinuxRunQuitCleanup(()=>Promise.resolve())", + "codexLinuxRunQuitCleanup(()=>{return Promise.resolve()})", ), ]; const descriptor = corePatchDescriptors().find( From d59fd7090bf2eb2011735b9d082bc24eb19eb699 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sun, 2 Aug 2026 18:39:38 +0300 Subject: [PATCH 062/112] test(computer-use): keep cursor socket path bounded --- computer-use-linux/src/server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/computer-use-linux/src/server.rs b/computer-use-linux/src/server.rs index 7dd6ef8fb..b56bdd450 100644 --- a/computer-use-linux/src/server.rs +++ b/computer-use-linux/src/server.rs @@ -5037,7 +5037,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) From 19ffd281493a63989e24502e2dc6b31a2b4a568c Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sun, 2 Aug 2026 18:48:10 +0300 Subject: [PATCH 063/112] HEROX-1202: Fix remote mobile Desktop app-server patch --- linux-features/remote-mobile-control/patch.js | 54 ++++++++++---- linux-features/remote-mobile-control/stage.sh | 2 +- linux-features/remote-mobile-control/test.js | 70 ++++++++++++++----- 3 files changed, 97 insertions(+), 29 deletions(-) diff --git a/linux-features/remote-mobile-control/patch.js b/linux-features/remote-mobile-control/patch.js index 40846841e..8a615809a 100644 --- a/linux-features/remote-mobile-control/patch.js +++ b/linux-features/remote-mobile-control/patch.js @@ -46,9 +46,9 @@ 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_CONTROL_APP_INITIAL_ASSET_PATTERN = /^app-initial-[^.]+\.js$/u; const REMOTE_CONTROL_LINUX_COPY_REPLACEMENTS = [ ["defaultMessage:`Mac`", "defaultMessage:`Linux`"], @@ -213,15 +213,41 @@ function applyLinuxRemoteMobileAppServerRemoteControlPatch(source) { if (source.includes(REMOTE_MOBILE_APP_SERVER_REMOTE_CONTROL_MARKER)) { 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 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 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()"); + "function codexLinuxRemoteMobileLocalAppServerArgs(){return process.platform===`linux`?[`--remote-control`]:[]}"; + const replacementTail = "`app-server`,...codexLinuxRemoteMobileLocalAppServerArgs(),`--analytics-default-enabled`]}"; + const replaced = `${source.slice(0, launchTailIndex)}${replacementTail}${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";') @@ -251,22 +277,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 (source.includes(REMOTE_MOBILE_APP_SERVER_REMOTE_CONTROL_MARKER)) { + 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 }; diff --git a/linux-features/remote-mobile-control/stage.sh b/linux-features/remote-mobile-control/stage.sh index ae4fdd73b..9876b4d52 100755 --- a/linux-features/remote-mobile-control/stage.sh +++ b/linux-features/remote-mobile-control/stage.sh @@ -14,7 +14,7 @@ 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 + grep -R -q "codexLinuxRemoteMobileLocalAppServerArgs" "$WORK_DIR/app-extracted/.vite/build" 2>/dev/null; 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 8c7b3d75e..03facd960 100644 --- a/linux-features/remote-mobile-control/test.js +++ b/linux-features/remote-mobile-control/test.js @@ -285,10 +285,17 @@ 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 syntheticCurrentLocalAppServerLaunchBundle() { + return [ + "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(""); +} + function syntheticCurrentSettingsBundle() { return [ "const i=`linux`,Q={jsx(){},jsxs(){}};", @@ -556,7 +563,7 @@ 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"), "globalThis.codexLinuxRemoteMobileLocalAppServerArgs=true;"); const first = runStageHook(env); const second = runStageHook(env); @@ -575,7 +582,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"); @@ -585,7 +592,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({ @@ -615,7 +622,7 @@ 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"), "globalThis.codexLinuxRemoteMobileLocalAppServerArgs=true;"); fs.writeFileSync(target, "preserved\n"); fs.symlinkSync(target, marker); @@ -1224,29 +1231,57 @@ 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); }); +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 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); }); @@ -2566,7 +2601,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( @@ -3545,7 +3580,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() + @@ -3639,7 +3677,7 @@ 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, /\{\.\.\.e,remote_control:!0\}/); From 81238abc9d46b14c47adc11f84502059d1f16571 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sun, 2 Aug 2026 18:51:01 +0300 Subject: [PATCH 064/112] HEROX-1202: Verify complete Desktop patch state --- linux-features/remote-mobile-control/patch.js | 27 ++++++-- linux-features/remote-mobile-control/stage.sh | 19 ++++- linux-features/remote-mobile-control/test.js | 69 ++++++++++++++++++- 3 files changed, 106 insertions(+), 9 deletions(-) diff --git a/linux-features/remote-mobile-control/patch.js b/linux-features/remote-mobile-control/patch.js index 8a615809a..9825ca0cd 100644 --- a/linux-features/remote-mobile-control/patch.js +++ b/linux-features/remote-mobile-control/patch.js @@ -49,6 +49,10 @@ const REMOTE_CONTROL_COPY_MARKER = "codexLinuxRemoteControlCopy"; 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`"], @@ -211,6 +215,11 @@ 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; } const baseArgsMatches = [ @@ -242,10 +251,7 @@ function applyLinuxRemoteMobileAppServerRemoteControlPatch(source) { return source; } - const helper = - "function codexLinuxRemoteMobileLocalAppServerArgs(){return process.platform===`linux`?[`--remote-control`]:[]}"; - const replacementTail = "`app-server`,...codexLinuxRemoteMobileLocalAppServerArgs(),`--analytics-default-enabled`]}"; - const replaced = `${source.slice(0, launchTailIndex)}${replacementTail}${source.slice( + 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 @@ -255,7 +261,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) { @@ -287,7 +300,7 @@ function applyLinuxRemoteMobileAppServerRemoteControlExtractedAppPatch(extracted matched += 1; fs.writeFileSync(filePath, patched, "utf8"); changed += 1; - } else if (source.includes(REMOTE_MOBILE_APP_SERVER_REMOTE_CONTROL_MARKER)) { + } else if (hasLinuxRemoteMobileLocalAppServerRemoteControlPatch(source)) { matched += 1; } } @@ -1493,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 9876b4d52..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 "codexLinuxRemoteMobileLocalAppServerArgs" "$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 03facd960..6692ed55e 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, @@ -563,7 +564,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.codexLinuxRemoteMobileLocalAppServerArgs=true;"); + fs.writeFileSync( + path.join(buildDir, "main.js"), + applyLinuxRemoteMobileAppServerRemoteControlPatch(syntheticCurrentLocalAppServerLaunchBundle()), + ); const first = runStageHook(env); const second = runStageHook(env); @@ -611,6 +615,33 @@ test("remote mobile stage hook removes a stale ownership marker when only the le } }); +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 { @@ -622,7 +653,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.codexLinuxRemoteMobileLocalAppServerArgs=true;"); + fs.writeFileSync( + path.join(buildDir, "main.js"), + applyLinuxRemoteMobileAppServerRemoteControlPatch(syntheticCurrentLocalAppServerLaunchBundle()), + ); fs.writeFileSync(target, "preserved\n"); fs.symlinkSync(target, marker); @@ -1245,6 +1279,14 @@ test("Linux remote mobile app-server launch enables remote control on the Deskto /return\[\.\.\.Iz,\.\.\.Lz\.flatMap\(.+`app-server`,\.\.\.codexLinuxRemoteMobileLocalAppServerArgs\(\),`--analytics-default-enabled`\]\}/, ); 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", () => { @@ -1276,6 +1318,29 @@ test("Linux remote mobile extracted-app patch modifies only the local Desktop tr } }); +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";${syntheticCurrentLocalAppServerLaunchBundle()}`; const patched = applyLinuxRemoteMobileAppServerRemoteControlPatch(source); From 75fa8caa9c67961eab0cdd3c5154ebba53d1fc4f Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sun, 2 Aug 2026 18:55:31 +0300 Subject: [PATCH 065/112] fix(computer-use): retain input safety through cancellation --- computer-use-linux/src/remote_desktop.rs | 203 +++++++--- computer-use-linux/src/server.rs | 451 +++++++++++++++------ computer-use-linux/src/windowing/target.rs | 92 ++++- 3 files changed, 549 insertions(+), 197 deletions(-) diff --git a/computer-use-linux/src/remote_desktop.rs b/computer-use-linux/src/remote_desktop.rs index 18236dcab..a7caccecc 100644 --- a/computer-use-linux/src/remote_desktop.rs +++ b/computer-use-linux/src/remote_desktop.rs @@ -69,6 +69,19 @@ pub struct PortalKeyboardSession { 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)] struct PortalStream { node_id: u32, @@ -96,6 +109,7 @@ struct PointerReleaseGuard { session_handle: OwnedObjectPath, button: Option, input_guard: Option>, + operation_guard: Option, valid: Arc, } @@ -104,6 +118,7 @@ struct KeyboardReleaseGuard { session_handle: OwnedObjectPath, pressed: Vec, input_guard: Option>, + operation_guard: Option, valid: Arc, } @@ -537,17 +552,18 @@ 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); + 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) { @@ -680,17 +696,18 @@ pub(crate) fn portal_scroll_axis_steps( (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); + 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( @@ -724,14 +741,15 @@ pub async fn drag( 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); + 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?; @@ -743,15 +761,16 @@ pub async fn type_text_with_keysyms( 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); + 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?; @@ -769,12 +788,17 @@ pub async fn press_keycode_chord( } impl PointerReleaseGuard { - fn new(session: &PortalPointerSession, input_guard: OwnedMutexGuard<()>) -> Self { + 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), } } @@ -791,44 +815,47 @@ impl PointerReleaseGuard { 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(); - if let Ok(runtime) = tokio::runtime::Handle::try_current() { - runtime.spawn(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; - drop(input_guard); - }); - } + 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<()>) -> Self { + 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), } } @@ -878,6 +905,7 @@ impl Drop for PortalSessionCleanup { 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; } @@ -885,43 +913,56 @@ impl Drop for KeyboardReleaseGuard { let connection = self.connection.clone(); let session_handle = self.session_handle.clone(); let pressed = std::mem::take(&mut self.pressed); - if let Ok(runtime) = tokio::runtime::Handle::try_current() { - runtime.spawn(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; - } + 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; - drop(input_guard); - }); - } + } + }) + .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); + }); } } @@ -2105,4 +2146,34 @@ mod tests { 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 b56bdd450..a96cbcb51 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, @@ -583,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.", @@ -619,7 +595,7 @@ impl ComputerUseLinux { )] async fn click(&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 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. @@ -757,26 +733,41 @@ 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 pointer = guard.as_mut()?; + Some(pointer.click(x, y, btn, count).is_ok()) + }) + .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; + if clicked == Ok(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 let Some(session) = self.cached_portal_pointer_session() { let Some((portal_x, portal_y)) = @@ -794,6 +785,7 @@ impl ComputerUseLinux { portal_y, PointerButton::from_name(params.button.as_deref()), params.click_count.unwrap_or(1).clamp(1, 10), + InputOperationGuard::new(input_guard), ) .await { @@ -835,6 +827,7 @@ impl ComputerUseLinux { portal_y, PointerButton::from_name(params.button.as_deref()), params.click_count.unwrap_or(1).clamp(1, 10), + InputOperationGuard::new(input_guard), ) .await { @@ -862,8 +855,22 @@ impl ComputerUseLinux { } } } - 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, + )); + } } } let commands = vec![ @@ -1134,7 +1141,15 @@ impl ComputerUseLinux { off_screen_note.clone(), )); }; - match portal_scroll(&session, portal_target_point, direction, units).await { + 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 { @@ -1150,7 +1165,11 @@ impl ComputerUseLinux { Err(error) => { self.clear_portal_pointer_session(&session); return Json(with_notes( - pointer_action_result(portal_action_error("scroll", error, received)), + pointer_action_result(portal_action_error( + "scroll", + anyhow::anyhow!(error), + received, + )), off_screen_note.clone(), )); } @@ -1170,7 +1189,16 @@ impl ComputerUseLinux { off_screen_note.clone(), )); }; - match portal_scroll(&session, portal_target_point, direction, units).await { + 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 { @@ -1188,15 +1216,31 @@ impl ComputerUseLinux { self.clear_portal_pointer_session(&session); return Json(with_notes( pointer_action_result(portal_action_error( - "scroll", error, received, + "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() { @@ -1243,28 +1287,35 @@ impl ComputerUseLinux { )] async fn drag(&self, Parameters(params): Parameters) -> Json { let received = Some(serde_json::json!(params)); - let input_guard = Arc::clone(&self.input_operation_lock).lock_owned().await; + 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() { + guard.as_mut().map(|pointer| { + pointer + .drag(start, end, crate::abs_pointer::PointerButton::Left) + .is_ok() + }) + } else { + None + } }) - } else { - None - } - }) - .await - .ok() - .flatten(); - if dragged == Some(true) { + .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; + if dragged == Ok(Some(true)) { return Json(pointer_action_result(ActionOutput { ok: true, implemented: true, @@ -1292,7 +1343,16 @@ impl ComputerUseLinux { "drag", received, ))); }; - match portal_drag(&session, start_x, start_y, end_x, end_y).await { + 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, @@ -1329,7 +1389,16 @@ impl ComputerUseLinux { "drag", received, ))); }; - match portal_drag(&session, start_x, start_y, end_x, end_y).await { + 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, @@ -1348,8 +1417,18 @@ impl ComputerUseLinux { } } } - 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 (input_guard, result) = run_cancellation_safe_input(input_guard, async move { @@ -1377,7 +1456,8 @@ impl ComputerUseLinux { Parameters(params): Parameters, ) -> Json { let received = Some(serde_json::json!(params.clone())); - let input_guard = Arc::clone(&self.input_operation_lock).lock_owned().await; + 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) => { @@ -1406,7 +1486,14 @@ impl ComputerUseLinux { .iter() .map(|modifier| i32::from(*modifier)) .collect::>(); - match press_keycode_chord(&session, &modifiers, i32::from(chord_key)).await { + 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( @@ -1430,8 +1517,22 @@ impl ComputerUseLinux { } } } - Ok(None) => {} - Err(_) => {} + 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 { @@ -1504,7 +1605,8 @@ impl ComputerUseLinux { Parameters(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 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) => { @@ -1520,8 +1622,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( @@ -1549,38 +1678,68 @@ 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().await { 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 { - Ok(()) => { - let notes = self.input_landing_notes(focus.as_ref(), true).await; - return Json(with_notes( - successful_action_with_focus( + 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( + successful_action_with_focus( + "type_text", + "Action sent through the remote desktop portal.", + received, + focus, + ), + notes, + )); + } + Err(error) => { + self.clear_portal_keyboard_session(&session); + return Json(action_result_with_focus( "type_text", - "Action sent through the remote desktop portal.", + Err(format!("{error:#}")), received, focus, - ), - notes, - )); - } - Err(error) => { - self.clear_portal_keyboard_session(&session); - return Json(action_result_with_focus( - "type_text", - Err(format!("{error:#}")), - received, - 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, + )); + } } } } @@ -4231,25 +4390,36 @@ fn wheel_mousemove_args(dx: i32, dy: i32) -> Vec { ] } -async fn run_cancellation_safe_input( - input_guard: tokio::sync::OwnedMutexGuard<()>, +async fn run_cancellation_safe_guarded( + guard: G, operation: F, -) -> ( - Option>, - std::result::Result, -) +) -> (Option, std::result::Result) where + G: Send + 'static, T: Send + 'static, - F: Future> + 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 { (input_guard, operation.await) }).await { - Ok((input_guard, result)) => (Some(input_guard), result), + 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> { @@ -4400,6 +4570,7 @@ impl KdeClipboardPasteError { async fn run_kde_clipboard_paste_text( session: &PortalKeyboardSession, text: &str, + operation_guard: InputOperationGuard, ) -> std::result::Result { let previous = kde_clipboard_contents() .await @@ -4408,9 +4579,14 @@ async fn run_kde_clipboard_paste_text( .await .map_err(KdeClipboardPasteError::before_text_input)?; - 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; @@ -6201,6 +6377,43 @@ mod tests { .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"] { diff --git a/computer-use-linux/src/windowing/target.rs b/computer-use-linux/src/windowing/target.rs index 406e761d6..fc0509e51 100644 --- a/computer-use-linux/src/windowing/target.rs +++ b/computer-use-linux/src/windowing/target.rs @@ -4,7 +4,8 @@ use anyhow::{bail, Result}; use std::future::Future; use tokio::time::{sleep_until, timeout_at, Duration, Instant}; -const FOCUS_VERIFY_TIMEOUT: Duration = Duration::from_secs(1); +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> { @@ -70,25 +71,33 @@ 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_TIMEOUT, || { - registry::focused_window_for_backend(&requested_window.backend) - }) + 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, - verify_timeout: Duration, + transition_timeout: Duration, + query_timeout: Duration, mut query: F, ) -> Option where F: FnMut() -> Fut, Fut: Future>>, { - let deadline = Instant::now() + verify_timeout; + // 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; loop { - match timeout_at(deadline, query()).await { + 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() @@ -401,7 +410,8 @@ mod tests { #[test] fn focus_verification_allows_workspace_transition_latency() { - assert!(FOCUS_VERIFY_TIMEOUT >= Duration::from_secs(1)); + assert!(FOCUS_VERIFY_TRANSITION_TIMEOUT >= Duration::from_secs(1)); + assert!(FOCUS_VERIFY_QUERY_TIMEOUT >= Duration::from_secs(4)); } #[tokio::test] @@ -422,14 +432,72 @@ mod tests { }; let started = Instant::now(); - let focused = - wait_for_focused_window_with(&requested_window, Duration::from_millis(20), || async { + 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; + }, + ) + .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)); + } } From 8dfcd297077f29dacf2963a478ac21d2165d73c4 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Sun, 2 Aug 2026 19:01:22 +0300 Subject: [PATCH 066/112] fix(computer-use): prevent ambiguous input replay --- computer-use-linux/src/abs_pointer.rs | 47 +++++-- computer-use-linux/src/server.rs | 185 ++++++++++++++++++-------- 2 files changed, 167 insertions(+), 65 deletions(-) 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/server.rs b/computer-use-linux/src/server.rs index a96cbcb51..c7748d22c 100644 --- a/computer-use-linux/src/server.rs +++ b/computer-use-linux/src/server.rs @@ -741,8 +741,14 @@ impl ComputerUseLinux { run_cancellation_safe_guarded(input_guard, async move { 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()) + 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() @@ -756,17 +762,33 @@ impl ComputerUseLinux { )); }; input_guard = returned_guard; - if clicked == Ok(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(), - )); + 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() { @@ -1297,11 +1319,15 @@ impl ComputerUseLinux { run_cancellation_safe_guarded(input_guard, async move { tokio::task::spawn_blocking(move || { if let Ok(mut guard) = abs_pointer.lock() { - guard.as_mut().map(|pointer| { + let result = guard.as_mut().map(|pointer| { pointer .drag(start, end, crate::abs_pointer::PointerButton::Left) - .is_ok() - }) + .map_err(|error| format!("{error:#}")) + }); + if result.as_ref().is_some_and(Result::is_err) { + guard.take(); + } + result } else { None } @@ -1315,14 +1341,27 @@ impl ComputerUseLinux { return Json(action_result("drag", Err(dragged.unwrap_err()), received)); }; input_guard = returned_guard; - if dragged == Ok(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, - })); + 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, + })); + } + 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() { @@ -1697,49 +1736,60 @@ impl ComputerUseLinux { } } if self.should_prefer_portal_keyboard_backend().await { - 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, Some(input_guard)).await { - Ok(()) => { - let notes = self.input_landing_notes(focus.as_ref(), true).await; - return Json(with_notes( - successful_action_with_focus( - "type_text", - "Action sent through the remote desktop portal.", - received, - focus, - ), - notes, - )); - } - Err(error) => { - self.clear_portal_keyboard_session(&session); - return Json(action_result_with_focus( + 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( + successful_action_with_focus( "type_text", - Err(format!("{error:#}")), + "Action sent through the remote desktop portal.", received, focus, - )); - } + ), + notes, + )); + } + Err(error) => { + self.clear_portal_keyboard_session(&session); + return Json(action_result_with_focus( + "type_text", + Err(format!("{error:#}")), + received, + focus, + )); } } - Ok(None) => { - return Json(action_result_with_focus( + } + 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( + } + 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, )); - } } } } @@ -4565,6 +4615,14 @@ 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( @@ -4575,9 +4633,18 @@ async fn run_kde_clipboard_paste_text( 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, @@ -5861,6 +5928,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( From e8b6bf6dc3a82920f2300d464ba9b1af45a1c4b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yannick=20Sch=C3=A4fer?= Date: Mon, 3 Aug 2026 14:22:36 +0200 Subject: [PATCH 067/112] fix(updater): use scope-safe module imports --- scripts/lib/linux-update-bridge-patch.js | 15 +++------------ scripts/patch-linux-window-ui.test.js | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/scripts/lib/linux-update-bridge-patch.js b/scripts/lib/linux-update-bridge-patch.js index 663881702..ee79576bf 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}"; } @@ -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(")) { diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index 711afa0ef..03030ec58 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -7752,6 +7752,10 @@ 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, /codexLinuxProbeUpdateManager\(\)\.then\(\(\)=>\{s=!0,i\(\),a\(\);return!0\}\)/); @@ -7764,6 +7768,23 @@ 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()); From 62e16178319f4fdcdc9082f6c992a34bea6234f5 Mon Sep 17 00:00:00 2001 From: Mohit Sahoo Date: Mon, 3 Aug 2026 19:32:33 +0530 Subject: [PATCH 068/112] project-group-last-updated-sort: retarget Codex 26.727 sorter symbols - Retarget current project-group sorter and call-site needles. - Preserve idempotent patching and drift coverage. --- .../project-group-last-updated-sort/patch.js | 8 ++++---- .../project-group-last-updated-sort/test.js | 19 +++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/linux-features/project-group-last-updated-sort/patch.js b/linux-features/project-group-last-updated-sort/patch.js index ffd841979..f92ee33da 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 h2o({groups:e,items:t,projectOrder:n}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return iZi(e.map((e,t)=>({group:e,index:t,recencyAt:y2o(e,r)})).sort((e,t)=>t.recencyAt-e.recencyAt||e.index-t.index).map(({group:e})=>e),n)}"; + "function Aos({groups:e,items:t,projectOrder:n}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return Sca(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 h2o({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:iZi(codexLinuxRecencySortedGroups,n))(e.map((e,t)=>({group:e,index:t,recencyAt:y2o(e,r)})).sort((e,t)=>t.recencyAt-e.recencyAt||e.index-t.index).map(({group:e})=>e))}"; + "function Aos({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:Sca(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=h2o({groups:m2o({groups:C,items:s}),items:s,projectOrder:ap(t,zl.PROJECT_ORDER)})"; + "A=Aos({groups:kos({groups:O,items:f}),items:f,projectOrder:Cp(t,Il.PROJECT_ORDER)})"; const patchedGroupSorterCall = - "T=h2o({groups:m2o({groups:C,items:s}),items:s,projectOrder:ap(t,zl.PROJECT_ORDER),sortMode:t(Ez).projectSortMode})"; + "A=Aos({groups:kos({groups:O,items:f}),items:f,projectOrder:Cp(t,Il.PROJECT_ORDER),sortMode:t(Sz).projectSortMode})"; function countOccurrences(source, needle) { return source.split(needle).length - 1; diff --git a/linux-features/project-group-last-updated-sort/test.js b/linux-features/project-group-last-updated-sort/test.js index dc47b019d..81c4933e5 100644 --- a/linux-features/project-group-last-updated-sort/test.js +++ b/linux-features/project-group-last-updated-sort/test.js @@ -18,13 +18,12 @@ const { } = require("./patch.js"); const currentProjectSource = [ - "function iZi(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 y2o(e,t){let n=e.projectUpdatedAt??0;for(let r of e.threadKeys)n=Math.max(n,t.get(r)??0);return n}", - "function h2o({groups:e,items:t,projectOrder:n}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return iZi(e.map((e,t)=>({group:e,index:t,recencyAt:y2o(e,r)})).sort((e,t)=>t.recencyAt-e.recencyAt||e.index-t.index).map(({group:e})=>e),n)}", + "function Sca(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 Aos({groups:e,items:t,projectOrder:n}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return Sca(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=h2o({groups:m2o({groups:C,items:s}),items:s,projectOrder:ap(t,zl.PROJECT_ORDER)});", + "A=Aos({groups:kos({groups:O,items:f}),items:f,projectOrder:Cp(t,Il.PROJECT_ORDER)});", ].join(""); function captureWarns(fn) { @@ -70,7 +69,7 @@ function withFeatureConfig(enabled, fn) { function evaluateGroupSorter(source) { const context = {}; const sorterSource = source.slice(0, source.indexOf("const prioritySortId")); - vm.runInNewContext(`${sorterSource};globalThis.sortProjectGroups=h2o`, context); + vm.runInNewContext(`${sorterSource};globalThis.sortProjectGroups=Aos`, context); return context.sortProjectGroups; } @@ -154,15 +153,15 @@ test("patch passes the selected project sort mode into the group sorter", () => const patched = applyPatchTwice(currentProjectSource); assert.ok( patched.includes( - "projectOrder:ap(t,zl.PROJECT_ORDER),sortMode:t(Ez).projectSortMode", + "projectOrder:Cp(t,Il.PROJECT_ORDER),sortMode:t(Sz).projectSortMode", ), ); }); test("drift leaves the asset byte-identical", () => { const source = currentProjectSource.replace( - "function h2o({groups:e,items:t,projectOrder:n})", - "function h2o({groups:e,items:t,projectOrder:n,unknown:o})", + "function Aos({groups:e,items:t,projectOrder:n})", + "function Aos({groups:e,items:t,projectOrder:n,unknown:o})", ); const { value, warnings } = captureWarns(() => applyProjectGroupLastUpdatedSortPatch(source), @@ -175,7 +174,7 @@ test("drift leaves the asset byte-identical", () => { test("missing current call site leaves the asset byte-identical", () => { const source = currentProjectSource.replace( - "projectOrder:ap(t,zl.PROJECT_ORDER)", + "projectOrder:Cp(t,Il.PROJECT_ORDER)", "projectOrder:unknownProjectOrder", ); const { value, warnings } = captureWarns(() => @@ -208,7 +207,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-BHB6SClA.js", + "app-initial-iBPGfcXU.js", ); fs.mkdirSync(assetsDir, { recursive: true }); fs.writeFileSync(assetPath, currentProjectSource); From 2a142b29a1dff48b7c27fe66de73d21780bf8f0c Mon Sep 17 00:00:00 2001 From: Mohit Sahoo Date: Mon, 3 Aug 2026 19:24:05 +0530 Subject: [PATCH 069/112] shared-app-server-socket: refresh current-DMG SSH transport matcher - Match the current SSH websocket lifecycle and minified transport symbols. - Keep synthetic-bundle coverage aligned with the refreshed upstream shape. --- .../shared-app-server-socket/patch.js | 2 +- .../shared-app-server-socket/test.js | 21 ++++++++++--------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/linux-features/shared-app-server-socket/patch.js b/linux-features/shared-app-server-socket/patch.js index d9cdeda0b..a083122fa 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; diff --git a/linux-features/shared-app-server-socket/test.js b/linux-features/shared-app-server-socket/test.js index a103b2aca..577f041ad 100755 --- a/linux-features/shared-app-server-socket/test.js +++ b/linux-features/shared-app-server-socket/test.js @@ -175,14 +175,15 @@ async function closeServer(server) { 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(""); } @@ -229,8 +230,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 +249,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 = []; From a4bec726819c174c3f93d972d0edf6a042a04699 Mon Sep 17 00:00:00 2001 From: prichardsondev Date: Mon, 3 Aug 2026 13:17:37 -0400 Subject: [PATCH 070/112] Document Raspberry Pi 5 validation --- README.md | 2 + docs/raspberry-pi-5.md | 114 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 docs/raspberry-pi-5.md diff --git a/README.md b/README.md index 47e810d2f..fc232a7d5 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,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 | @@ -360,6 +361,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/docs/raspberry-pi-5.md b/docs/raspberry-pi-5.md new file mode 100644 index 000000000..167bf3226 --- /dev/null +++ b/docs/raspberry-pi-5.md @@ -0,0 +1,114 @@ +# 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 + +## Remaining validation + +The baseline proves the core desktop and Codex workflow, but it does not cover +every optional integration. Browser Use and Computer Use should be tested and +reported separately. In particular, 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. + +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. From e23e074fb690ba5dad3664d440f3fde9ecd83b2d Mon Sep 17 00:00:00 2001 From: Caio Faheina <69549574+PinguuSS@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:37:57 -0400 Subject: [PATCH 071/112] fix(updater): preserve feature picker settings (#1211) * fix(updater): preserve feature picker settings * docs: note preserved feature picker config * fix(updater): seed first feature picker save * chore(updater): bump version to 0.10.5 --- CHANGELOG.md | 2 + Cargo.lock | 2 +- updater/Cargo.toml | 2 +- updater/src/feature_picker.rs | 146 +++++++++++++++++++++++++++++++++- 4 files changed, 146 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bf4ef436..0b98d147f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- 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 diff --git a/Cargo.lock b/Cargo.lock index 8130ee1db..b5b77677a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -561,7 +561,7 @@ dependencies = [ [[package]] name = "codex-update-manager" -version = "0.10.4" +version = "0.10.5" dependencies = [ "anyhow", "chrono", diff --git a/updater/Cargo.toml b/updater/Cargo.toml index c94e597e4..628331b9b 100644 --- a/updater/Cargo.toml +++ b/updater/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-update-manager" -version = "0.10.4" +version = "0.10.5" edition = "2021" [dependencies] 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(); From 0f088bd3cbfca540b97a157bdf8bb6a95cd88343 Mon Sep 17 00:00:00 2001 From: prichardsondev Date: Mon, 3 Aug 2026 14:27:21 -0400 Subject: [PATCH 072/112] Record unavailable Pi optional capabilities --- docs/raspberry-pi-5.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/raspberry-pi-5.md b/docs/raspberry-pi-5.md index 167bf3226..ea28d430c 100644 --- a/docs/raspberry-pi-5.md +++ b/docs/raspberry-pi-5.md @@ -87,13 +87,20 @@ The following checks passed on the test Pi: - integrated command execution - Python, SQLite, automated test, and local Git workflows -## Remaining validation +## Optional capability results + +Browser Use and Computer Use were not available in the validated Pi session. +The core workflow test did not diagnose a single cause for both features, so +their absence should not be attributed solely to ARM64. Computer Use UI access +can also depend on local opt-in and upstream account rollout. -The baseline proves the core desktop and Codex workflow, but it does not cover -every optional integration. Browser Use and Computer Use should be tested and -reported separately. In particular, 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. +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. Treat Browser Use and +Computer Use as unavailable on this validated baseline until separate ARM64 +testing demonstrates otherwise. + +## Remaining validation Long-running thermal behavior, peak memory use, and the automatic update manager were not measured during this first validation. From c4e5b5ad3eceb2bf66141a7695ddc3a2b6148f0f Mon Sep 17 00:00:00 2001 From: prichardsondev Date: Mon, 3 Aug 2026 14:58:03 -0400 Subject: [PATCH 073/112] Correct Raspberry Pi Computer Use results --- docs/raspberry-pi-5.md | 51 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/docs/raspberry-pi-5.md b/docs/raspberry-pi-5.md index ea28d430c..b430e330f 100644 --- a/docs/raspberry-pi-5.md +++ b/docs/raspberry-pi-5.md @@ -86,19 +86,56 @@ The following checks passed on the test Pi: - workspace file creation and editing - integrated command execution - Python, SQLite, automated test, and local Git workflows +- Chromium control through Linux Computer Use, including navigation, accessible + element discovery, clicking, typing, publishing, and result verification ## Optional capability results -Browser Use and Computer Use were not available in the validated Pi session. -The core workflow test did not diagnose a single cause for both features, so -their absence should not be attributed solely to ARM64. Computer Use UI access -can also depend on local opt-in and upstream account rollout. +Linux Computer Use was validated end to end on the Labwc Wayland session after +the desktop-control dependencies were completed. Initially, screenshots worked +but accessibility discovery, window targeting, pointer input, and keyboard +input were incomplete. + +The successful Pi configuration added: + +- `at-spi2-core` and toolkit accessibility for AT-SPI element discovery +- `wlrctl` for window discovery and focus through Labwc's wlroots + foreign-toplevel interface +- 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 +- positive Chromium focus verification before keyboard injection + +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 readiness instructions. + +The final test used Chromium through Linux Computer Use to open an external +user-owned web application, inspect its accessibility tree, complete a content +form, publish a temporary test item, and read back its public URL. One initial +keyboard attempt reached the wrong window before explicit Chromium focus +verification was added; the completed workflow then succeeded. + +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. Treat Browser Use and -Computer Use as unavailable on this validated baseline until separate ARM64 -testing demonstrates otherwise. +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 From 943693fba4a894a028f91727133318617c6c932f Mon Sep 17 00:00:00 2001 From: prichardsondev Date: Mon, 3 Aug 2026 15:05:26 -0400 Subject: [PATCH 074/112] Record persistent Pi test artifact --- docs/raspberry-pi-5.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/raspberry-pi-5.md b/docs/raspberry-pi-5.md index b430e330f..1929761c6 100644 --- a/docs/raspberry-pi-5.md +++ b/docs/raspberry-pi-5.md @@ -121,7 +121,7 @@ for the general dependency, daemon, UI opt-in, and readiness instructions. The final test used Chromium through Linux Computer Use to open an external user-owned web application, inspect its accessibility tree, complete a content -form, publish a temporary test item, and read back its public URL. One initial +form, publish a persistent test item, and read back its public URL. One initial keyboard attempt reached the wrong window before explicit Chromium focus verification was added; the completed workflow then succeeded. From 85e71d478d232c220caabb5a21165dc0e56191c1 Mon Sep 17 00:00:00 2001 From: mohit Date: Tue, 4 Aug 2026 01:13:00 +0530 Subject: [PATCH 075/112] Clean up orphaned shared app-server authorities (#1209) * Fix orphaned shared app-server authority cleanup * Bind orphan cleanup to locked authority --- .../shared-app-server-socket/README.md | 10 + .../shared-app-server-socket/feature.json | 12 + .../shared-app-server-socket/orphan-reaper.js | 276 ++++++++++++++++++ .../shared-app-server-socket/patch.js | 9 +- .../shared-app-server-socket/socket-env.sh | 26 +- .../shared-app-server-socket/test.js | 260 ++++++++++++++++- 6 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 linux-features/shared-app-server-socket/orphan-reaper.js diff --git a/linux-features/shared-app-server-socket/README.md b/linux-features/shared-app-server-socket/README.md index 54032d49a..b1b5226fe 100644 --- a/linux-features/shared-app-server-socket/README.md +++ b/linux-features/shared-app-server-socket/README.md @@ -36,6 +36,16 @@ 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, reparented `codex app-server +--listen unix://PATH` process serving the exact locked socket. 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..8c8b40255 --- /dev/null +++ b/linux-features/shared-app-server-socket/orphan-reaper.js @@ -0,0 +1,276 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); + +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(")"); + if (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, + 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 verifiedOrphanTargets(lock, listeners) { + const authority = readProcess(lock.authorityPid); + if ( + authority == null || + authority.startTime !== lock.authorityStartTime || + (expectedUid != null && authority.uid !== expectedUid) || + authority.ppid !== 1 || + !isExpectedAuthority(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(); + if ( + !unchangedLock(lock) || + !ownerIsDead(lock.ownerPid, lock.ownerStartTime) || + socketPathState(socket) !== "same" || + verifiedInodes.length !== 1 || + verifiedInodes[0] !== inode || + 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 a083122fa..43270047e 100644 --- a/linux-features/shared-app-server-socket/patch.js +++ b/linux-features/shared-app-server-socket/patch.js @@ -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 577f041ad..b6c78bc58 100755 --- a/linux-features/shared-app-server-socket/test.js +++ b/linux-features/shared-app-server-socket/test.js @@ -23,6 +23,7 @@ const { } = require("./patch.js"); const socketEnvHook = path.join(__dirname, "socket-env.sh"); +const orphanReaper = path.join(__dirname, "orphan-reaper.js"); function withFeatureConfig(enabled, callback) { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "shared-app-server-socket-feature-")); @@ -89,6 +90,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,6 +175,104 @@ 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 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( + () => { + try { + const rawStat = fs.readFileSync(`/proc/${pid}/stat`, "utf8"); + const commandEnd = rawStat.lastIndexOf(")"); + const fields = rawStat.slice(commandEnd + 2).trim().split(/\s+/); + return Number(fields[1]) === 1 && fs.existsSync(socketPath); + } catch (error) { + if (error.code === "ENOENT") return false; + throw error; + } + }, + "detached authority to be reparented", + ); + return { pid, startTime }; +} + function syntheticBundle() { return [ "var gC=class{options;kind=`websocket`;logger=i.i(`AppServerTransportSshWebsocket`);proxyStreams=new Set;hasConnected=!1;supportsReconnect(){return!0}", @@ -199,14 +299,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 }); @@ -291,6 +404,135 @@ 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 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"); @@ -378,6 +620,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; @@ -604,6 +850,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; @@ -812,6 +1063,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) { From 9f4a5ed743b16919883442091bdaca8c86bc36ff Mon Sep 17 00:00:00 2001 From: prichardsondev Date: Mon, 3 Aug 2026 15:53:27 -0400 Subject: [PATCH 076/112] Clarify manual Labwc window control --- docs/raspberry-pi-5.md | 42 ++++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/docs/raspberry-pi-5.md b/docs/raspberry-pi-5.md index 1929761c6..34429f4c8 100644 --- a/docs/raspberry-pi-5.md +++ b/docs/raspberry-pi-5.md @@ -86,25 +86,27 @@ The following checks passed on the test Pi: - workspace file creation and editing - integrated command execution - Python, SQLite, automated test, and local Git workflows -- Chromium control through Linux Computer Use, including navigation, accessible - element discovery, clicking, typing, publishing, and result verification +- 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 -Linux Computer Use was validated end to end on the Labwc Wayland session after -the desktop-control dependencies were completed. Initially, screenshots worked -but accessibility discovery, window targeting, pointer input, and keyboard -input were incomplete. +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 -- `wlrctl` for window discovery and focus through Labwc's wlroots - foreign-toplevel interface +- 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 -- positive Chromium focus verification before keyboard injection A scoped udev rule granted the `input` group read/write access to `/dev/uinput`: @@ -117,13 +119,21 @@ 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 readiness instructions. - -The final test used Chromium through Linux Computer Use to open an external -user-owned web application, inspect its accessibility tree, complete a content -form, publish a persistent test item, and read back its public URL. One initial -keyboard attempt reached the wrong window before explicit Chromium focus -verification was added; the completed workflow then succeeded. +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 From 8b8daf9e8b76aca56165812029f22bdd9b4a6d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yannick=20Sch=C3=A4fer?= Date: Mon, 3 Aug 2026 23:51:28 +0200 Subject: [PATCH 077/112] fix(updater): ignore documentation-only wrapper changes --- CHANGELOG.md | 2 + updater/src/app.rs | 2 +- updater/src/wrapper.rs | 87 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b98d147f..f0f2a113f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- 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 diff --git a/updater/src/app.rs b/updater/src/app.rs index 8d41546a0..0467e6e14 100644 --- a/updater/src/app.rs +++ b/updater/src/app.rs @@ -689,7 +689,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)?; 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(); From 21472a248e0272d192d23e59748dd475c07a3873 Mon Sep 17 00:00:00 2001 From: Agentix Software Date: Tue, 4 Aug 2026 12:41:13 -0700 Subject: [PATCH 078/112] fix(api-key-model-visibility): match refactored upstream model gate Upstream split the app main webview chunk from app-initial~app-main~*.js to app-initial-*.js and refactored the allowlist gate into a per-model visibility helper (q-style) with an additionalAvailableModels parameter, so the feature patch no longer matched and the picker hid API-key provider models behind the desktop allowlist. - Match both webview chunk naming schemes in the patch descriptor. - Patch the refactored helper gate (add apikey exclusion) and keep the legacy inline-gate pattern as a fail-soft fallback. - Guard against double application and mid-expression patching. - Update fixtures/tests to the current bundle shape (8/8 pass). --- .../api-key-model-visibility/patch.js | 54 ++++++++++++------- .../api-key-model-visibility/test.js | 13 +++-- 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/linux-features/api-key-model-visibility/patch.js b/linux-features/api-key-model-visibility/patch.js index 012408699..af24890dd 100644 --- a/linux-features/api-key-model-visibility/patch.js +++ b/linux-features/api-key-model-visibility/patch.js @@ -8,35 +8,49 @@ 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` + + `[\\s\\S]{0,300}?)\\3&&\\2!==\\\`amazonBedrock\\\`(?=[,;?])`, "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, + helperPattern, (_match, prefix, authMethodVar, useHiddenModelsVar) => `${prefix}${useHiddenModelsVar}&&${authMethodVar}!==\`amazonBedrock\`&&` + `${authMethodVar}!==\`apikey\`/*${PATCH_MARKER}*/`, ); - if (patched !== source) { return patched; } - if (patchedVisibilityPattern.test(source)) { - return source; + // Legacy upstream shape: inline gate in the catalog filter function with + // authMethod as the first destructured parameter. + const legacyPattern = 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\\\`(?=[,;])`, + "g", + ); + const patchedLegacy = source.replace( + legacyPattern, + (_match, prefix, authMethodVar, useHiddenModelsVar) => + `${prefix}${useHiddenModelsVar}&&${authMethodVar}!==\`amazonBedrock\`&&` + + `${authMethodVar}!==\`apikey\`/*${PATCH_MARKER}*/`, + ); + if (patchedLegacy !== source) { + return patchedLegacy; } if ( @@ -55,7 +69,9 @@ const descriptors = [ phase: "webview-asset", order: 20550, ciPolicy: "optional", - pattern: /^app-initial~app-main~.*\.js$/, + // Upstream renamed the app main webview chunk from `app-initial~app-main~*.js` + // to `app-initial-*.js` (vite split the combined chunk); match both shapes. + pattern: /^app-initial(~app-main~|-).*\.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..949084032 100644 --- a/linux-features/api-key-model-visibility/test.js +++ b/linux-features/api-key-model-visibility/test.js @@ -30,7 +30,9 @@ function applyPatchTwice(patchFn, source) { } 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}}"; + // 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}}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() { @@ -107,6 +109,7 @@ test("descriptor is optional and targets app main webview chunks", () => { [["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-iBPGfcXU.js"), true); assert.equal(descriptors[0].pattern.test("settings-page-abc.js"), false); }); @@ -114,7 +117,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", @@ -166,8 +169,8 @@ test("model visibility and API key service tier patches compose in either order" 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;", + "t!==`amazonBedrock`?n.has(r.model)", + "t!==`amazonBedrock`&&featureGate?n.has(r.model)", ); assert.equal(applyApiKeyModelVisibilityPatch(source), source); @@ -177,7 +180,7 @@ 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-iBPGfcXU.js"); fs.mkdirSync(assetsDir, { recursive: true }); fs.writeFileSync(assetPath, modelCatalogFixture()); From 39b4c43714931ce21ae8b1a581c4c95755b16a35 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:49:27 +0300 Subject: [PATCH 079/112] fix(nix): refresh upstream Nix pins for 26.730.61309 (#1221) Refreshed Codex.dmg SRI hash to sha256-4+P+DfI5AodS53RPM2xKo2Ut3WYDC8CL+radlun48ys= and synced codexVersion / electronVersion / native-module pins to the current upstream DMG. Verified all ChatGPT Desktop Nix package outputs against the refreshed DMG. Source-Main-SHA: 7166d1153fd99647fb080605c8b2a8f22b50b08f Upstream-DMG-SHA256: e3e3fe0df239028752e7744f336c4aa3652ddd66030bc08bfab69d96e9f8f32b Co-authored-by: codex-dmg-hash-bot --- flake.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index a636bdddd..ac7f86e6d 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-RewAag8/D6AEtv1NbVUpl5oFNh+ZXKLFHeOjsE3u4SM="; + hash = "sha256-4+P+DfI5AodS53RPM2xKo2Ut3WYDC8CL+radlun48ys="; }; - codexVersion = "26.727.51351"; + codexVersion = "26.730.61309"; electronVersion = "42.3.0"; electronPlatform = { From 111ec9d1a39f06a9b9df9fd34807e0f381cee865 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Wed, 5 Aug 2026 05:44:43 +0300 Subject: [PATCH 080/112] Fix optional upstream DMG drift (#1222) * Fix optional upstream DMG drift optional-drift-watchdog-action: source-commit * Fix optional upstream DMG drift optional-drift-watchdog-action: source-commit --- scripts/lib/linux-update-bridge-patch.js | 4 +- scripts/patch-linux-window-ui.test.js | 45 ++-- .../patch.js | 22 +- .../impl/webview-browser-use-external.test.js | 230 +++++++++++++++++ scripts/patches/impl/webview/index.js | 244 ++++++++++++++---- 5 files changed, 471 insertions(+), 74 deletions(-) create mode 100644 scripts/patches/impl/webview-browser-use-external.test.js diff --git a/scripts/lib/linux-update-bridge-patch.js b/scripts/lib/linux-update-bridge-patch.js index ee79576bf..04ce878c6 100644 --- a/scripts/lib/linux-update-bridge-patch.js +++ b/scripts/lib/linux-update-bridge-patch.js @@ -60,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; @@ -87,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/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index 03030ec58..2a4c77328 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -182,6 +182,7 @@ const { applyLinuxAppSunsetPatch, applyLinuxBrowserUseAvailabilityPatch, applyLinuxBrowserUseExternalAvailabilityPatch, + patchLinuxBrowserUseExternalAvailabilityAssets, applyLinuxBrowserUseWebviewHostRecoveryPatch, applyLinuxBrowserUseWebviewRemountStorePatch, applyLinuxBrowserUseNonLocalNavigationPatch, @@ -1888,9 +1889,9 @@ 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(""); } @@ -7890,12 +7891,12 @@ test("keeps the current Sparkle menu contract callable across Linux updater prob 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(() => @@ -7909,7 +7910,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( @@ -8612,34 +8613,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", () => { 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/impl/webview-browser-use-external.test.js b/scripts/patches/impl/webview-browser-use-external.test.js new file mode 100644 index 000000000..0a141cdbf --- /dev/null +++ b/scripts/patches/impl/webview-browser-use-external.test.js @@ -0,0 +1,230 @@ +"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`]}"; + +function currentBrowserRegistry(variableName) { + return `var ${variableName}={chrome:{backendCompatibilityKey:\`chrome\`,displayName:\`Google Chrome\`,${currentChromeLinuxRegistry}},edge:{backendCompatibilityKey:\`chrome\`,displayName:\`Microsoft Edge\`,linux:{installations:[{commands:[\`microsoft-edge\`,\`microsoft-edge-stable\`],userDataDirName:\`microsoft-edge\`}],nativeMessagingManifestDirectories:[\`.config/microsoft-edge/NativeMessagingHosts\`],processNames:[\`msedge\`],userDataDirectorySegments:[\`.config\`,\`microsoft-edge\`]}}};`; +} + +function currentMainRegistryFixture() { + return [ + currentBrowserRegistry("ob"), + "function fb(e){return Object.hasOwn(ob,e)}", + "Object.defineProperty(exports,`So`,{enumerable:!0,get:function(){return ob}}),Object.defineProperty(exports,`wo`,{enumerable:!0,get:function(){return fb}});", + ].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 sne({browserFamily:e,platform:t=process.platform}){return t===`darwin`?!1:t===`win32`?!1:t===`linux`&&Ol(e)!=null}", + "async function lne({browserFamily:e,platform:t=process.platform,runCommand:n=ml,url:r}){await El({browserFamily:e,platform:t,runCommand:n,unsupportedPlatformError:`unsupported`,url:r})}", + "async function El({browserFamily:e,platform:t,runCommand:r,url:i}){let a=n.So[e];if(t===`linux`){let t=Ol(e);if(t==null)throw Error(`${a.displayName} is not installed`);await r(t,[i]);return}throw Error(`unsupported`)}", + "function Ol(e){let t=n.So[e],r=t.linux.installations;for(let e of r){let t=kl(e);if(t!=null)return t}return null}function kl(e){for(let t of e.commands){let e=dl(t);if(e!=null)return e}return null}", + "var oce={parse:e=>e},sce=class{async getInstalledBrowserFamilies(){let e=Object.keys(n.So).filter(n.wo);return(await Promise.all(e.map(async e=>({browserFamily:e,installed:await sne({browserFamily:e})})))).flatMap(({browserFamily:e,installed:t})=>t?[e]:[])}async openUrl({browserFamily:e,url:t}){await lne({browserFamily:oce.parse(e),url:t})}};globalThis.BrowserService=sce;", + ].join(""); +} + +function currentRendererFixture() { + return [ + currentBrowserRegistry("Kl"), + "function Gl(e){return Object.hasOwn(Kl,e)}", + "function rendererLinuxRegistry(){return Object.keys(Kl).filter(Gl).map(e=>({browserFamily:e,installations:Kl[e].linux.installations,manifestDirectories:Kl[e].linux.nativeMessagingManifestDirectories,processNames:Kl[e].linux.processNames}))}", + "function wfi(){return{enabled:!1,featureName:`browser_use_external`,gate:`410065390`}}", + "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`}", + "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 and both Brave registries atomically", async () => { + const fixture = createCurrentExternalBrowserUseAssets(); + try { + assert.deepEqual( + patchLinuxBrowserUseExternalAvailabilityAssets(fixture.extractedDir), + { matched: 3, changed: 2 }, + ); + + 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()), ["chrome"]); + await braveOnly.service.openUrl({ browserFamily: "chrome", 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 rendererEdge = rendererRegistry.find(({ browserFamily }) => browserFamily === "edge"); + assert.ok(rendererChrome.installations.some(({ commands }) => commands.includes("brave-browser"))); + assert.ok(rendererChrome.installations.some(({ commands }) => commands.includes("google-chrome"))); + assert.ok(rendererChrome.installations.some(({ commands }) => commands.includes("chromium"))); + assert.ok( + rendererChrome.installations.some( + ({ userDataDirName }) => userDataDirName === "BraveSoftware/Brave-Browser", + ), + ); + assert.ok( + rendererChrome.manifestDirectories.includes( + ".config/BraveSoftware/Brave-Browser/NativeMessagingHosts", + ), + ); + assert.ok(rendererChrome.processNames.includes("brave")); + assert.ok(rendererChrome.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 both exact-DMG Browser Use registry files when the second write fails", () => { + const fixture = createCurrentExternalBrowserUseAssets(); + try { + const before = new Map([ + [fixture.srcPath, fs.readFileSync(fixture.srcPath, "utf8")], + [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 === 2) { + 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 ab273edf6..b47eebc71 100644 --- a/scripts/patches/impl/webview/index.js +++ b/scripts/patches/impl/webview/index.js @@ -12,6 +12,9 @@ const { 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. @@ -1102,68 +1105,218 @@ function applyLinuxBrowserUseWebviewHostRecoveryPatch(currentSource) { ); } +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 LINUX_BRAVE_BROWSER_USE_CHROME_REGISTRY = + "linux:{installations:[{commands:[`google-chrome`,`google-chrome-stable`],userDataDirName:`google-chrome`},{commands:[`brave-browser`,`brave`],userDataDirName:`BraveSoftware/Brave-Browser`},{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/BraveSoftware/Brave-Browser/NativeMessagingHosts`,`.config/chromium/NativeMessagingHosts`,`.config/google-chrome-beta/NativeMessagingHosts`,`.config/google-chrome-unstable/NativeMessagingHosts`,`.config/google-chrome-for-testing/NativeMessagingHosts`],processNames:[`chrome`,`brave`,`brave-browser`],userDataDirectorySegments:[`.config`,`google-chrome`]}"; +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; +} + function applyLinuxBrowserUseExternalAvailabilityPatch(currentSource) { const externalFeatureNeedle = "featureName:`browser_use_external`"; const statsigNeedle = "410065390"; - let changed = false; + const patchedSource = patchCurrentExternalBrowserUseAvailabilitySource(currentSource); + if (patchedSource != null) { + return patchedSource; + } - const alreadyPatched = () => - /featureName:`browser_use_external`[\s\S]{0,900}?navigator\.userAgent\.includes\(`Linux`\)/.test(currentSource); + if (currentSource.includes(externalFeatureNeedle) && currentSource.includes(statsigNeedle)) { + console.warn( + "WARN: Could not find Browser Use external availability gate — skipping Linux external Browser Use availability patch", + ); + } - 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; + return currentSource; +} - 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 currentBrowserUseRegistryState(source) { + const currentCount = source.split(CURRENT_BROWSER_USE_CHROME_LINUX_REGISTRY).length - 1; + const patchedCount = source.split(LINUX_BRAVE_BROWSER_USE_CHROME_REGISTRY).length - 1; + if (currentCount === 1 && patchedCount === 0) { + return "current"; + } + if (currentCount === 0 && patchedCount === 1) { + return "patched"; + } + return "drifted"; +} - 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 patchCurrentBrowserUseRegistrySource(source) { + const state = currentBrowserUseRegistryState(source); + if (state === "patched") { + return source; + } + if (state !== "current") { + return null; + } + return source.replace( + CURRENT_BROWSER_USE_CHROME_LINUX_REGISTRY, + LINUX_BRAVE_BROWSER_USE_CHROME_REGISTRY, ); +} - 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 currentBrowserUseMainCallerContract(source) { + return source.includes("async function sne({browserFamily:") && + source.includes("async function lne({browserFamily:") && + source.includes("function Ol(") && + source.includes("getInstalledBrowserFamilies(){") && + source.includes("async openUrl({browserFamily:"); +} + +function currentBrowserUseMainRegistryContract(source) { + return currentBrowserUseRegistryState(source) !== "drifted" && + source.includes("function fb(e){return Object.hasOwn(ob,e)}") && + /Object\.defineProperty\(exports,["'`]So["'`],\{enumerable:!0,get:function\(\)\{return ob\}\}\)/u.test(source); +} + +function currentBrowserUseRendererContract(source) { + return currentBrowserUseRegistryState(source) !== "drifted" && + source.includes("featureName:`browser_use_external`") && + source.includes("410065390") && + source.includes("function Gl(e){return Object.hasOwn(Kl,e)}") && + source.includes("Object.keys(Kl).filter(Gl)") && + (externalBrowserUseAvailabilityCurrentPattern.test(source) || + externalBrowserUseAvailabilityPatchedPattern.test(source)); +} + +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 || alreadyPatched()) { - return patchedSource; +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 (currentSource.includes(externalFeatureNeedle) && currentSource.includes(statsigNeedle)) { +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: 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 patchedMainRegistry = patchCurrentBrowserUseRegistrySource(mainRegistry.source); + const rendererWithAvailability = patchCurrentExternalBrowserUseAvailabilitySource(renderer.source); + const patchedRenderer = rendererWithAvailability == null + ? null + : patchCurrentBrowserUseRegistrySource(rendererWithAvailability); + if (patchedMainRegistry == null || patchedRenderer == 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, + }; + } + + const candidates = [ + { ...mainRegistry, patched: patchedMainRegistry }, + { ...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) { @@ -2321,6 +2474,7 @@ module.exports = { applyLinuxChatSearchHydrationPatch, applyLinuxBrowserUseAvailabilityPatch, applyLinuxBrowserUseExternalAvailabilityPatch, + patchLinuxBrowserUseExternalAvailabilityAssets, applyLinuxBrowserUseNonLocalNavigationPatch, applyLinuxBrowserUseWebviewHostRecoveryPatch, applyLinuxBrowserUseWebviewRemountStorePatch, From 2253b46fde6c366b7c8a112325af4d341edbe800 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 07:44:01 +0300 Subject: [PATCH 081/112] fix(nix): refresh upstream Nix pins for 26.730.61639 (#1223) Refreshed Codex.dmg SRI hash to sha256-M61HAaH3I3MzE68F6ygWOStos2froJIdvgeNLVeXGbU= and synced codexVersion / electronVersion / native-module pins to the current upstream DMG. Verified all ChatGPT Desktop Nix package outputs against the refreshed DMG. Source-Main-SHA: 111ec9d1a39f06a9b9df9fd34807e0f381cee865 Upstream-DMG-SHA256: 33ad4701a1f723733313af05eb2816392b68b367eba0921dbe078d2d579719b5 Co-authored-by: codex-dmg-hash-bot --- flake.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index ac7f86e6d..e9da5e47a 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-4+P+DfI5AodS53RPM2xKo2Ut3WYDC8CL+radlun48ys="; + hash = "sha256-M61HAaH3I3MzE68F6ygWOStos2froJIdvgeNLVeXGbU="; }; - codexVersion = "26.730.61309"; + codexVersion = "26.730.61639"; electronVersion = "42.3.0"; electronPlatform = { From 3a7eb3b8f1868e3876d526430a1d41c212f1e684 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Wed, 5 Aug 2026 08:44:13 +0300 Subject: [PATCH 082/112] Fix optional upstream DMG drift (#1224) optional-drift-watchdog-action: source-commit --- scripts/patches/impl/webview-browser-use-external.test.js | 8 ++++---- scripts/patches/impl/webview/index.js | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/patches/impl/webview-browser-use-external.test.js b/scripts/patches/impl/webview-browser-use-external.test.js index 0a141cdbf..8a45d6568 100644 --- a/scripts/patches/impl/webview-browser-use-external.test.js +++ b/scripts/patches/impl/webview-browser-use-external.test.js @@ -39,11 +39,11 @@ function currentMainCallerFixture() { function currentRendererFixture() { return [ - currentBrowserRegistry("Kl"), - "function Gl(e){return Object.hasOwn(Kl,e)}", - "function rendererLinuxRegistry(){return Object.keys(Kl).filter(Gl).map(e=>({browserFamily:e,installations:Kl[e].linux.installations,manifestDirectories:Kl[e].linux.nativeMessagingManifestDirectories,processNames:Kl[e].linux.processNames}))}", + currentBrowserRegistry("Xl"), + "function Yl(e){return Object.hasOwn(Xl,e)}", + "function rendererLinuxRegistry(){return Object.keys(Xl).filter(Yl).map(e=>({browserFamily:e,installations:Xl[e].linux.installations,manifestDirectories:Xl[e].linux.nativeMessagingManifestDirectories,processNames:Xl[e].linux.processNames}))}", "function wfi(){return{enabled:!1,featureName:`browser_use_external`,gate:`410065390`}}", - "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`}", + "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(""); } diff --git a/scripts/patches/impl/webview/index.js b/scripts/patches/impl/webview/index.js index b47eebc71..2cda3d272 100644 --- a/scripts/patches/impl/webview/index.js +++ b/scripts/patches/impl/webview/index.js @@ -1190,8 +1190,8 @@ function currentBrowserUseRendererContract(source) { return currentBrowserUseRegistryState(source) !== "drifted" && source.includes("featureName:`browser_use_external`") && source.includes("410065390") && - source.includes("function Gl(e){return Object.hasOwn(Kl,e)}") && - source.includes("Object.keys(Kl).filter(Gl)") && + source.includes("function Yl(e){return Object.hasOwn(Xl,e)}") && + source.includes("Object.keys(Xl).filter(Yl)") && (externalBrowserUseAvailabilityCurrentPattern.test(source) || externalBrowserUseAvailabilityPatchedPattern.test(source)); } From ee3f5708961060a9b4a722a5765df8c9694287e1 Mon Sep 17 00:00:00 2001 From: Avi Fenesh <55848801+avifenesh@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:56:37 +0300 Subject: [PATCH 083/112] sync(computer-use): port standalone v0.4.6 (#1220) --- CHANGELOG.md | 11 +- Cargo.lock | 2 +- computer-use-linux/Cargo.toml | 2 +- computer-use-linux/src/diagnostics.rs | 87 ++++++++-- computer-use-linux/src/server.rs | 237 +++++++++++++++++++++++++- docs/linux-computer-use.md | 14 +- 6 files changed, 329 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0f2a113f..ed4e3a712 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added -- The embedded Computer Use backend is synchronized to standalone v0.4.5 as - `0.4.5-linux-alpha1`, including generic X11/EWMH window control, X11 - `xdotool` keyboard/text input, KDE portal scroll polarity, and portal key - chords, with generic X11 registered last. +- 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 @@ -39,6 +39,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- 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 diff --git a/Cargo.lock b/Cargo.lock index b5b77677a..20b3662e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -490,7 +490,7 @@ dependencies = [ [[package]] name = "codex-computer-use-linux" -version = "0.4.5-linux-alpha1" +version = "0.4.6-linux-alpha1" dependencies = [ "anyhow", "atspi", diff --git a/computer-use-linux/Cargo.toml b/computer-use-linux/Cargo.toml index 481474632..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.4.5-linux-alpha1" +version = "0.4.6-linux-alpha1" edition = "2021" [[bin]] diff --git a/computer-use-linux/src/diagnostics.rs b/computer-use-linux/src/diagnostics.rs index 54003f64a..469374f56 100644 --- a/computer-use-linux/src/diagnostics.rs +++ b/computer-use-linux/src/diagnostics.rs @@ -409,17 +409,48 @@ 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> { @@ -1272,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())]); diff --git a/computer-use-linux/src/server.rs b/computer-use-linux/src/server.rs index c7748d22c..f49bb6423 100644 --- a/computer-use-linux/src/server.rs +++ b/computer-use-linux/src/server.rs @@ -895,6 +895,44 @@ impl ComputerUseLinux { } } } + 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 commands = vec![ absolute_mousemove_args(x, y), vec![ @@ -1890,7 +1928,7 @@ impl ComputerUseLinux { // 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.4.5-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 {} @@ -2576,6 +2614,20 @@ impl ComputerUseLinux { && 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(&[ @@ -3812,6 +3864,27 @@ fn session_is_wayland(session_type: Option<&str>, wayland_display: Option<&str>) } } +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, @@ -4430,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(), @@ -5582,6 +5710,56 @@ mod tests { 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] fn window_crop_happens_before_screenshot_payload_resize() { let (cropped, width, height) = crop_png(&solid_png(400, 200), 50, 20, 200, 100).unwrap(); @@ -6170,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!( @@ -6361,6 +6570,32 @@ mod tests { 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!( diff --git a/docs/linux-computer-use.md b/docs/linux-computer-use.md index 677fb29fa..abd8b8064 100644 --- a/docs/linux-computer-use.md +++ b/docs/linux-computer-use.md @@ -51,13 +51,15 @@ sudo usermod -a -G input "$USER" Then log out and back in. On X11, install `xdotool` for layout-correct XTEST keyboard/text input 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 +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. +`*_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 From f7e84dec5617b161ff98ba436b36a688658bca3c Mon Sep 17 00:00:00 2001 From: ClayFu <113490377+FuHao0119@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:49:27 +0800 Subject: [PATCH 084/112] Docs/add simplified chinese readme (#1228) * docs: add Simplified Chinese README * docs: add-simplified-chinese-readme --- README.md | 4 + README.zh-CN.md | 328 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 README.zh-CN.md diff --git a/README.md b/README.md index fc232a7d5..7701c0998 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 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 From 1e4e1140841ea9303d1e08a75fe5c81086678e53 Mon Sep 17 00:00:00 2001 From: huaixv <44743118+huaixv@users.noreply.github.com> Date: Wed, 5 Aug 2026 06:01:58 -0500 Subject: [PATCH 085/112] fix(remote-control): keep outbound tab visible on Linux (#1226) --- linux-features/remote-mobile-control/patch.js | 50 +++++------ linux-features/remote-mobile-control/test.js | 83 +++++++++---------- 2 files changed, 62 insertions(+), 71 deletions(-) diff --git a/linux-features/remote-mobile-control/patch.js b/linux-features/remote-mobile-control/patch.js index 9825ca0cd..f054c0bff 100644 --- a/linux-features/remote-mobile-control/patch.js +++ b/linux-features/remote-mobile-control/patch.js @@ -18,9 +18,7 @@ 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_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"; @@ -478,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; @@ -691,19 +677,33 @@ function applyLinuxRemoteControlSettingsUxPatch(source) { let patched = applyLinuxRemoteControlSshInstallReleasePatch(replaceLinuxRemoteControlCopy(source).patched); patched = applyLinuxRemoteControlSshInstallActionPatch(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]}`); + patched = applyLinuxRemoteControlOutboundTabGatePatch(patched); + + 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; } diff --git a/linux-features/remote-mobile-control/test.js b/linux-features/remote-mobile-control/test.js index 6692ed55e..41335e74e 100644 --- a/linux-features/remote-mobile-control/test.js +++ b/linux-features/remote-mobile-control/test.js @@ -257,17 +257,6 @@ 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`;", - "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,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}}", @@ -297,17 +286,6 @@ function syntheticCurrentLocalAppServerLaunchBundle() { ].join(""); } -function syntheticCurrentSettingsBundle() { - 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`}", - ].join(""); -} - function syntheticCurrentSettingsRefreshBundle() { return [ "var Jn=`[remote-connections/settings]`,Yn=15e3,Xn=[],Zn=[];", @@ -1654,22 +1632,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() + syntheticSshInstallSettingsBundle(); +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, /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); @@ -1776,18 +1748,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", () => { @@ -2646,14 +2637,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"))); }); @@ -2698,7 +2689,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", ), @@ -3663,8 +3654,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(), ); @@ -3747,7 +3738,7 @@ test("remote mobile control feature participates in ASAR patching and reports", assert.match(patchedRemoteConnectionVisibilityFile, /codexLinuxRemoteControlLoadGateEnabled/); 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/); From 8acb015937a72197bd50546ec7333602a06656ad Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Wed, 5 Aug 2026 14:10:07 +0300 Subject: [PATCH 086/112] fix(api-key-model-visibility): address review feedback --- .../api-key-model-visibility/patch.js | 46 ++++++--------- .../api-key-model-visibility/test.js | 59 ++++++++----------- 2 files changed, 44 insertions(+), 61 deletions(-) diff --git a/linux-features/api-key-model-visibility/patch.js b/linux-features/api-key-model-visibility/patch.js index af24890dd..0d6a37292 100644 --- a/linux-features/api-key-model-visibility/patch.js +++ b/linux-features/api-key-model-visibility/patch.js @@ -18,41 +18,33 @@ function applyApiKeyModelVisibilityPatch(source) { // 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` + - `[\\s\\S]{0,300}?)\\3&&\\2!==\\\`amazonBedrock\\\`(?=[,;?])`, + `(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 patched = source.replace( helperPattern, - (_match, prefix, authMethodVar, useHiddenModelsVar) => + ( + _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; } - // Legacy upstream shape: inline gate in the catalog filter function with - // authMethod as the first destructured parameter. - const legacyPattern = 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\\\`(?=[,;])`, - "g", - ); - const patchedLegacy = source.replace( - legacyPattern, - (_match, prefix, authMethodVar, useHiddenModelsVar) => - `${prefix}${useHiddenModelsVar}&&${authMethodVar}!==\`amazonBedrock\`&&` + - `${authMethodVar}!==\`apikey\`/*${PATCH_MARKER}*/`, - ); - if (patchedLegacy !== source) { - return patchedLegacy; - } - if ( source.includes("list-models-for-host") && source.includes("useHiddenModels") && @@ -69,9 +61,7 @@ const descriptors = [ phase: "webview-asset", order: 20550, ciPolicy: "optional", - // Upstream renamed the app main webview chunk from `app-initial~app-main~*.js` - // to `app-initial-*.js` (vite split the combined chunk); match both shapes. - 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 949084032..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,14 +26,14 @@ function applyPatchTwice(patchFn, source) { return once; } +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 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}}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}}"; + 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) { @@ -108,8 +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-iBPGfcXU.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); }); @@ -137,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", @@ -151,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( - "t!==`amazonBedrock`?n.has(r.model)", - "t!==`amazonBedrock`&&featureGate?n.has(r.model)", - ); - - 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-iBPGfcXU.js"); + const assetPath = path.join(assetsDir, "app-initial-CKNQDTeE.js"); fs.mkdirSync(assetsDir, { recursive: true }); fs.writeFileSync(assetPath, modelCatalogFixture()); From 61b67559395c68712c4d4a573181ec403884b779 Mon Sep 17 00:00:00 2001 From: Andres De Abreu <591778+jadabreu@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:29:43 +0200 Subject: [PATCH 087/112] Fix Chrome plugin env compatibility and cache refresh --- launcher/start.sh.template | 31 ++++++++++ .../browser-client-node-repl-runtime.test.js | 60 ++++++++++++++++++- scripts/lib/bundled-plugins.sh | 49 +++++++++++---- tests/scripts_smoke.sh | 7 ++- 4 files changed, 133 insertions(+), 14 deletions(-) diff --git a/launcher/start.sh.template b/launcher/start.sh.template index 084cb0fdc..2401475c6 100644 --- a/launcher/start.sh.template +++ b/launcher/start.sh.template @@ -1247,6 +1247,10 @@ 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 marketplace_root local marketplace_plugins_dir local marketplace_plugin_link @@ -1380,6 +1384,33 @@ 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.$$" + remove_tree_if_exists "$managed_tmp_plugin" + if cp -R "$source_plugin" "$managed_tmp_plugin"; then + find "$managed_tmp_plugin" -type f -name '*:com.apple.*' -delete + make_tree_owner_writable "$managed_tmp_plugin" + chmod --reference="$managed_cache_plugin" "$managed_tmp_plugin" 2>/dev/null || true + remove_tree_if_exists "$managed_cache_plugin" + mv "$managed_tmp_plugin" "$managed_cache_plugin" + echo "Chrome app-server cache refreshed from bundled resources: $managed_cache_plugin" + else + remove_tree_if_exists "$managed_tmp_plugin" + 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" diff --git a/scripts/lib/browser-client-node-repl-runtime.test.js b/scripts/lib/browser-client-node-repl-runtime.test.js index c562e3aea..86397e74e 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,63 @@ 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( "staged Browser and Chrome clients import through the real node_repl runtime", { skip: !runtimePath || !pluginsRoot }, diff --git a/scripts/lib/bundled-plugins.sh b/scripts/lib/bundled-plugins.sh index 60922513b..6f4626251 100644 --- a/scripts/lib/bundled-plugins.sh +++ b/scripts/lib/bundled-plugins.sh @@ -1253,7 +1253,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 +1264,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 } diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index abd3e5782..5c57696f9 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -6523,6 +6523,8 @@ for (const required of [ '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"', + 'Chrome app-server cache refreshed from bundled resources', 'write_chrome_native_host_manifests "$host_path" "$cache_root/latest"', ]) { if (!chromeBody.includes(required)) { @@ -6655,11 +6657,13 @@ 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" -mkdir -p "$(dirname "$official_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 \ @@ -6674,6 +6678,7 @@ chmod 0775 \ sync_chrome_bundled_plugin_cache grep -qx trusted-module "$cache_plugin/scripts/node_modules/classic-level.mjs" +grep -qx trusted-client "$official_client" for trusted_path in \ "$CODEX_HOME" \ "$CODEX_HOME/plugins" \ From b78154d78087bc136a2dbc2a41d0e67e83b8e585 Mon Sep 17 00:00:00 2001 From: anupamme Date: Wed, 5 Aug 2026 11:38:53 +0000 Subject: [PATCH 088/112] fix: CVE-2026-13697 security vulnerability Automated dependency upgrade by OrbisAI Security --- nix/native-modules/package-lock.json | 16 +++------------- nix/native-modules/package.json | 3 +++ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/nix/native-modules/package-lock.json b/nix/native-modules/package-lock.json index 026979dd9..d6597e0c8 100644 --- a/nix/native-modules/package-lock.json +++ b/nix/native-modules/package-lock.json @@ -583,15 +583,6 @@ "node": ">=20" } }, - "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==", - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, "node_modules/node-gyp/node_modules/which": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", @@ -983,11 +974,10 @@ } }, "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": { "node": ">=20.18.1" } diff --git a/nix/native-modules/package.json b/nix/native-modules/package.json index 41b5b4899..fbda299e1 100644 --- a/nix/native-modules/package.json +++ b/nix/native-modules/package.json @@ -8,5 +8,8 @@ "electron": "42.3.0", "node-abi": "^4.31.0", "node-pty": "1.1.0" + }, + "overrides": { + "undici": "7.29.0" } } From f3d144fc6ad8a9b441061fea85c43829984e1b46 Mon Sep 17 00:00:00 2001 From: Anupam Mediratta Date: Wed, 5 Aug 2026 18:01:47 +0530 Subject: [PATCH 089/112] fix: scope undici 7.29.0 override to @electron/get (CVE-2026-13697) @electron/get declares undici ^7.24.4. Versions before 7.29.0 are affected by CVE-2026-13697. Scope the override to @electron/get so node-gyp's ^6.25.0 dependency continues to resolve within the 6.x range (now 6.28.0), which is unaffected by the advisory. Runtime reachability via @electron/get in this build has not been confirmed; this is a precautionary upgrade. Co-Authored-By: Claude Sonnet 4.6 --- nix/native-modules/package-lock.json | 10 ++++++++++ nix/native-modules/package.json | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/nix/native-modules/package-lock.json b/nix/native-modules/package-lock.json index d6597e0c8..0aba8fc54 100644 --- a/nix/native-modules/package-lock.json +++ b/nix/native-modules/package-lock.json @@ -583,6 +583,15 @@ "node": ">=20" } }, + "node_modules/node-gyp/node_modules/undici": { + "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" + } + }, "node_modules/node-gyp/node_modules/which": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", @@ -978,6 +987,7 @@ "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "license": "MIT", + "optional": true, "engines": { "node": ">=20.18.1" } diff --git a/nix/native-modules/package.json b/nix/native-modules/package.json index fbda299e1..03e53bfef 100644 --- a/nix/native-modules/package.json +++ b/nix/native-modules/package.json @@ -10,6 +10,8 @@ "node-pty": "1.1.0" }, "overrides": { - "undici": "7.29.0" + "@electron/get": { + "undici": "7.29.0" + } } } From e78ddeb9724e13237fa0a885caa53a338b6171ce Mon Sep 17 00:00:00 2001 From: Andres De Abreu <591778+jadabreu@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:04:40 +0200 Subject: [PATCH 090/112] Make Chrome cache promotion failure-safe --- launcher/start.sh.template | 32 +++++++++++++++++++++++++------- tests/scripts_smoke.sh | 29 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/launcher/start.sh.template b/launcher/start.sh.template index 2401475c6..41422d542 100644 --- a/launcher/start.sh.template +++ b/launcher/start.sh.template @@ -1251,6 +1251,7 @@ sync_chrome_bundled_plugin_cache() { 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 @@ -1397,16 +1398,33 @@ sync_chrome_bundled_plugin_cache() { [ -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.$$" - remove_tree_if_exists "$managed_tmp_plugin" - if cp -R "$source_plugin" "$managed_tmp_plugin"; then - find "$managed_tmp_plugin" -type f -name '*:com.apple.*' -delete + 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 - remove_tree_if_exists "$managed_cache_plugin" - mv "$managed_tmp_plugin" "$managed_cache_plugin" - echo "Chrome app-server cache refreshed from bundled resources: $managed_cache_plugin" + 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" + remove_tree_if_exists "$managed_tmp_plugin" || true echo "Chrome app-server cache refresh failed; continuing with the existing install." fi fi diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index 5c57696f9..e15d90c34 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -6524,6 +6524,9 @@ for (const required of [ '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"', ]) { @@ -6594,6 +6597,7 @@ SCRIPT_DIR="$root/app" HOME="$root/home" CODEX_HOME="$HOME/.codex" source_plugin="$SCRIPT_DIR/resources/plugins/openai-bundled/plugins/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" @@ -6679,6 +6683,31 @@ 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" \ From 6aa19ed722ba8ebbf30fcd1f130067ddbd21d37c Mon Sep 17 00:00:00 2001 From: Mohit Sahoo Date: Wed, 5 Aug 2026 19:23:06 +0530 Subject: [PATCH 091/112] updater: defer background builds behind user toggle - Keep detected DMGs pending when automatic builds are disabled. - Expose independent build and install preferences in Linux settings. - Build pending updates when the user explicitly checks for updates. --- docs/updater.md | 18 ++ linux-features/codex-wrapper-updater/patch.js | 9 +- linux-features/codex-wrapper-updater/test.js | 10 +- scripts/patch-linux-window-ui.test.js | 4 +- scripts/patches/impl/keybinds-settings.js | 42 ++++- .../patches/impl/keybinds-settings.test.js | 9 +- scripts/patches/lib/settings-keys.js | 1 + updater/src/app.rs | 169 +++++++++++++++++- updater/src/config.rs | 24 +++ updater/src/state.rs | 1 + 10 files changed, 263 insertions(+), 24 deletions(-) diff --git a/docs/updater.md b/docs/updater.md index a216bebf2..3024aefe4 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -8,6 +8,7 @@ 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` + when automatic builds are enabled or the user explicitly checks for updates - 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 @@ -175,6 +176,23 @@ Runtime files: ~/.local/state/codex-desktop/app.pid ``` +## Update Preferences + +The Linux desktop settings page exposes two independent update controls: + +- **Build updates automatically** defaults on. When off, background checks + detect and download a new upstream DMG, leave the updater in + `update_available`, and notify without starting the local package build. + Choosing **Check for updates** explicitly starts the pending build. +- **Install updates when you close ChatGPT** controls only installation after a + package has been built. When off, a ready package waits for the user to choose + **Update**. + +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. + ## Generated Artifact Cleanup The updater always prunes unreferenced updater workspaces under diff --git a/linux-features/codex-wrapper-updater/patch.js b/linux-features/codex-wrapper-updater/patch.js index 23d8375cd..07a267b93 100644 --- a/linux-features/codex-wrapper-updater/patch.js +++ b/linux-features/codex-wrapper-updater/patch.js @@ -104,6 +104,9 @@ function applyWebviewRuntimePatch(source) { function applyWrapperUpdateSettingsPatch(source) { let next = source; + if (!next.includes(`autoBuildUpdates:${JSON.stringify(linuxSettingsKeys.autoBuildUpdates)}`)) { + throw new Error("could not find automatic update build setting"); + } if (!next.includes("wrapperUpdates:")) { const keyNeedle = `autoUpdateOnExit:"codex-linux-auto-update-on-exit"`; if (!next.includes(keyNeedle)) { @@ -127,15 +130,15 @@ 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")`; + next = 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/codex-wrapper-updater/test.js b/linux-features/codex-wrapper-updater/test.js index 4752682d3..979393dae 100644 --- a/linux-features/codex-wrapper-updater/test.js +++ b/linux-features/codex-wrapper-updater/test.js @@ -111,8 +111,8 @@ test("webview runtime is not swallowed by a trailing sourcemap comment", () => { test("settings patch adds wrapper update toggle", () => { const source = - `var KEYS={autoUpdateOnExit:"codex-linux-auto-update-on-exit"};` + - `function Settings(){return $.jsx(SettingsGroup,{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."})})}`; + `var KEYS={autoBuildUpdates:"codex-linux-auto-build-updates",autoUpdateOnExit:"codex-linux-auto-update-on-exit"};` + + `function Settings(){return $.jsx(SettingsGroup,{children:[$.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."}),$.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 patched = applyWrapperUpdateSettingsPatch(source); @@ -150,8 +150,8 @@ test("settings asset patch prefers generated Linux desktop settings bundle", () const assetsDir = path.join(appDir, "webview", "assets"); fs.mkdirSync(assetsDir, { recursive: true }); const linuxDesktopSettings = - `var KEYS={autoUpdateOnExit:"codex-linux-auto-update-on-exit"};` + - `function Settings(){return $.jsx(SettingsGroup,{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."})})}`; + `var KEYS={autoBuildUpdates:"codex-linux-auto-build-updates",autoUpdateOnExit:"codex-linux-auto-update-on-exit"};` + + `function Settings(){return $.jsx(SettingsGroup,{children:[$.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."}),$.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 generalSettings = `function Br(){return null}`; fs.writeFileSync(path.join(assetsDir, "linux-desktop-settings-linux.js"), linuxDesktopSettings); fs.writeFileSync(path.join(assetsDir, "general-settings-z.js"), generalSettings); @@ -184,7 +184,7 @@ test("settings asset patch leaves current asset unchanged on synthetic drift", ( assert.deepEqual(withoutWarnings(() => patchWrapperUpdateSettingsAssets(appDir)), { matched: false, changed: 0, - reason: "could not find Linux update toggle", + reason: "could not find automatic update build setting", }); assert.equal(fs.readFileSync(settingsPath, "utf8"), driftedSettings); } finally { diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index 2a4c77328..44747d5f8 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -6643,18 +6643,20 @@ test("renders the generated Linux desktop settings page with working switches", assert.ok(text.includes("Compact prompt window")); assert.ok(text.includes("System tray")); assert.ok(text.includes("Warm start")); + assert.ok(text.includes("Build updates automatically")); assert.ok(text.includes("Install updates when you close ChatGPT")); const switches = rendered.filter( (value) => typeof value === "object" && value.type === "button" && value.props.role === "switch", ); - assert.equal(switches.length, 4); + assert.equal(switches.length, 5); assert.deepEqual( switches.map((element) => element.props["aria-label"]), [ "Compact prompt window", "System tray", "Warm start", + "Build updates automatically", "Install updates when you close ChatGPT", ], ); diff --git a/scripts/patches/impl/keybinds-settings.js b/scripts/patches/impl/keybinds-settings.js index bcf4badab..98c9dfe74 100644 --- a/scripts/patches/impl/keybinds-settings.js +++ b/scripts/patches/impl/keybinds-settings.js @@ -22,7 +22,7 @@ const linuxDesktopSettingsAsset = "linux-desktop-settings-linux.js"; const linuxKeybindOverridesKey = "codex-linux-keybind-overrides"; const linuxReactRuntimeExport = "codexLinuxReact"; const linuxJsxRuntimeExport = "codexLinuxJsx"; -const linuxDesktopSettingsSourceVersion = 1; +const linuxDesktopSettingsSourceVersion = 2; const linuxDesktopSettingsSourceMarker = `var codexLinuxDesktopSettingsVersion=${linuxDesktopSettingsSourceVersion},KEYS={`; @@ -39,6 +39,37 @@ function linuxDesktopSettingsControlsSource() { return `function codexLinuxChecked(next){return next&&typeof next=="object"&&next.target&&typeof next.target.checked=="boolean"?next.target.checked:next===!0}class LinuxToggle extends React.Component{constructor(props){super(props),this._alive=!1,this.state={value:props.defaultValue??!0,isLoading:!0,error:null},this.load=this.load.bind(this),this.update=this.update.bind(this)}componentDidMount(){this._alive=!0,this.load()}componentDidUpdate(previous){(previous.settingKey!==this.props.settingKey||previous.defaultValue!==this.props.defaultValue)&&this.load()}componentWillUnmount(){this._alive=!1}load(){let{settingKey:key,defaultValue=!0}=this.props;this.setState({isLoading:!0}),__post("get-global-state",{params:{key}}).then(result=>{this._alive&&this.setState({value:result?.value??defaultValue,error:null})}).catch(err=>{this._alive&&this.setState({error:err instanceof Error?err.message:String(err)})}).finally(()=>{this._alive&&this.setState({isLoading:!1})})}update(next){let value=codexLinuxChecked(next),previous=this.state.value,{settingKey:key}=this.props;this.setState({value,error:null}),__post("set-global-state",{params:{key,value}}).catch(err=>{this._alive&&this.setState({value:previous,error:err instanceof Error?err.message:String(err)})})}render(){let{label,description}=this.props,{value,isLoading,error}=this.state,details=error?$.jsxs("div",{className:"flex flex-col gap-1",children:[$.jsx("span",{children:description}),$.jsx("span",{className:"text-token-error-foreground",children:error})]}):description;return $.jsx(SettingsRow,{label,description:details,control:$.jsx(Toggle,{checked:value,disabled:isLoading,onChange:this.update,ariaLabel:label})})}}`; } +function addAutoBuildUpdatesSetting(source) { + const keysNeedle = + `warmStart:${JSON.stringify(linuxSettingsKeys.warmStart)},` + + `autoUpdateOnExit:${JSON.stringify(linuxSettingsKeys.autoUpdateOnExit)}`; + const keysReplacement = + `warmStart:${JSON.stringify(linuxSettingsKeys.warmStart)},` + + `autoBuildUpdates:${JSON.stringify(linuxSettingsKeys.autoBuildUpdates)},` + + `autoUpdateOnExit:${JSON.stringify(linuxSettingsKeys.autoUpdateOnExit)}`; + const controlsNeedle = + `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."})`; + const controlsReplacement = + `children:[$.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."}),` + + `$.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 (!source.includes(keysNeedle) || !source.includes(controlsNeedle)) { + throw new Error("Required Keybinds settings patch failed: could not add automatic update build control"); + } + return source + .replace(keysNeedle, keysReplacement) + .replace(controlsNeedle, controlsReplacement); +} + function buildKeybindsSettingsSource({ chunkAsset, reactAsset, @@ -504,10 +535,9 @@ function resolveLinuxDesktopSettingsAsset(extractedDir) { includeHotkeySettings: false, }); - const source = buildLinuxDesktopSettingsSource(dependencies).replace( - "var KEYS={", - linuxDesktopSettingsSourceMarker, - ); + const source = addAutoBuildUpdatesSetting( + buildLinuxDesktopSettingsSource(dependencies), + ).replace("var KEYS={", linuxDesktopSettingsSourceMarker); return { filePath: path.join(webviewAssetsDir, linuxDesktopSettingsAsset), source, @@ -815,6 +845,7 @@ function hasCompleteLinuxDesktopSettingsSource(previousSource) { `promptWindow:${JSON.stringify(linuxSettingsKeys.promptWindow)}`, `systemTray:${JSON.stringify(linuxSettingsKeys.systemTray)}`, `warmStart:${JSON.stringify(linuxSettingsKeys.warmStart)}`, + `autoBuildUpdates:${JSON.stringify(linuxSettingsKeys.autoBuildUpdates)}`, `autoUpdateOnExit:${JSON.stringify(linuxSettingsKeys.autoUpdateOnExit)}`, "function codexLinuxChecked(", "class LinuxToggle extends React.Component", @@ -830,6 +861,7 @@ function hasCompleteLinuxDesktopSettingsSource(previousSource) { "settingKey:KEYS.promptWindow", "settingKey:KEYS.systemTray", "settingKey:KEYS.warmStart", + "settingKey:KEYS.autoBuildUpdates", "settingKey:KEYS.autoUpdateOnExit", "$.jsx(LinuxBuildInfoPanel,{})", ]; diff --git a/scripts/patches/impl/keybinds-settings.test.js b/scripts/patches/impl/keybinds-settings.test.js index ed2d0a6f7..5bea95686 100644 --- a/scripts/patches/impl/keybinds-settings.test.js +++ b/scripts/patches/impl/keybinds-settings.test.js @@ -46,7 +46,11 @@ test("preserves wrapper updater extensions across Linux settings patch passes", const settingsPath = path.join(assetsDir, linuxDesktopSettingsAsset); assert.match( fs.readFileSync(settingsPath, "utf8"), - /var codexLinuxDesktopSettingsVersion=1,KEYS=\{/, + /var codexLinuxDesktopSettingsVersion=2,KEYS=\{/, + ); + assert.match( + fs.readFileSync(settingsPath, "utf8"), + /settingKey:KEYS\.autoBuildUpdates,label:"Build updates automatically"/, ); const firstFeatureResult = patchWrapperUpdateSettingsAssets(extractedDir); @@ -81,8 +85,8 @@ for (const [name, damage] of [ "rejects incomplete generated Linux settings markers without writing assets", (source) => source.replace( - "codexLinuxDesktopSettingsVersion=1", "codexLinuxDesktopSettingsVersion=2", + "codexLinuxDesktopSettingsVersion=3", ), ], [ @@ -93,6 +97,7 @@ for (const [name, damage] of [ "promptWindow", "systemTray", "warmStart", + "autoBuildUpdates", "autoUpdateOnExit", ].map((key) => [ `rejects generated Linux settings without the ${key} control`, diff --git a/scripts/patches/lib/settings-keys.js b/scripts/patches/lib/settings-keys.js index 7f7e8a30a..d64d14273 100644 --- a/scripts/patches/lib/settings-keys.js +++ b/scripts/patches/lib/settings-keys.js @@ -6,6 +6,7 @@ const linuxSettingsKeys = { promptWindow: "codex-linux-prompt-window-enabled", systemTray: "codex-linux-system-tray-enabled", warmStart: "codex-linux-warm-start-enabled", + autoBuildUpdates: "codex-linux-auto-build-updates", autoUpdateOnExit: "codex-linux-auto-update-on-exit", wrapperUpdates: "codex-linux-wrapper-updates-enabled", featurePickerOnUpdate: "codex-linux-feature-picker-on-update", diff --git a/updater/src/app.rs b/updater/src/app.rs index 0467e6e14..f90eec0f4 100644 --- a/updater/src/app.rs +++ b/updater/src/app.rs @@ -177,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(explicit_build: bool) -> bool { + explicit_build || crate::config::settings_auto_build_updates_override().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 { @@ -607,7 +611,17 @@ 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, + lock_behavior, + if_stale, + true, + true, + !if_stale, + ) + .await } /// Detects a newer wrapper release and records it into state. Returns @@ -1084,6 +1098,7 @@ async fn run_check_cycle_from_disk( false, false, false, + false, ) .await } @@ -1103,10 +1118,33 @@ async fn run_check_cycle( false, false, false, + 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, @@ -1115,6 +1153,7 @@ async fn run_check_cycle_with_options( if_stale: bool, recover_entrypoint_state: bool, reconcile_after_check: bool, + explicit_build: bool, ) -> Result<()> { let Some(_check_lock) = acquire_check_lock(paths, lock_behavior).await? else { info!("skipping upstream check because another check is already active"); @@ -1138,6 +1177,34 @@ async fn run_check_cycle_with_options( warn!(?error, "wrapper update detection failed during check cycle"); } + let build_detected_update = should_build_detected_update(explicit_build); + if matches!( + state.status, + UpdateStatus::UpdateDetected | UpdateStatus::UpdateAvailable + ) { + if !build_detected_update { + if state.status != UpdateStatus::UpdateAvailable { + state.status = UpdateStatus::UpdateAvailable; + persist_state(paths, state)?; + } + info!("automatic update builds are disabled; keeping detected update pending"); + maybe_prune_caches(config, state); + return Ok(()); + } + + let result = build_pending_detected_update(config, state, paths).await; + maybe_prune_caches(config, state); + if let Err(error) = result { + mark_failed_and_persist(state, paths, error.to_string())?; + let _ = notify_failure(config, state, paths, &error); + return Err(error); + } + if reconcile_after_check { + reconcile_pending_install(config, state, paths).await?; + } + return Ok(()); + } + if update_install_is_pending(&state.status) { info!("skipping upstream check because an update is already pending"); maybe_prune_caches(config, state); @@ -1238,16 +1305,21 @@ 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 { + state.status = UpdateStatus::UpdateAvailable; + persist_state(paths, state)?; + return Ok(()); + } + + build_pending_detected_update(config, state, paths).await?; drop(downloaded); - maybe_notify_update_ready(state, paths, config.notifications)?; Ok(()) } .await; @@ -1608,6 +1680,7 @@ fn dmg_update_state_can_be_cleared_as_current(status: &UpdateStatus) -> bool { matches!( status, UpdateStatus::UpdateDetected + | UpdateStatus::UpdateAvailable | UpdateStatus::DownloadingDmg | UpdateStatus::PreparingWorkspace | UpdateStatus::PatchingApp @@ -2318,6 +2391,7 @@ mod tests { for status in [ UpdateStatus::Idle, UpdateStatus::CheckingUpstream, + UpdateStatus::UpdateAvailable, UpdateStatus::ReadyToInstall, UpdateStatus::WaitingForAppExit, UpdateStatus::Installing, @@ -2722,6 +2796,85 @@ mod tests { Ok(()) } + #[test] + fn auto_build_toggle_defers_background_build_until_explicit_check() -> 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"new-codex-dmg"; + Mock::given(method("HEAD")) + .and(path("/Codex.dmg")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("ETag", "\"new-dmg\"") + .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; + + let temp = tempfile::tempdir()?; + let paths = test_paths(temp.path()); + paths.ensure_dirs()?; + let settings_path = temp.path().join("settings.json"); + std::fs::write( + &settings_path, + r#"{"codex-linux-auto-build-updates": false}"#, + )?; + std::env::set_var("CODEX_LINUX_SETTINGS_FILE", &settings_path); + 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()); + let mut state = PersistedState::new(true); + + state.save(&paths.state_file)?; + run_check_cycle_with_options( + &config, + &mut state, + &paths, + CheckLockBehavior::SkipIfBusy, + false, + false, + false, + false, + ) + .await?; + server.verify().await; + + assert_eq!(state.status, UpdateStatus::UpdateAvailable); + assert!(state.candidate_version.is_some()); + assert!(state + .artifact_paths + .dmg_path + .as_deref() + .is_some_and(Path::is_file)); + assert_eq!(state.artifact_paths.workspace_dir, None); + assert_eq!(state.artifact_paths.package_path, None); + + 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); + Ok(()) + }) + } + #[tokio::test] async fn interrupted_download_with_cached_hash_reaches_build_path() -> Result<()> { let server = MockServer::start().await; diff --git a/updater/src/config.rs b/updater/src/config.rs index 7daaddd62..723b26719 100644 --- a/updater/src/config.rs +++ b/updater/src/config.rs @@ -225,6 +225,7 @@ impl RuntimeConfig { const APP_SETTINGS_FILE: &str = "settings.json"; pub(crate) const DEFAULT_APP_ID: &str = "codex-desktop"; +const AUTO_BUILD_UPDATES_SETTING_KEY: &str = "codex-linux-auto-build-updates"; const AUTO_INSTALL_SETTING_KEY: &str = "codex-linux-auto-update-on-exit"; const WRAPPER_UPDATES_SETTING_KEY: &str = "codex-linux-wrapper-updates-enabled"; @@ -318,6 +319,11 @@ pub fn settings_auto_install_override() -> Option { settings_bool_override(AUTO_INSTALL_SETTING_KEY) } +/// Reads whether background update checks should build detected updates. +pub fn settings_auto_build_updates_override() -> Option { + settings_bool_override(AUTO_BUILD_UPDATES_SETTING_KEY) +} + /// Reads the user's opt-in wrapper update tracking preference from app settings. pub fn settings_wrapper_updates_override() -> Option { settings_bool_override(WRAPPER_UPDATES_SETTING_KEY) @@ -456,6 +462,24 @@ app_executable_path = "/opt/codex-desktop/electron" ); } + #[test] + fn auto_build_updates_override_reads_explicit_bool() { + assert_eq!( + override_with_settings( + Some(r#"{"codex-linux-auto-build-updates": false}"#), + AUTO_BUILD_UPDATES_SETTING_KEY + ), + Some(false) + ); + assert_eq!( + override_with_settings( + Some(r#"{"codex-linux-auto-build-updates": true}"#), + AUTO_BUILD_UPDATES_SETTING_KEY + ), + Some(true) + ); + } + #[test] fn settings_override_coerces_string_and_number() { assert_eq!( diff --git a/updater/src/state.rs b/updater/src/state.rs index 26583e694..acb9eacfe 100644 --- a/updater/src/state.rs +++ b/updater/src/state.rs @@ -22,6 +22,7 @@ pub enum UpdateStatus { Idle, CheckingUpstream, UpdateDetected, + UpdateAvailable, DownloadingDmg, PreparingWorkspace, PatchingApp, From 65ce021ff23945dc0e5c4c1c0064f4cce16ea88d Mon Sep 17 00:00:00 2001 From: Mohit Sahoo Date: Wed, 5 Aug 2026 19:31:03 +0530 Subject: [PATCH 092/112] project-group-last-updated-sort: retarget Codex 26.730 sorter symbols --- .../project-group-last-updated-sort/patch.js | 8 ++++---- .../project-group-last-updated-sort/test.js | 19 ++++++++++--------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/linux-features/project-group-last-updated-sort/patch.js b/linux-features/project-group-last-updated-sort/patch.js index f92ee33da..6941941d5 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 Aos({groups:e,items:t,projectOrder:n}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return Sca(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)}"; + "function Drs({groups:e,items:t,projectOrder:n}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return voa(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 Aos({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:Sca(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))}"; + "function Drs({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:voa(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 = - "A=Aos({groups:kos({groups:O,items:f}),items:f,projectOrder:Cp(t,Il.PROJECT_ORDER)})"; + "A=Drs({groups:Ers({groups:O,items:f}),items:f,projectOrder:Cp(t,Nl.PROJECT_ORDER)})"; const patchedGroupSorterCall = - "A=Aos({groups:kos({groups:O,items:f}),items:f,projectOrder:Cp(t,Il.PROJECT_ORDER),sortMode:t(Sz).projectSortMode})"; + "A=Drs({groups:Ers({groups:O,items:f}),items:f,projectOrder:Cp(t,Nl.PROJECT_ORDER),sortMode:t(UR).projectSortMode})"; function countOccurrences(source, needle) { return source.split(needle).length - 1; diff --git a/linux-features/project-group-last-updated-sort/test.js b/linux-features/project-group-last-updated-sort/test.js index 81c4933e5..00061563f 100644 --- a/linux-features/project-group-last-updated-sort/test.js +++ b/linux-features/project-group-last-updated-sort/test.js @@ -18,12 +18,13 @@ const { } = require("./patch.js"); const currentProjectSource = [ - "function Sca(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 Aos({groups:e,items:t,projectOrder:n}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return Sca(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)}", + "function goa(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 voa(e,t){let n=goa(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 Drs({groups:e,items:t,projectOrder:n}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return voa(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`;", - "A=Aos({groups:kos({groups:O,items:f}),items:f,projectOrder:Cp(t,Il.PROJECT_ORDER)});", + "A=Drs({groups:Ers({groups:O,items:f}),items:f,projectOrder:Cp(t,Nl.PROJECT_ORDER)});", ].join(""); function captureWarns(fn) { @@ -69,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=Aos`, context); + vm.runInNewContext(`${sorterSource};globalThis.sortProjectGroups=Drs`, context); return context.sortProjectGroups; } @@ -153,15 +154,15 @@ test("patch passes the selected project sort mode into the group sorter", () => const patched = applyPatchTwice(currentProjectSource); assert.ok( patched.includes( - "projectOrder:Cp(t,Il.PROJECT_ORDER),sortMode:t(Sz).projectSortMode", + "projectOrder:Cp(t,Nl.PROJECT_ORDER),sortMode:t(UR).projectSortMode", ), ); }); test("drift leaves the asset byte-identical", () => { const source = currentProjectSource.replace( - "function Aos({groups:e,items:t,projectOrder:n})", - "function Aos({groups:e,items:t,projectOrder:n,unknown:o})", + "function Drs({groups:e,items:t,projectOrder:n})", + "function Drs({groups:e,items:t,projectOrder:n,unknown:o})", ); const { value, warnings } = captureWarns(() => applyProjectGroupLastUpdatedSortPatch(source), @@ -174,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:Cp(t,Il.PROJECT_ORDER)", + "projectOrder:Cp(t,Nl.PROJECT_ORDER)", "projectOrder:unknownProjectOrder", ); const { value, warnings } = captureWarns(() => @@ -207,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-iBPGfcXU.js", + "app-initial-CKNQDTeE.js", ); fs.mkdirSync(assetsDir, { recursive: true }); fs.writeFileSync(assetPath, currentProjectSource); From 366574ab74f8d979a297a90ed0b42d0c2c1b5a89 Mon Sep 17 00:00:00 2001 From: Mohit Sahoo Date: Wed, 5 Aug 2026 19:36:55 +0530 Subject: [PATCH 093/112] updater: bundle check cycle options --- updater/src/app.rs | 79 +++++++++++++++++++++++++++------------------- 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/updater/src/app.rs b/updater/src/app.rs index f90eec0f4..9834d4367 100644 --- a/updater/src/app.rs +++ b/updater/src/app.rs @@ -431,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() @@ -615,11 +624,13 @@ async fn run_check_now( config, state, paths, - lock_behavior, - if_stale, - true, - true, - !if_stale, + CheckCycleOptions { + lock_behavior, + if_stale, + recover_entrypoint_state: true, + reconcile_after_check: true, + explicit_build: !if_stale, + }, ) .await } @@ -1094,11 +1105,13 @@ async fn run_check_cycle_from_disk( config, state, paths, - CheckLockBehavior::SkipIfBusy, - false, - false, - false, - false, + CheckCycleOptions { + lock_behavior: CheckLockBehavior::SkipIfBusy, + if_stale: false, + recover_entrypoint_state: false, + reconcile_after_check: false, + explicit_build: false, + }, ) .await } @@ -1114,11 +1127,13 @@ async fn run_check_cycle( config, state, paths, - CheckLockBehavior::SkipIfBusy, - false, - false, - false, - true, + CheckCycleOptions { + lock_behavior: CheckLockBehavior::SkipIfBusy, + if_stale: false, + recover_entrypoint_state: false, + reconcile_after_check: false, + explicit_build: true, + }, ) .await } @@ -1149,13 +1164,9 @@ 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, - explicit_build: 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(()); }; @@ -1164,7 +1175,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)?; @@ -1177,7 +1188,7 @@ async fn run_check_cycle_with_options( warn!(?error, "wrapper update detection failed during check cycle"); } - let build_detected_update = should_build_detected_update(explicit_build); + let build_detected_update = should_build_detected_update(options.explicit_build); if matches!( state.status, UpdateStatus::UpdateDetected | UpdateStatus::UpdateAvailable @@ -1199,7 +1210,7 @@ async fn run_check_cycle_with_options( 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?; } return Ok(()); @@ -1208,7 +1219,7 @@ async fn run_check_cycle_with_options( 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(()); @@ -1221,13 +1232,13 @@ async fn run_check_cycle_with_options( ); } - if if_stale + if options.if_stale && !update_check_should_retry(&state.status) && 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(()); @@ -1333,7 +1344,7 @@ async fn run_check_cycle_with_options( return Err(error); } - if reconcile_after_check { + if options.reconcile_after_check { reconcile_pending_install(config, state, paths).await?; } @@ -2845,11 +2856,13 @@ mod tests { &config, &mut state, &paths, - CheckLockBehavior::SkipIfBusy, - false, - false, - false, - false, + CheckCycleOptions { + lock_behavior: CheckLockBehavior::SkipIfBusy, + if_stale: false, + recover_entrypoint_state: false, + reconcile_after_check: false, + explicit_build: false, + }, ) .await?; server.verify().await; From db7e54d075353a5afcb307ebc3f0668ded2d49c6 Mon Sep 17 00:00:00 2001 From: pinguuss Date: Wed, 5 Aug 2026 09:34:04 -0400 Subject: [PATCH 094/112] test: cover current Dock icon main contract --- linux-features/ui-tweaks/dock-icon.test.js | 50 +++++++++++++++++----- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/linux-features/ui-tweaks/dock-icon.test.js b/linux-features/ui-tweaks/dock-icon.test.js index 3411a68fe..f99123394 100644 --- a/linux-features/ui-tweaks/dock-icon.test.js +++ b/linux-features/ui-tweaks/dock-icon.test.js @@ -28,8 +28,8 @@ 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=_S(`${F_(e,t)}.png`),i=_S(n.dark),a=_S(n.light);return r==null||i==null||a==null?null:{appDefault:r,codexDark:i,codexLight:a}}", - "function _S(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 = [ @@ -37,11 +37,11 @@ const currentRuntimeSource = [ "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.Ec.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),Gce({preference:e,resourceName:e===`codex-system`?M.light:null}).then(e=>{e&&P(k())})};", + "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 xwe({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 = @@ -166,8 +166,8 @@ test("main patch enables official previews and synchronizes Linux window and tra assert.match(patched, /codexLinuxDockIconResourcePath/); assert.match(patched, /codexLinuxApplyDockIcon/); assert.match(patched, /i!==a\.a\.Dev/); - assert.match(patched, /e===n\.Ec\.ChatGPT/); - assert.doesNotMatch(patched, /n\.Ml\.ChatGPT/); + 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, @@ -190,7 +190,7 @@ test("main patch enables official previews and synchronizes Linux window and tra ); 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, @@ -200,12 +200,12 @@ 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 _S(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)}", + "onWindowRegistered:e=>{I?.registerWindow(e),C?.(e)}", "codexLinuxRegisterTray(new l.Tray(t.defaultIcon))", ]; @@ -236,6 +236,34 @@ test("main patch rejects mixed patched and clean contracts byte-identically", () 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("main patch rejects the previous DMG contract byte-identically", () => { + const previousDmgSource = currentMainSource + .replaceAll("MS(", "_S(") + .replace("n.gc.ChatGPT", "n.Ec.ChatGPT") + .replace("Yce({preference", "Gce({preference") + .replace("I?.registerWindow(e)", "ee?.registerWindow(e)"); + const { value, warnings } = captureWarns(() => applyDockIconMainPatch(previousDmgSource)); + + assert.equal(value, previousDmgSource); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /current Dock icon main-process contract/); +}); + test("settings patch exposes the native row on Linux", () => { const patched = applyDockIconSettingsPatch(currentSettingsSource); const secondPass = captureWarns(() => applyDockIconSettingsPatch(patched)); From 969f96ec7ce8a7e80ecf1d47a8f370ce915c6227 Mon Sep 17 00:00:00 2001 From: pinguuss Date: Wed, 5 Aug 2026 09:34:13 -0400 Subject: [PATCH 095/112] fix: retarget Dock icon to current desktop bundle --- linux-features/ui-tweaks/patches/dock-icon.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/linux-features/ui-tweaks/patches/dock-icon.js b/linux-features/ui-tweaks/patches/dock-icon.js index 01024ae44..297f9ff53 100644 --- a/linux-features/ui-tweaks/patches/dock-icon.js +++ b/linux-features/ui-tweaks/patches/dock-icon.js @@ -4,29 +4,29 @@ 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 _S(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 _S(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`&&i!==a.a.Dev&&(l.app.isPackaged||e===n.Ec.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)}"; + "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`&&i!==a.a.Dev&&(l.app.isPackaged||e===n.Ec.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)}"; + "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),Gce({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),Gce({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(!W9)return"; const patchedTrayRegistration = From a999a08ea1f25326d72928d8c9ac339511136b30 Mon Sep 17 00:00:00 2001 From: pinguuss Date: Wed, 5 Aug 2026 10:09:19 -0400 Subject: [PATCH 096/112] test: harden current Dock contract drift coverage --- linux-features/ui-tweaks/dock-icon.test.js | 45 ++++++++++++---------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/linux-features/ui-tweaks/dock-icon.test.js b/linux-features/ui-tweaks/dock-icon.test.js index f99123394..33b889759 100644 --- a/linux-features/ui-tweaks/dock-icon.test.js +++ b/linux-features/ui-tweaks/dock-icon.test.js @@ -224,16 +224,32 @@ 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));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", () => { @@ -251,19 +267,6 @@ test("main patch rejects duplicate clean, patched, and mixed contracts byte-iden } }); -test("main patch rejects the previous DMG contract byte-identically", () => { - const previousDmgSource = currentMainSource - .replaceAll("MS(", "_S(") - .replace("n.gc.ChatGPT", "n.Ec.ChatGPT") - .replace("Yce({preference", "Gce({preference") - .replace("I?.registerWindow(e)", "ee?.registerWindow(e)"); - const { value, warnings } = captureWarns(() => applyDockIconMainPatch(previousDmgSource)); - - assert.equal(value, previousDmgSource); - assert.equal(warnings.length, 1); - assert.match(warnings[0], /current Dock icon main-process contract/); -}); - test("settings patch exposes the native row on Linux", () => { const patched = applyDockIconSettingsPatch(currentSettingsSource); const secondPass = captureWarns(() => applyDockIconSettingsPatch(patched)); From 2d0d4bdf4cf01e82ed7f1fffcc46665a24a30403 Mon Sep 17 00:00:00 2001 From: Mohit Sahoo Date: Wed, 5 Aug 2026 21:23:34 +0530 Subject: [PATCH 097/112] updater: make deferred builds opt-in and revalidate DMGs - Move automatic-build preferences into a disabled-by-default feature policy. - Revalidate or redownload pending DMGs before explicit builds and recover legacy state safely. - Bump updater 0.11.0 with bridge, patch, test, and documentation coverage. --- CHANGELOG.md | 10 + Cargo.lock | 2 +- docs/updater.md | 30 +- linux-features/codex-wrapper-updater/patch.js | 8 +- linux-features/codex-wrapper-updater/test.js | 10 +- .../deferred-update-build/README.md | 33 +++ .../deferred-update-build/feature.json | 9 + linux-features/deferred-update-build/patch.js | 90 ++++++ linux-features/deferred-update-build/test.js | 117 ++++++++ .../deferred-update-build/updater-policy.json | 4 + scripts/lib/linux-update-bridge-patch.js | 4 +- scripts/patch-linux-window-ui.test.js | 6 +- scripts/patches/impl/keybinds-settings.js | 42 +-- .../patches/impl/keybinds-settings.test.js | 26 +- scripts/patches/lib/settings-keys.js | 1 - updater/Cargo.toml | 2 +- updater/src/app.rs | 280 ++++++++++++++---- updater/src/config.rs | 140 +++++++-- updater/src/state.rs | 33 ++- 19 files changed, 687 insertions(+), 160 deletions(-) create mode 100644 linux-features/deferred-update-build/README.md create mode 100644 linux-features/deferred-update-build/feature.json create mode 100644 linux-features/deferred-update-build/patch.js create mode 100644 linux-features/deferred-update-build/test.js create mode 100644 linux-features/deferred-update-build/updater-policy.json diff --git a/CHANGELOG.md b/CHANGELOG.md index ed4e3a712..3cacc91dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added +- 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 @@ -39,6 +43,12 @@ 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. 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. diff --git a/Cargo.lock b/Cargo.lock index 20b3662e4..8cde2c441 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -561,7 +561,7 @@ dependencies = [ [[package]] name = "codex-update-manager" -version = "0.10.5" +version = "0.11.0" dependencies = [ "anyhow", "chrono", diff --git a/docs/updater.md b/docs/updater.md index 3024aefe4..4b451e186 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -8,7 +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` - when automatic builds are enabled or the user explicitly checks for updates + 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 @@ -178,20 +179,29 @@ Runtime files: ## Update Preferences -The Linux desktop settings page exposes two independent update controls: +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**. -- **Build updates automatically** defaults on. When off, background checks - detect and download a new upstream DMG, leave the updater in - `update_available`, and notify without starting the local package build. - Choosing **Check for updates** explicitly starts the pending build. -- **Install updates when you close ChatGPT** 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. 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. +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 diff --git a/linux-features/codex-wrapper-updater/patch.js b/linux-features/codex-wrapper-updater/patch.js index 07a267b93..05e07e990 100644 --- a/linux-features/codex-wrapper-updater/patch.js +++ b/linux-features/codex-wrapper-updater/patch.js @@ -104,9 +104,6 @@ function applyWebviewRuntimePatch(source) { function applyWrapperUpdateSettingsPatch(source) { let next = source; - if (!next.includes(`autoBuildUpdates:${JSON.stringify(linuxSettingsKeys.autoBuildUpdates)}`)) { - throw new Error("could not find automatic update build setting"); - } if (!next.includes("wrapperUpdates:")) { const keyNeedle = `autoUpdateOnExit:"codex-linux-auto-update-on-exit"`; if (!next.includes(keyNeedle)) { @@ -138,7 +135,10 @@ function applyWrapperUpdateSettingsPatch(source) { `$.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 = `$.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")`; - next = next.replace(toggleNeedle, `${toggleNeedle},${wrapperToggle},${pickerToggle}`); + 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/codex-wrapper-updater/test.js b/linux-features/codex-wrapper-updater/test.js index 979393dae..4752682d3 100644 --- a/linux-features/codex-wrapper-updater/test.js +++ b/linux-features/codex-wrapper-updater/test.js @@ -111,8 +111,8 @@ test("webview runtime is not swallowed by a trailing sourcemap comment", () => { test("settings patch adds wrapper update toggle", () => { const source = - `var KEYS={autoBuildUpdates:"codex-linux-auto-build-updates",autoUpdateOnExit:"codex-linux-auto-update-on-exit"};` + - `function Settings(){return $.jsx(SettingsGroup,{children:[$.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."}),$.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."})]})}`; + `var KEYS={autoUpdateOnExit:"codex-linux-auto-update-on-exit"};` + + `function Settings(){return $.jsx(SettingsGroup,{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."})})}`; const patched = applyWrapperUpdateSettingsPatch(source); @@ -150,8 +150,8 @@ test("settings asset patch prefers generated Linux desktop settings bundle", () const assetsDir = path.join(appDir, "webview", "assets"); fs.mkdirSync(assetsDir, { recursive: true }); const linuxDesktopSettings = - `var KEYS={autoBuildUpdates:"codex-linux-auto-build-updates",autoUpdateOnExit:"codex-linux-auto-update-on-exit"};` + - `function Settings(){return $.jsx(SettingsGroup,{children:[$.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."}),$.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."})]})}`; + `var KEYS={autoUpdateOnExit:"codex-linux-auto-update-on-exit"};` + + `function Settings(){return $.jsx(SettingsGroup,{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."})})}`; const generalSettings = `function Br(){return null}`; fs.writeFileSync(path.join(assetsDir, "linux-desktop-settings-linux.js"), linuxDesktopSettings); fs.writeFileSync(path.join(assetsDir, "general-settings-z.js"), generalSettings); @@ -184,7 +184,7 @@ test("settings asset patch leaves current asset unchanged on synthetic drift", ( assert.deepEqual(withoutWarnings(() => patchWrapperUpdateSettingsAssets(appDir)), { matched: false, changed: 0, - reason: "could not find automatic update build setting", + reason: "could not find Linux update toggle", }); assert.equal(fs.readFileSync(settingsPath, "utf8"), driftedSettings); } finally { diff --git a/linux-features/deferred-update-build/README.md b/linux-features/deferred-update-build/README.md new file mode 100644 index 000000000..e132a9560 --- /dev/null +++ b/linux-features/deferred-update-build/README.md @@ -0,0 +1,33 @@ +# 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. + +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/scripts/lib/linux-update-bridge-patch.js b/scripts/lib/linux-update-bridge-patch.js index 04ce878c6..dfd8ca70c 100644 --- a/scripts/lib/linux-update-bridge-patch.js +++ b/scripts/lib/linux-update-bridge-patch.js @@ -23,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:()=>{},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()}}}`; + 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:{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) { diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index 44747d5f8..895c1b1fd 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -6643,20 +6643,18 @@ test("renders the generated Linux desktop settings page with working switches", assert.ok(text.includes("Compact prompt window")); assert.ok(text.includes("System tray")); assert.ok(text.includes("Warm start")); - assert.ok(text.includes("Build updates automatically")); assert.ok(text.includes("Install updates when you close ChatGPT")); const switches = rendered.filter( (value) => typeof value === "object" && value.type === "button" && value.props.role === "switch", ); - assert.equal(switches.length, 5); + assert.equal(switches.length, 4); assert.deepEqual( switches.map((element) => element.props["aria-label"]), [ "Compact prompt window", "System tray", "Warm start", - "Build updates automatically", "Install updates when you close ChatGPT", ], ); @@ -7761,6 +7759,8 @@ test("adds Linux package updater to current bootstrap updater wiring", () => { 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, /getIsUpdateReady:\(\)=>s&&t/); diff --git a/scripts/patches/impl/keybinds-settings.js b/scripts/patches/impl/keybinds-settings.js index 98c9dfe74..bcf4badab 100644 --- a/scripts/patches/impl/keybinds-settings.js +++ b/scripts/patches/impl/keybinds-settings.js @@ -22,7 +22,7 @@ const linuxDesktopSettingsAsset = "linux-desktop-settings-linux.js"; const linuxKeybindOverridesKey = "codex-linux-keybind-overrides"; const linuxReactRuntimeExport = "codexLinuxReact"; const linuxJsxRuntimeExport = "codexLinuxJsx"; -const linuxDesktopSettingsSourceVersion = 2; +const linuxDesktopSettingsSourceVersion = 1; const linuxDesktopSettingsSourceMarker = `var codexLinuxDesktopSettingsVersion=${linuxDesktopSettingsSourceVersion},KEYS={`; @@ -39,37 +39,6 @@ function linuxDesktopSettingsControlsSource() { return `function codexLinuxChecked(next){return next&&typeof next=="object"&&next.target&&typeof next.target.checked=="boolean"?next.target.checked:next===!0}class LinuxToggle extends React.Component{constructor(props){super(props),this._alive=!1,this.state={value:props.defaultValue??!0,isLoading:!0,error:null},this.load=this.load.bind(this),this.update=this.update.bind(this)}componentDidMount(){this._alive=!0,this.load()}componentDidUpdate(previous){(previous.settingKey!==this.props.settingKey||previous.defaultValue!==this.props.defaultValue)&&this.load()}componentWillUnmount(){this._alive=!1}load(){let{settingKey:key,defaultValue=!0}=this.props;this.setState({isLoading:!0}),__post("get-global-state",{params:{key}}).then(result=>{this._alive&&this.setState({value:result?.value??defaultValue,error:null})}).catch(err=>{this._alive&&this.setState({error:err instanceof Error?err.message:String(err)})}).finally(()=>{this._alive&&this.setState({isLoading:!1})})}update(next){let value=codexLinuxChecked(next),previous=this.state.value,{settingKey:key}=this.props;this.setState({value,error:null}),__post("set-global-state",{params:{key,value}}).catch(err=>{this._alive&&this.setState({value:previous,error:err instanceof Error?err.message:String(err)})})}render(){let{label,description}=this.props,{value,isLoading,error}=this.state,details=error?$.jsxs("div",{className:"flex flex-col gap-1",children:[$.jsx("span",{children:description}),$.jsx("span",{className:"text-token-error-foreground",children:error})]}):description;return $.jsx(SettingsRow,{label,description:details,control:$.jsx(Toggle,{checked:value,disabled:isLoading,onChange:this.update,ariaLabel:label})})}}`; } -function addAutoBuildUpdatesSetting(source) { - const keysNeedle = - `warmStart:${JSON.stringify(linuxSettingsKeys.warmStart)},` + - `autoUpdateOnExit:${JSON.stringify(linuxSettingsKeys.autoUpdateOnExit)}`; - const keysReplacement = - `warmStart:${JSON.stringify(linuxSettingsKeys.warmStart)},` + - `autoBuildUpdates:${JSON.stringify(linuxSettingsKeys.autoBuildUpdates)},` + - `autoUpdateOnExit:${JSON.stringify(linuxSettingsKeys.autoUpdateOnExit)}`; - const controlsNeedle = - `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."})`; - const controlsReplacement = - `children:[$.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."}),` + - `$.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 (!source.includes(keysNeedle) || !source.includes(controlsNeedle)) { - throw new Error("Required Keybinds settings patch failed: could not add automatic update build control"); - } - return source - .replace(keysNeedle, keysReplacement) - .replace(controlsNeedle, controlsReplacement); -} - function buildKeybindsSettingsSource({ chunkAsset, reactAsset, @@ -535,9 +504,10 @@ function resolveLinuxDesktopSettingsAsset(extractedDir) { includeHotkeySettings: false, }); - const source = addAutoBuildUpdatesSetting( - buildLinuxDesktopSettingsSource(dependencies), - ).replace("var KEYS={", linuxDesktopSettingsSourceMarker); + const source = buildLinuxDesktopSettingsSource(dependencies).replace( + "var KEYS={", + linuxDesktopSettingsSourceMarker, + ); return { filePath: path.join(webviewAssetsDir, linuxDesktopSettingsAsset), source, @@ -845,7 +815,6 @@ function hasCompleteLinuxDesktopSettingsSource(previousSource) { `promptWindow:${JSON.stringify(linuxSettingsKeys.promptWindow)}`, `systemTray:${JSON.stringify(linuxSettingsKeys.systemTray)}`, `warmStart:${JSON.stringify(linuxSettingsKeys.warmStart)}`, - `autoBuildUpdates:${JSON.stringify(linuxSettingsKeys.autoBuildUpdates)}`, `autoUpdateOnExit:${JSON.stringify(linuxSettingsKeys.autoUpdateOnExit)}`, "function codexLinuxChecked(", "class LinuxToggle extends React.Component", @@ -861,7 +830,6 @@ function hasCompleteLinuxDesktopSettingsSource(previousSource) { "settingKey:KEYS.promptWindow", "settingKey:KEYS.systemTray", "settingKey:KEYS.warmStart", - "settingKey:KEYS.autoBuildUpdates", "settingKey:KEYS.autoUpdateOnExit", "$.jsx(LinuxBuildInfoPanel,{})", ]; diff --git a/scripts/patches/impl/keybinds-settings.test.js b/scripts/patches/impl/keybinds-settings.test.js index 5bea95686..fc04d9328 100644 --- a/scripts/patches/impl/keybinds-settings.test.js +++ b/scripts/patches/impl/keybinds-settings.test.js @@ -8,6 +8,9 @@ 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, @@ -36,7 +39,7 @@ function assetSources(assetsDir) { ); } -test("preserves wrapper updater extensions across Linux settings patch passes", () => { +test("preserves optional update settings extensions across Linux settings patch passes", () => { const { extractedDir, assetsDir } = createModernNativeKeyboardShortcutsSettingsFixture(); try { @@ -46,15 +49,15 @@ test("preserves wrapper updater extensions across Linux settings patch passes", const settingsPath = path.join(assetsDir, linuxDesktopSettingsAsset); assert.match( fs.readFileSync(settingsPath, "utf8"), - /var codexLinuxDesktopSettingsVersion=2,KEYS=\{/, - ); - assert.match( - fs.readFileSync(settingsPath, "utf8"), - /settingKey:KEYS\.autoBuildUpdates,label:"Build updates automatically"/, + /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, @@ -64,6 +67,10 @@ test("preserves wrapper updater extensions across Linux settings patch passes", 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); @@ -74,6 +81,10 @@ test("preserves wrapper updater extensions across Linux settings patch passes", 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 }); @@ -85,8 +96,8 @@ for (const [name, damage] of [ "rejects incomplete generated Linux settings markers without writing assets", (source) => source.replace( + "codexLinuxDesktopSettingsVersion=1", "codexLinuxDesktopSettingsVersion=2", - "codexLinuxDesktopSettingsVersion=3", ), ], [ @@ -97,7 +108,6 @@ for (const [name, damage] of [ "promptWindow", "systemTray", "warmStart", - "autoBuildUpdates", "autoUpdateOnExit", ].map((key) => [ `rejects generated Linux settings without the ${key} control`, diff --git a/scripts/patches/lib/settings-keys.js b/scripts/patches/lib/settings-keys.js index d64d14273..7f7e8a30a 100644 --- a/scripts/patches/lib/settings-keys.js +++ b/scripts/patches/lib/settings-keys.js @@ -6,7 +6,6 @@ const linuxSettingsKeys = { promptWindow: "codex-linux-prompt-window-enabled", systemTray: "codex-linux-system-tray-enabled", warmStart: "codex-linux-warm-start-enabled", - autoBuildUpdates: "codex-linux-auto-build-updates", autoUpdateOnExit: "codex-linux-auto-update-on-exit", wrapperUpdates: "codex-linux-wrapper-updates-enabled", featurePickerOnUpdate: "codex-linux-feature-picker-on-update", diff --git a/updater/Cargo.toml b/updater/Cargo.toml index 628331b9b..269027cb0 100644 --- a/updater/Cargo.toml +++ b/updater/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-update-manager" -version = "0.10.5" +version = "0.11.0" edition = "2021" [dependencies] diff --git a/updater/src/app.rs b/updater/src/app.rs index 9834d4367..fe5832698 100644 --- a/updater/src/app.rs +++ b/updater/src/app.rs @@ -177,8 +177,8 @@ 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(explicit_build: bool) -> bool { - explicit_build || crate::config::settings_auto_build_updates_override().unwrap_or(true) +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) { @@ -1188,33 +1188,7 @@ async fn run_check_cycle_with_options( warn!(?error, "wrapper update detection failed during check cycle"); } - let build_detected_update = should_build_detected_update(options.explicit_build); - if matches!( - state.status, - UpdateStatus::UpdateDetected | UpdateStatus::UpdateAvailable - ) { - if !build_detected_update { - if state.status != UpdateStatus::UpdateAvailable { - state.status = UpdateStatus::UpdateAvailable; - persist_state(paths, state)?; - } - info!("automatic update builds are disabled; keeping detected update pending"); - maybe_prune_caches(config, state); - return Ok(()); - } - - let result = build_pending_detected_update(config, state, paths).await; - maybe_prune_caches(config, state); - if let Err(error) = result { - mark_failed_and_persist(state, paths, error.to_string())?; - let _ = notify_failure(config, state, paths, &error); - return Err(error); - } - if options.reconcile_after_check { - reconcile_pending_install(config, state, paths).await?; - } - return Ok(()); - } + 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"); @@ -1304,6 +1278,7 @@ 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()); @@ -1324,8 +1299,7 @@ async fn run_check_cycle_with_options( )?; if !build_detected_update { - state.status = UpdateStatus::UpdateAvailable; - persist_state(paths, state)?; + info!("automatic update builds are disabled; keeping the current DMG pending"); return Ok(()); } @@ -1691,7 +1665,6 @@ fn dmg_update_state_can_be_cleared_as_current(status: &UpdateStatus) -> bool { matches!( status, UpdateStatus::UpdateDetected - | UpdateStatus::UpdateAvailable | UpdateStatus::DownloadingDmg | UpdateStatus::PreparingWorkspace | UpdateStatus::PatchingApp @@ -1711,6 +1684,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); @@ -2359,6 +2333,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 { @@ -2402,7 +2431,6 @@ mod tests { for status in [ UpdateStatus::Idle, UpdateStatus::CheckingUpstream, - UpdateStatus::UpdateAvailable, UpdateStatus::ReadyToInstall, UpdateStatus::WaitingForAppExit, UpdateStatus::Installing, @@ -2808,7 +2836,7 @@ mod tests { } #[test] - fn auto_build_toggle_defers_background_build_until_explicit_check() -> Result<()> { + 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", @@ -2818,37 +2846,18 @@ mod tests { runtime.block_on(async { let server = MockServer::start().await; - let body = b"new-codex-dmg"; - Mock::given(method("HEAD")) - .and(path("/Codex.dmg")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("ETag", "\"new-dmg\"") - .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; + 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()?; - let settings_path = temp.path().join("settings.json"); - std::fs::write( - &settings_path, - r#"{"codex-linux-auto-build-updates": false}"#, - )?; - std::env::set_var("CODEX_LINUX_SETTINGS_FILE", &settings_path); 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)?; @@ -2865,18 +2874,22 @@ mod tests { }, ) .await?; - server.verify().await; - - assert_eq!(state.status, UpdateStatus::UpdateAvailable); + assert_eq!(state.status, UpdateStatus::UpdateDetected); + assert!(state.deferred_build); assert!(state.candidate_version.is_some()); - assert!(state + let candidate_a_path = state .artifact_paths .dmg_path - .as_deref() - .is_some_and(Path::is_file)); + .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"); @@ -2884,6 +2897,147 @@ mod tests { .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(()) }) } diff --git a/updater/src/config.rs b/updater/src/config.rs index 723b26719..40b47d3fd 100644 --- a/updater/src/config.rs +++ b/updater/src/config.rs @@ -225,7 +225,6 @@ impl RuntimeConfig { const APP_SETTINGS_FILE: &str = "settings.json"; pub(crate) const DEFAULT_APP_ID: &str = "codex-desktop"; -const AUTO_BUILD_UPDATES_SETTING_KEY: &str = "codex-linux-auto-build-updates"; const AUTO_INSTALL_SETTING_KEY: &str = "codex-linux-auto-update-on-exit"; const WRAPPER_UPDATES_SETTING_KEY: &str = "codex-linux-wrapper-updates-enabled"; @@ -319,11 +318,6 @@ pub fn settings_auto_install_override() -> Option { settings_bool_override(AUTO_INSTALL_SETTING_KEY) } -/// Reads whether background update checks should build detected updates. -pub fn settings_auto_build_updates_override() -> Option { - settings_bool_override(AUTO_BUILD_UPDATES_SETTING_KEY) -} - /// Reads the user's opt-in wrapper update tracking preference from app settings. pub fn settings_wrapper_updates_override() -> Option { settings_bool_override(WRAPPER_UPDATES_SETTING_KEY) @@ -331,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 @@ -359,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. @@ -462,24 +529,6 @@ app_executable_path = "/opt/codex-desktop/electron" ); } - #[test] - fn auto_build_updates_override_reads_explicit_bool() { - assert_eq!( - override_with_settings( - Some(r#"{"codex-linux-auto-build-updates": false}"#), - AUTO_BUILD_UPDATES_SETTING_KEY - ), - Some(false) - ); - assert_eq!( - override_with_settings( - Some(r#"{"codex-linux-auto-build-updates": true}"#), - AUTO_BUILD_UPDATES_SETTING_KEY - ), - Some(true) - ); - } - #[test] fn settings_override_coerces_string_and_number() { assert_eq!( @@ -591,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/state.rs b/updater/src/state.rs index acb9eacfe..d810daa89 100644 --- a/updater/src/state.rs +++ b/updater/src/state.rs @@ -21,8 +21,8 @@ const STATE_LOCK_FILE_NAME: &str = "state.lock"; pub enum UpdateStatus { Idle, CheckingUpstream, + #[serde(alias = "update_available")] UpdateDetected, - UpdateAvailable, DownloadingDmg, PreparingWorkspace, PatchingApp, @@ -81,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, @@ -146,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, @@ -303,6 +309,10 @@ impl PersistedState { } } +fn is_false(value: &bool) -> bool { + !*value +} + pub(crate) fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> { let parent = path .parent() @@ -423,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()); @@ -443,6 +454,26 @@ 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(()) } From 1cfca15ac358ba11242c8421a458e4bc928dacbe Mon Sep 17 00:00:00 2001 From: kortylokai-web <263109108+kortylokai-web@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:59:49 +0200 Subject: [PATCH 098/112] fix(shared-app-server-socket): support systemd user adoption --- .../shared-app-server-socket/README.md | 17 +- .../shared-app-server-socket/orphan-reaper.js | 48 ++- .../shared-app-server-socket/test.js | 359 +++++++++++++++++- 3 files changed, 396 insertions(+), 28 deletions(-) diff --git a/linux-features/shared-app-server-socket/README.md b/linux-features/shared-app-server-socket/README.md index b1b5226fe..da59fad60 100644 --- a/linux-features/shared-app-server-socket/README.md +++ b/linux-features/shared-app-server-socket/README.md @@ -37,14 +37,15 @@ 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, reparented `codex app-server ---listen unix://PATH` process serving the exact locked socket. 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. +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 diff --git a/linux-features/shared-app-server-socket/orphan-reaper.js b/linux-features/shared-app-server-socket/orphan-reaper.js index 8c8b40255..edba05853 100644 --- a/linux-features/shared-app-server-socket/orphan-reaper.js +++ b/linux-features/shared-app-server-socket/orphan-reaper.js @@ -2,6 +2,7 @@ "use strict"; const fs = require("node:fs"); +const path = require("node:path"); const socketPath = process.argv[2]; if (!socketPath) { @@ -21,7 +22,8 @@ function readProcess(pid) { const procStat = fs.statSync(procPath); const rawStat = fs.readFileSync(`${procPath}/stat`, "utf8"); const commandEnd = rawStat.lastIndexOf(")"); - if (commandEnd < 0) return null; + 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`) @@ -34,6 +36,7 @@ function readProcess(pid) { state: fields[0], ppid: Number(fields[1]), startTime: fields[19] ?? null, + comm: rawStat.slice(commandStart + 1, commandEnd), commandLine, }; } catch (error) { @@ -109,15 +112,42 @@ function isExpectedAuthority(processInfo) { ); } +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 ( - authority == null || - authority.startTime !== lock.authorityStartTime || - (expectedUid != null && authority.uid !== expectedUid) || - authority.ppid !== 1 || - !isExpectedAuthority(authority) - ) { + if (!isExpectedLockedAuthority(lock, authority)) { throw new Error("locked authority is not the expected reparented Codex process"); } @@ -220,12 +250,14 @@ async function reapOrphan() { 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"); diff --git a/linux-features/shared-app-server-socket/test.js b/linux-features/shared-app-server-socket/test.js index b6c78bc58..be5535c2f 100755 --- a/linux-features/shared-app-server-socket/test.js +++ b/linux-features/shared-app-server-socket/test.js @@ -25,6 +25,210 @@ const { 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-")); const configPath = path.join(tempDir, "features.json"); @@ -187,6 +391,11 @@ function processStartTime(pid) { } } +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")) { @@ -257,18 +466,8 @@ async function spawnOrphanAuthority(socketPath) { const startTime = processStartTime(pid); assert.notEqual(startTime, null); await waitForCondition( - () => { - try { - const rawStat = fs.readFileSync(`/proc/${pid}/stat`, "utf8"); - const commandEnd = rawStat.lastIndexOf(")"); - const fields = rawStat.slice(commandEnd + 2).trim().split(/\s+/); - return Number(fields[1]) === 1 && fs.existsSync(socketPath); - } catch (error) { - if (error.code === "ENOENT") return false; - throw error; - } - }, - "detached authority to be reparented", + () => hasLegitimateOrphanAdoption(pid) && fs.existsSync(socketPath), + "detached authority to be adopted by PID 1 or the systemd user manager", ); return { pid, startTime }; } @@ -468,6 +667,142 @@ test("orphan reaper fails closed on an unknown live listener", async () => { } }); +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"); From e1d339ef6b360e236e1c34d0b4b53b8583d880ec Mon Sep 17 00:00:00 2001 From: Mohit Sahoo Date: Thu, 6 Aug 2026 09:49:10 +0530 Subject: [PATCH 099/112] updater: preserve deferred candidates across stale launch checks - Keep deferred candidates stable during fresh and unchanged-HEAD checks. - Restore pending state when offline refreshes fail. - Cover fresh, stale, and offline candidate behavior. --- CHANGELOG.md | 11 +- docs/updater.md | 4 + .../deferred-update-build/README.md | 5 + updater/src/app.rs | 247 +++++++++++++++++- 4 files changed, 250 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cacc91dc..8b5da7827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,10 +45,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - 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. 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. + 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. diff --git a/docs/updater.md b/docs/updater.md index 4b451e186..396b547e2 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -190,6 +190,10 @@ 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, diff --git a/linux-features/deferred-update-build/README.md b/linux-features/deferred-update-build/README.md index e132a9560..bf766ccc6 100644 --- a/linux-features/deferred-update-build/README.md +++ b/linux-features/deferred-update-build/README.md @@ -13,6 +13,11 @@ 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 diff --git a/updater/src/app.rs b/updater/src/app.rs index fe5832698..4914b7fbd 100644 --- a/updater/src/app.rs +++ b/updater/src/app.rs @@ -504,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()); @@ -1207,7 +1212,7 @@ async fn run_check_cycle_with_options( } if options.if_stale - && !update_check_should_retry(&state.status) + && !update_check_should_retry(state) && upstream_check_is_fresh(config, state) { info!("skipping check-now because the last successful upstream check is still fresh"); @@ -1218,9 +1223,13 @@ async fn run_check_cycle_with_options( return Ok(()); } + let stable_deferred = stable_deferred_candidate(state); + let deferred_refresh_snapshot = + (options.if_stale && 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?; @@ -1232,9 +1241,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)?; @@ -1268,7 +1297,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)?; @@ -1284,6 +1316,7 @@ async fn run_check_cycle_with_options( state.artifact_paths.dmg_path = Some(downloaded.path.clone()); state.notified_events.clear(); state.save_updater(&paths.state_file)?; + candidate_refresh_committed = true; maybe_notify( state, @@ -1313,6 +1346,18 @@ 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); @@ -2425,9 +2470,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, @@ -2436,7 +2488,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:?}"); } } @@ -2472,9 +2526,176 @@ 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 offline_if_stale_check_preserves_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(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, 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?; + + 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()?; From 9db2ba5fb006ffbb91029531a5637c6e9e9e5c24 Mon Sep 17 00:00:00 2001 From: Mohit Sahoo Date: Thu, 6 Aug 2026 10:12:11 +0530 Subject: [PATCH 100/112] updater: resume deferred builds after stale launch checks - Let fresh --if-stale checks continue resumed automatic builds. - Preserve deferred candidates across periodic offline checks. - Cover resumed-build and repeated offline refresh behavior. --- updater/src/app.rs | 71 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/updater/src/app.rs b/updater/src/app.rs index 4914b7fbd..36fb5b59e 100644 --- a/updater/src/app.rs +++ b/updater/src/app.rs @@ -1211,7 +1211,10 @@ async fn run_check_cycle_with_options( ); } + 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) { @@ -1223,9 +1226,8 @@ async fn run_check_cycle_with_options( return Ok(()); } - let stable_deferred = stable_deferred_candidate(state); let deferred_refresh_snapshot = - (options.if_stale && stable_deferred && !build_detected_update).then(|| state.clone()); + (stable_deferred && !build_detected_update).then(|| state.clone()); let client = upstream::http_client()?; let retrying_update = prepare_upstream_check(state, paths)?; @@ -2643,7 +2645,7 @@ mod tests { } #[test] - fn offline_if_stale_check_preserves_deferred_candidate() -> Result<()> { + 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", @@ -2653,13 +2655,73 @@ mod tests { 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(503)) + .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()?; @@ -2681,6 +2743,7 @@ mod tests { 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); From df725adf06e732c6017324e26eb73adcb35a15c9 Mon Sep 17 00:00:00 2001 From: Gadi Cohen Date: Thu, 6 Aug 2026 13:39:56 +0100 Subject: [PATCH 101/112] fix(directory-watch): route current Parcel working tree (#1238) --- CHANGELOG.md | 4 + .../README.md | 19 + .../patch.js | 529 ++++++++++++++--- .../directory-only-working-tree-watch/test.js | 533 +++++++++++++++++- 4 files changed, 1008 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b5da7827..4482209d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - 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 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 ec6775bcb..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,43 +2219,278 @@ 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 findLocalFileWatchBundles(extractedDir, settings) { @@ -2196,45 +2503,127 @@ function findLocalFileWatchBundles(extractedDir, settings) { .filter((entry) => entry.isFile() && entry.name.endsWith(".js")) .map((entry) => path.join(buildDir, entry.name)) .sort(); - const targets = []; - - 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) { - targets.push({ bundlePath, result: patchWorkerSource(source, settings) }); - continue; - } - LOCAL_FILE_WATCH_METHOD.lastIndex = 0; - const matches = [...source.matchAll(LOCAL_FILE_WATCH_METHOD)].length; - if (matches > 0) { - targets.push({ bundlePath, result: patchWorkerSource(source, settings) }); - } + 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) }; } - const targetNames = targets.map(({ bundlePath }) => path.basename(bundlePath)); - const hasWorker = targetNames.filter((name) => name === "worker.js").length === 1; - const srcCount = targetNames.filter((name) => - /^src-[A-Za-z0-9_-]+\.js$/u.test(name), - ).length; - if ( - targets.length !== 2 || - !hasWorker || - srcCount !== 1 || - targets.some(({ result }) => result.matched !== 1) - ) { - return { - targets: [], - reason: - `Found ${targets.length} current local startFileWatch bundles ` + - `(${targetNames.join(", ") || "none"}) across ${bundlePaths.length} build bundles`, - }; + 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) }; } + 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 }; } +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 = findLocalFileWatchBundles(extractedDir, normalizedSettings(context)); if (discovery.targets.length !== 2) { @@ -2243,11 +2632,7 @@ function patchWorker(extractedDir, context = {}) { return { matched: 0, changed: 0, reason }; } - for (const { bundlePath, result } of discovery.targets) { - if (result.changed === 1) { - fs.writeFileSync(bundlePath, result.source, "utf8"); - } - } + writePreparedBundleTargets(discovery.targets, context); const changed = discovery.targets.reduce((count, { result }) => count + result.changed, 0); return { matched: discovery.targets.length, @@ -2278,6 +2663,8 @@ module.exports = { DEFAULT_MAX_WATCHES, HELPER_NAME, LOCAL_FILE_WATCH_METHOD, + PARCEL_WATCH_MARKER, + PARCEL_WORKING_TREE_WATCH, codexLinuxStartDirectoryOnlyWorkingTreeWatch, descriptors, findLocalFileWatchBundles, diff --git a/linux-features/directory-only-working-tree-watch/test.js b/linux-features/directory-only-working-tree-watch/test.js index ad9eae3e7..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()); @@ -179,16 +326,16 @@ test("feature patches the current local host copies in src and worker bundles", 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, localWorkerSource()); - fs.writeFileSync(localHostPath, localWorkerSource()); + fs.writeFileSync(workerPath, currentWorkerSource()); + fs.writeFileSync(localHostPath, currentSrcSource()); const first = patchWorker(root); assert.equal(first.matched, 2); assert.equal(first.changed, 2); assert.deepEqual(first.targets, [ - path.join(".vite", "build", "src-current.js"), + path.join(".vite", "build", "src-Bn_6ASpg.js"), path.join(".vite", "build", "worker.js"), ]); for (const bundlePath of [localHostPath, workerPath]) { @@ -196,6 +343,7 @@ test("feature patches the current local host copies in src and worker bundles", 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, 2); @@ -204,6 +352,379 @@ test("feature patches the current local host copies in src and worker bundles", }); }); +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 local host copies outside the current src and worker pair", async () => { await withTempTree((root) => { const buildDir = path.join(root, ".vite", "build"); @@ -222,7 +743,7 @@ test("feature rejects local host copies outside the current src and worker pair" } assert.equal(result.matched, 0); assert.equal(result.changed, 0); - assert.match(result.reason, /Found 3 current local startFileWatch bundles/); + assert.match(result.reason, /Found 3 local startFileWatch implementations/); }); }); From be1acdb57c1a288f3d0a5b677b070482494f36fd Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Fri, 7 Aug 2026 07:05:33 +0300 Subject: [PATCH 102/112] Fix upstream DMG drift (#1242) * Fix upstream DMG drift watchdog-v2-action: commit-source * Fix upstream DMG drift watchdog-v2-action: commit-source * Fix upstream DMG drift watchdog-v2-action: commit-source --- .github/workflows/ci.yml | 1 + .github/workflows/upstream-build-app.yml | 9 +- docs/windowless-warm-start-fix-report.md | 4 +- flake.nix | 4 +- linux-features/ui-tweaks/README.md | 2 +- .../patches/reasoning-effort-labels.js | 1 - linux-features/ui-tweaks/test.js | 5 +- scripts/ci/container-entrypoint.sh | 2 +- scripts/ci/download-upstream-dmg.sh | 83 ++++++++ scripts/ci/update-nix-hashes.sh | 3 +- scripts/ci/upstream-dmg-acceptance.test.js | 102 +++++++++ scripts/ci/validate-nix-pins.sh | 3 +- scripts/patch-linux-window-ui.test.js | 197 +++++++++--------- scripts/patches/impl/avatar-overlay.js | 49 +++-- .../impl/main-process/quit-lifecycle.js | 10 +- tests/scripts_smoke.sh | 4 +- 16 files changed, 341 insertions(+), 138 deletions(-) create mode 100755 scripts/ci/download-upstream-dmg.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94934a909..70fbf5de4 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 diff --git a/.github/workflows/upstream-build-app.yml b/.github/workflows/upstream-build-app.yml index 013b800b7..8b606443e 100644 --- a/.github/workflows/upstream-build-app.yml +++ b/.github/workflows/upstream-build-app.yml @@ -10,6 +10,7 @@ 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 @@ -33,6 +34,7 @@ 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 @@ -124,11 +126,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 diff --git a/docs/windowless-warm-start-fix-report.md b/docs/windowless-warm-start-fix-report.md index 39c3fa17a..edb85bc94 100644 --- a/docs/windowless-warm-start-fix-report.md +++ b/docs/windowless-warm-start-fix-report.md @@ -26,8 +26,8 @@ would route work into a partially destroyed application context. The current upstream main bundle has one targeted lifecycle `will-quit` handler with two cleanup branches: -- a reduced branch stops Codex Micro and flushes tracing; -- a full branch also flushes global state and settings. +- 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 diff --git a/flake.nix b/flake.nix index e9da5e47a..e5f7e69ef 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-M61HAaH3I3MzE68F6ygWOStos2froJIdvgeNLVeXGbU="; + hash = "sha256-+KWnSss4qrSlmsCtf+87tLtPKAp+0l88t+9hRd9eh0c="; }; - codexVersion = "26.730.61639"; + codexVersion = "26.803.41515"; electronVersion = "42.3.0"; electronPlatform = { 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/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/test.js b/linux-features/ui-tweaks/test.js index b2a0664f8..a34c1fcf6 100644 --- a/linux-features/ui-tweaks/test.js +++ b/linux-features/ui-tweaks/test.js @@ -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/scripts/ci/container-entrypoint.sh b/scripts/ci/container-entrypoint.sh index 829f52a62..c4582d6f6 100755 --- a/scripts/ci/container-entrypoint.sh +++ b/scripts/ci/container-entrypoint.sh @@ -691,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 9b1933ab8..db4f73d55 100644 --- a/scripts/ci/upstream-dmg-acceptance.test.js +++ b/scripts/ci/upstream-dmg-acceptance.test.js @@ -211,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", () => { 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/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index 895c1b1fd..f247caa2b 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -1403,7 +1403,7 @@ function beforeQuitConfirmationBundleFixture() { function willQuitDrainBundleFixture() { return [ - "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([p(),m()]).then(t);return}e.preventDefault(),v=!0,c.dispose(),u.dispose(),Promise.allSettled([d.flush(),f.flush(),p(),m()]).then(t)});", + "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(""); } @@ -1903,17 +1903,19 @@ function currentSparkleUpdateMenuContractFixture() { 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)}", @@ -2840,7 +2842,7 @@ test("Linux reduced will-quit branch shares the complete cleanup deadline", asyn assert.equal(contextDisposeCalls, 1); assert.equal(disposablesCalls, 1); - assert.equal(globalStateFlushCalls, 0); + assert.equal(globalStateFlushCalls, 1); assert.equal(settingsFlushCalls, 0); assert.equal(stopCodexMicroCalls, 1); assert.equal(flushTracingCalls, 1); @@ -2981,6 +2983,10 @@ test("current will-quit drift fails the required lifecycle patch", () => { 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()", @@ -3053,7 +3059,7 @@ test("does not accept damaged Linux quit cleanup factory bodies", () => { ); const sources = [ patched.replace( - "codexLinuxRunQuitCleanup(()=>{c.dispose(),u.dispose();return Promise.allSettled([p(),m()])})", + "codexLinuxRunQuitCleanup(()=>{c.dispose(),u.dispose();return Promise.allSettled([d.flush(),p(),m()])})", "codexLinuxRunQuitCleanup(()=>{return Promise.resolve()})", ), patched.replace( @@ -4498,8 +4504,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/); @@ -4516,23 +4521,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/); @@ -4549,7 +4546,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", () => { @@ -4576,7 +4594,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() {}, @@ -4585,7 +4602,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 }, }; }, @@ -4602,26 +4619,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); }); @@ -4636,7 +4655,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() {}, @@ -4645,7 +4663,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 }, }; }, @@ -4662,29 +4680,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", () => { @@ -4695,9 +4715,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( @@ -4712,7 +4732,6 @@ test("Linux avatar overlay interactivity is bounded to avatar regions", () => { latestAvatarOverlayBundleFixture(), ); const cursor = { x: 5843, y: 1036 }; - let ozonePlatform = ""; const context = { globalThis: {}, process: { @@ -4728,7 +4747,6 @@ test("Linux avatar overlay interactivity is bounded to avatar regions", () => { return { app: { getName: () => "Codex", - commandLine: { getSwitchValue: () => ozonePlatform }, }, screen: { getCursorScreenPoint: () => cursor, @@ -4769,30 +4787,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({ @@ -4801,52 +4827,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", () => { @@ -4864,7 +4863,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`/); }); diff --git a/scripts/patches/impl/avatar-overlay.js b/scripts/patches/impl/avatar-overlay.js index 096084885..2f69467c7 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 ( @@ -355,7 +366,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/main-process/quit-lifecycle.js b/scripts/patches/impl/main-process/quit-lifecycle.js index 21a8e5171..a7488ed06 100644 --- a/scripts/patches/impl/main-process/quit-lifecycle.js +++ b/scripts/patches/impl/main-process/quit-lifecycle.js @@ -44,7 +44,7 @@ function parseCurrentWillQuitDrainBody(body, eventVar, listenerElectronVar) { `^(?${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})\\(\\),(?${identifier})\\(\\)\\]\\)\\.then\\((?${identifier})\\)$`, + `^(?${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})\\)$`, @@ -65,6 +65,7 @@ function parseCurrentWillQuitDrainBody(body, eventVar, listenerElectronVar) { 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 || @@ -167,7 +168,7 @@ function hasAppliedWillQuitCleanupPostcondition(currentSource, appliedFinalizerS } const reducedMatch = reducedBody.match(new RegExp( - `codexLinuxRunQuitCleanup\\(\\(\\)=>\\{(?${identifier})\\.dispose\\(\\),(?${identifier})\\.dispose\\(\\);return Promise\\.allSettled\\(\\[(?${identifier})\\(\\),(?${identifier})\\(\\)\\]\\)\\}\\)`, + `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})\\(\\)\\]\\)\\}\\)`, @@ -179,6 +180,7 @@ function hasAppliedWillQuitCleanupPostcondition(currentSource, appliedFinalizerS 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 ); @@ -234,8 +236,8 @@ function applyLinuxWillQuitDrainTimeoutPatch(currentSource) { `let ${originalFinalizer},${linuxFinalizer};`, ); patchedBody = patchedBody.replace( - `${reduced.hotkey}.dispose(),${reduced.dictation}.dispose(),Promise.allSettled([${reduced.stop}(),${reduced.trace}()]).then(${outer.upstreamFinalize})`, - `codexLinuxRunQuitCleanup(()=>{${reduced.hotkey}.dispose(),${reduced.dictation}.dispose();return Promise.allSettled([${reduced.stop}(),${reduced.trace}()])})`, + `${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})`, diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index e15d90c34..4197a8407 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -3952,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" <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()}); -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([p(),m()]).then(t);return}e.preventDefault(),v=!0,c.dispose(),u.dispose(),Promise.allSettled([d.flush(),f.flush(),p(),m()]).then(t)}); +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" From 06d4209e21afb3b9da34f346d6aae77b503c03f7 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Fri, 7 Aug 2026 09:01:06 +0300 Subject: [PATCH 103/112] Fix optional upstream DMG drift (#1245) * Fix optional upstream DMG drift optional-drift-watchdog-action: source-commit * Fix optional upstream DMG drift optional-drift-watchdog-action: source-commit --- scripts/lib/linux-update-bridge-patch.js | 2 +- scripts/patch-linux-window-ui.test.js | 140 ++++++++++++++++-- .../extracted-app/browser-annotation/patch.js | 4 +- scripts/patches/impl/main-process/tray.js | 91 ++++++++++-- .../impl/webview-browser-use-external.test.js | 58 +++++--- scripts/patches/impl/webview/index.js | 111 +++++++------- tests/scripts_smoke.sh | 12 +- 7 files changed, 311 insertions(+), 107 deletions(-) diff --git a/scripts/lib/linux-update-bridge-patch.js b/scripts/lib/linux-update-bridge-patch.js index dfd8ca70c..6d903b7a4 100644 --- a/scripts/lib/linux-update-bridge-patch.js +++ b/scripts/lib/linux-update-bridge-patch.js @@ -27,7 +27,7 @@ function buildBridgeSource({ childProcessVar, fsVar, pathVar }) { } 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,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:{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()}}}`; + 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) { diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index f247caa2b..3bb7e4eec 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -1365,12 +1365,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(""); } @@ -2676,10 +2683,25 @@ 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\(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); + + assert.match( + patched, + /r=codexLinuxRegisterTray\(new c\.Tray\(t\.defaultIcon,process\.platform===`win32`&&c\.app\.isPackaged\?dEe\(e\.buildFlavor\):void 0\)\)/, + ); + assert.doesNotThrow(() => new Function(patched)); + assert.equal(patchMainBundleSource(patched, null), patched); +}); + test("bypasses the upstream before-quit confirmation after a Linux explicit quit", () => { const source = `${currentMainBundlePrefix}${beforeQuitConfirmationBundleFixture()}`; const patched = applyPatchTwice( @@ -4368,20 +4390,20 @@ 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 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})}", + "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\(pt&&N\?\.annotation\.anchor\.kind===`element`\)\{let t=fs\(N\.annotation\.anchor\);Lt=void 0,It=js/, + /if\(ht&&j\?\.annotation\.anchor\.kind===`element`\)\{let t=ls\(j\.annotation\.anchor\);zt=void 0,Rt=Os/, ); assert.match(patched, /selectionIndexOffset:1/); - assert.doesNotMatch(patched, /e\?\.rect\?\?fs/); + assert.doesNotMatch(patched, /e\?\.rect\?\?ls/); }); test("keeps the current stored annotation anchor shape unchanged", () => { @@ -4391,7 +4413,7 @@ test("keeps the current stored annotation anchor shape unchanged", () => { assert.equal(applyPatchTwice(applyBrowserAnnotationScreenshotPatch, source), source); }); -test("reports current comment preload screenshot anchor drift", () => { +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), @@ -7761,7 +7783,8 @@ test("adds Linux package updater to current bootstrap updater wiring", () => { 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\}/); @@ -7802,6 +7825,105 @@ test("implements the current Sparkle AppView, menu, and RPC contract on Linux", /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 () => { 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/impl/main-process/tray.js b/scripts/patches/impl/main-process/tray.js index 7f1077482..5cd0ed444 100644 --- a/scripts/patches/impl/main-process/tray.js +++ b/scripts/patches/impl/main-process/tray.js @@ -2,6 +2,68 @@ 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 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 +132,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 +142,21 @@ function applyLinuxTrayPatch(currentSource, iconPathExpression) { return currentSource; } - const [, trayVar, electronVar, constructorArgs] = constructorMatch; + const { + args: constructorArgs, + closeIndex: constructorCloseIndex, + electronVar, + retained, + startIndex: constructorStartIndex, + trayVar, + } = constructorMatch; 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); + if (!retained) { + patchedSource = + patchedSource.slice(0, constructorStartIndex) + + retainedConstructor + + patchedSource.slice(constructorCloseIndex + 1); } if (!patchedSource.includes("codexLinuxRegisterTray=e=>")) { diff --git a/scripts/patches/impl/webview-browser-use-external.test.js b/scripts/patches/impl/webview-browser-use-external.test.js index 8a45d6568..0804c7899 100644 --- a/scripts/patches/impl/webview-browser-use-external.test.js +++ b/scripts/patches/impl/webview-browser-use-external.test.js @@ -13,35 +13,46 @@ const { 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\`,linux:{installations:[{commands:[\`microsoft-edge\`,\`microsoft-edge-stable\`],userDataDirName:\`microsoft-edge\`}],nativeMessagingManifestDirectories:[\`.config/microsoft-edge/NativeMessagingHosts\`],processNames:[\`msedge\`],userDataDirectorySegments:[\`.config\`,\`microsoft-edge\`]}}};`; + 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("ob"), - "function fb(e){return Object.hasOwn(ob,e)}", - "Object.defineProperty(exports,`So`,{enumerable:!0,get:function(){return ob}}),Object.defineProperty(exports,`wo`,{enumerable:!0,get:function(){return fb}});", + 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 sne({browserFamily:e,platform:t=process.platform}){return t===`darwin`?!1:t===`win32`?!1:t===`linux`&&Ol(e)!=null}", - "async function lne({browserFamily:e,platform:t=process.platform,runCommand:n=ml,url:r}){await El({browserFamily:e,platform:t,runCommand:n,unsupportedPlatformError:`unsupported`,url:r})}", - "async function El({browserFamily:e,platform:t,runCommand:r,url:i}){let a=n.So[e];if(t===`linux`){let t=Ol(e);if(t==null)throw Error(`${a.displayName} is not installed`);await r(t,[i]);return}throw Error(`unsupported`)}", - "function Ol(e){let t=n.So[e],r=t.linux.installations;for(let e of r){let t=kl(e);if(t!=null)return t}return null}function kl(e){for(let t of e.commands){let e=dl(t);if(e!=null)return e}return null}", - "var oce={parse:e=>e},sce=class{async getInstalledBrowserFamilies(){let e=Object.keys(n.So).filter(n.wo);return(await Promise.all(e.map(async e=>({browserFamily:e,installed:await sne({browserFamily:e})})))).flatMap(({browserFamily:e,installed:t})=>t?[e]:[])}async openUrl({browserFamily:e,url:t}){await lne({browserFamily:oce.parse(e),url:t})}};globalThis.BrowserService=sce;", + "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("Xl"), - "function Yl(e){return Object.hasOwn(Xl,e)}", - "function rendererLinuxRegistry(){return Object.keys(Xl).filter(Yl).map(e=>({browserFamily:e,installations:Xl[e].linux.installations,manifestDirectories:Xl[e].linux.nativeMessagingManifestDirectories,processNames:Xl[e].linux.processNames}))}", + 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;", @@ -80,12 +91,12 @@ function jsonValue(value) { return JSON.parse(JSON.stringify(value)); } -test("patches exact-DMG Browser Use availability and both Brave registries atomically", async () => { +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: 2 }, + { matched: 3, changed: 1 }, ); const mainSource = fs.readFileSync(fixture.mainPath, "utf8"); @@ -93,8 +104,8 @@ test("patches exact-DMG Browser Use availability and both Brave registries atomi const rendererSource = fs.readFileSync(fixture.rendererPath, "utf8"); const braveOnly = evaluateMainBrowserService(srcSource, mainSource, ["brave-browser"]); - assert.deepEqual(jsonValue(await braveOnly.service.getInstalledBrowserFamilies()), ["chrome"]); - await braveOnly.service.openUrl({ browserFamily: "chrome", url: "https://example.com/brave" }); + 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"]], ]); @@ -110,22 +121,22 @@ test("patches exact-DMG Browser Use availability and both Brave registries atomi 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("brave-browser"))); assert.ok(rendererChrome.installations.some(({ commands }) => commands.includes("google-chrome"))); assert.ok(rendererChrome.installations.some(({ commands }) => commands.includes("chromium"))); assert.ok( - rendererChrome.installations.some( + rendererBrave.installations.some( ({ userDataDirName }) => userDataDirName === "BraveSoftware/Brave-Browser", ), ); assert.ok( - rendererChrome.manifestDirectories.includes( + rendererBrave.manifestDirectories.includes( ".config/BraveSoftware/Brave-Browser/NativeMessagingHosts", ), ); - assert.ok(rendererChrome.processNames.includes("brave")); - assert.ok(rendererChrome.processNames.includes("brave-browser")); + assert.ok(rendererBrave.processNames.includes("brave")); + assert.ok(rendererBrave.processNames.includes("brave-browser")); assert.deepEqual(rendererEdge.installations[0].commands, [ "microsoft-edge", "microsoft-edge-stable", @@ -188,11 +199,10 @@ test("leaves every exact-DMG Browser Use asset unchanged when a registry seam dr } }); -test("rolls back both exact-DMG Browser Use registry files when the second write fails", () => { +test("rolls back the exact-DMG Browser Use availability file when its write fails", () => { const fixture = createCurrentExternalBrowserUseAssets(); try { const before = new Map([ - [fixture.srcPath, fs.readFileSync(fixture.srcPath, "utf8")], [fixture.rendererPath, fs.readFileSync(fixture.rendererPath, "utf8")], ]); let writeCount = 0; @@ -204,7 +214,7 @@ test("rolls back both exact-DMG Browser Use registry files when the second write result = patchLinuxBrowserUseExternalAvailabilityAssets(fixture.extractedDir, { writeFileSync(filePath, source, encoding) { writeCount += 1; - if (writeCount === 2) { + if (writeCount === 1) { fs.writeFileSync(filePath, "partially-written", encoding); throw new Error("simulated renderer write failure"); } diff --git a/scripts/patches/impl/webview/index.js b/scripts/patches/impl/webview/index.js index 2cda3d272..a5b5997cc 100644 --- a/scripts/patches/impl/webview/index.js +++ b/scripts/patches/impl/webview/index.js @@ -1107,8 +1107,14 @@ function applyLinuxBrowserUseWebviewHostRecoveryPatch(currentSource) { 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 LINUX_BRAVE_BROWSER_USE_CHROME_REGISTRY = - "linux:{installations:[{commands:[`google-chrome`,`google-chrome-stable`],userDataDirName:`google-chrome`},{commands:[`brave-browser`,`brave`],userDataDirName:`BraveSoftware/Brave-Browser`},{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/BraveSoftware/Brave-Browser/NativeMessagingHosts`,`.config/chromium/NativeMessagingHosts`,`.config/google-chrome-beta/NativeMessagingHosts`,`.config/google-chrome-unstable/NativeMessagingHosts`,`.config/google-chrome-for-testing/NativeMessagingHosts`],processNames:[`chrome`,`brave`,`brave-browser`],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"; @@ -1146,52 +1152,57 @@ function applyLinuxBrowserUseExternalAvailabilityPatch(currentSource) { return currentSource; } -function currentBrowserUseRegistryState(source) { - const currentCount = source.split(CURRENT_BROWSER_USE_CHROME_LINUX_REGISTRY).length - 1; - const patchedCount = source.split(LINUX_BRAVE_BROWSER_USE_CHROME_REGISTRY).length - 1; - if (currentCount === 1 && patchedCount === 0) { - return "current"; - } - if (currentCount === 0 && patchedCount === 1) { - return "patched"; - } - return "drifted"; -} - -function patchCurrentBrowserUseRegistrySource(source) { - const state = currentBrowserUseRegistryState(source); - if (state === "patched") { - return source; - } - if (state !== "current") { - return null; - } - return source.replace( +function currentNativeBrowserUseRegistryContract(source, registryVar, checkerVar) { + const exactLinuxRegistries = [ CURRENT_BROWSER_USE_CHROME_LINUX_REGISTRY, - LINUX_BRAVE_BROWSER_USE_CHROME_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)}`); } function currentBrowserUseMainCallerContract(source) { - return source.includes("async function sne({browserFamily:") && - source.includes("async function lne({browserFamily:") && - source.includes("function Ol(") && - source.includes("getInstalledBrowserFamilies(){") && - source.includes("async openUrl({browserFamily:"); + 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:", + ); } function currentBrowserUseMainRegistryContract(source) { - return currentBrowserUseRegistryState(source) !== "drifted" && - source.includes("function fb(e){return Object.hasOwn(ob,e)}") && - /Object\.defineProperty\(exports,["'`]So["'`],\{enumerable:!0,get:function\(\)\{return ob\}\}\)/u.test(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)"); } function currentBrowserUseRendererContract(source) { - return currentBrowserUseRegistryState(source) !== "drifted" && + return currentNativeBrowserUseRegistryContract(source, "Fu", "Pu") && source.includes("featureName:`browser_use_external`") && source.includes("410065390") && - source.includes("function Yl(e){return Object.hasOwn(Xl,e)}") && - source.includes("Object.keys(Xl).filter(Yl)") && + source.includes("Object.keys(Fu).filter(Pu)") && (externalBrowserUseAvailabilityCurrentPattern.test(source) || externalBrowserUseAvailabilityPatchedPattern.test(source)); } @@ -1283,12 +1294,8 @@ function patchLinuxBrowserUseExternalAvailabilityAssets(extractedDir, { }; } - const patchedMainRegistry = patchCurrentBrowserUseRegistrySource(mainRegistry.source); - const rendererWithAvailability = patchCurrentExternalBrowserUseAvailabilitySource(renderer.source); - const patchedRenderer = rendererWithAvailability == null - ? null - : patchCurrentBrowserUseRegistrySource(rendererWithAvailability); - if (patchedMainRegistry == null || patchedRenderer == null) { + const patchedRenderer = patchCurrentExternalBrowserUseAvailabilitySource(renderer.source); + if (patchedRenderer == null) { console.warn( `WARN: ${CURRENT_BROWSER_USE_CONTRACT_MISSING_REASON} — skipping Linux external Browser Use availability patch`, ); @@ -1300,7 +1307,6 @@ function patchLinuxBrowserUseExternalAvailabilityAssets(extractedDir, { } const candidates = [ - { ...mainRegistry, patched: patchedMainRegistry }, { ...renderer, patched: patchedRenderer }, ].filter(({ source, patched }) => source !== patched); if (candidates.length === 0) { @@ -2447,19 +2453,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 }; @@ -2492,5 +2503,5 @@ module.exports = { applyLocalEnvironmentActionModalDraftPatch, applySubagentNicknameMetadataPatch, codexLinuxWatchBrowserWebviewAttachment, - patchCommentPreloadBundle, + patchBrowserPagePreloadBundle, }; diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index 4197a8407..b756fed5a 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -9466,19 +9466,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' + 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=fs(N.annotation.anchor);Lt=void 0,It=js' - assert_contains "$extracted/.vite/build/comment-preload.js" 'selectionIndexOffset:1' - assert_not_contains "$extracted/.vite/build/comment-preload.js" 'e?.rect??fs' + 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=fs(N.annotation.anchor)' '1' - assert_occurrence_count "$extracted/.vite/build/comment-preload.js" 'selectionIndexOffset:1' '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() { From 07dc1a3896bf2581c3c547d4db2fb808e8c9f816 Mon Sep 17 00:00:00 2001 From: Gary Lysenko Date: Fri, 7 Aug 2026 10:07:52 +0300 Subject: [PATCH 104/112] Fix Linux tray startup and watchdog metadata (#1247) * Fix Linux tray startup and watchdog metadata * Harden watchdog reconciliation metadata * Trust only maintainer-owned watchdog issues * Normalize watchdog issues after create races --- .github/workflows/upstream-build-app.yml | 7 + install.sh | 8 +- linux-features/ui-tweaks/dock-icon.test.js | 8 +- linux-features/ui-tweaks/patches/dock-icon.js | 4 +- scripts/ci/upstream-dmg-issue.js | 250 +++++++++++++--- scripts/ci/upstream-dmg-issue.test.js | 276 +++++++++++++++++- scripts/lib/build-info.js | 24 ++ scripts/lib/build-info.sh | 12 + scripts/lib/build-info.test.js | 86 ++++++ scripts/patch-linux-window-ui.test.js | 33 ++- scripts/patches/impl/main-process/tray.js | 48 ++- 11 files changed, 702 insertions(+), 54 deletions(-) create mode 100644 scripts/lib/build-info.test.js diff --git a/.github/workflows/upstream-build-app.yml b/.github/workflows/upstream-build-app.yml index 8b606443e..beaeef693 100644 --- a/.github/workflows/upstream-build-app.yml +++ b/.github/workflows/upstream-build-app.yml @@ -17,6 +17,9 @@ on: - 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 @@ -41,6 +44,9 @@ on: - 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 @@ -280,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/install.sh b/install.sh index 850197e9d..48a9cfb61 100755 --- a/install.sh +++ b/install.sh @@ -70,7 +70,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("="); @@ -134,6 +138,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 @@ -331,6 +336,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/linux-features/ui-tweaks/dock-icon.test.js b/linux-features/ui-tweaks/dock-icon.test.js index 33b889759..57ac79d78 100644 --- a/linux-features/ui-tweaks/dock-icon.test.js +++ b/linux-features/ui-tweaks/dock-icon.test.js @@ -45,7 +45,7 @@ const currentRuntimeSource = [ ].join(""); const currentTraySource = - "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(t.defaultIcon));if(!W9)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; @@ -186,7 +186,7 @@ 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, @@ -206,7 +206,7 @@ test("main patch rejects drift at every current-DMG insertion point byte-identic "F=()=>{if(!v)return", "if(v){F();let e=()=>", "onWindowRegistered:e=>{I?.registerWindow(e),C?.(e)}", - "codexLinuxRegisterTray(new l.Tray(t.defaultIcon))", + "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) { @@ -234,7 +234,7 @@ test("main patch rejects drift at every patched insertion point byte-identically "F=()=>{if(!v&&process.platform!==`linux`)return", "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));if(!W9)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", ]; for (const insertionPoint of insertionPoints) { diff --git a/linux-features/ui-tweaks/patches/dock-icon.js b/linux-features/ui-tweaks/patches/dock-icon.js index 297f9ff53..87bb850a8 100644 --- a/linux-features/ui-tweaks/patches/dock-icon.js +++ b/linux-features/ui-tweaks/patches/dock-icon.js @@ -28,9 +28,9 @@ const currentWindowRegistration = const patchedWindowRegistration = "onWindowRegistered:e=>{I?.registerWindow(e),C?.(e),process.platform===`linux`&&setImmediate(F)}"; const currentTrayRegistration = - "n=codexLinuxRegisterTray(new l.Tray(t.defaultIcon));if(!W9)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(!W9)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, 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/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/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index 3bb7e4eec..e77d80fbb 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -2685,7 +2685,7 @@ test("retains the current native Linux tray when quit-state helpers already exis assert.match(patched, /let codexLinuxTray=null,codexLinuxRegisterTray=e=>/); assert.match( patched, - /r=codexLinuxRegisterTray\(new c\.Tray\(t\.defaultIcon,process\.platform===`win32`&&c\.app\.isPackaged\?dEe\(e\.buildFlavor\):void 0\)\)/, + /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`/); }); @@ -2693,13 +2693,36 @@ test("retains the current native Linux tray when quit-state helpers already exis 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.match( - patched, - /r=codexLinuxRegisterTray\(new c\.Tray\(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", () => { diff --git a/scripts/patches/impl/main-process/tray.js b/scripts/patches/impl/main-process/tray.js index 5cd0ed444..f128b1648 100644 --- a/scripts/patches/impl/main-process/tray.js +++ b/scripts/patches/impl/main-process/tray.js @@ -35,6 +35,48 @@ function findMatchingParenthesis(source, openIndex) { 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; @@ -150,8 +192,12 @@ function applyLinuxTrayPatch(currentSource, iconPathExpression) { 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}))`; + `${trayVar}=codexLinuxRegisterTray(${trayConstructor})`; if (!retained) { patchedSource = patchedSource.slice(0, constructorStartIndex) + From 4d429d78441cef3f721a408d3daa2328d91b1935 Mon Sep 17 00:00:00 2001 From: mohit Date: Fri, 7 Aug 2026 14:44:06 +0530 Subject: [PATCH 105/112] project-group-last-updated-sort: retarget patch to 26.803 bundle (#1248) - Update minified sorter and call-site needles for the current webview bundle. - Refresh the fixture and asset filename for 26.803 drift coverage. --- .../project-group-last-updated-sort/patch.js | 8 ++++---- .../project-group-last-updated-sort/test.js | 20 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/linux-features/project-group-last-updated-sort/patch.js b/linux-features/project-group-last-updated-sort/patch.js index 6941941d5..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 Drs({groups:e,items:t,projectOrder:n}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return voa(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)}"; + "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 Drs({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:voa(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))}"; + "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 = - "A=Drs({groups:Ers({groups:O,items:f}),items:f,projectOrder:Cp(t,Nl.PROJECT_ORDER)})"; + "N=p5o({groups:A,items:f,projectOrder:jm(t,_u.PROJECT_ORDER)})"; const patchedGroupSorterCall = - "A=Drs({groups:Ers({groups:O,items:f}),items:f,projectOrder:Cp(t,Nl.PROJECT_ORDER),sortMode:t(UR).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; diff --git a/linux-features/project-group-last-updated-sort/test.js b/linux-features/project-group-last-updated-sort/test.js index 00061563f..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 goa(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 voa(e,t){let n=goa(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 Drs({groups:e,items:t,projectOrder:n}){let r=new Map(t.map(e=>[e.task.key,e.recencyAt]));return voa(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)}", + "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`;", - "A=Drs({groups:Ers({groups:O,items:f}),items:f,projectOrder:Cp(t,Nl.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=Drs`, 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:Cp(t,Nl.PROJECT_ORDER),sortMode:t(UR).projectSortMode", + "projectOrder:jm(t,_u.PROJECT_ORDER),sortMode:M", ), ); }); test("drift leaves the asset byte-identical", () => { const source = currentProjectSource.replace( - "function Drs({groups:e,items:t,projectOrder:n})", - "function Drs({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:Cp(t,Nl.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-CKNQDTeE.js", + "app-initial-Biw83Aiz.js", ); fs.mkdirSync(assetsDir, { recursive: true }); fs.writeFileSync(assetPath, currentProjectSource); From 13999326ebec71edc9ce7bc832632be4fc08f260 Mon Sep 17 00:00:00 2001 From: Anupam Mediratta Date: Fri, 7 Aug 2026 17:29:50 +0530 Subject: [PATCH 106/112] =?UTF-8?q?fix:=20bump=20rustls-webpki=200.103.10?= =?UTF-8?q?=20=E2=86=92=200.103.13=20(GHSA-82j2-j2ch-gfr8)=20(#1246)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: GHSA-82j2-j2ch-gfr8 security vulnerability Automated dependency upgrade by OrbisAI Security * fix: bump codex-update-manager to 0.11.1, correct PR metadata Per CONTRIBUTING.md versioning rules, security/maintenance updates to the updater crate require a patch bump. Bumps 0.11.0 → 0.11.1. Also corrects the rustls-webpki advisory description: this PR tracks the 0.103.x patch line (0.103.10 → 0.103.13), not 0.104.0-alpha.7. The 0.103.11–0.103.13 releases include certificate-validation fixes beyond malformed CRL handling. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: OrbisAI Security Co-authored-by: Claude Sonnet 4.6 --- Cargo.lock | 6 +++--- updater/Cargo.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8cde2c441..572129c0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -561,7 +561,7 @@ dependencies = [ [[package]] name = "codex-update-manager" -version = "0.11.0" +version = "0.11.1" dependencies = [ "anyhow", "chrono", @@ -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/updater/Cargo.toml b/updater/Cargo.toml index 269027cb0..a700755de 100644 --- a/updater/Cargo.toml +++ b/updater/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-update-manager" -version = "0.11.0" +version = "0.11.1" edition = "2021" [dependencies] From 9a4cc391e2cf3555f7a9c5b8ea2221e2b697871c Mon Sep 17 00:00:00 2001 From: mohit Date: Fri, 7 Aug 2026 17:55:17 +0530 Subject: [PATCH 107/112] browser-runtime: preserve Linux hooks across runtime clone (#1250) --- .../browser-client-node-repl-runtime.test.js | 58 +++++++++++++++++++ scripts/lib/bundled-plugins.sh | 53 +++++++++++++++++ tests/scripts_smoke.sh | 6 +- 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/scripts/lib/browser-client-node-repl-runtime.test.js b/scripts/lib/browser-client-node-repl-runtime.test.js index 86397e74e..4ecdfd5a1 100644 --- a/scripts/lib/browser-client-node-repl-runtime.test.js +++ b/scripts/lib/browser-client-node-repl-runtime.test.js @@ -167,6 +167,64 @@ test("guards every Browser client nodeRepl env read", () => { } }); +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/bundled-plugins.sh b/scripts/lib/bundled-plugins.sh index 6f4626251..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" @@ -1506,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" @@ -1626,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/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index b756fed5a..f9ca2c569 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 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} JS } @@ -7921,6 +7921,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" @@ -8581,6 +8582,8 @@ const browserPreference={};function preferredWindowIdFor(){}function getForUrl() 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()=>[]; 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 @@ -8775,6 +8778,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" From f33377f5c5a805e1ea880c6383b41f46a7b974ea Mon Sep 17 00:00:00 2001 From: Eeeeye <145125620+Eeeeye@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:09:57 +0800 Subject: [PATCH 108/112] fix(chrome): support concurrent browser clients (#1240) * fix(chrome): support concurrent browser clients * test(chrome): satisfy clippy for aborted rollout fixture * fix(chrome): isolate concurrent client I/O * fix(chrome): harden multi-client fanout --- .../src/bin/codex-chrome-extension-host.rs | 1371 +++++++++++++++-- 1 file changed, 1220 insertions(+), 151 deletions(-) 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 { From d48fa56a712a9a2635e984c6f384b01a498a4d31 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Fri, 7 Aug 2026 17:31:39 +0200 Subject: [PATCH 109/112] Fix Nix PipeWire microphone support (#1249) Add PipeWire ALSA plugin support to the Nix Electron runtime, preserve explicit ALSA plugin configuration, and exercise the wrapper contract in CI. --- .github/workflows/ci.yml | 1 + flake.nix | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70fbf5de4..7ef4210f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -292,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 ) diff --git a/flake.nix b/flake.nix index e5f7e69ef..674df3393 100644 --- a/flake.nix +++ b/flake.nix @@ -327,6 +327,7 @@ mesa libgbm alsa-lib + pipewire libX11 libXcomposite libXdamage @@ -739,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" \ @@ -840,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} From 2c95511163aa2e318d8e564565a66d5292943db1 Mon Sep 17 00:00:00 2001 From: Jurgen Mahn Date: Fri, 7 Aug 2026 13:08:09 +0200 Subject: [PATCH 110/112] fix(browser-use): re-target the IAB socket listing filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Unix socket listing that in-app-browser discovery walks changed shape in ChatGPT.dmg 26.803.41515. It used to be a zero-argument helper closing over the socket directory: CV=async()=>(await yP(Cb)).map(e=>wP.resolve(Cb,e)) and now resolves the directory from an options argument: e4=async e=>{let t=ys(e.platform);return(await BE(t)).map(n=>NE.resolve(t,n))} The pattern matched zero targets, so the `extension-` prefix filter was never inserted and discovery could pick up extension-host sockets. It is fail-soft, so nothing in the build reports it — the protection is simply gone. Matches the current shape with named groups. The Windows listing has a similar shape but declares its own pipe prefix and already filters, so it still cannot match and the exactly-one-target guard is preserved. Co-Authored-By: Claude Opus 5 --- .../patch-browser-client-iab-socket-scope.js | 25 ++++++++++--------- ...ch-browser-client-iab-socket-scope.test.js | 23 +++++++++-------- tests/scripts_smoke.sh | 4 +-- 3 files changed, 28 insertions(+), 24 deletions(-) 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/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index f9ca2c569..60630a7fe 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}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 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 } @@ -8579,7 +8579,7 @@ MD 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} From b7b81f75c67ab230ee4895b6058feb57134f071c Mon Sep 17 00:00:00 2001 From: Jurgen Mahn Date: Fri, 7 Aug 2026 12:23:27 +0200 Subject: [PATCH 111/112] fix(computer-use): re-target the settings card injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Computer Use settings component in ChatGPT.dmg 26.803.41515 is compiled with the React Compiler. Its plugin card is no longer derived from a plain declaration chain ending in a bare `computerUsePlugin;` declarator, but inside memo-cache branches: let x=ee(u,b),S=Ut(u),…,F,I; … t[9]!==S||t[10]!==x.availablePlugins?(re=Y(x.availablePlugins,lr,S),…) The card half of the settings contract matched nothing, and because the contract requires both halves, the availability half was discarded too — so `linux-computer-use-ui-availability` skipped entirely and Computer Use reported itself unavailable on Linux even with the backend built. The card target is now resolved from the derivation itself: find the `selector(plugins.availablePlugins, name, path)` call whose name identifier holds the `computer-use` slug, then inject the synthetic bundled-marketplace card at the end of the declaration that produces the plugins query. `findStatementEnd` locates that boundary while ignoring separators nested in calls, literals, and template substitutions. Anchoring on the plugin slug also makes the match stricter than before: the old pattern keyed on shape alone and would have accepted the sibling derivation for a different plugin. Co-Authored-By: Claude Opus 5 --- scripts/patch-linux-window-ui.test.js | 8 +- scripts/patches/impl/computer-use.js | 135 +++++++++++++++++++------- tests/scripts_smoke.sh | 2 +- 3 files changed, 105 insertions(+), 40 deletions(-) diff --git a/scripts/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index e77d80fbb..46ef02010 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -9966,7 +9966,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]})}" + @@ -9985,7 +9985,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]})}" + @@ -10191,7 +10191,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]})}" + @@ -11124,7 +11124,7 @@ test("patchExtractedApp selects the exact 26.721 Computer Use app-initial contra ); fs.writeFileSync( path.join(assetsDir, "computer-use-settings-BzkBOuLk.js"), - "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]})}" + "function Wt(e){let t=cache(35),{computerUseAvailability:n,platform:i}=e,{selectedHostId:s}=host();" + diff --git a/scripts/patches/impl/computer-use.js b/scripts/patches/impl/computer-use.js index 844440114..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 && diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index 60630a7fe..525dea620 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -10092,7 +10092,7 @@ 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} +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 )" app_initial_body="$(cat <<'JS' From 7c652d275f42a51b7b93e1696c6edd65716d4a7e Mon Sep 17 00:00:00 2001 From: robustonian <174159519+robustonian@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:41:32 +0900 Subject: [PATCH 112/112] fix: harden latest DMG rebuild startup Reject patch-created main bundle syntax errors before writing app.asar, require a compatible Rust toolchain for install-latest, and remove the obsolete multi-callsite titlebar drift test after integrating upstream 26.803 support. --- scripts/install-latest.sh | 13 +++--- scripts/lib/rust-toolchain.sh | 39 +++++++++++++++++ scripts/patch-linux-window-ui.test.js | 62 +++++++++++++++++---------- scripts/patches/runner.js | 29 +++++++++++-- 4 files changed, 112 insertions(+), 31 deletions(-) create mode 100644 scripts/lib/rust-toolchain.sh 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/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/patch-linux-window-ui.test.js b/scripts/patch-linux-window-ui.test.js index 3b31b9247..3187ffcca 100644 --- a/scripts/patch-linux-window-ui.test.js +++ b/scripts/patch-linux-window-ui.test.js @@ -3494,28 +3494,6 @@ test("redirects the renamed Linux-aware titlebar overlay sync away from the tran assert.deepEqual(warnings, []); }); -test("updates every Linux zoom titlebar overlay refresh call site", () => { - 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)))", - "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.doesNotMatch( - patched, - /\(process\.platform===`win32`\|\|process\.platform===`linux`\)&&\(this\.windowZooms\.set\([^)]+\),[A-Za-z_$][\w$]*\.setTitleBarOverlay\(b2\([^)]+\)\)\)/, - ); -}); - function windowControlsSafeAreaFixture(firstInset = 0, secondInset = 0) { return [ `var l=Object.freeze({default:Object.freeze({left:0,right:0}),mac:Object.freeze({legacy:Object.freeze({left:66+c,right:0}),modern:Object.freeze({left:76+c,right:0})}),applicationMenu:Object.freeze({left:0,right:${firstInset}})});`, @@ -11606,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 { diff --git a/scripts/patches/runner.js b/scripts/patches/runner.js index c98f8e848..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, @@ -55,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 })); @@ -243,19 +253,30 @@ function patchExtractedApp(extractedDir, options = {}) { 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 } : {}), }, ); }