diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee56a22..5227844 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,12 +60,18 @@ jobs: node-version: 24 cache: npm + - name: Install Rust target + run: rustup target add x86_64-unknown-linux-musl + - name: Install system deps - run: sudo apt-get update && sudo apt-get install -y p7zip-full jq libarchive-tools xvfb x11-utils + run: sudo apt-get update && sudo apt-get install -y p7zip-full jq libarchive-tools musl-tools xvfb x11-utils - name: Install repo deps run: npm ci + - name: Run tests + run: npm test + - name: Install browser deps run: npx playwright install --with-deps chromium @@ -125,8 +131,11 @@ jobs: cache: npm registry-url: https://registry.npmjs.org + - name: Install Rust target + run: rustup target add x86_64-unknown-linux-musl + - name: Install system deps - run: sudo apt-get update && sudo apt-get install -y p7zip-full jq libarchive-tools xvfb x11-utils + run: sudo apt-get update && sudo apt-get install -y p7zip-full jq libarchive-tools musl-tools xvfb x11-utils - name: Install repo deps run: npm ci @@ -311,8 +320,11 @@ jobs: cache: npm registry-url: https://registry.npmjs.org + - name: Install Rust target + run: rustup target add x86_64-unknown-linux-musl + - name: Install system deps - run: sudo apt-get update && sudo apt-get install -y p7zip-full jq libarchive-tools xvfb x11-utils + run: sudo apt-get update && sudo apt-get install -y p7zip-full jq libarchive-tools musl-tools xvfb x11-utils - name: Install repo deps run: npm ci diff --git a/.github/workflows/upstream-canary.yml b/.github/workflows/upstream-canary.yml index 27baeb6..3f17b7a 100644 --- a/.github/workflows/upstream-canary.yml +++ b/.github/workflows/upstream-canary.yml @@ -22,12 +22,18 @@ jobs: node-version: 24 cache: npm + - name: Install Rust target + run: rustup target add x86_64-unknown-linux-musl + - name: Install system deps - run: sudo apt-get update && sudo apt-get install -y p7zip-full jq libarchive-tools xvfb x11-utils + run: sudo apt-get update && sudo apt-get install -y p7zip-full jq libarchive-tools musl-tools xvfb x11-utils - name: Install repo deps run: npm ci + - name: Run tests + run: npm test + - name: Install browser deps run: npx playwright install --with-deps chromium diff --git a/.gitignore b/.gitignore index 9ece9c3..bf77995 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist stage tmp .env +/native/chrome-extension-host/target/ diff --git a/README.md b/README.md index 7f6ddac..0e93969 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,11 @@ Run the Codex desktop app on Linux via npm, aur, and a nix flake. - downloads the matching Linux desktop binary archive on first run - launches the desktop app with `CODEX_CLI_PATH` wired up +OpenAI now publishes the desktop archive as the combined ChatGPT app. This +project follows those ChatGPT prod/beta archives while intentionally retaining +the existing `codex-app-linux` npm, AUR, command, and release identities so the +upstream rebrand does not break Linux installations. + ## Quick Start ### aur @@ -49,6 +54,9 @@ Run the Codex desktop app on Linux via npm, aur, and a nix flake. npx codex-app-linux web --open ``` + `web` only serves the Codex UI in a browser. It does not install or replace + the ChatGPT browser extension. + - Disable browser auth entirely (unsafe; only behind a trusted reverse proxy / tailnet): ```bash npx codex-app-linux web --dangerously-disable-auth true @@ -59,6 +67,23 @@ Run the Codex desktop app on Linux via npm, aur, and a nix flake. - Linux x64 - GitHub access not required for normal app launch +## Browser Use + +Linux desktop builds support Browser Use through the official +[ChatGPT Chrome extension](https://chromewebstore.google.com/detail/chatgpt/hehggadaopoacecdllhhajmbjkdcmajg). + +1. Install the extension in stable Google Chrome. +2. Start or restart the Codex desktop app. +3. Open the extension side panel and use Codex from there. + +The desktop app installs the Linux native-messaging manifest and its bundled +extension host automatically. Current support is Linux x64 + stable Google +Chrome. Chromium-family variants using different profile/manifest locations +are not wired up yet. + +Browser Use is separate from Computer Use. This enables the extension-driven +Chrome workflow; it does not add Linux desktop mouse/keyboard control. + If `CODEX_CLI_PATH` is already set, the launcher uses it. Otherwise it uses the bundled `resources/codex`, then falls back to `which codex`. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..5bf04ff --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1783224372, + "narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "d407951447dcd00442e97087bf374aad70c04cea", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix index e7d5aa8..fce694e 100644 --- a/flake.nix +++ b/flake.nix @@ -12,7 +12,7 @@ pkgs = import nixpkgs { inherit system; }; releaseProd = pkgs.writeShellApplication { name = "release-prod"; - runtimeInputs = with pkgs; [ nodejs_24 p7zip libarchive gnutar ]; + runtimeInputs = with pkgs; [ nodejs_24 p7zip libarchive gnutar rustup ]; text = '' cd ${self} exec node scripts/release-channel.mjs --channel prod "$@" @@ -20,7 +20,7 @@ }; releaseBeta = pkgs.writeShellApplication { name = "release-beta"; - runtimeInputs = with pkgs; [ nodejs_24 p7zip libarchive gnutar ]; + runtimeInputs = with pkgs; [ nodejs_24 p7zip libarchive gnutar rustup ]; text = '' cd ${self} exec node scripts/release-channel.mjs --channel beta "$@" @@ -48,6 +48,7 @@ p7zip libarchive gnutar + rustup ]; }; }); diff --git a/native/chrome-extension-host/.cargo/config.toml b/native/chrome-extension-host/.cargo/config.toml new file mode 100644 index 0000000..0db497a --- /dev/null +++ b/native/chrome-extension-host/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.aarch64-unknown-linux-musl] +linker = "rust-lld" diff --git a/native/chrome-extension-host/Cargo.lock b/native/chrome-extension-host/Cargo.lock new file mode 100644 index 0000000..e55449f --- /dev/null +++ b/native/chrome-extension-host/Cargo.lock @@ -0,0 +1,440 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "codex-chrome-extension-host" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "libc", + "serde", + "serde_json", + "sha2", + "tungstenite", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[patch.unused]] +name = "gtk4-layer-shell" +version = "0.7.1" + +[[patch.unused]] +name = "gtk4-layer-shell-sys" +version = "0.5.2" diff --git a/native/chrome-extension-host/Cargo.toml b/native/chrome-extension-host/Cargo.toml new file mode 100644 index 0000000..5bcf2d6 --- /dev/null +++ b/native/chrome-extension-host/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "codex-chrome-extension-host" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Linux native messaging host for the Codex Chrome extension" + +[dependencies] +anyhow = "1.0.103" +base64 = "0.22.1" +libc = "0.2.186" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +sha2 = "0.11.0" +tungstenite = { version = "0.29.0", default-features = false, features = ["handshake"] } + +[profile.release] +codegen-units = 1 +lto = true +opt-level = "s" +panic = "abort" +strip = true + diff --git a/native/chrome-extension-host/LICENSES/ilysenko-MIT.txt b/native/chrome-extension-host/LICENSES/ilysenko-MIT.txt new file mode 100644 index 0000000..c0ec1b5 --- /dev/null +++ b/native/chrome-extension-host/LICENSES/ilysenko-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ilya Lysenko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/native/chrome-extension-host/README.md b/native/chrome-extension-host/README.md new file mode 100644 index 0000000..3c2feea --- /dev/null +++ b/native/chrome-extension-host/README.md @@ -0,0 +1,27 @@ +# Codex Chrome extension host for Linux + +Production Rust native-messaging host for the Codex Chrome extension. It keeps +the established Browser Use Unix-socket relay and implements the extension's +protocol-v2 runtime methods on Linux: + +- starts the configured Codex CLI as app-server --analytics-default-enabled; +- exposes it through a token- and Origin-gated loopback WebSocket; +- stores bounded tab-context assets under the system temporary directory; +- validates local files before opening them with xdg-open. + +The desktop-managed host resolves registry-v2 on every runtime request, first +from `$XDG_STATE_HOME/openai-codex/chrome-native-hosts-v2.json`, then from +`$CODEX_HOME/chrome-native-hosts-v2.json`. The adjacent +`extension-host-config.json` schema remains the same installer fallback used by +the Darwin host. The Browser Use relay uses `/tmp/codex-browser-use` by default; +`CODEX_BROWSER_USE_SOCKET_DIR` overrides it. + +## Build and verify + + cargo test + cargo clippy --all-targets -- -D warnings + cargo build --release --target x86_64-unknown-linux-musl + +The release profile strips and LTO-optimizes static binaries suitable for Linux +x86-64 distributions. See `THIRD_PARTY_NOTICES.md` for relay provenance and the +packaged `RUST_DEPENDENCY_LICENSES.md` for the complete Cargo dependency notices. diff --git a/native/chrome-extension-host/THIRD_PARTY_NOTICES.md b/native/chrome-extension-host/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..3ae8251 --- /dev/null +++ b/native/chrome-extension-host/THIRD_PARTY_NOTICES.md @@ -0,0 +1,12 @@ +# Third-party notices + +The Unix-domain browser relay, JSON-RPC identifier routing, and rollout-completion +tracking are derived from codex-chrome-extension-host.rs in +ilysenko/codex-desktop-linux, pinned at commit +ddcd9b559274abc7d4c8ae8ba54813a8acbcb982. + +That source is distributed under the MIT License. Its copyright notice and +license text are reproduced in LICENSES/ilysenko-MIT.txt. + +The static host's Cargo dependency notices are generated from `Cargo.lock` and +shipped beside the binary as `RUST_DEPENDENCY_LICENSES.md`. diff --git a/native/chrome-extension-host/src/assets.rs b/native/chrome-extension-host/src/assets.rs new file mode 100644 index 0000000..01d6bee --- /dev/null +++ b/native/chrome-extension-host/src/assets.rs @@ -0,0 +1,314 @@ +use anyhow::{Context, Result, bail}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde_json::{Value, json}; +use std::{ + collections::HashMap, + env, fs, + fs::{File, OpenOptions}, + io::{Read, Write}, + os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}, + path::{Path, PathBuf}, +}; + +const MAX_ACTIVE_ASSETS: usize = 16; +const MAX_ASSET_BYTES: u64 = 100 * 1024 * 1024; +const MAX_TOTAL_BYTES: u64 = 256 * 1024 * 1024; +const MAX_CHUNK_BYTES: usize = 4 * 1024 * 1024; +const MAX_FILE_NAME_BYTES: usize = 200; +const FALLBACK_FILE_NAME: &str = "tab-context.txt"; + +struct Asset { + file: Option, + path: PathBuf, + bytes: u64, +} + +pub struct AssetStore { + root: PathBuf, + active: HashMap, + total_bytes: u64, +} + +impl AssetStore { + pub fn from_environment() -> Result { + Self::new(env::temp_dir().join("codex-tab-context-assets")) + } + + pub fn new(root: PathBuf) -> Result { + prepare_root(&root)?; + Ok(Self { + root, + active: HashMap::new(), + total_bytes: 0, + }) + } + + pub fn create(&mut self, file_name: &str) -> Result { + if self.active.len() >= MAX_ACTIVE_ASSETS { + bail!("Too many active Chrome tab context assets"); + } + let safe_name = safe_file_name(file_name); + for _ in 0..8 { + let asset_id = random_hex(16)?; + let path = self.root.join(format!("{asset_id}-{safe_name}")); + let file = match OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(error) + .with_context(|| format!("failed to create {}", path.display())); + } + }; + self.active.insert( + asset_id.clone(), + Asset { + file: Some(file), + path: path.clone(), + bytes: 0, + }, + ); + return Ok(json!({"assetId": asset_id, "path": path})); + } + bail!("failed to allocate a unique tab context asset") + } + + pub fn append_chunk(&mut self, asset_id: &str, data_base64: &str) -> Result { + let estimated = data_base64.len().div_ceil(4).saturating_mul(3); + if estimated > MAX_CHUNK_BYTES { + bail!("Chrome tab context asset chunk is too large"); + } + let bytes = STANDARD + .decode(data_base64) + .context("dataBase64 is not valid base64")?; + if bytes.len() > MAX_CHUNK_BYTES { + bail!("Chrome tab context asset chunk is too large"); + } + let asset = self + .active + .get_mut(asset_id) + .context("Chrome tab context asset was not found")?; + let next_asset_bytes = asset.bytes.saturating_add(bytes.len() as u64); + let next_total_bytes = self.total_bytes.saturating_add(bytes.len() as u64); + if next_asset_bytes > MAX_ASSET_BYTES || next_total_bytes > MAX_TOTAL_BYTES { + bail!("Chrome tab context asset is too large"); + } + let file = asset + .file + .as_mut() + .context("Chrome tab context asset is already finished")?; + file.write_all(&bytes) + .with_context(|| format!("failed to append {}", asset.path.display()))?; + asset.bytes = next_asset_bytes; + self.total_bytes = next_total_bytes; + Ok(json!({})) + } + + pub fn finish(&mut self, asset_id: &str) -> Result { + let asset = self + .active + .get_mut(asset_id) + .context("Chrome tab context asset was not found")?; + if let Some(file) = asset.file.take() { + file.sync_all() + .with_context(|| format!("failed to flush {}", asset.path.display()))?; + } + Ok(json!({"assetId": asset_id, "path": asset.path})) + } + + pub fn abort(&mut self, asset_id: &str) -> Result { + self.remove_asset(asset_id)?; + Ok(json!({})) + } + + pub fn remove(&mut self, asset_id: &str) -> Result { + self.remove_asset(asset_id)?; + Ok(json!({})) + } + + fn remove_asset(&mut self, asset_id: &str) -> Result<()> { + let Some(asset) = self.active.remove(asset_id) else { + return Ok(()); + }; + self.total_bytes = self.total_bytes.saturating_sub(asset.bytes); + drop(asset.file); + fs::remove_file(&asset.path) + .with_context(|| format!("failed to remove {}", asset.path.display())) + } +} + +impl Drop for AssetStore { + fn drop(&mut self) { + for (_, asset) in self.active.drain() { + drop(asset.file); + if let Err(error) = fs::remove_file(&asset.path) { + crate::log(format_args!( + "failed to clean tab context asset {}: {error}", + asset.path.display() + )); + } + } + } +} + +fn prepare_root(root: &Path) -> Result<()> { + fs::create_dir_all(root).with_context(|| format!("failed to create {}", root.display()))?; + let metadata = + fs::symlink_metadata(root).with_context(|| format!("failed to stat {}", root.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!("asset root is not a real directory: {}", root.display()); + } + let effective_uid = unsafe { libc::geteuid() }; + if metadata.uid() != effective_uid { + bail!( + "asset root is owned by uid {}, expected {effective_uid}: {}", + metadata.uid(), + root.display() + ); + } + fs::set_permissions(root, fs::Permissions::from_mode(0o700)) + .with_context(|| format!("failed to chmod {}", root.display())) +} + +fn safe_file_name(file_name: &str) -> String { + let candidate = file_name.rsplit(['/', '\\']).next().unwrap_or_default(); + let cleaned = candidate + .chars() + .filter(|character| !character.is_control()) + .collect::(); + let cleaned = cleaned.trim(); + if cleaned.is_empty() || matches!(cleaned, "." | "..") { + return FALLBACK_FILE_NAME.to_string(); + } + if cleaned.len() <= MAX_FILE_NAME_BYTES { + return cleaned.to_string(); + } + + let extension = Path::new(cleaned) + .extension() + .and_then(|extension| extension.to_str()) + .filter(|extension| !extension.is_empty() && extension.len() <= 32); + if let Some(extension) = extension { + let suffix = format!(".{extension}"); + let stem = cleaned.strip_suffix(&suffix).unwrap_or(cleaned); + let stem = truncate_utf8(stem, MAX_FILE_NAME_BYTES - suffix.len()); + if !stem.is_empty() { + return format!("{stem}{suffix}"); + } + } + truncate_utf8(cleaned, MAX_FILE_NAME_BYTES).to_string() +} + +fn truncate_utf8(value: &str, maximum_bytes: usize) -> &str { + let mut end = value.len().min(maximum_bytes); + while !value.is_char_boundary(end) { + end -= 1; + } + &value[..end] +} + +fn random_hex(bytes: usize) -> Result { + let mut random = vec![0_u8; bytes]; + File::open("/dev/urandom") + .context("failed to open /dev/urandom")? + .read_exact(&mut random) + .context("failed to read /dev/urandom")?; + Ok(random.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn store() -> AssetStore { + let root = env::temp_dir().join(format!( + "codex-host-assets-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + AssetStore::new(root).unwrap() + } + + #[test] + fn create_append_finish_and_remove_round_trip() { + let mut store = store(); + let created = store.create("tab-context.txt").unwrap(); + let id = created["assetId"].as_str().unwrap(); + let path = PathBuf::from(created["path"].as_str().unwrap()); + store + .append_chunk(id, &STANDARD.encode(b"hello browser")) + .unwrap(); + let finished = store.finish(id).unwrap(); + let finished_again = store.finish(id).unwrap(); + assert_eq!(finished["path"], created["path"]); + assert_eq!(finished_again, finished); + assert_eq!(fs::read(&path).unwrap(), b"hello browser"); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + store.remove(id).unwrap(); + store.remove(id).unwrap(); + assert!(!path.exists()); + } + + #[test] + fn rejects_traversal_and_oversized_chunk() { + let mut store = store(); + let stripped = store.create("../safe-name.txt").unwrap(); + assert!( + Path::new(stripped["path"].as_str().unwrap()) + .file_name() + .unwrap() + .to_string_lossy() + .ends_with("-safe-name.txt") + ); + let windows = store.create(r"C:\temp\windows-name.txt").unwrap(); + assert!( + Path::new(windows["path"].as_str().unwrap()) + .file_name() + .unwrap() + .to_string_lossy() + .ends_with("-windows-name.txt") + ); + let created = store.create("safe.txt").unwrap(); + let id = created["assetId"].as_str().unwrap(); + let oversized = STANDARD.encode(vec![0_u8; MAX_CHUNK_BYTES + 1]); + assert!(store.append_chunk(id, &oversized).is_err()); + } + + #[test] + fn unsafe_empty_names_fall_back_and_long_utf8_names_preserve_extension() { + let mut store = store(); + for unsafe_name in ["", "../", ".", "..", "\0\n"] { + let created = store.create(unsafe_name).unwrap(); + assert!( + Path::new(created["path"].as_str().unwrap()) + .file_name() + .unwrap() + .to_string_lossy() + .ends_with("-tab-context.txt") + ); + } + + let long_name = format!("{}.snapshot.json", "🦀".repeat(100)); + let created = store.create(&long_name).unwrap(); + let file_name = Path::new(created["path"].as_str().unwrap()) + .file_name() + .unwrap() + .to_str() + .unwrap(); + let normalized = file_name.split_once('-').unwrap().1; + assert!(normalized.len() <= 200); + assert!(normalized.ends_with(".json")); + assert!(normalized.is_char_boundary(normalized.len())); + } +} diff --git a/native/chrome-extension-host/src/config.rs b/native/chrome-extension-host/src/config.rs new file mode 100644 index 0000000..79417b7 --- /dev/null +++ b/native/chrome-extension-host/src/config.rs @@ -0,0 +1,445 @@ +mod managed; + +use anyhow::{Context, Result, bail}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::{ + env, fs, + io::Read, + path::{Path, PathBuf}, +}; + +pub const CONFIG_FILE_NAME: &str = "extension-host-config.json"; + +/// The extension constraints used to select one desktop-managed Codex install. +#[derive(Clone, Copy, Debug)] +pub struct ConfigRequest<'a> { + pub extension_build_channel: &'a str, + pub extension_id: &'a str, + pub native_host_name: &'a str, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct HostConfig { + pub schema_version: u64, + #[serde(default)] + pub app_version: Option, + #[serde(default)] + pub browser_client_path: Option, + #[serde(default)] + pub channel: Option, + pub codex_cli_path: PathBuf, + #[serde(default)] + pub codex_home: Option, + #[serde(default)] + pub cli_version: Option, + #[serde(default)] + pub entry_id: Option, + #[serde(default)] + pub extension_id: Option, + #[serde(default)] + pub native_host_version: Option, + #[serde(default)] + pub node_module_dirs: Vec, + pub node_path: PathBuf, + #[serde(default)] + pub node_repl_path: Option, + #[serde(default = "default_proxy_host")] + pub proxy_host: String, + #[serde(default)] + pub proxy_port: u16, + #[serde(default)] + pub resources_path: Option, +} + +#[derive(Clone, Debug)] +pub struct HostConfigSource { + kind: SourceKind, +} + +#[derive(Clone, Debug)] +enum SourceKind { + Files { + adjacent_path: PathBuf, + executable: PathBuf, + registry_paths: Vec, + }, + #[cfg(test)] + Fixed(Box), +} + +fn default_proxy_host() -> String { + "127.0.0.1".to_string() +} + +impl HostConfigSource { + pub fn for_current_exe() -> Result { + let executable = + env::current_exe().context("failed to locate extension host executable")?; + let directory = executable + .parent() + .context("extension host executable has no parent directory")?; + Ok(Self { + kind: SourceKind::Files { + adjacent_path: directory.join(CONFIG_FILE_NAME), + executable, + registry_paths: managed::registry_paths(), + }, + }) + } + + /// Resolve on every `ensure` request. The desktop rewrites registry-v2 at + /// runtime, and the Darwin host observes those updates without restarting. + pub fn resolve(&self, request: ConfigRequest<'_>) -> Result { + match &self.kind { + SourceKind::Files { + adjacent_path, + executable, + registry_paths, + } => match managed::load(executable, registry_paths, request)? { + Some(config) => Ok(config), + None => { + if adjacent_path + .try_exists() + .with_context(|| format!("failed to inspect {}", adjacent_path.display()))? + { + let config = HostConfig::load(adjacent_path)?; + validate_installer_request(&config, request)?; + return Ok(config); + } + bail!("no compatible desktop-managed or adjacent Chrome host configuration") + } + }, + #[cfg(test)] + SourceKind::Fixed(config) => { + validate_installer_request(config, request)?; + Ok((**config).clone()) + } + } + } + + /// Chrome passes the calling extension origin to native hosts. The + /// adjacent installer config remains usable for direct host invocations. + pub fn extension_id(&self, argument_id: Option) -> Result { + if let Some(argument_id) = argument_id { + return Ok(argument_id); + } + match &self.kind { + SourceKind::Files { adjacent_path, .. } => HostConfig::load(adjacent_path)? + .extension_id + .context("extensionId is missing from config and Chrome arguments"), + #[cfg(test)] + SourceKind::Fixed(config) => config + .extension_id + .clone() + .context("extensionId is missing from fixed config"), + } + } + + #[cfg(test)] + pub(crate) fn fixed(config: HostConfig) -> Self { + Self { + kind: SourceKind::Fixed(Box::new(config)), + } + } + + #[cfg(test)] + pub(crate) fn from_paths( + executable: PathBuf, + adjacent_path: PathBuf, + registry_paths: Vec, + ) -> Self { + Self { + kind: SourceKind::Files { + adjacent_path, + executable, + registry_paths, + }, + } + } +} + +impl HostConfig { + pub fn load(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?; + let config: Self = serde_json::from_slice(&bytes) + .with_context(|| format!("failed to parse {}", path.display()))?; + config.validate()?; + Ok(config) + } + + pub fn validate(&self) -> Result<()> { + if self.schema_version != 1 { + bail!( + "unsupported extension-host-config schema {}", + self.schema_version + ); + } + validate_proxy_host(&self.proxy_host)?; + validate_required_file(&self.codex_cli_path, "codexCliPath")?; + validate_required_file(&self.node_path, "nodePath")?; + if let Some(path) = &self.node_repl_path { + validate_required_file(path, "nodeReplPath")?; + } + if let Some(path) = &self.browser_client_path { + validate_required_file(path, "browserClientPath")?; + } + if let Some(path) = &self.codex_home { + validate_required_directory(path, "codexHome")?; + } + if let Some(path) = &self.resources_path { + validate_required_directory(path, "resourcesPath")?; + } + for path in &self.node_module_dirs { + validate_required_directory(path, "nodeModuleDirs")?; + } + Ok(()) + } + + pub(super) fn validate_managed(&self) -> Result<()> { + validate_proxy_host(&self.proxy_host)?; + validate_required_file(&self.codex_cli_path, "codexCliPath")?; + validate_required_file(&self.node_path, "nodePath")?; + let codex_home = self + .codex_home + .as_deref() + .context("managed config is missing codexHome")?; + validate_required_directory(codex_home, "codexHome")?; + let resources_path = self + .resources_path + .as_deref() + .context("managed config is missing resourcesPath")?; + validate_required_directory(resources_path, "resourcesPath") + } + + pub fn browser_client_sha256(&self) -> Result> { + let Some(path) = &self.browser_client_path else { + return Ok(None); + }; + let mut file = fs::File::open(path) + .with_context(|| format!("failed to hash browser client {}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = file + .read(&mut buffer) + .with_context(|| format!("failed to hash browser client {}", path.display()))?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + } + let digest = hasher.finalize(); + Ok(Some( + digest.iter().map(|byte| format!("{byte:02x}")).collect(), + )) + } +} + +fn validate_installer_request(config: &HostConfig, request: ConfigRequest<'_>) -> Result<()> { + if let Some(extension_id) = config.extension_id.as_deref() + && extension_id != request.extension_id + { + bail!("version_mismatch: extensionId does not match installer config"); + } + if let Some(channel) = config.channel.as_deref() + && channel != request.extension_build_channel + { + bail!("version_mismatch: extension build channel does not match installer config"); + } + Ok(()) +} + +fn validate_proxy_host(proxy_host: &str) -> Result<()> { + if proxy_host != "127.0.0.1" && proxy_host != "::1" && proxy_host != "localhost" { + bail!("proxyHost must resolve only to loopback; got {proxy_host}"); + } + Ok(()) +} + +fn validate_required_file(path: &Path, field: &str) -> Result<()> { + if !path.is_absolute() { + bail!("{field} must be an absolute path: {}", path.display()); + } + let metadata = fs::metadata(path) + .with_context(|| format!("required {field} is missing: {}", path.display()))?; + if !metadata.is_file() { + bail!("{field} is not a regular file: {}", path.display()); + } + Ok(()) +} + +fn validate_required_directory(path: &Path, field: &str) -> Result<()> { + if !path.is_absolute() { + bail!("{field} must be an absolute path: {}", path.display()); + } + let metadata = fs::metadata(path) + .with_context(|| format!("required {field} is missing: {}", path.display()))?; + if !metadata.is_dir() { + bail!("{field} is not a directory: {}", path.display()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + io::Write, + os::unix::fs::PermissionsExt, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn fixture() -> (PathBuf, PathBuf) { + let root = env::temp_dir().join(format!( + "codex-host-config-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&root).unwrap(); + let binary = root.join("binary"); + let mut file = fs::File::create(&binary).unwrap(); + file.write_all(b"browser client").unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); + (root, binary) + } + + fn request<'a>(extension_id: &'a str) -> ConfigRequest<'a> { + ConfigRequest { + extension_build_channel: "prod", + extension_id, + native_host_name: crate::HOST_NAME, + } + } + + #[test] + fn loads_installer_schema_and_hashes_browser_client() { + let (root, binary) = fixture(); + let path = root.join(CONFIG_FILE_NAME); + let config_json = serde_json::json!({ + "schemaVersion": 1, + "browserClientPath": binary, + "channel": "prod", + "codexCliPath": binary, + "extensionId": "hehggadaopoacecdllhhajmbjkdcmajg", + "nodePath": binary, + "nodeReplPath": binary, + "proxyHost": "127.0.0.1", + "proxyPort": 0 + }); + fs::write(&path, serde_json::to_vec(&config_json).unwrap()).unwrap(); + let config = HostConfig::load(&path).unwrap(); + assert_eq!(config.channel.as_deref(), Some("prod")); + assert_eq!(config.browser_client_sha256().unwrap().unwrap().len(), 64); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn rejects_non_loopback_proxy() { + let (_root, binary) = fixture(); + let config = HostConfig { + schema_version: 1, + app_version: None, + browser_client_path: None, + channel: None, + codex_cli_path: binary.clone(), + codex_home: None, + cli_version: None, + entry_id: None, + extension_id: None, + native_host_version: None, + node_module_dirs: Vec::new(), + node_path: binary.clone(), + node_repl_path: Some(binary), + proxy_host: "0.0.0.0".to_string(), + proxy_port: 0, + resources_path: None, + }; + assert!( + config + .validate() + .unwrap_err() + .to_string() + .contains("loopback") + ); + } + + #[test] + fn falls_back_to_adjacent_installer_config() { + let (root, binary) = fixture(); + let adjacent = root.join(CONFIG_FILE_NAME); + let config_json = serde_json::json!({ + "schemaVersion": 1, + "channel": "prod", + "codexCliPath": binary, + "extensionId": "hehggadaopoacecdllhhajmbjkdcmajg", + "nodePath": binary, + "nodeReplPath": binary + }); + fs::write(&adjacent, serde_json::to_vec(&config_json).unwrap()).unwrap(); + let source = HostConfigSource::from_paths( + binary.clone(), + adjacent, + vec![root.join("missing-registry.json")], + ); + + let config = source + .resolve(request("hehggadaopoacecdllhhajmbjkdcmajg")) + .unwrap(); + assert_eq!(config.codex_cli_path, binary); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn desktop_registry_takes_precedence_over_adjacent_config() { + let (root, binary) = fixture(); + let adjacent = root.join(CONFIG_FILE_NAME); + let registry_path = root.join("chrome-native-hosts-v2.json"); + let adjacent_json = serde_json::json!({ + "schemaVersion": 1, + "channel": "prod", + "codexCliPath": binary, + "extensionId": "hehggadaopoacecdllhhajmbjkdcmajg", + "nodePath": binary, + "nodeReplPath": binary + }); + fs::write(&adjacent, serde_json::to_vec(&adjacent_json).unwrap()).unwrap(); + let registry = serde_json::json!({ + "schemaVersion": 2, + "entries": [{ + "schemaVersion": 2, + "appServerProtocolVersion": 2, + "appVersion": "26.707.31123", + "channel": "prod", + "cliVersion": "0.140.0", + "entryId": "managed-entry", + "extensionBuildChannels": ["prod"], + "extensionIds": ["hehggadaopoacecdllhhajmbjkdcmajg"], + "nativeHostNames": [crate::HOST_NAME], + "nativeHostProtocolVersion": 2, + "nativeHostVersion": "26.707.31123", + "paths": { + "codexCliPath": binary, + "codexHome": root, + "extensionHostPath": binary, + "nodePath": binary, + "resourcesPath": root + }, + "updatedAt": "2026-07-09T21:42:12.025Z" + }] + }); + fs::write(®istry_path, serde_json::to_vec(®istry).unwrap()).unwrap(); + let source = HostConfigSource::from_paths(binary, adjacent, vec![registry_path]); + + let config = source + .resolve(request("hehggadaopoacecdllhhajmbjkdcmajg")) + .unwrap(); + + assert_eq!(config.entry_id.as_deref(), Some("managed-entry")); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/native/chrome-extension-host/src/config/managed.rs b/native/chrome-extension-host/src/config/managed.rs new file mode 100644 index 0000000..731f95a --- /dev/null +++ b/native/chrome-extension-host/src/config/managed.rs @@ -0,0 +1,529 @@ +use super::{ConfigRequest, HostConfig, default_proxy_host}; +use anyhow::{Context, Result}; +use serde::Deserialize; +use serde_json::Value; +use std::{ + env, fs, + path::{Path, PathBuf}, +}; + +const FILE_NAME: &str = "chrome-native-hosts-v2.json"; +const SCHEMA_VERSION: u64 = 2; +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Registry { + schema_version: u64, + entries: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Entry { + app_version: String, + channel: String, + cli_version: String, + entry_id: String, + native_host_version: String, + paths: EntryPaths, + #[serde(default = "default_proxy_host")] + proxy_host: String, + #[serde(default)] + proxy_port: u16, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EntryPaths { + #[serde(default)] + browser_client_path: Option, + codex_cli_path: PathBuf, + codex_home: PathBuf, + node_path: PathBuf, + #[serde(default)] + node_module_dirs: Vec, + #[serde(default)] + node_repl_path: Option, + resources_path: PathBuf, +} + +#[derive(Debug)] +struct RankedRaw { + entry_id: String, + score: u64, + updated_at: String, + value: Value, +} + +pub(super) fn load( + executable: &Path, + registry_paths: &[PathBuf], + request: ConfigRequest<'_>, +) -> Result> { + let executable = fs::canonicalize(executable).with_context(|| { + format!( + "failed to resolve extension host executable {}", + executable.display() + ) + })?; + let mut registries_found = 0_usize; + let mut first_registry_error = None; + let mut candidates = Vec::::new(); + + for registry_path in registry_paths { + let bytes = match fs::read(registry_path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + first_registry_error.get_or_insert_with(|| { + anyhow::Error::new(error) + .context(format!("failed to read {}", registry_path.display())) + }); + continue; + } + }; + registries_found += 1; + let registry: Registry = match serde_json::from_slice(&bytes) { + Ok(registry) => registry, + Err(error) => { + first_registry_error.get_or_insert_with(|| { + anyhow::Error::new(error) + .context(format!("failed to parse {}", registry_path.display())) + }); + continue; + } + }; + if registry.schema_version != SCHEMA_VERSION { + first_registry_error.get_or_insert_with(|| { + anyhow::anyhow!( + "unsupported managed Chrome host registry schema {} in {}", + registry.schema_version, + registry_path.display() + ) + }); + continue; + } + + // Rank raw JSON first; only the Darwin-compatible winner must fully deserialize. + for value in registry.entries { + if let Some(candidate) = rank_raw(value, &executable, request) { + candidates.push(candidate); + } + } + } + + if registries_found == 0 { + if let Some(error) = first_registry_error { + return Err(error); + } + return Ok(None); + } + let Some(candidate) = candidates.into_iter().max_by(|left, right| { + left.score + .cmp(&right.score) + .then_with(|| left.updated_at.cmp(&right.updated_at)) + .then_with(|| left.entry_id.cmp(&right.entry_id)) + }) else { + if let Some(error) = first_registry_error { + return Err(error); + } + return Ok(None); + }; + let entry: Entry = serde_json::from_value(candidate.value) + .context("matching managed Chrome host entry is malformed")?; + let config = entry.into_host_config(request.extension_id); + config.validate_managed()?; + Ok(Some(config)) +} + +pub(super) fn registry_paths() -> Vec { + let home = env::var_os("HOME").map(PathBuf::from); + let state_root = env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .or_else(|| home.as_ref().map(|path| path.join(".local/state"))); + let codex_home = env::var_os("CODEX_HOME") + .map(PathBuf::from) + .or_else(|| home.as_ref().map(|path| path.join(".codex"))); + let mut paths = Vec::new(); + if let Some(root) = state_root { + paths.push(root.join("openai-codex").join(FILE_NAME)); + } + if let Some(root) = codex_home { + let path = root.join(FILE_NAME); + if !paths.contains(&path) { + paths.push(path); + } + } + paths +} + +impl Entry { + fn into_host_config(self, extension_id: &str) -> HostConfig { + HostConfig { + schema_version: 1, + app_version: Some(self.app_version), + browser_client_path: self.paths.browser_client_path, + channel: Some(self.channel), + codex_cli_path: self.paths.codex_cli_path, + codex_home: Some(self.paths.codex_home), + cli_version: Some(self.cli_version), + entry_id: Some(self.entry_id), + extension_id: Some(extension_id.to_string()), + native_host_version: Some(self.native_host_version), + node_module_dirs: self.paths.node_module_dirs, + node_path: self.paths.node_path, + node_repl_path: self.paths.node_repl_path, + proxy_host: self.proxy_host, + proxy_port: self.proxy_port, + resources_path: Some(self.paths.resources_path), + } + } +} + +fn rank_raw(value: Value, executable: &Path, request: ConfigRequest<'_>) -> Option { + if value.get("schemaVersion")?.as_u64()? != SCHEMA_VERSION + || value.get("appServerProtocolVersion")?.as_u64()? != crate::APP_SERVER_PROTOCOL_VERSION + || value.get("nativeHostProtocolVersion")?.as_u64()? != crate::NATIVE_HOST_PROTOCOL_VERSION + { + return None; + } + let paths = value.get("paths")?; + let required_file = |field| { + Path::new(paths.get(field)?.as_str()?) + .is_file() + .then_some(()) + }; + let required_directory = |field| { + Path::new(paths.get(field)?.as_str()?) + .is_dir() + .then_some(()) + }; + required_file("codexCliPath")?; + required_directory("codexHome")?; + required_file("nodePath")?; + required_directory("resourcesPath")?; + + let array_contains = |field: &str, expected: &str| { + value + .get(field) + .and_then(Value::as_array) + .is_some_and(|values| values.iter().any(|value| value.as_str() == Some(expected))) + }; + // Registry identity fields are compatibility constraints, not preferences. + // Ranking a mismatch could launch another app installation and then relabel + // it with the caller's extension ID in the returned host configuration. + if !array_contains("nativeHostNames", request.native_host_name) + || !array_contains("extensionIds", request.extension_id) + || !array_contains("extensionBuildChannels", request.extension_build_channel) + { + return None; + } + + // Among compatible entries, prefer this executable and a live desktop; + // updatedAt and entryId provide deterministic tie-breaking in `load`. + let mut score = 0_u64; + if paths + .get("extensionHostPath") + .and_then(Value::as_str) + .is_some_and(|path| paths_match(Path::new(path), executable)) + { + score += 100; + } + if value + .pointer("/presence/pid") + .and_then(Value::as_u64) + .is_some_and(|pid| pid > 0) + { + score += 1; + } + Some(RankedRaw { + entry_id: value + .get("entryId") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + score, + updated_at: value + .get("updatedAt") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + value, + }) +} + +fn paths_match(candidate: &Path, executable: &Path) -> bool { + candidate == executable + || fs::canonicalize(candidate).is_ok_and(|resolved| resolved == executable) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + os::unix::fs::symlink, + time::{SystemTime, UNIX_EPOCH}, + }; + + const EXTENSION_ID: &str = "hehggadaopoacecdllhhajmbjkdcmajg"; + + fn fixture() -> (PathBuf, PathBuf) { + let root = env::temp_dir().join(format!( + "codex-managed-config-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&root).unwrap(); + let binary = root.join("extension-host"); + fs::write(&binary, b"host").unwrap(); + (root, binary) + } + + fn request<'a>(extension_id: &'a str) -> ConfigRequest<'a> { + ConfigRequest { + extension_build_channel: "prod", + extension_id, + native_host_name: crate::HOST_NAME, + } + } + + fn entry(root: &Path, host: &Path, extension_id: &str) -> Value { + serde_json::json!({ + "schemaVersion": 2, + "appServerProtocolVersion": 2, + "appVersion": "26.707.31123", + "channel": "prod", + "cliVersion": "0.140.0", + "entryId": "managed-test-entry", + "extensionBuildChannels": ["prod"], + "extensionIds": [extension_id], + "nativeHostNames": [crate::HOST_NAME], + "nativeHostProtocolVersion": 2, + "nativeHostVersion": "26.707.31123", + "paths": { + "browserClientPath": host, + "codexCliPath": host, + "codexHome": root, + "extensionHostPath": host, + "nodePath": host, + "nodeModuleDirs": [root], + "nodeReplPath": host, + "resourcesPath": root + }, + "presence": {"pid": 42}, + "proxyHost": "127.0.0.1", + "proxyPort": 0, + "updatedAt": "2026-07-09T21:42:12.025Z" + }) + } + + fn write_registry(path: &Path, entries: Vec) { + fs::write( + path, + serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 2, + "entries": entries + })) + .unwrap(), + ) + .unwrap(); + } + + #[test] + fn loads_desktop_managed_registry_for_current_host() { + let (root, binary) = fixture(); + let registry_path = root.join(FILE_NAME); + write_registry(®istry_path, vec![entry(&root, &binary, EXTENSION_ID)]); + + let config = load(&binary, &[registry_path], request(EXTENSION_ID)) + .unwrap() + .unwrap(); + + assert_eq!(config.entry_id.as_deref(), Some("managed-test-entry")); + assert_eq!(config.app_version.as_deref(), Some("26.707.31123")); + assert_eq!(config.cli_version.as_deref(), Some("0.140.0")); + assert_eq!(config.codex_home.as_deref(), Some(root.as_path())); + assert_eq!(config.node_module_dirs, vec![root.clone()]); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn excludes_entries_for_other_host_extension_or_channel() { + let (root, binary) = fixture(); + let registry_path = root.join(FILE_NAME); + let mut wrong_host = entry(&root, &binary, EXTENSION_ID); + wrong_host["entryId"] = serde_json::json!("wrong-host"); + wrong_host["nativeHostNames"] = serde_json::json!(["com.openai.other"]); + let mut wrong_extension = entry(&root, &binary, EXTENSION_ID); + wrong_extension["entryId"] = serde_json::json!("wrong-extension"); + wrong_extension["extensionIds"] = serde_json::json!(["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]); + let mut wrong_channel = entry(&root, &binary, EXTENSION_ID); + wrong_channel["entryId"] = serde_json::json!("wrong-channel"); + wrong_channel["extensionBuildChannels"] = serde_json::json!(["dev"]); + write_registry( + ®istry_path, + vec![wrong_host, wrong_extension, wrong_channel], + ); + + let config = load(&binary, &[registry_path], request(EXTENSION_ID)).unwrap(); + + assert!(config.is_none()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn scores_identity_matches_and_uses_newest_tie() { + let (root, binary) = fixture(); + let registry_path = root.join(FILE_NAME); + let other_binary = root.join("other-host"); + fs::write(&other_binary, b"other").unwrap(); + let mut lower_score = entry(&root, &other_binary, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + lower_score["entryId"] = serde_json::json!("newer-but-unmatched"); + lower_score["updatedAt"] = serde_json::json!("2027-01-01T00:00:00.000Z"); + let mut newest_tie = entry(&root, &binary, EXTENSION_ID); + newest_tie["entryId"] = serde_json::json!("newest-tie"); + newest_tie["updatedAt"] = serde_json::json!("2026-07-10T00:00:00.000Z"); + write_registry( + ®istry_path, + vec![entry(&root, &binary, EXTENSION_ID), lower_score, newest_tie], + ); + + let config = load(&binary, &[registry_path], request(EXTENSION_ID)) + .unwrap() + .unwrap(); + + assert_eq!(config.entry_id.as_deref(), Some("newest-tie")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn request_channel_selects_the_matching_build() { + let (root, binary) = fixture(); + let registry_path = root.join(FILE_NAME); + let prod = entry(&root, &binary, EXTENSION_ID); + let mut dev = entry(&root, &binary, EXTENSION_ID); + dev["entryId"] = serde_json::json!("dev-entry"); + dev["channel"] = serde_json::json!("dev"); + dev["extensionBuildChannels"] = serde_json::json!(["dev"]); + dev["updatedAt"] = serde_json::json!("2027-01-01T00:00:00.000Z"); + write_registry(®istry_path, vec![dev, prod]); + + let config = load(&binary, &[registry_path], request(EXTENSION_ID)) + .unwrap() + .unwrap(); + + assert_eq!(config.entry_id.as_deref(), Some("managed-test-entry")); + assert_eq!(config.channel.as_deref(), Some("prod")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn tolerates_malformed_and_missing_path_entries() { + let (root, binary) = fixture(); + let registry_path = root.join(FILE_NAME); + let mut missing_path = entry(&root, &binary, EXTENSION_ID); + missing_path["entryId"] = serde_json::json!("missing-path"); + missing_path["paths"]["codexCliPath"] = serde_json::json!(root.join("missing")); + write_registry( + ®istry_path, + vec![ + serde_json::json!({"broken": true}), + missing_path, + entry(&root, &binary, EXTENSION_ID), + ], + ); + + let config = load(&binary, &[registry_path], request(EXTENSION_ID)) + .unwrap() + .unwrap(); + + assert_eq!(config.entry_id.as_deref(), Some("managed-test-entry")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn canonical_executable_match_outranks_newer_compatible_entry() { + let (root, binary) = fixture(); + let symlink_path = root.join("latest-host"); + symlink(&binary, &symlink_path).unwrap(); + let registry_path = root.join(FILE_NAME); + let other_binary = root.join("other-host"); + fs::write(&other_binary, b"other").unwrap(); + let mut canonical_match = entry(&root, &symlink_path, EXTENSION_ID); + canonical_match["entryId"] = serde_json::json!("canonical-match"); + let mut newer_compatible = entry(&root, &other_binary, EXTENSION_ID); + newer_compatible["entryId"] = serde_json::json!("newer-compatible"); + newer_compatible["updatedAt"] = serde_json::json!("2027-01-01T00:00:00.000Z"); + write_registry(®istry_path, vec![newer_compatible, canonical_match]); + + let config = load(&binary, &[registry_path], request(EXTENSION_ID)) + .unwrap() + .unwrap(); + + assert_eq!(config.entry_id.as_deref(), Some("canonical-match")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn excludes_protocol_mismatch_and_uses_newest_registry_copy() { + let (root, binary) = fixture(); + let global = root.join("global.json"); + let codex = root.join("codex.json"); + let mut incompatible = entry(&root, &binary, EXTENSION_ID); + incompatible["entryId"] = serde_json::json!("incompatible"); + incompatible["nativeHostProtocolVersion"] = serde_json::json!(3); + let mut older = entry(&root, &binary, EXTENSION_ID); + older["updatedAt"] = serde_json::json!("2025-01-01T00:00:00.000Z"); + write_registry(&global, vec![incompatible, older]); + write_registry(&codex, vec![entry(&root, &binary, EXTENSION_ID)]); + + let config = load(&binary, &[global, codex], request(EXTENSION_ID)) + .unwrap() + .unwrap(); + + assert_eq!(config.app_version.as_deref(), Some("26.707.31123")); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn rejects_a_malformed_highest_ranked_entry() { + let (root, binary) = fixture(); + let registry_path = root.join(FILE_NAME); + let other_binary = root.join("other-host"); + fs::write(&other_binary, b"other").unwrap(); + let valid_lower_score = entry(&root, &other_binary, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let mut malformed_winner = entry(&root, &binary, EXTENSION_ID); + malformed_winner + .as_object_mut() + .unwrap() + .remove("appVersion"); + write_registry(®istry_path, vec![valid_lower_score, malformed_winner]); + + let error = load(&binary, &[registry_path], request(EXTENSION_ID)).unwrap_err(); + + assert!( + error + .to_string() + .contains("matching managed Chrome host entry is malformed") + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn malformed_registry_copy_does_not_mask_a_valid_copy() { + let (root, binary) = fixture(); + let malformed = root.join("malformed.json"); + let valid = root.join("valid.json"); + fs::write(&malformed, b"not json").unwrap(); + write_registry(&valid, vec![entry(&root, &binary, EXTENSION_ID)]); + + let config = load(&binary, &[malformed, valid], request(EXTENSION_ID)) + .unwrap() + .unwrap(); + + assert_eq!(config.entry_id.as_deref(), Some("managed-test-entry")); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/native/chrome-extension-host/src/framing.rs b/native/chrome-extension-host/src/framing.rs new file mode 100644 index 0000000..d0df926 --- /dev/null +++ b/native/chrome-extension-host/src/framing.rs @@ -0,0 +1,125 @@ +//! Chrome native-messaging framing. +//! +//! Chrome uses a four-byte little-endian length prefix. Hosts may receive up +//! to 64 MiB and may send at most 1 MiB. See Chrome's native messaging docs. + +use serde_json::Value; +use std::io::{self, ErrorKind, Read, Write}; + +pub const MAX_INBOUND_BYTES: usize = 64 * 1024 * 1024; +pub const MAX_OUTBOUND_BYTES: usize = 1024 * 1024; + +pub fn read_frame(reader: &mut impl Read) -> io::Result> { + read_frame_with_limit(reader, MAX_INBOUND_BYTES) +} + +pub fn read_frame_with_limit( + reader: &mut impl Read, + maximum_bytes: usize, +) -> io::Result> { + let mut header = [0_u8; 4]; + let mut header_bytes = 0; + while header_bytes < header.len() { + match reader.read(&mut header[header_bytes..]) { + Ok(0) if header_bytes == 0 => return Ok(None), + Ok(0) => { + return Err(io::Error::new( + ErrorKind::UnexpectedEof, + "native message ended inside its length prefix", + )); + } + Ok(count) => header_bytes += count, + Err(error) if error.kind() == ErrorKind::Interrupted => {} + Err(error) => return Err(error), + } + } + + let length = u32::from_le_bytes(header) as usize; + if length > maximum_bytes { + return Err(io::Error::new( + ErrorKind::InvalidData, + format!("native message is {length} bytes; limit is {maximum_bytes}"), + )); + } + + let mut body = vec![0_u8; length]; + reader.read_exact(&mut body)?; + serde_json::from_slice(&body).map(Some).map_err(|error| { + io::Error::new( + ErrorKind::InvalidData, + format!("invalid JSON frame: {error}"), + ) + }) +} + +pub fn write_frame(writer: &mut impl Write, message: &Value) -> io::Result<()> { + write_frame_with_limit(writer, message, MAX_OUTBOUND_BYTES) +} + +pub fn write_frame_with_limit( + writer: &mut impl Write, + message: &Value, + maximum_bytes: usize, +) -> io::Result<()> { + let body = serde_json::to_vec(message).map_err(io::Error::other)?; + if body.len() > maximum_bytes { + return Err(io::Error::new( + ErrorKind::InvalidInput, + format!( + "native message is {} bytes; outbound limit is {maximum_bytes}", + body.len() + ), + )); + } + let length = u32::try_from(body.len()).map_err(|_| { + io::Error::new(ErrorKind::InvalidInput, "message does not fit a u32 prefix") + })?; + writer.write_all(&length.to_le_bytes())?; + writer.write_all(&body)?; + writer.flush() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn round_trip_uses_little_endian_prefix() { + let message = json!({"jsonrpc": "2.0", "id": 1, "method": "ping"}); + let mut encoded = Vec::new(); + write_frame(&mut encoded, &message).unwrap(); + assert_eq!( + u32::from_le_bytes(encoded[..4].try_into().unwrap()) as usize, + encoded.len() - 4 + ); + assert_eq!( + read_frame(&mut io::Cursor::new(encoded)).unwrap(), + Some(message) + ); + } + + #[test] + fn clean_eof_differs_from_truncated_header() { + assert_eq!( + read_frame(&mut io::Cursor::new(Vec::::new())).unwrap(), + None + ); + let error = read_frame(&mut io::Cursor::new(vec![1, 0])).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::UnexpectedEof); + } + + #[test] + fn rejects_oversized_input_before_allocating_body() { + let encoded = ((MAX_INBOUND_BYTES + 1) as u32).to_le_bytes(); + let error = read_frame(&mut io::Cursor::new(encoded)).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::InvalidData); + } + + #[test] + fn rejects_oversized_output() { + let message = Value::String("x".repeat(32)); + let error = write_frame_with_limit(&mut Vec::new(), &message, 8).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::InvalidInput); + } +} diff --git a/native/chrome-extension-host/src/host.rs b/native/chrome-extension-host/src/host.rs new file mode 100644 index 0000000..0498146 --- /dev/null +++ b/native/chrome-extension-host/src/host.rs @@ -0,0 +1,149 @@ +use crate::{assets::AssetStore, open_file::open_local_file, rpc, runtime::RuntimeManager}; +use serde_json::{Value, json}; +use std::path::Path; + +const MAX_ERROR_MESSAGE_BYTES: usize = 4 * 1024; +const TRUNCATED_SUFFIX: &str = "...[truncated]"; + +pub struct ProtocolHost { + runtime: RuntimeManager, + assets: AssetStore, +} + +impl ProtocolHost { + pub fn new(runtime: RuntimeManager, assets: AssetStore) -> Self { + Self { runtime, assets } + } + + pub fn handles(message: &Value) -> bool { + message + .get("method") + .and_then(Value::as_str) + .is_some_and(|method| method.starts_with("codexRuntime/")) + } + + pub fn handle(&mut self, message: &Value) -> Value { + let id = rpc::id(message); + match self.handle_result(message) { + Ok(result) => rpc::result(id, result), + Err(error) => { + let text = bounded_error_text(&error.to_string()); + if text.starts_with("method not found:") { + return rpc::error(id, rpc::METHOD_NOT_FOUND, text); + } + let error_type = if text.contains("version_mismatch") { + "version_mismatch" + } else if text.contains("path") + || text.contains("fileName") + || text.contains("assetId") + || text.contains("dataBase64") + || text.contains("constraints") + { + "invalid_params" + } else { + "app_server_runtime_error" + }; + let code = if error_type == "invalid_params" { + rpc::INVALID_PARAMS + } else { + rpc::SERVER_ERROR + }; + rpc::typed_error(id, code, text, error_type) + } + } + } + + pub fn shutdown(&self) { + self.runtime.shutdown(); + } + + fn handle_result(&mut self, message: &Value) -> anyhow::Result { + let method = message + .get("method") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing method"))?; + let params = rpc::params(message); + match method { + "codexRuntime/hello" => { + self.runtime.validate_request(params)?; + Ok(self.runtime.hello()) + } + "codexRuntime/ensure" => self.runtime.ensure(params, false), + "codexRuntime/restart" => self.runtime.ensure(params, true), + "codexRuntime/openLocalFile" => { + let path = required_string(params, "path")?; + open_local_file(Path::new(path))?; + Ok(json!({})) + } + "codexRuntime/tabContextAsset/create" => { + self.assets.create(required_string(params, "fileName")?) + } + "codexRuntime/tabContextAsset/appendChunk" => self.assets.append_chunk( + required_string(params, "assetId")?, + required_string(params, "dataBase64")?, + ), + "codexRuntime/tabContextAsset/finish" => { + self.assets.finish(required_string(params, "assetId")?) + } + "codexRuntime/tabContextAsset/abort" => { + self.assets.abort(required_string(params, "assetId")?) + } + "codexRuntime/tabContextAsset/remove" => { + self.assets.remove(required_string(params, "assetId")?) + } + _ => anyhow::bail!("method not found: {method}"), + } + } +} + +fn bounded_error_text(message: &str) -> String { + if message.len() <= MAX_ERROR_MESSAGE_BYTES { + return message.to_string(); + } + let maximum_prefix = MAX_ERROR_MESSAGE_BYTES - TRUNCATED_SUFFIX.len(); + let mut end = maximum_prefix; + while !message.is_char_boundary(end) { + end -= 1; + } + format!("{}{}", &message[..end], TRUNCATED_SUFFIX) +} + +fn required_string<'a>(params: &'a Value, field: &str) -> anyhow::Result<&'a str> { + params + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow::anyhow!("missing or invalid {field}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn intercepts_only_runtime_namespace() { + assert!(ProtocolHost::handles( + &json!({"method":"codexRuntime/hello"}) + )); + assert!(!ProtocolHost::handles(&json!({"method":"getTabs"}))); + assert!(!ProtocolHost::handles(&json!({"result":{}}))); + } + + #[test] + fn required_strings_reject_empty_or_wrong_type() { + assert_eq!( + required_string(&json!({"path":"/tmp/report"}), "path").unwrap(), + "/tmp/report" + ); + assert!(required_string(&json!({"path":""}), "path").is_err()); + assert!(required_string(&json!({"path":1}), "path").is_err()); + } + + #[test] + fn native_error_messages_are_bounded_for_chrome_output() { + let oversized = "x".repeat(16 * 1024); + let bounded = bounded_error_text(&oversized); + assert!(bounded.len() <= MAX_ERROR_MESSAGE_BYTES); + assert!(bounded.ends_with("...[truncated]")); + } +} diff --git a/native/chrome-extension-host/src/legacy.rs b/native/chrome-extension-host/src/legacy.rs new file mode 100644 index 0000000..6f11b56 --- /dev/null +++ b/native/chrome-extension-host/src/legacy.rs @@ -0,0 +1,504 @@ +//! Legacy Browser Use relay over a private Unix-domain socket. + +#[path = "legacy_connection.rs"] +mod connection; +#[path = "legacy_info.rs"] +mod info; + +use crate::{ + framing::{MAX_OUTBOUND_BYTES, read_frame_with_limit, write_frame}, + rollout::RolloutTracker, + rpc, + uds::authorize_peer, +}; +use connection::ConnectionPermit; +#[cfg(test)] +use connection::MAX_CLIENT_CONNECTIONS; +use info::{extension_info_response, missing_runtime_get_version}; +use serde_json::{Value, json}; +use std::{ + collections::HashMap, + io::Write, + net::Shutdown, + os::unix::net::{UnixListener, UnixStream}, + process, + sync::{ + Arc, Mutex, + atomic::AtomicUsize, + mpsc::{self, SyncSender}, + }, + thread, + time::{Duration, Instant}, +}; + +const OUTPUT_QUEUE: usize = 256; +const CLIENT_QUEUE: usize = 256; +const MAX_PENDING_REQUESTS: usize = 2048; +const PENDING_TTL: Duration = Duration::from_secs(2 * 60); + +pub fn spawn_chrome_writer(writer: impl Write + Send + 'static) -> SyncSender { + let (sender, receiver) = mpsc::sync_channel(OUTPUT_QUEUE); + thread::Builder::new() + .name("codex-native-stdout".to_string()) + .spawn(move || { + let mut writer = writer; + for message in receiver { + if let Err(error) = write_frame(&mut writer, &message) { + crate::log(format_args!("native stdout error: {error}")); + process::exit(1); + } + } + }) + .expect("failed to spawn native stdout writer"); + sender +} + +pub struct LegacyBridge { + state: Arc>, + _rollout_tracker: RolloutTracker, +} + +struct State { + chrome_output: SyncSender, + extension_id: Option, + clients: HashMap, + pending_chrome: HashMap, + pending_clients: HashMap, + next_client_id: u64, + next_chrome_id: u64, + next_client_request_id: u64, +} + +struct Client { + sender: SyncSender, + shutdown: UnixStream, +} + +struct PendingChrome { + client_id: u64, + original_id: Value, + fallback_extension_info: bool, + created_at: Instant, +} + +struct PendingClient { + client_id: u64, + original_id: Value, + created_at: Instant, +} + +impl LegacyBridge { + pub fn start( + listener: UnixListener, + chrome_output: SyncSender, + extension_id: Option, + ) -> Self { + let state = Arc::new(Mutex::new(State { + chrome_output: chrome_output.clone(), + extension_id, + clients: HashMap::new(), + pending_chrome: HashMap::new(), + pending_clients: HashMap::new(), + next_client_id: 1, + next_chrome_id: 1, + next_client_request_id: 1, + })); + let rollout_tracker = RolloutTracker::start(chrome_output); + let accept_state = Arc::clone(&state); + let accept_tracker = rollout_tracker.clone(); + thread::Builder::new() + .name("codex-browser-relay-accept".to_string()) + .spawn(move || accept_clients(listener, accept_state, accept_tracker)) + .expect("failed to spawn browser relay listener"); + Self { + state, + _rollout_tracker: rollout_tracker, + } + } + + pub fn handle_chrome_message(&self, message: Value) { + if rpc::is_response(&message) { + self.handle_chrome_response(message); + } else if rpc::is_request(&message) { + self.handle_chrome_request(message); + } else { + let senders = { + let state = self.state.lock().expect("browser relay mutex poisoned"); + state + .clients + .iter() + .map(|(id, client)| (*id, client.sender.clone())) + .collect::>() + }; + for (client_id, sender) in senders { + if sender.send(message.clone()).is_err() { + crate::log(format_args!( + "browser client {client_id} output channel disconnected" + )); + remove_client(&self.state, client_id); + } + } + } + } + + fn handle_chrome_response(&self, message: Value) { + let Some(id) = rpc::string_id(&message) else { + return; + }; + let route = { + let mut state = self.state.lock().expect("browser relay mutex poisoned"); + state.prune_pending(); + let Some(pending) = state.pending_chrome.remove(id) else { + return; + }; + let response = + if pending.fallback_extension_info && missing_runtime_get_version(&message) { + extension_info_response(pending.original_id, state.extension_id.as_deref()) + } else { + rpc::replace_id(message, pending.original_id) + }; + state + .clients + .get(&pending.client_id) + .map(|client| (pending.client_id, client.sender.clone(), response)) + }; + if let Some((client_id, sender, response)) = route + && sender.send(response).is_err() + { + crate::log(format_args!( + "browser client {client_id} response channel disconnected" + )); + remove_client(&self.state, client_id); + } + } + + fn handle_chrome_request(&self, message: Value) { + let original_id = rpc::id(&message); + let route: std::result::Result<_, _> = { + let mut state = self.state.lock().expect("browser relay mutex poisoned"); + state.prune_pending(); + if state.clients.len() != 1 { + let error_message = if state.clients.is_empty() { + "No Codex browser client is connected" + } else { + "Multiple Codex browser clients are connected" + }; + Err(( + state.chrome_output.clone(), + rpc::error(original_id, rpc::SERVER_ERROR, error_message), + )) + } else if state.pending_clients.len() >= MAX_PENDING_REQUESTS { + Err(( + state.chrome_output.clone(), + rpc::error( + original_id, + rpc::SERVER_ERROR, + "Too many pending browser client requests", + ), + )) + } else { + let (&client_id, client) = state.clients.iter().next().expect("one client"); + let sender = client.sender.clone(); + let routed_id = + format!("chrome-{}-{}", process::id(), state.next_client_request_id); + state.next_client_request_id += 1; + state.pending_clients.insert( + routed_id.clone(), + PendingClient { + client_id, + original_id, + created_at: Instant::now(), + }, + ); + Ok(( + client_id, + sender, + rpc::replace_id(message, Value::String(routed_id)), + )) + } + }; + match route { + Ok((client_id, sender, message)) => { + if sender.send(message).is_err() { + crate::log(format_args!( + "browser client {client_id} request channel disconnected" + )); + remove_client(&self.state, client_id); + } + } + Err((chrome, message)) => { + if chrome.send(message).is_err() { + crate::log("native stdout channel disconnected"); + } + } + } + } +} + +fn accept_clients(listener: UnixListener, state: Arc>, tracker: RolloutTracker) { + let active = Arc::new(AtomicUsize::new(0)); + for stream in listener.incoming() { + let stream = match stream { + Ok(stream) => stream, + Err(error) => { + crate::log(format_args!("browser relay accept error: {error}")); + continue; + } + }; + if let Err(error) = authorize_peer(&stream) { + crate::log(error); + continue; + } + let Some(permit) = ConnectionPermit::acquire(Arc::clone(&active)) else { + crate::log("browser relay rejected excess client connection"); + let _ = stream.shutdown(Shutdown::Both); + continue; + }; + let state = Arc::clone(&state); + let tracker = tracker.clone(); + if let Err(error) = thread::Builder::new() + .name("codex-browser-relay-client".to_string()) + .spawn(move || { + let _permit = permit; + serve_client(stream, state, tracker); + }) + { + crate::log(format_args!( + "failed to spawn browser relay client: {error}" + )); + } + } +} + +fn serve_client(mut stream: UnixStream, state: Arc>, tracker: RolloutTracker) { + if let Err(error) = stream.set_read_timeout(Some(Duration::from_secs(5))) { + crate::log(format_args!("browser client timeout setup: {error}")); + return; + } + let first = match read_frame_with_limit(&mut stream, MAX_OUTBOUND_BYTES) { + Ok(Some(message)) => message, + Ok(None) => return, + Err(error) => { + crate::log(format_args!("browser client first frame: {error}")); + return; + } + }; + if let Err(error) = stream.set_read_timeout(None) { + crate::log(format_args!("browser client timeout reset: {error}")); + return; + } + let writer_stream = match stream.try_clone() { + Ok(stream) => stream, + Err(error) => { + crate::log(format_args!("browser socket clone error: {error}")); + return; + } + }; + if let Err(error) = writer_stream.set_write_timeout(Some(Duration::from_secs(5))) { + crate::log(format_args!("browser client write timeout setup: {error}")); + return; + } + let shutdown = match stream.try_clone() { + Ok(stream) => stream, + Err(error) => { + crate::log(format_args!("browser socket clone error: {error}")); + return; + } + }; + let (sender, receiver) = mpsc::sync_channel(CLIENT_QUEUE); + thread::spawn(move || client_writer(writer_stream, receiver)); + let client_id = register_client(&state, sender, shutdown); + handle_client_message(&state, &tracker, client_id, first); + loop { + match read_frame_with_limit(&mut stream, MAX_OUTBOUND_BYTES) { + Ok(Some(message)) => handle_client_message(&state, &tracker, client_id, message), + Ok(None) => break, + Err(error) => { + crate::log(format_args!("browser client frame: {error}")); + break; + } + } + } + remove_client(&state, client_id); +} + +fn client_writer(mut stream: UnixStream, receiver: mpsc::Receiver) { + for message in receiver { + if let Err(error) = write_frame(&mut stream, &message) { + crate::log(format_args!("browser socket write error: {error}")); + break; + } + } +} + +fn register_client( + state: &Arc>, + sender: SyncSender, + shutdown: UnixStream, +) -> u64 { + let evicted = { + let mut state = state.lock().expect("browser relay mutex poisoned"); + let evicted = state + .clients + .drain() + .map(|(_, client)| client) + .collect::>(); + state.pending_chrome.clear(); + state.pending_clients.clear(); + let id = state.next_client_id; + state.next_client_id += 1; + state.clients.insert(id, Client { sender, shutdown }); + (id, evicted) + }; + for client in evicted.1 { + let _ = client.shutdown.shutdown(Shutdown::Both); + } + evicted.0 +} + +fn remove_client(state: &Arc>, client_id: u64) { + let mut state = state.lock().expect("browser relay mutex poisoned"); + state.clients.remove(&client_id); + state + .pending_chrome + .retain(|_, pending| pending.client_id != client_id); + state + .pending_clients + .retain(|_, pending| pending.client_id != client_id); +} + +fn handle_client_message( + state: &Arc>, + tracker: &RolloutTracker, + client_id: u64, + message: Value, +) { + if rpc::is_response(&message) { + handle_client_response(state, client_id, message); + return; + } + if !rpc::is_request(&message) { + let output = { + let state = state.lock().expect("browser relay mutex poisoned"); + state + .clients + .contains_key(&client_id) + .then(|| state.chrome_output.clone()) + }; + if let Some(output) = output + && output.send(message).is_err() + { + crate::log("native stdout channel disconnected"); + } + return; + } + tracker.observe_request(&message); + if message.get("method").and_then(Value::as_str) == Some("ping") { + let response = rpc::result(rpc::id(&message), json!("pong")); + let sender = { + let state = state.lock().expect("browser relay mutex poisoned"); + state + .clients + .get(&client_id) + .map(|client| client.sender.clone()) + }; + if let Some(sender) = sender + && sender.send(response).is_err() + { + crate::log(format_args!( + "browser client {client_id} ping channel disconnected" + )); + remove_client(state, client_id); + } + return; + } + route_client_request(state, client_id, message); +} + +fn handle_client_response(state: &Arc>, client_id: u64, message: Value) { + let Some(id) = rpc::string_id(&message) else { + return; + }; + let output = { + let mut state = state.lock().expect("browser relay mutex poisoned"); + state.prune_pending(); + let Some(pending) = state.pending_clients.get(id) else { + return; + }; + if pending.client_id != client_id { + return; + } + let pending = state.pending_clients.remove(id).expect("pending exists"); + ( + state.chrome_output.clone(), + rpc::replace_id(message, pending.original_id), + ) + }; + if output.0.send(output.1).is_err() { + crate::log("native stdout channel disconnected"); + } +} + +fn route_client_request(state: &Arc>, client_id: u64, message: Value) { + let original_id = rpc::id(&message); + let route = { + let mut locked = state.lock().expect("browser relay mutex poisoned"); + locked.prune_pending(); + if !locked.clients.contains_key(&client_id) { + return; + } + if locked.pending_chrome.len() >= MAX_PENDING_REQUESTS { + let response = rpc::error( + original_id, + rpc::SERVER_ERROR, + "Too many pending Chrome requests", + ); + let sender = locked + .clients + .get(&client_id) + .map(|client| client.sender.clone()); + drop(locked); + if let Some(sender) = sender + && sender.send(response).is_err() + { + crate::log(format_args!( + "browser client {client_id} error channel disconnected" + )); + remove_client(state, client_id); + } + return; + } + let routed_id = format!("linux-{}-{}", process::id(), locked.next_chrome_id); + locked.next_chrome_id += 1; + locked.pending_chrome.insert( + routed_id.clone(), + PendingChrome { + client_id, + original_id, + fallback_extension_info: message.get("method").and_then(Value::as_str) + == Some("getInfo"), + created_at: Instant::now(), + }, + ); + ( + locked.chrome_output.clone(), + rpc::replace_id(message, Value::String(routed_id)), + ) + }; + if route.0.send(route.1).is_err() { + crate::log("native stdout channel disconnected"); + } +} + +impl State { + fn prune_pending(&mut self) { + self.pending_chrome + .retain(|_, request| request.created_at.elapsed() < PENDING_TTL); + self.pending_clients + .retain(|_, request| request.created_at.elapsed() < PENDING_TTL); + } +} + +#[cfg(test)] +#[path = "legacy_tests.rs"] +mod tests; diff --git a/native/chrome-extension-host/src/legacy_connection.rs b/native/chrome-extension-host/src/legacy_connection.rs new file mode 100644 index 0000000..d08b74c --- /dev/null +++ b/native/chrome-extension-host/src/legacy_connection.rs @@ -0,0 +1,27 @@ +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +pub(super) const MAX_CLIENT_CONNECTIONS: usize = 8; + +/// Bounds same-UID clients before they can hold a thread or native frame buffer. +pub(super) struct ConnectionPermit { + active: Arc, +} + +impl ConnectionPermit { + pub(super) fn acquire(active: Arc) -> Option { + if active.fetch_add(1, Ordering::AcqRel) >= MAX_CLIENT_CONNECTIONS { + active.fetch_sub(1, Ordering::AcqRel); + return None; + } + Some(Self { active }) + } +} + +impl Drop for ConnectionPermit { + fn drop(&mut self) { + self.active.fetch_sub(1, Ordering::AcqRel); + } +} diff --git a/native/chrome-extension-host/src/legacy_info.rs b/native/chrome-extension-host/src/legacy_info.rs new file mode 100644 index 0000000..47938a6 --- /dev/null +++ b/native/chrome-extension-host/src/legacy_info.rs @@ -0,0 +1,31 @@ +use crate::rpc; +use serde_json::{Value, json}; + +pub(super) fn missing_runtime_get_version(message: &Value) -> bool { + message + .get("error") + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + .is_some_and(|message| message.contains("chrome.runtime.getVersion is not a function")) +} + +pub(super) fn extension_info_response(id: Value, extension_id: Option<&str>) -> Value { + let metadata = extension_id + .map(|extension_id| json!({"extensionId": extension_id})) + .unwrap_or_else(|| json!({})); + rpc::result( + id, + json!({ + "name": "Chrome", + "version": "unknown", + "type": "extension", + "capabilities": { + "tab": [{ + "id": "pageAssets", + "description": "List page assets and bundle selected assets into a temporary local artifact." + }] + }, + "metadata": metadata + }), + ) +} diff --git a/native/chrome-extension-host/src/legacy_tests.rs b/native/chrome-extension-host/src/legacy_tests.rs new file mode 100644 index 0000000..43517f4 --- /dev/null +++ b/native/chrome-extension-host/src/legacy_tests.rs @@ -0,0 +1,177 @@ +use super::*; + +fn bridge() -> ( + LegacyBridge, + mpsc::Receiver, + SyncSender, + mpsc::Receiver, +) { + let (chrome_output, chrome_messages) = mpsc::sync_channel(32); + let tracker = RolloutTracker::start_with_root(chrome_output.clone(), None); + let (client_output, client_messages) = mpsc::sync_channel(32); + let (shutdown, _peer) = UnixStream::pair().unwrap(); + let state = Arc::new(Mutex::new(State { + chrome_output, + extension_id: Some("abcdefghijklmnopabcdefghijklmnop".to_string()), + clients: HashMap::from([( + 7, + Client { + sender: client_output.clone(), + shutdown, + }, + )]), + pending_chrome: HashMap::new(), + pending_clients: HashMap::new(), + next_client_id: 8, + next_chrome_id: 1, + next_client_request_id: 1, + })); + ( + LegacyBridge { + state, + _rollout_tracker: tracker, + }, + chrome_messages, + client_output, + client_messages, + ) +} + +#[test] +fn missing_runtime_method_is_the_only_get_info_fallback() { + assert!(missing_runtime_get_version(&json!({ + "id": 1, + "error": {"message":"chrome.runtime.getVersion is not a function"} + }))); + assert!(!missing_runtime_get_version(&json!({ + "id": 1, + "error": {"message":"permission denied"} + }))); +} + +#[test] +fn extension_info_contains_discovery_metadata() { + let response = + extension_info_response(json!("request"), Some("abcdefghijklmnopabcdefghijklmnop")); + assert_eq!(response["id"], "request"); + assert_eq!(response["result"]["type"], "extension"); + assert_eq!( + response["result"]["metadata"]["extensionId"], + "abcdefghijklmnopabcdefghijklmnop" + ); +} + +#[test] +fn ping_is_answered_without_forwarding_to_chrome() { + let (bridge, chrome_messages, _client_output, client_messages) = bridge(); + handle_client_message( + &bridge.state, + &bridge._rollout_tracker, + 7, + json!({"jsonrpc":"2.0","id":"ping-1","method":"ping"}), + ); + assert_eq!( + client_messages + .recv_timeout(Duration::from_secs(1)) + .unwrap(), + json!({"jsonrpc":"2.0","id":"ping-1","result":"pong"}) + ); + assert!(chrome_messages.try_recv().is_err()); +} + +#[test] +fn client_request_and_chrome_response_restore_original_id() { + let (bridge, chrome_messages, _client_output, client_messages) = bridge(); + handle_client_message( + &bridge.state, + &bridge._rollout_tracker, + 7, + json!({"jsonrpc":"2.0","id":"original","method":"getTabs","params":{"x":1}}), + ); + let forwarded = chrome_messages + .recv_timeout(Duration::from_secs(1)) + .unwrap(); + assert_eq!(forwarded["method"], "getTabs"); + assert_ne!(forwarded["id"], "original"); + bridge.handle_chrome_message(json!({ + "jsonrpc":"2.0", + "id":forwarded["id"], + "result":{"tabs":[]} + })); + let restored = client_messages + .recv_timeout(Duration::from_secs(1)) + .unwrap(); + assert_eq!(restored["id"], "original"); + assert_eq!(restored["result"]["tabs"], json!([])); +} + +#[test] +fn chrome_request_and_client_response_restore_original_id() { + let (bridge, chrome_messages, _client_output, client_messages) = bridge(); + bridge.handle_chrome_message( + json!({"jsonrpc":"2.0","id":"chrome-original","method":"tabContext"}), + ); + let forwarded = client_messages + .recv_timeout(Duration::from_secs(1)) + .unwrap(); + assert_ne!(forwarded["id"], "chrome-original"); + handle_client_message( + &bridge.state, + &bridge._rollout_tracker, + 7, + json!({"jsonrpc":"2.0","id":forwarded["id"],"result":{"ok":true}}), + ); + let restored = chrome_messages + .recv_timeout(Duration::from_secs(1)) + .unwrap(); + assert_eq!(restored["id"], "chrome-original"); + assert_eq!(restored["result"]["ok"], true); +} + +#[test] +fn browser_connection_limit_releases_capacity_on_drop() { + let active = Arc::new(AtomicUsize::new(0)); + let permits = (0..MAX_CLIENT_CONNECTIONS) + .map(|_| ConnectionPermit::acquire(Arc::clone(&active)).unwrap()) + .collect::>(); + assert!(ConnectionPermit::acquire(Arc::clone(&active)).is_none()); + drop(permits); + assert!(ConnectionPermit::acquire(active).is_some()); +} + +#[test] +fn bounded_legacy_queues_deliver_bursts_without_silent_drops() { + let (bridge, chrome_messages, _client_output, client_messages) = bridge(); + let client_reader = std::thread::spawn(move || { + (0..96) + .map(|_| { + client_messages.recv().unwrap()["sequence"] + .as_u64() + .unwrap() + }) + .collect::>() + }); + for sequence in 0..96 { + bridge.handle_chrome_message(json!({"sequence":sequence})); + } + assert_eq!(client_reader.join().unwrap(), (0..96).collect::>()); + + let chrome_reader = std::thread::spawn(move || { + (0..96) + .map(|_| { + chrome_messages.recv().unwrap()["sequence"] + .as_u64() + .unwrap() + }) + .collect::>() + }); + for sequence in 0..96 { + handle_client_message( + &bridge.state, + &bridge._rollout_tracker, + 7, + json!({"method":"notice","sequence":sequence}), + ); + } + assert_eq!(chrome_reader.join().unwrap(), (0..96).collect::>()); +} diff --git a/native/chrome-extension-host/src/lib.rs b/native/chrome-extension-host/src/lib.rs new file mode 100644 index 0000000..1d715ba --- /dev/null +++ b/native/chrome-extension-host/src/lib.rs @@ -0,0 +1,24 @@ +//! Linux implementation of the Codex Chrome native messaging host. +//! +//! The legacy browser relay is derived from the MIT-licensed implementation +//! pinned in THIRD_PARTY_NOTICES.md. Protocol-v2 runtime support is native to +//! this crate. + +pub mod assets; +pub mod config; +pub mod framing; +pub mod host; +pub mod legacy; +pub mod open_file; +pub mod rollout; +pub mod rpc; +pub mod runtime; +pub mod uds; + +pub const HOST_NAME: &str = "com.openai.codexextension"; +pub const NATIVE_HOST_PROTOCOL_VERSION: u64 = 2; +pub const APP_SERVER_PROTOCOL_VERSION: u64 = 2; + +pub fn log(message: impl std::fmt::Display) { + eprintln!("[{HOST_NAME}] {message}"); +} diff --git a/native/chrome-extension-host/src/main.rs b/native/chrome-extension-host/src/main.rs new file mode 100644 index 0000000..2749ee0 --- /dev/null +++ b/native/chrome-extension-host/src/main.rs @@ -0,0 +1,81 @@ +use anyhow::{Context, Result, bail}; +use codex_chrome_extension_host::{ + assets::AssetStore, + config::HostConfigSource, + framing::read_frame, + host::ProtocolHost, + legacy::{LegacyBridge, spawn_chrome_writer}, + runtime::RuntimeManager, + uds::SocketGuard, +}; +use std::{env, io, sync::Arc}; + +fn main() { + if let Err(error) = run() { + codex_chrome_extension_host::log(format_args!("{error:#}")); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + let argument_id = extension_id_from_args(); + let config_source = Arc::new(HostConfigSource::for_current_exe()?); + let extension_id = config_source.extension_id(argument_id)?; + if !is_extension_id(&extension_id) { + bail!("configured extensionId is invalid: {extension_id}"); + } + let (listener, socket_guard) = SocketGuard::bind()?; + codex_chrome_extension_host::log(format_args!( + "browser relay listening on {}", + socket_guard.path().display() + )); + + let chrome_output = spawn_chrome_writer(io::stdout()); + let legacy = LegacyBridge::start(listener, chrome_output.clone(), Some(extension_id.clone())); + let runtime = RuntimeManager::new(config_source, extension_id); + let assets = AssetStore::from_environment()?; + let mut protocol = ProtocolHost::new(runtime, assets); + + let stdin = io::stdin(); + let mut reader = stdin.lock(); + while let Some(message) = + read_frame(&mut reader).context("extension-host native input failed")? + { + if ProtocolHost::handles(&message) { + chrome_output + .send(protocol.handle(&message)) + .context("native output writer stopped")?; + } else { + legacy.handle_chrome_message(message); + } + } + protocol.shutdown(); + drop(socket_guard); + Ok(()) +} + +fn extension_id_from_args() -> Option { + env::args().skip(1).find_map(|argument| { + argument + .strip_prefix("chrome-extension://") + .and_then(|origin| origin.split('/').next()) + .filter(|value| is_extension_id(value)) + .map(ToString::to_string) + }) +} + +fn is_extension_id(value: &str) -> bool { + value.len() == 32 && value.bytes().all(|byte| matches!(byte, b'a'..=b'p')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_chrome_extension_ids() { + assert!(is_extension_id("abcdefghijklmnopabcdefghijklmnop")); + assert!(!is_extension_id("hehggadaopoacecdllhhajmbjkdcmajz")); + assert!(!is_extension_id("short")); + } +} diff --git a/native/chrome-extension-host/src/open_file.rs b/native/chrome-extension-host/src/open_file.rs new file mode 100644 index 0000000..a9ca586 --- /dev/null +++ b/native/chrome-extension-host/src/open_file.rs @@ -0,0 +1,125 @@ +use anyhow::{Context, Result, bail}; +use std::{ + fs, + os::unix::{ffi::OsStrExt, fs::PermissionsExt}, + path::{Component, Path, PathBuf}, + process::{Command, Stdio}, +}; + +pub fn open_local_file(path: &Path) -> Result<()> { + let validated = validate_local_file(path)?; + let mut child = Command::new("xdg-open") + .arg(&validated) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .with_context(|| format!("failed to run xdg-open for {}", validated.display()))?; + std::thread::spawn(move || { + let _ = child.wait(); + }); + Ok(()) +} + +pub fn validate_local_file(path: &Path) -> Result { + if !path.is_absolute() { + bail!("local file path must be absolute"); + } + if path.as_os_str().as_bytes().contains(&0) { + bail!("local file path contains a NUL byte"); + } + if executable_like_extension(path) { + bail!("refusing to open an executable-like local file"); + } + reject_symlink_components(path)?; + let metadata = + fs::symlink_metadata(path).with_context(|| format!("failed to stat {}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!("local file must be a regular non-symlink file"); + } + if metadata.permissions().mode() & 0o111 != 0 { + bail!("refusing to open an executable local file"); + } + path.canonicalize() + .with_context(|| format!("failed to canonicalize {}", path.display())) +} + +fn executable_like_extension(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + matches!( + extension.to_ascii_lowercase().as_str(), + "command" | "desktop" | "jar" | "terminal" | "tool" + ) + }) +} + +fn reject_symlink_components(path: &Path) -> Result<()> { + let mut current = PathBuf::from("/"); + for component in path.components() { + match component { + Component::RootDir => continue, + Component::Normal(part) => current.push(part), + Component::CurDir | Component::ParentDir | Component::Prefix(_) => { + bail!("local file path is not normalized") + } + } + let metadata = fs::symlink_metadata(¤t) + .with_context(|| format!("failed to stat {}", current.display()))?; + if metadata.file_type().is_symlink() { + bail!("local file path contains a symlink: {}", current.display()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + env, + os::unix::fs::symlink, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn fixture() -> PathBuf { + let root = env::temp_dir().join(format!( + "codex-host-open-file-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&root).unwrap(); + root + } + + #[test] + fn accepts_plain_non_executable_regular_file() { + let root = fixture(); + let path = root.join("report.txt"); + fs::write(&path, "safe").unwrap(); + assert_eq!(validate_local_file(&path).unwrap(), path); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn rejects_relative_symlink_directory_and_executable() { + let root = fixture(); + let file = root.join("target.txt"); + fs::write(&file, "safe").unwrap(); + assert!(validate_local_file(Path::new("target.txt")).is_err()); + let link = root.join("link.txt"); + symlink(&file, &link).unwrap(); + assert!(validate_local_file(&link).is_err()); + assert!(validate_local_file(&root).is_err()); + fs::set_permissions(&file, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(validate_local_file(&file).is_err()); + let desktop = root.join("launch.Desktop"); + fs::write(&desktop, "[Desktop Entry]").unwrap(); + assert!(validate_local_file(&desktop).is_err()); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/native/chrome-extension-host/src/rollout.rs b/native/chrome-extension-host/src/rollout.rs new file mode 100644 index 0000000..dfcfeaf --- /dev/null +++ b/native/chrome-extension-host/src/rollout.rs @@ -0,0 +1,401 @@ +//! Bounded rollout watcher used to synthesize the legacy turnEnded event. + +use serde_json::{Value, json}; +use std::{ + collections::HashMap, + env, fs, + fs::File, + io::{self, BufRead, BufReader, Seek}, + path::{Path, PathBuf}, + sync::mpsc::{self, SyncSender}, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +const POLL_INTERVAL: Duration = Duration::from_millis(500); +const OBSERVED_TURN_TTL: Duration = Duration::from_secs(6 * 60 * 60); +const SEARCH_MAX_DEPTH: usize = 5; +const SEARCH_MAX_ENTRIES: usize = 10_000; +const MAX_OBSERVED_TURNS: usize = 1024; +const MAX_ROLLOUT_LINE_BYTES: usize = 1024 * 1024; +const INITIAL_TAIL_BYTES: u64 = 8 * 1024 * 1024; +const MAX_IDENTIFIER_BYTES: usize = 128; +const MAX_DISCOVERY_BACKOFF: Duration = Duration::from_secs(30); + +#[derive(Clone)] +pub struct RolloutTracker { + observations: SyncSender, +} + +#[derive(Debug)] +struct Observation { + session_id: String, + turn_id: String, +} + +struct ObservedTurn { + session_id: String, + turn_id: String, + path: Option, + offset: u64, + created_at: Instant, + next_discovery_at: Instant, + discovery_backoff: Duration, +} + +impl RolloutTracker { + pub fn start(chrome_output: SyncSender) -> Self { + Self::start_with_root(chrome_output, sessions_root()) + } + + pub fn start_with_root( + chrome_output: SyncSender, + sessions_root: Option, + ) -> Self { + let (sender, receiver) = mpsc::sync_channel(MAX_OBSERVED_TURNS); + thread::Builder::new() + .name("codex-rollout-tracker".to_string()) + .spawn(move || { + let mut observed = HashMap::new(); + loop { + match receiver.recv_timeout(POLL_INTERVAL) { + Ok(observation) => insert_observation(&mut observed, observation), + Err(mpsc::RecvTimeoutError::Disconnected) => break, + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + while let Ok(observation) = receiver.try_recv() { + insert_observation(&mut observed, observation); + } + process_observed(&mut observed, sessions_root.as_deref(), &chrome_output); + } + }) + .expect("failed to spawn rollout tracker"); + Self { + observations: sender, + } + } + + pub fn observe_request(&self, message: &Value) { + let Some((session_id, turn_id)) = session_turn_from_message(message) else { + return; + }; + if let Err(error) = self.observations.send(Observation { + session_id, + turn_id, + }) { + crate::log(format_args!( + "rollout observation channel disconnected: {error}" + )); + } + } +} + +fn insert_observation(observed: &mut HashMap, item: Observation) { + let key = observed_turn_key(&item.session_id, &item.turn_id); + if observed.contains_key(&key) { + return; + } + if observed.len() >= MAX_OBSERVED_TURNS { + crate::log("rollout observation limit reached; request was not tracked"); + return; + } + let now = Instant::now(); + observed.insert( + key, + ObservedTurn { + session_id: item.session_id, + turn_id: item.turn_id, + path: None, + offset: 0, + created_at: now, + next_discovery_at: now, + discovery_backoff: POLL_INTERVAL, + }, + ); +} + +fn process_observed( + observed: &mut HashMap, + root: Option<&Path>, + chrome_output: &SyncSender, +) { + let mut remove = Vec::new(); + for (key, turn) in observed.iter_mut() { + if turn.created_at.elapsed() >= OBSERVED_TURN_TTL { + remove.push(key.clone()); + continue; + } + let Some(root) = root else { + continue; + }; + if turn.path.is_none() { + let now = Instant::now(); + if now < turn.next_discovery_at { + continue; + } + if let Some(path) = find_rollout_path(root, &turn.session_id) { + turn.offset = fs::metadata(&path) + .map(|metadata| metadata.len().saturating_sub(INITIAL_TAIL_BYTES)) + .unwrap_or(0); + turn.path = Some(path); + } else { + turn.next_discovery_at = now + turn.discovery_backoff; + turn.discovery_backoff = turn + .discovery_backoff + .saturating_mul(2) + .min(MAX_DISCOVERY_BACKOFF); + } + } + let Some(path) = turn.path.as_deref() else { + continue; + }; + match drain_rollout_file(path, turn.offset, &turn.turn_id) { + Ok((offset, true)) => { + turn.offset = offset; + let message = json!({ + "jsonrpc": "2.0", + "id": format!("native-turn-ended:{}:{}", turn.session_id, turn.turn_id), + "method": "turnEnded", + "params": { + "session_id": turn.session_id, + "turn_id": turn.turn_id + } + }); + if chrome_output.send(message).is_err() { + crate::log("turnEnded could not be delivered: native stdout disconnected"); + } + remove.push(key.clone()); + } + Ok((offset, false)) => turn.offset = offset, + Err(error) => crate::log(format_args!( + "failed to read rollout {}: {error}", + path.display() + )), + } + } + for key in remove { + observed.remove(&key); + } +} + +fn sessions_root() -> Option { + if let Some(path) = env::var_os("CODEX_BROWSER_USE_SESSIONS_DIR") { + return Some(PathBuf::from(path)); + } + if let Some(path) = env::var_os("CODEX_HOME") { + return Some(PathBuf::from(path).join("sessions")); + } + env::var_os("HOME").map(|home| PathBuf::from(home).join(".codex/sessions")) +} + +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 turn_id = non_empty_string(params.get("turn_id")?)?; + Some((session_id.to_string(), turn_id.to_string())) +} + +fn non_empty_string(value: &Value) -> Option<&str> { + let string = value.as_str()?.trim(); + (valid_identifier(string)).then_some(string) +} + +fn valid_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_IDENTIFIER_BYTES + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +fn observed_turn_key(session_id: &str, turn_id: &str) -> String { + format!("{session_id}\n{turn_id}") +} + +fn find_rollout_path(root: &Path, session_id: &str) -> Option { + let mut stack = vec![(root.to_path_buf(), 0_usize)]; + let mut visited = 0; + let mut best: Option<(SystemTime, PathBuf)> = None; + while let Some((directory, depth)) = stack.pop() { + let Ok(entries) = fs::read_dir(&directory) else { + continue; + }; + for entry in entries.flatten() { + visited += 1; + if visited > SEARCH_MAX_ENTRIES { + return best.map(|(_, path)| path); + } + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + if depth < SEARCH_MAX_DEPTH { + stack.push((path, depth + 1)); + } + continue; + } + if !file_type.is_file() { + continue; + } + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + if !file_name.contains(session_id) + || !(file_name.ends_with(".jsonl") || file_name.ends_with(".json")) + { + continue; + } + let modified = entry + .metadata() + .and_then(|metadata| metadata.modified()) + .unwrap_or(UNIX_EPOCH); + if best + .as_ref() + .is_none_or(|(best_modified, _)| modified > *best_modified) + { + best = Some((modified, path)); + } + } + } + best.map(|(_, path)| path) +} + +fn drain_rollout_file(path: &Path, offset: u64, turn_id: &str) -> io::Result<(u64, bool)> { + let mut file = File::open(path)?; + let length = file.metadata()?.len(); + let start = if offset > length { 0 } else { offset }; + file.seek(io::SeekFrom::Start(start))?; + let mut reader = BufReader::new(file); + let mut complete = false; + loop { + let line_start = reader.stream_position()?; + let mut line = Vec::new(); + let (has_data, terminated) = read_bounded_until_newline(&mut reader, &mut line)?; + if !has_data { + break; + } + if !terminated { + return Ok((line_start, complete)); + } + if line.len() <= MAX_ROLLOUT_LINE_BYTES + && std::str::from_utf8(&line) + .ok() + .is_some_and(|line| line_marks_turn_complete(line, turn_id)) + { + complete = true; + } + } + Ok((reader.stream_position()?, complete)) +} + +fn read_bounded_until_newline( + reader: &mut impl BufRead, + output: &mut Vec, +) -> io::Result<(bool, bool)> { + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + return Ok((!output.is_empty(), false)); + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let take = newline.map_or(available.len(), |index| index + 1); + if output.len() < MAX_ROLLOUT_LINE_BYTES { + let remaining = MAX_ROLLOUT_LINE_BYTES - output.len(); + output.extend_from_slice(&available[..take.min(remaining)]); + } + reader.consume(take); + if newline.is_some() { + return Ok((true, true)); + } + } +} + +fn line_marks_turn_complete(line: &str, turn_id: &str) -> bool { + let Ok(value) = serde_json::from_str::(line) else { + return false; + }; + let payload = value.get("payload").unwrap_or(&value); + if payload.get("type").and_then(Value::as_str) == Some("task_complete") + && payload.get("turn_id").and_then(Value::as_str) == Some(turn_id) + { + return true; + } + value.get("type").and_then(Value::as_str) == Some("turn") + && matches!( + value.get("kind").and_then(Value::as_str), + Some("end" | "completed" | "complete") + ) + && value.get("turn_id").and_then(Value::as_str) == Some(turn_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn root(label: &str) -> PathBuf { + env::temp_dir().join(format!( + "{label}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } + + #[test] + fn finds_nested_rollout_and_detects_completion() { + let root = root("codex-rollout"); + let nested = root.join("2026/07/09"); + fs::create_dir_all(&nested).unwrap(); + let path = nested.join("rollout-session-1.jsonl"); + writeln!( + File::create(&path).unwrap(), + "{}", + json!({"type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1"}}) + ) + .unwrap(); + assert_eq!(find_rollout_path(&root, "session-1"), Some(path.clone())); + assert!(drain_rollout_file(&path, 0, "turn-1").unwrap().1); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn truncated_rollout_restarts_from_beginning() { + let root = root("codex-rollout-truncate"); + fs::create_dir_all(&root).unwrap(); + let path = root.join("rollout-session-1.jsonl"); + fs::write( + &path, + "{\"type\":\"turn\",\"kind\":\"complete\",\"turn_id\":\"turn-1\"}\n", + ) + .unwrap(); + assert!(drain_rollout_file(&path, 10_000, "turn-1").unwrap().1); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn extracts_only_non_empty_session_and_turn() { + let request = json!({"params":{"session_id":"session-1","turn_id":"turn-1"}}); + assert_eq!( + session_turn_from_message(&request), + Some(("session-1".to_string(), "turn-1".to_string())) + ); + assert!( + session_turn_from_message(&json!({"params":{"session_id":"","turn_id":"x"}})).is_none() + ); + assert!( + session_turn_from_message( + &json!({"params":{"session_id":"../escape","turn_id":"turn-1"}}) + ) + .is_none() + ); + assert!( + session_turn_from_message( + &json!({"params":{"session_id":"session-1","turn_id":"x".repeat(129)}}) + ) + .is_none() + ); + } +} diff --git a/native/chrome-extension-host/src/rpc.rs b/native/chrome-extension-host/src/rpc.rs new file mode 100644 index 0000000..d2c7fed --- /dev/null +++ b/native/chrome-extension-host/src/rpc.rs @@ -0,0 +1,70 @@ +use serde_json::{Value, json}; + +pub const INTERNAL_ERROR: i64 = -32603; +pub const INVALID_PARAMS: i64 = -32602; +pub const METHOD_NOT_FOUND: i64 = -32601; +pub const SERVER_ERROR: i64 = -32000; + +pub fn is_request(message: &Value) -> bool { + message.get("id").is_some() && message.get("method").and_then(Value::as_str).is_some() +} + +pub fn is_response(message: &Value) -> bool { + message.get("id").is_some() && message.get("method").is_none() +} + +pub fn id(message: &Value) -> Value { + message.get("id").cloned().unwrap_or(Value::Null) +} + +pub fn string_id(message: &Value) -> Option<&str> { + message.get("id").and_then(Value::as_str) +} + +pub fn replace_id(mut message: Value, id: Value) -> Value { + if let Value::Object(object) = &mut message { + object.insert("id".to_string(), id); + } + message +} + +pub fn result(id: Value, result: Value) -> Value { + json!({"jsonrpc": "2.0", "id": id, "result": result}) +} + +pub fn error(id: Value, code: i64, message: impl Into) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": {"code": code, "message": message.into()} + }) +} + +pub fn typed_error(id: Value, code: i64, message: impl Into, error_type: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message.into(), + "data": {"type": error_type, "errorType": error_type} + } + }) +} + +pub fn params(message: &Value) -> &Value { + message.get("params").unwrap_or(&Value::Null) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn replacement_preserves_request() { + assert_eq!( + replace_id(json!({"id": 1, "method": "getTabs"}), json!("routed")), + json!({"id": "routed", "method": "getTabs"}) + ); + } +} diff --git a/native/chrome-extension-host/src/runtime/broker.rs b/native/chrome-extension-host/src/runtime/broker.rs new file mode 100644 index 0000000..d4ba0e2 --- /dev/null +++ b/native/chrome-extension-host/src/runtime/broker.rs @@ -0,0 +1,479 @@ +use crate::rpc::{self, is_response, replace_id}; +use anyhow::{Context, Result, bail}; +use serde_json::{Value, json}; +use std::{ + collections::HashMap, + process, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + mpsc::{self, Receiver, SyncSender, TrySendError}, + }, + time::{Duration, Instant}, +}; + +const CLIENT_QUEUE_CAPACITY: usize = 64; +const MAX_CLIENTS: usize = 64; +const MAX_PENDING_REQUESTS: usize = 4096; +const PENDING_TTL: Duration = Duration::from_secs(2 * 60); +pub(super) const DEFAULT_CLIENT_ID: &str = "default"; + +pub(super) struct Broker { + state: Mutex, + child_input: SyncSender, + healthy: AtomicBool, +} + +struct State { + clients: HashMap, + pending: HashMap, + server_pending: HashMap, + initialize: InitializeState, + next_connection: u64, + next_request: u64, +} + +struct Client { + generation: u64, + sender: SyncSender, + alive: Arc, +} + +#[derive(Clone)] +struct PendingRoute { + client_id: String, + generation: u64, + original_id: Value, + created_at: Instant, +} + +#[derive(Default)] +struct InitializeState { + internal_id: Option, + response: Option, + waiters: Vec, +} + +pub(super) struct ClientConnection { + pub client_id: String, + pub generation: u64, + pub receiver: Receiver, + pub alive: Arc, +} + +enum ServerMessageRoute { + Initialize(Vec), + Response(PendingRoute), + UnknownResponse, +} + +impl Broker { + pub fn new(child_input: SyncSender) -> Self { + Self { + state: Mutex::new(State { + clients: HashMap::new(), + pending: HashMap::new(), + server_pending: HashMap::new(), + initialize: InitializeState::default(), + next_connection: 1, + next_request: 1, + }), + child_input, + healthy: AtomicBool::new(true), + } + } + + pub fn is_healthy(&self) -> bool { + self.healthy.load(Ordering::Acquire) + } + + pub fn mark_unhealthy(&self) { + self.healthy.store(false, Ordering::Release); + } + + pub fn register(&self, client_id: String) -> Result { + validate_client_id(&client_id)?; + let (sender, receiver) = mpsc::sync_channel(CLIENT_QUEUE_CAPACITY); + let alive = Arc::new(AtomicBool::new(true)); + let mut state = self + .state + .lock() + .map_err(|_| anyhow::anyhow!("app-server broker mutex poisoned"))?; + state.prune(); + if !state.clients.contains_key(&client_id) && state.clients.len() >= MAX_CLIENTS { + bail!("Too many connected app-server clients"); + } + let generation = state.next_connection; + state.next_connection = state.next_connection.wrapping_add(1).max(1); + if let Some(previous) = state.clients.insert( + client_id.clone(), + Client { + generation, + sender, + alive: Arc::clone(&alive), + }, + ) { + previous.alive.store(false, Ordering::Release); + state.remove_routes(&client_id, previous.generation); + } + Ok(ClientConnection { + client_id, + generation, + receiver, + alive, + }) + } + + pub fn unregister(&self, client_id: &str, generation: u64) { + let Ok(mut state) = self.state.lock() else { + return; + }; + if state + .clients + .get(client_id) + .is_some_and(|client| client.generation == generation) + { + if let Some(client) = state.clients.remove(client_id) { + client.alive.store(false, Ordering::Release); + } + state.remove_routes(client_id, generation); + } + } + + pub fn route_client_message( + &self, + client_id: &str, + generation: u64, + message: Value, + ) -> Result<()> { + self.ensure_current(client_id, generation)?; + if is_response(&message) { + return self.route_client_response(client_id, generation, message); + } + let method = message.get("method").and_then(Value::as_str); + if method == Some("initialize") { + return self.route_initialize(client_id, generation, message); + } + if method.is_none() || message.get("id").is_none() { + return self.send_child(message); + } + + let original_id = message["id"].clone(); + let internal_id = { + let mut state = self.state()?; + state.prune(); + state.ensure_current(client_id, generation)?; + if state.pending.len() >= MAX_PENDING_REQUESTS { + bail!("Too many pending app-server requests"); + } + let internal_id = state.next_id("client"); + state.pending.insert( + internal_id.clone(), + PendingRoute { + client_id: client_id.to_string(), + generation, + original_id, + created_at: Instant::now(), + }, + ); + internal_id + }; + let routed = replace_id(message, Value::String(internal_id.clone())); + if let Err(error) = self.send_child(routed) { + if let Ok(mut state) = self.state.lock() { + state.pending.remove(&internal_id); + } + return Err(error); + } + Ok(()) + } + + pub fn route_server_message(&self, message: Value) -> Result<()> { + if is_response(&message) { + return self.route_server_response(message); + } + if message.get("method").is_some() && message.get("id").is_some() { + return self.route_server_request(message); + } + self.broadcast(message) + } + + fn route_initialize(&self, client_id: &str, generation: u64, message: Value) -> Result<()> { + let original_id = message + .get("id") + .cloned() + .context("initialize request is missing id")?; + let route = PendingRoute { + client_id: client_id.to_string(), + generation, + original_id, + created_at: Instant::now(), + }; + let (replay, forward) = { + let mut state = self.state()?; + state.ensure_current(client_id, generation)?; + if let Some(response) = state.initialize.response.clone() { + (Some(replace_id(response, route.original_id.clone())), None) + } else { + state.initialize.waiters.retain(|waiter| { + waiter.client_id != client_id || waiter.generation != generation + }); + state.initialize.waiters.push(route.clone()); + if state.initialize.internal_id.is_some() { + (None, None) + } else { + let internal_id = state.next_id("initialize"); + state.initialize.internal_id = Some(internal_id.clone()); + (None, Some(replace_id(message, Value::String(internal_id)))) + } + } + }; + if let Some(response) = replay { + return self.send_to_client(&route, response); + } + if let Some(request) = forward + && let Err(error) = self.send_child(request) + { + self.fail_initialize(&error.to_string()); + return Err(error); + } + Ok(()) + } + + fn route_server_response(&self, message: Value) -> Result<()> { + let Some(internal_id) = message.get("id").and_then(Value::as_str) else { + bail!("app-server response is missing a string id"); + }; + let route = { + let mut state = self.state()?; + state.prune(); + if state.initialize.internal_id.as_deref() == Some(internal_id) { + state.initialize.response = Some(message.clone()); + ServerMessageRoute::Initialize(std::mem::take(&mut state.initialize.waiters)) + } else if let Some(route) = state.pending.remove(internal_id) { + ServerMessageRoute::Response(route) + } else { + ServerMessageRoute::UnknownResponse + } + }; + match route { + ServerMessageRoute::Initialize(waiters) => { + let mut failures = 0; + for waiter in waiters { + let response = replace_id(message.clone(), waiter.original_id.clone()); + if self.send_to_client(&waiter, response).is_err() { + failures += 1; + } + } + if failures > 0 { + bail!("initialize response failed for {failures} disconnected clients"); + } + Ok(()) + } + ServerMessageRoute::Response(route) => { + let response = replace_id(message, route.original_id.clone()); + self.send_to_client(&route, response) + } + ServerMessageRoute::UnknownResponse => { + bail!("app-server response has no pending route: {internal_id}") + } + } + } + + fn route_server_request(&self, message: Value) -> Result<()> { + let original_id = message["id"].clone(); + let (route, routed) = { + let mut state = self.state()?; + state.prune(); + let Some((client_id, client)) = state.clients.iter().next() else { + bail!("app-server request has no connected side panel"); + }; + let route = PendingRoute { + client_id: client_id.clone(), + generation: client.generation, + original_id, + created_at: Instant::now(), + }; + if state.server_pending.len() >= MAX_PENDING_REQUESTS { + bail!("Too many pending app-server client requests"); + } + let routed_id = state.next_id("server"); + state + .server_pending + .insert(routed_id.clone(), route.clone()); + (route, replace_id(message, Value::String(routed_id))) + }; + self.send_to_client(&route, routed) + } + + fn route_client_response( + &self, + client_id: &str, + generation: u64, + message: Value, + ) -> Result<()> { + let route = message.get("id").and_then(Value::as_str).and_then(|id| { + self.state.lock().ok().and_then(|mut state| { + let route = state.server_pending.get(id)?; + if route.client_id != client_id || route.generation != generation { + return None; + } + state.server_pending.remove(id) + }) + }); + let forwarded = route + .map(|route| replace_id(message.clone(), route.original_id)) + .unwrap_or(message); + self.send_child(forwarded) + } + + fn broadcast(&self, message: Value) -> Result<()> { + let routes = { + let state = self.state()?; + state + .clients + .iter() + .map(|(client_id, client)| PendingRoute { + client_id: client_id.clone(), + generation: client.generation, + original_id: Value::Null, + created_at: Instant::now(), + }) + .collect::>() + }; + if routes.is_empty() { + bail!("app-server notification has no connected side panels"); + } + let mut failures = 0; + for route in routes { + if self.send_to_client(&route, message.clone()).is_err() { + failures += 1; + } + } + if failures > 0 { + bail!("notification delivery overloaded {failures} clients"); + } + Ok(()) + } + + fn send_to_client(&self, route: &PendingRoute, message: Value) -> Result<()> { + let target = { + let state = self.state()?; + state + .clients + .get(&route.client_id) + .filter(|client| client.generation == route.generation) + .map(|client| (client.sender.clone(), Arc::clone(&client.alive))) + } + .context("target side panel disconnected")?; + match target.0.try_send(message) { + Ok(()) => Ok(()), + Err(TrySendError::Full(_)) => { + target.1.store(false, Ordering::Release); + self.unregister(&route.client_id, route.generation); + bail!("side panel output queue overloaded; client disconnected") + } + Err(TrySendError::Disconnected(_)) => { + target.1.store(false, Ordering::Release); + self.unregister(&route.client_id, route.generation); + bail!("side panel output channel disconnected") + } + } + } + + fn send_child(&self, message: Value) -> Result<()> { + match self.child_input.try_send(message) { + Ok(()) => Ok(()), + Err(TrySendError::Full(_)) => bail!("app-server input queue overloaded"), + Err(TrySendError::Disconnected(_)) => bail!("app-server input channel disconnected"), + } + } + + fn fail_initialize(&self, reason: &str) { + let waiters = { + let Ok(mut state) = self.state.lock() else { + return; + }; + state.initialize.internal_id = None; + std::mem::take(&mut state.initialize.waiters) + }; + for waiter in waiters { + let message = json!({ + "id": waiter.original_id, + "error": {"code": rpc::INTERNAL_ERROR, "message": reason} + }); + let _ = self.send_to_client(&waiter, message); + } + } + + fn ensure_current(&self, client_id: &str, generation: u64) -> Result<()> { + self.state()?.ensure_current(client_id, generation) + } + + fn state(&self) -> Result> { + self.state + .lock() + .map_err(|_| anyhow::anyhow!("app-server broker mutex poisoned")) + } + + #[cfg(test)] + pub fn client_count(&self) -> usize { + self.state.lock().unwrap().clients.len() + } +} + +impl State { + fn ensure_current(&self, client_id: &str, generation: u64) -> Result<()> { + if self + .clients + .get(client_id) + .is_some_and(|client| client.generation == generation) + { + Ok(()) + } else { + bail!("side panel connection was replaced") + } + } + + fn next_id(&mut self, direction: &str) -> String { + let sequence = self.next_request; + self.next_request = self.next_request.wrapping_add(1).max(1); + format!("native-proxy-{direction}-{}-{sequence}", process::id()) + } + + fn remove_routes(&mut self, client_id: &str, generation: u64) { + self.pending + .retain(|_, route| route.client_id != client_id || route.generation != generation); + self.server_pending + .retain(|_, route| route.client_id != client_id || route.generation != generation); + self.initialize + .waiters + .retain(|route| route.client_id != client_id || route.generation != generation); + } + + fn prune(&mut self) { + self.pending + .retain(|_, route| route.created_at.elapsed() < PENDING_TTL); + self.server_pending + .retain(|_, route| route.created_at.elapsed() < PENDING_TTL); + self.initialize + .waiters + .retain(|route| route.created_at.elapsed() < PENDING_TTL); + } +} + +pub(super) fn validate_client_id(client_id: &str) -> Result<()> { + if client_id.is_empty() + || client_id.len() > 128 + || !client_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + bail!("clientId contains unsupported characters"); + } + Ok(()) +} + +#[cfg(test)] +#[path = "broker_tests.rs"] +mod tests; diff --git a/native/chrome-extension-host/src/runtime/broker_tests.rs b/native/chrome-extension-host/src/runtime/broker_tests.rs new file mode 100644 index 0000000..b6ea4d7 --- /dev/null +++ b/native/chrome-extension-host/src/runtime/broker_tests.rs @@ -0,0 +1,141 @@ +use super::*; +use std::sync::mpsc; + +fn broker() -> (Broker, mpsc::Receiver) { + let (child_input, child_messages) = mpsc::sync_channel(256); + (Broker::new(child_input), child_messages) +} + +#[test] +fn colliding_client_ids_are_rewritten_and_restored() { + let (broker, child) = broker(); + let first = broker.register("window-1".to_string()).unwrap(); + let second = broker.register("window-2".to_string()).unwrap(); + for (connection, marker) in [(&first, "first"), (&second, "second")] { + broker + .route_client_message( + &connection.client_id, + connection.generation, + json!({"id":"collision","method":"echo","params":{"marker":marker}}), + ) + .unwrap(); + } + let first_forwarded = child.recv().unwrap(); + let second_forwarded = child.recv().unwrap(); + assert_ne!(first_forwarded["id"], second_forwarded["id"]); + broker + .route_server_message(json!({ + "id": first_forwarded["id"], + "result": {"marker": first_forwarded["params"]["marker"]} + })) + .unwrap(); + broker + .route_server_message(json!({ + "id": second_forwarded["id"], + "result": {"marker": second_forwarded["params"]["marker"]} + })) + .unwrap(); + assert_eq!(first.receiver.recv().unwrap()["id"], "collision"); + assert_eq!(second.receiver.recv().unwrap()["id"], "collision"); +} + +#[test] +fn notifications_fan_out_to_all_clients() { + let (broker, _child) = broker(); + let first = broker.register("window-1".to_string()).unwrap(); + let second = broker.register("window-2".to_string()).unwrap(); + let notification = json!({"method":"server/notice","params":{"sequence":1}}); + broker.route_server_message(notification.clone()).unwrap(); + assert_eq!(first.receiver.recv().unwrap(), notification); + assert_eq!(second.receiver.recv().unwrap(), notification); +} + +#[test] +fn initialize_inflight_waiters_and_cached_reconnect_each_receive_own_id() { + let (broker, child) = broker(); + let first = broker.register("window-1".to_string()).unwrap(); + broker + .route_client_message( + &first.client_id, + first.generation, + json!({"id":"init-first","method":"initialize"}), + ) + .unwrap(); + let initialize = child.recv().unwrap(); + + let second = broker.register("window-2".to_string()).unwrap(); + broker + .route_client_message( + &second.client_id, + second.generation, + json!({"id":"init-second","method":"initialize"}), + ) + .unwrap(); + assert!(child.try_recv().is_err()); + broker + .route_server_message(json!({"id":initialize["id"],"result":{"ready":true}})) + .unwrap(); + assert_eq!(first.receiver.recv().unwrap()["id"], "init-first"); + assert_eq!(second.receiver.recv().unwrap()["id"], "init-second"); + + let reconnect = broker.register("window-1".to_string()).unwrap(); + broker + .route_client_message( + &reconnect.client_id, + reconnect.generation, + json!({"id":"init-reconnect","method":"initialize"}), + ) + .unwrap(); + assert_eq!(reconnect.receiver.recv().unwrap()["id"], "init-reconnect"); + assert!(child.try_recv().is_err()); +} + +#[test] +fn burst_beyond_old_eight_message_queue_is_lossless() { + let (broker, child) = broker(); + let client = broker.register("window-1".to_string()).unwrap(); + for sequence in 0..32 { + broker + .route_client_message( + &client.client_id, + client.generation, + json!({"id":sequence,"method":"echo"}), + ) + .unwrap(); + } + for sequence in 0..32 { + let forwarded = child.recv().unwrap(); + broker + .route_server_message(json!({"id":forwarded["id"],"result":{"sequence":sequence}})) + .unwrap(); + } + for sequence in 0..32 { + let response = client.receiver.recv().unwrap(); + assert_eq!(response["id"], sequence); + assert_eq!(response["result"]["sequence"], sequence); + } +} + +#[test] +fn full_client_queue_disconnects_and_returns_an_error() { + let (broker, _child) = broker(); + let _client = broker.register("window-1".to_string()).unwrap(); + for sequence in 0..CLIENT_QUEUE_CAPACITY { + broker + .route_server_message(json!({"method":"notice","params":{"sequence":sequence}})) + .unwrap(); + } + let error = broker + .route_server_message(json!({"method":"notice","params":{"sequence":"overflow"}})) + .unwrap_err(); + assert!(error.to_string().contains("overloaded")); + assert_eq!(broker.client_count(), 0); +} +#[test] +fn broker_health_can_invalidate_an_alive_child_process() { + let (child, _receiver) = mpsc::sync_channel(1); + let broker = Broker::new(child); + assert!(broker.is_healthy()); + broker.mark_unhealthy(); + assert!(!broker.is_healthy()); +} diff --git a/native/chrome-extension-host/src/runtime/desktop_agent_mode.rs b/native/chrome-extension-host/src/runtime/desktop_agent_mode.rs new file mode 100644 index 0000000..24b0404 --- /dev/null +++ b/native/chrome-extension-host/src/runtime/desktop_agent_mode.rs @@ -0,0 +1,154 @@ +use serde_json::{Map, Value, json}; +use std::{fs::File, io::Read, path::Path}; + +const GLOBAL_STATE_FILE: &str = ".codex-global-state.json"; +const PERSISTED_ATOMS_KEY: &str = "electron-persisted-atom-state"; +const LEGACY_AGENT_MODES_KEY: &str = "agent-mode-by-host-id"; +const LEGACY_NON_FULL_ACCESS_MODES_KEY: &str = "preferred-non-full-access-agent-mode-by-host-id"; +const MAX_GLOBAL_STATE_BYTES: usize = 8 * 1024 * 1024; + +/// Mirror the Darwin host's bridge from desktop persisted atoms to the +/// canonical extension runtime shape. The extension validates individual mode +/// values; the host only carries the two persisted maps across process bounds. +pub(super) fn load(codex_home: Option<&Path>) -> Value { + let Some(codex_home) = codex_home else { + return defaults(); + }; + let Ok(file) = File::open(codex_home.join(GLOBAL_STATE_FILE)) else { + return defaults(); + }; + let mut bytes = Vec::new(); + if file + .take((MAX_GLOBAL_STATE_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .is_err() + || bytes.len() > MAX_GLOBAL_STATE_BYTES + { + return defaults(); + } + let Ok(root) = serde_json::from_slice::(&bytes) else { + return defaults(); + }; + let Some(atoms) = root.get(PERSISTED_ATOMS_KEY).and_then(Value::as_object) else { + return defaults(); + }; + + json!({ + "agentModesByHostId": object_value(atoms.get(LEGACY_AGENT_MODES_KEY)), + "preferredNonFullAccessModesByHostId": object_value( + atoms.get(LEGACY_NON_FULL_ACCESS_MODES_KEY) + ) + }) +} + +fn object_value(value: Option<&Value>) -> Value { + value + .and_then(Value::as_object) + .cloned() + .map(Value::Object) + .unwrap_or_else(|| Value::Object(Map::new())) +} + +fn defaults() -> Value { + json!({ + "agentModesByHostId": {}, + "preferredNonFullAccessModesByHostId": {} + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn fixture(label: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "codex-desktop-agent-mode-{label}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&root).unwrap(); + root + } + + #[test] + fn missing_and_malformed_state_use_canonical_empty_maps() { + let root = fixture("defaults"); + assert_eq!(load(Some(&root)), defaults()); + + fs::write(root.join(GLOBAL_STATE_FILE), b"not json").unwrap(); + assert_eq!(load(Some(&root)), defaults()); + + fs::write( + root.join(GLOBAL_STATE_FILE), + serde_json::to_vec(&json!({PERSISTED_ATOMS_KEY: []})).unwrap(), + ) + .unwrap(); + assert_eq!(load(Some(&root)), defaults()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn legacy_desktop_maps_are_emitted_under_canonical_keys() { + let root = fixture("legacy"); + fs::write( + root.join(GLOBAL_STATE_FILE), + serde_json::to_vec(&json!({ + PERSISTED_ATOMS_KEY: { + LEGACY_AGENT_MODES_KEY: { + "local": "full-access", + "remote": "read-only" + }, + LEGACY_NON_FULL_ACCESS_MODES_KEY: {"local": "auto"}, + "agentModesByHostId": {"ignored": "custom"} + } + })) + .unwrap(), + ) + .unwrap(); + + assert_eq!( + load(Some(&root)), + json!({ + "agentModesByHostId": { + "local": "full-access", + "remote": "read-only" + }, + "preferredNonFullAccessModesByHostId": {"local": "auto"} + }) + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn non_object_maps_and_oversized_state_use_empty_maps() { + let root = fixture("bounded"); + fs::write( + root.join(GLOBAL_STATE_FILE), + serde_json::to_vec(&json!({ + PERSISTED_ATOMS_KEY: { + LEGACY_AGENT_MODES_KEY: [], + LEGACY_NON_FULL_ACCESS_MODES_KEY: null + } + })) + .unwrap(), + ) + .unwrap(); + assert_eq!(load(Some(&root)), defaults()); + + fs::write( + root.join(GLOBAL_STATE_FILE), + vec![b' '; MAX_GLOBAL_STATE_BYTES + 1], + ) + .unwrap(); + assert_eq!(load(Some(&root)), defaults()); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/native/chrome-extension-host/src/runtime/http.rs b/native/chrome-extension-host/src/runtime/http.rs new file mode 100644 index 0000000..877a54b --- /dev/null +++ b/native/chrome-extension-host/src/runtime/http.rs @@ -0,0 +1,102 @@ +use std::{ + io::{self, Read, Write}, + net::TcpStream, + time::Duration, +}; + +const MAX_HTTP_HEADER_BYTES: usize = 64 * 1024; +const HEADER_DELIMITER: &[u8] = b"\r\n\r\n"; + +/// Limits only the HTTP upgrade headers. Once the delimiter is observed, +/// WebSocket frames pass through without counting against the header budget. +pub(super) struct HeaderLimitedStream { + inner: S, + header_bytes: usize, + delimiter_state: usize, + complete: bool, +} + +impl HeaderLimitedStream { + pub(super) fn new(inner: S) -> Self { + Self { + inner, + header_bytes: 0, + delimiter_state: 0, + complete: false, + } + } +} + +impl Read for HeaderLimitedStream { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + let count = self.inner.read(buffer)?; + if self.complete { + return Ok(count); + } + for byte in &buffer[..count] { + self.header_bytes += 1; + if self.header_bytes > MAX_HTTP_HEADER_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "WebSocket HTTP headers exceeded 64KiB", + )); + } + if *byte == HEADER_DELIMITER[self.delimiter_state] { + self.delimiter_state += 1; + if self.delimiter_state == HEADER_DELIMITER.len() { + self.complete = true; + break; + } + } else { + self.delimiter_state = usize::from(*byte == HEADER_DELIMITER[0]); + } + } + Ok(count) + } +} + +impl Write for HeaderLimitedStream { + fn write(&mut self, buffer: &[u8]) -> io::Result { + self.inner.write(buffer) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} + +impl HeaderLimitedStream { + pub(super) fn set_read_timeout(&self, timeout: Option) -> io::Result<()> { + self.inner.set_read_timeout(timeout) + } + + pub(super) fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { + self.inner.set_write_timeout(timeout) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allows_large_websocket_body_after_small_headers() { + let mut bytes = b"GET / HTTP/1.1\r\n\r\n".to_vec(); + bytes.extend(vec![b'x'; MAX_HTTP_HEADER_BYTES + 1]); + let mut stream = HeaderLimitedStream::new(io::Cursor::new(bytes.clone())); + let mut output = Vec::new(); + stream.read_to_end(&mut output).unwrap(); + assert_eq!(output, bytes); + } + + #[test] + fn rejects_headers_larger_than_64_kib() { + let bytes = vec![b'x'; MAX_HTTP_HEADER_BYTES + 1]; + let mut stream = HeaderLimitedStream::new(io::Cursor::new(bytes)); + let mut output = Vec::new(); + assert_eq!( + stream.read_to_end(&mut output).unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + } +} diff --git a/native/chrome-extension-host/src/runtime/mod.rs b/native/chrome-extension-host/src/runtime/mod.rs new file mode 100644 index 0000000..602438e --- /dev/null +++ b/native/chrome-extension-host/src/runtime/mod.rs @@ -0,0 +1,392 @@ +mod broker; +mod desktop_agent_mode; +mod http; +mod process; +mod proxy; + +use crate::{ + APP_SERVER_PROTOCOL_VERSION, NATIVE_HOST_PROTOCOL_VERSION, + config::{ConfigRequest, HostConfig, HostConfigSource}, +}; +use anyhow::{Context, Result, bail}; +use serde_json::{Value, json}; +use std::{ + env, + sync::{Arc, Mutex}, +}; + +pub struct RuntimeManager { + config_source: Arc, + extension_id: String, + session: Mutex>, +} + +struct ActiveRuntime { + config: HostConfig, + session: Arc, +} + +impl RuntimeManager { + pub fn new(config_source: Arc, extension_id: String) -> Self { + Self { + config_source, + extension_id, + session: Mutex::new(None), + } + } + + pub fn hello(&self) -> Value { + // This shape is the exact protocol-v2 contract shipped by the Darwin + // host. Asset methods are intentionally callable but not advertised. + json!({ + "manifestSchemaVersion": 2, + "nativeHostProtocolVersion": NATIVE_HOST_PROTOCOL_VERSION, + "nativeHostVersion": env!("CARGO_PKG_VERSION"), + "supportedProtocolVersions": [NATIVE_HOST_PROTOCOL_VERSION], + "supportedMethods": ["codexRuntime/openLocalFile"] + }) + } + + pub fn validate_request(&self, params: &Value) -> Result<()> { + self.config_request(params).map(|_| ()) + } + + fn config_request<'a>(&self, params: &'a Value) -> Result> { + let request = parse_constraints(params)?; + if request.native_host_name != crate::HOST_NAME { + bail!("version_mismatch: nativeHostName does not match this host"); + } + if request.extension_id != self.extension_id { + bail!("version_mismatch: extensionId does not match the allowed origin"); + } + Ok(request) + } + + pub fn ensure(&self, params: &Value, restart: bool) -> Result { + let request = self.config_request(params)?; + let config = self.config_source.resolve(request)?; + let client_id = params + .get("clientId") + .and_then(Value::as_str) + .unwrap_or(broker::DEFAULT_CLIENT_ID); + validate_client_id(client_id)?; + + let mut session_slot = self + .session + .lock() + .map_err(|_| anyhow::anyhow!("app-server process mutex poisoned"))?; + if restart && let Some(active) = session_slot.take() { + active.session.stop(); + } + if let Some(active) = session_slot.as_ref() { + if active.config == config && active.session.is_alive()? { + return self.runtime_result(&active.config, &active.session); + } + active.session.stop(); + *session_slot = None; + } + let session = proxy::RuntimeSession::start(&config, self.extension_id.clone())?; + let result = self.runtime_result(&config, &session)?; + *session_slot = Some(ActiveRuntime { config, session }); + Ok(result) + } + + pub fn shutdown(&self) { + let Ok(mut session) = self.session.lock() else { + return; + }; + if let Some(active) = session.take() { + active.session.stop(); + } + } + + fn runtime_result( + &self, + config: &HostConfig, + session: &proxy::RuntimeSession, + ) -> Result { + let browser_client_sha256 = config.browser_client_sha256()?; + let codex_home = config.codex_home.clone().or_else(|| { + env::var_os("CODEX_HOME") + .map(std::path::PathBuf::from) + .or_else(|| { + env::var_os("HOME") + .map(std::path::PathBuf::from) + .map(|home| home.join(".codex")) + }) + }); + let trusted_hashes = browser_client_sha256 + .as_ref() + .map(|hash| vec![hash.clone()]) + .unwrap_or_default(); + let desktop_agent_mode_defaults = desktop_agent_mode::load(codex_home.as_deref()); + Ok(json!({ + "entryId": config.entry_id.as_deref().unwrap_or("linux-bundled"), + "localAppServerUrl": session.url(), + "runtimeSessionId": session.id(), + "selected": { + "appServerProtocolVersion": APP_SERVER_PROTOCOL_VERSION, + "appVersion": config.app_version.clone().unwrap_or_else(|| env::var("CODEX_APP_VERSION").unwrap_or_else(|_| "linux".to_string())), + "channel": config.channel.as_deref().unwrap_or("prod"), + "cliVersion": config.cli_version.as_deref().unwrap_or("bundled"), + "nativeHostProtocolVersion": NATIVE_HOST_PROTOCOL_VERSION, + "nativeHostVersion": config.native_host_version.as_deref().unwrap_or(env!("CARGO_PKG_VERSION")) + }, + "runtimeConfig": { + "platform": "linux", + "codexCliPath": config.codex_cli_path, + "codexHome": codex_home, + "desktopAgentModeDefaults": desktop_agent_mode_defaults, + "nodePath": config.node_path, + "nodeReplPath": config.node_repl_path, + "nodeModuleDirs": config.node_module_dirs, + "browserClientPath": config.browser_client_path, + "trustedBrowserClientSha256s": trusted_hashes + } + })) + } +} + +impl Drop for RuntimeManager { + fn drop(&mut self) { + self.shutdown(); + } +} + +fn parse_constraints(params: &Value) -> Result> { + let constraints = params.get("constraints").context("missing constraints")?; + let required_host = constraints + .get("requiredNativeHostProtocolVersion") + .and_then(Value::as_u64) + .context("missing requiredNativeHostProtocolVersion")?; + let required_server = constraints + .get("requiredAppServerProtocolVersion") + .and_then(Value::as_u64) + .context("missing requiredAppServerProtocolVersion")?; + if required_host != NATIVE_HOST_PROTOCOL_VERSION + || required_server != APP_SERVER_PROTOCOL_VERSION + { + bail!( + "version_mismatch: extension requires native host {required_host} and app-server {required_server}" + ); + } + let required_string = |field| { + constraints + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .with_context(|| format!("missing {field}")) + }; + Ok(ConfigRequest { + extension_build_channel: required_string("extensionBuildChannel")?, + extension_id: required_string("extensionId")?, + native_host_name: required_string("nativeHostName")?, + }) +} + +fn validate_client_id(client_id: &str) -> Result<()> { + broker::validate_client_id(client_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + thread, + time::Duration, + time::{SystemTime, UNIX_EPOCH}, + }; + + const TEST_EXTENSION_ID: &str = "hehggadaopoacecdllhhajmbjkdcmajg"; + + fn constraints(host: u64, server: u64) -> Value { + json!({ + "constraints": { + "extensionBuildChannel": "prod", + "extensionId": TEST_EXTENSION_ID, + "nativeHostName": crate::HOST_NAME, + "requiredNativeHostProtocolVersion": host, + "requiredAppServerProtocolVersion": server + } + }) + } + + fn full_params(client_id: &str) -> Value { + json!({ + "clientId": client_id, + "constraints": { + "extensionBuildChannel": "prod", + "extensionId": TEST_EXTENSION_ID, + "nativeHostName": crate::HOST_NAME, + "requiredAppServerProtocolVersion": 2, + "requiredNativeHostProtocolVersion": 2 + } + }) + } + + fn runtime_fixture() -> (PathBuf, Arc, PathBuf) { + let root = std::env::temp_dir().join(format!( + "codex-runtime-manager-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&root).unwrap(); + let process_count = root.join("process-count"); + let cli = root.join("codex"); + fs::write( + &cli, + format!( + "#!/bin/sh\nprintf 'p\\n' >> \"{}\"\nwhile IFS= read -r line; do :; done\n", + process_count.display() + ), + ) + .unwrap(); + fs::set_permissions(&cli, fs::Permissions::from_mode(0o700)).unwrap(); + let config = Arc::new(HostConfig { + schema_version: 1, + app_version: None, + browser_client_path: Some(cli.clone()), + channel: Some("prod".to_string()), + codex_cli_path: cli.clone(), + codex_home: Some(root.clone()), + cli_version: None, + entry_id: None, + extension_id: Some(TEST_EXTENSION_ID.to_string()), + native_host_version: None, + node_module_dirs: Vec::new(), + node_path: cli.clone(), + node_repl_path: Some(cli), + proxy_host: "127.0.0.1".to_string(), + proxy_port: 0, + resources_path: None, + }); + ( + root, + Arc::new(HostConfigSource::fixed(Arc::unwrap_or_clone(config))), + process_count, + ) + } + + fn wait_for_process_count(path: &std::path::Path, expected_lines: usize) -> String { + for _ in 0..100 { + if let Ok(contents) = fs::read_to_string(path) + && contents.lines().count() >= expected_lines + { + return contents; + } + thread::sleep(Duration::from_millis(10)); + } + fs::read_to_string(path).unwrap() + } + + fn write_managed_registry(path: &Path, root: &Path, cli: &Path, entry_id: &str) { + let registry = json!({ + "schemaVersion": 2, + "entries": [{ + "schemaVersion": 2, + "appServerProtocolVersion": 2, + "appVersion": "26.707.31123", + "channel": "prod", + "cliVersion": "0.140.0", + "entryId": entry_id, + "extensionBuildChannels": ["prod"], + "extensionIds": [TEST_EXTENSION_ID], + "nativeHostNames": [crate::HOST_NAME], + "nativeHostProtocolVersion": 2, + "nativeHostVersion": "26.707.31123", + "paths": { + "browserClientPath": cli, + "codexCliPath": cli, + "codexHome": root, + "extensionHostPath": cli, + "nodePath": cli, + "nodeModuleDirs": [root], + "nodeReplPath": cli, + "resourcesPath": root + }, + "proxyHost": "127.0.0.1", + "proxyPort": 0, + "updatedAt": "2026-07-09T21:42:12.025Z" + }] + }); + fs::write(path, serde_json::to_vec(®istry).unwrap()).unwrap(); + } + + #[test] + fn accepts_only_protocol_v2_constraints() { + parse_constraints(&constraints(2, 2)).unwrap(); + assert!(parse_constraints(&constraints(1, 2)).is_err()); + assert!(parse_constraints(&constraints(2, 3)).is_err()); + } + + #[test] + fn client_ids_are_bounded_and_path_safe() { + validate_client_id("sidepanel-window-42").unwrap(); + assert!(validate_client_id("../escape").is_err()); + assert!(validate_client_id(&"x".repeat(129)).is_err()); + } + + #[test] + fn ensure_reuses_one_runtime_for_all_clients_and_restart_replaces_it() { + let (root, config, process_count) = runtime_fixture(); + let manager = RuntimeManager::new(config, TEST_EXTENSION_ID.to_string()); + let first = manager.ensure(&full_params("window-1"), false).unwrap(); + let second = manager.ensure(&full_params("window-2"), false).unwrap(); + assert_eq!(first["localAppServerUrl"], second["localAppServerUrl"]); + assert_eq!(first["runtimeSessionId"], second["runtimeSessionId"]); + assert_eq!( + first["runtimeConfig"]["desktopAgentModeDefaults"], + json!({ + "agentModesByHostId": {}, + "preferredNonFullAccessModesByHostId": {} + }) + ); + assert!(first["runtimeConfig"].get("browserClientSha256").is_none()); + assert_eq!(wait_for_process_count(&process_count, 1), "p\n"); + + let restarted = manager.ensure(&full_params("window-2"), true).unwrap(); + assert_ne!(first["runtimeSessionId"], restarted["runtimeSessionId"]); + assert_eq!(wait_for_process_count(&process_count, 2), "p\np\n"); + manager.shutdown(); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn ensure_observes_registry_updates_and_replaces_the_runtime() { + let (root, _fixed_source, process_count) = runtime_fixture(); + let cli = root.join("codex"); + let registry = root.join("chrome-native-hosts-v2.json"); + write_managed_registry(®istry, &root, &cli, "entry-a"); + let source = Arc::new(HostConfigSource::from_paths( + cli.clone(), + root.join("missing-adjacent.json"), + vec![registry.clone()], + )); + let manager = RuntimeManager::new(source, TEST_EXTENSION_ID.to_string()); + + let first = manager.ensure(&full_params("window-1"), false).unwrap(); + let unchanged = manager.ensure(&full_params("window-2"), false).unwrap(); + assert_eq!(first["runtimeSessionId"], unchanged["runtimeSessionId"]); + assert_eq!(first["entryId"], "entry-a"); + assert_eq!( + first["runtimeConfig"]["codexHome"], + root.display().to_string() + ); + assert_eq!(wait_for_process_count(&process_count, 1), "p\n"); + + write_managed_registry(®istry, &root, &cli, "entry-b"); + let updated = manager.ensure(&full_params("window-2"), false).unwrap(); + assert_ne!(first["runtimeSessionId"], updated["runtimeSessionId"]); + assert_eq!(updated["entryId"], "entry-b"); + assert_eq!(wait_for_process_count(&process_count, 2), "p\np\n"); + + manager.shutdown(); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/native/chrome-extension-host/src/runtime/process.rs b/native/chrome-extension-host/src/runtime/process.rs new file mode 100644 index 0000000..b62d8ca --- /dev/null +++ b/native/chrome-extension-host/src/runtime/process.rs @@ -0,0 +1,144 @@ +use super::broker::Broker; +use serde_json::Value; +use std::{ + io::{self, BufRead, BufReader, Read, Write}, + process::ChildStdin, + sync::{ + Arc, + mpsc::{Receiver, SyncSender}, + }, + thread, +}; + +pub(super) const CHILD_INPUT_CAPACITY: usize = 256; +const MAX_APP_SERVER_MESSAGE_BYTES: usize = 64 * 1024 * 1024; + +pub(super) fn child_channel() -> (SyncSender, Receiver) { + std::sync::mpsc::sync_channel(CHILD_INPUT_CAPACITY) +} + +pub(super) fn spawn_child_writer(mut stdin: ChildStdin, receiver: Receiver) { + thread::Builder::new() + .name("codex-app-server-stdin".to_string()) + .spawn(move || { + for message in receiver { + let bytes = match serde_json::to_vec(&message) { + Ok(bytes) if bytes.len() <= MAX_APP_SERVER_MESSAGE_BYTES => bytes, + Ok(bytes) => { + crate::log(format_args!( + "app-server input is {} bytes; limit is {MAX_APP_SERVER_MESSAGE_BYTES}", + bytes.len() + )); + break; + } + Err(error) => { + crate::log(format_args!( + "failed to serialize app-server input: {error}" + )); + break; + } + }; + if let Err(error) = stdin + .write_all(&bytes) + .and_then(|()| stdin.write_all(b"\n")) + .and_then(|()| stdin.flush()) + { + crate::log(format_args!("app-server stdin failed: {error}")); + break; + } + } + }) + .expect("failed to spawn app-server stdin writer"); +} + +pub(super) fn spawn_stdout_reader(stdout: impl Read + Send + 'static, broker: Arc) { + thread::Builder::new() + .name("codex-app-server-stdout".to_string()) + .spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + match read_bounded_line(&mut reader, MAX_APP_SERVER_MESSAGE_BYTES) { + Ok(Some(line)) => match serde_json::from_str::(&line) { + Ok(message) => { + if let Err(error) = broker.route_server_message(message) { + crate::log(format_args!( + "app-server output routing failed: {error}" + )); + } + } + Err(error) => { + crate::log(format_args!("app-server emitted invalid JSON: {error}")); + broker.mark_unhealthy(); + break; + } + }, + Ok(None) => { + broker.mark_unhealthy(); + break; + } + Err(error) => { + crate::log(format_args!("app-server stdout failed: {error}")); + broker.mark_unhealthy(); + break; + } + } + } + }) + .expect("failed to spawn app-server stdout reader"); +} + +fn read_bounded_line(reader: &mut impl BufRead, limit: usize) -> io::Result> { + let mut bytes = Vec::new(); + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + if bytes.is_empty() { + return Ok(None); + } + break; + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let take = newline.map_or(available.len(), |index| index + 1); + if bytes.len().saturating_add(take) > limit { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "app-server message exceeded WebSocket limit", + )); + } + bytes.extend_from_slice(&available[..take]); + reader.consume(take); + if newline.is_some() { + break; + } + } + while matches!(bytes.last(), Some(b'\n' | b'\r')) { + bytes.pop(); + } + String::from_utf8(bytes) + .map(Some) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bounded_line_reader_handles_newline_eof_and_limit() { + let mut reader = BufReader::new(io::Cursor::new(b"{\"id\":1}\nsecond".to_vec())); + assert_eq!( + read_bounded_line(&mut reader, 32).unwrap().as_deref(), + Some("{\"id\":1}") + ); + assert_eq!( + read_bounded_line(&mut reader, 32).unwrap().as_deref(), + Some("second") + ); + assert!(read_bounded_line(&mut reader, 32).unwrap().is_none()); + let mut oversized = BufReader::new(io::Cursor::new(vec![b'x'; 33])); + assert_eq!( + read_bounded_line(&mut oversized, 32).unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + } +} diff --git a/native/chrome-extension-host/src/runtime/proxy.rs b/native/chrome-extension-host/src/runtime/proxy.rs new file mode 100644 index 0000000..b7e9e18 --- /dev/null +++ b/native/chrome-extension-host/src/runtime/proxy.rs @@ -0,0 +1,387 @@ +use super::{ + broker::{Broker, ClientConnection, DEFAULT_CLIENT_ID, validate_client_id}, + http::HeaderLimitedStream, + process::{child_channel, spawn_child_writer, spawn_stdout_reader}, +}; +use crate::config::HostConfig; +use anyhow::{Context, Result, bail}; +use serde_json::{Value, json}; +use std::{ + fs::File, + io::{self, Read}, + net::{TcpListener, TcpStream}, + process::{Child, Command, Stdio}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicUsize, Ordering}, + mpsc, + }, + thread, + time::Duration, +}; +use tungstenite::{ + Error as WebSocketError, Message, + handshake::server::{Callback, ErrorResponse, Request, Response}, + http::StatusCode, + protocol::WebSocketConfig, +}; + +const MAX_WS_MESSAGE_BYTES: usize = 64 * 1024 * 1024; +const MAX_HANDSHAKES: usize = 16; +const SOCKET_TIMEOUT: Duration = Duration::from_secs(5); + +pub struct RuntimeSession { + id: String, + url: String, + child: Mutex, + broker: Arc, + stop: Arc, +} + +impl RuntimeSession { + pub fn start(config: &HostConfig, extension_id: String) -> Result> { + let listener = TcpListener::bind((config.proxy_host.as_str(), config.proxy_port)) + .with_context(|| { + format!( + "failed to bind app-server proxy at {}:{}", + config.proxy_host, config.proxy_port + ) + })?; + listener + .set_nonblocking(true) + .context("failed to make app-server proxy nonblocking")?; + let address = listener + .local_addr() + .context("failed to read proxy address")?; + if !address.ip().is_loopback() { + bail!("app-server proxy did not bind to loopback: {address}"); + } + + let mut command = Command::new(&config.codex_cli_path); + command + .arg("app-server") + .arg("--analytics-default-enabled") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .env("CODEX_CLI_PATH", &config.codex_cli_path) + .env("CODEX_EXTENSION_ID", &extension_id) + .env("CODEX_BROWSER_USE_NODE_PATH", &config.node_path) + .env("CODEX_APP_SERVER_PROXY_HOST", address.ip().to_string()) + .env("CODEX_APP_SERVER_PROXY_PORT", address.port().to_string()); + if let Some(path) = &config.browser_client_path { + command.env("CODEX_BROWSER_CLIENT_PATH", path); + } + if let Some(path) = &config.codex_home { + command.env("CODEX_HOME", path); + } + if let Some(path) = &config.node_repl_path { + command.env("CODEX_NODE_REPL_PATH", path); + } + let mut child = command.spawn().with_context(|| { + format!( + "failed to spawn {} app-server", + config.codex_cli_path.display() + ) + })?; + let child_stdin = child + .stdin + .take() + .context("Codex app-server stdin is unavailable")?; + let child_stdout = child + .stdout + .take() + .context("Codex app-server stdout is unavailable")?; + let (child_sender, child_receiver) = child_channel(); + let broker = Arc::new(Broker::new(child_sender)); + spawn_child_writer(child_stdin, child_receiver); + spawn_stdout_reader(child_stdout, Arc::clone(&broker)); + + let token = random_hex(32)?; + let session_id = random_hex(16)?; + let host = match address.ip() { + std::net::IpAddr::V6(ip) => format!("[{ip}]"), + std::net::IpAddr::V4(ip) => ip.to_string(), + }; + let session = Arc::new(Self { + id: session_id, + url: format!("ws://{host}:{}/?token={token}", address.port()), + child: Mutex::new(child), + broker, + stop: Arc::new(AtomicBool::new(false)), + }); + spawn_proxy_listener(listener, Arc::clone(&session), token, extension_id)?; + Ok(session) + } + + pub fn url(&self) -> &str { + &self.url + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn is_alive(&self) -> Result { + if !self.broker.is_healthy() { + return Ok(false); + } + let mut child = self + .child + .lock() + .map_err(|_| anyhow::anyhow!("app-server child mutex poisoned"))?; + Ok(child.try_wait()?.is_none()) + } + + pub fn stop(&self) { + self.stop.store(true, Ordering::Release); + let Ok(mut child) = self.child.lock() else { + return; + }; + if child.try_wait().ok().flatten().is_none() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +impl Drop for RuntimeSession { + fn drop(&mut self) { + self.stop(); + } +} + +fn spawn_proxy_listener( + listener: TcpListener, + session: Arc, + token: String, + extension_id: String, +) -> Result<()> { + thread::Builder::new() + .name("codex-app-server-proxy".to_string()) + .spawn(move || { + let handshakes = Arc::new(AtomicUsize::new(0)); + while !session.stop.load(Ordering::Acquire) { + match listener.accept() { + Ok((stream, _)) => { + if handshakes.fetch_add(1, Ordering::AcqRel) >= MAX_HANDSHAKES { + handshakes.fetch_sub(1, Ordering::AcqRel); + crate::log("app-server proxy rejected excess handshake"); + continue; + } + let session = Arc::clone(&session); + let handshakes = Arc::clone(&handshakes); + let token = token.clone(); + let extension_id = extension_id.clone(); + thread::spawn(move || { + if let Err(error) = + serve_connection(stream, &session, &token, &extension_id) + { + crate::log(format_args!("app-server proxy connection: {error}")); + } + handshakes.fetch_sub(1, Ordering::AcqRel); + }); + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(20)); + } + Err(error) => { + crate::log(format_args!("app-server proxy accept error: {error}")); + thread::sleep(Duration::from_millis(100)); + } + } + } + }) + .context("failed to spawn app-server proxy listener")?; + Ok(()) +} + +fn serve_connection( + stream: TcpStream, + session: &RuntimeSession, + token: &str, + extension_id: &str, +) -> Result<()> { + stream + .set_read_timeout(Some(SOCKET_TIMEOUT)) + .context("failed to set WebSocket handshake read timeout")?; + stream + .set_write_timeout(Some(SOCKET_TIMEOUT)) + .context("failed to set WebSocket handshake write timeout")?; + let expected_origin = format!("chrome-extension://{extension_id}"); + let selected_client = Arc::new(Mutex::new(None)); + let config = WebSocketConfig::default() + .read_buffer_size(16 * 1024) + .write_buffer_size(0) + .max_write_buffer_size(MAX_WS_MESSAGE_BYTES * 2) + .max_message_size(Some(MAX_WS_MESSAGE_BYTES)) + .max_frame_size(Some(MAX_WS_MESSAGE_BYTES)) + .accept_unmasked_frames(false); + let mut websocket = tungstenite::accept_hdr_with_config( + HeaderLimitedStream::new(stream), + UpgradeAuthorizer { + token, + expected_origin: &expected_origin, + selected_client: Arc::clone(&selected_client), + }, + Some(config), + ) + .map_err(|error| anyhow::anyhow!("WebSocket handshake failed: {error}"))?; + websocket + .get_mut() + .set_read_timeout(Some(Duration::from_millis(50)))?; + websocket + .get_mut() + .set_write_timeout(Some(SOCKET_TIMEOUT))?; + let client_id = selected_client + .lock() + .map_err(|_| anyhow::anyhow!("selected client mutex poisoned"))? + .take() + .unwrap_or_else(|| DEFAULT_CLIENT_ID.to_string()); + let connection = session.broker.register(client_id)?; + let result = serve_registered_connection(&mut websocket, session, &connection); + session + .broker + .unregister(&connection.client_id, connection.generation); + result +} + +fn serve_registered_connection( + websocket: &mut tungstenite::WebSocket>, + session: &RuntimeSession, + connection: &ClientConnection, +) -> Result<()> { + loop { + if !connection.alive.load(Ordering::Acquire) { + bail!("side panel connection was replaced or overloaded"); + } + for _ in 0..64 { + match connection.receiver.try_recv() { + Ok(message) => { + websocket.send(Message::text(serde_json::to_string(&message)?))?; + } + Err(mpsc::TryRecvError::Empty) => break, + Err(mpsc::TryRecvError::Disconnected) => return Ok(()), + } + } + match websocket.read() { + Ok(Message::Text(text)) => { + let message = serde_json::from_str::(text.as_str()) + .context("side panel sent invalid app-server JSON")?; + if let Err(error) = session.broker.route_client_message( + &connection.client_id, + connection.generation, + message.clone(), + ) { + let response = json!({ + "id": message.get("id").cloned().unwrap_or(Value::Null), + "error": {"code": -32603, "message": error.to_string()} + }); + websocket.send(Message::text(serde_json::to_string(&response)?))?; + return Err(error); + } + } + Ok(Message::Close(_)) => return Ok(()), + Ok(Message::Binary(_)) => bail!("binary app-server messages are not supported"), + Ok(_) => websocket.flush()?, + Err(WebSocketError::Io(error)) + if matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) => {} + Err(WebSocketError::ConnectionClosed | WebSocketError::AlreadyClosed) => return Ok(()), + Err(error) => return Err(error.into()), + } + if session.stop.load(Ordering::Acquire) { + return Ok(()); + } + } +} + +struct UpgradeAuthorizer<'a> { + token: &'a str, + expected_origin: &'a str, + selected_client: Arc>>, +} + +impl Callback for UpgradeAuthorizer<'_> { + fn on_request( + self, + request: &Request, + response: Response, + ) -> std::result::Result { + if !authorize_upgrade(request, self.token, self.expected_origin) { + return Err(forbidden()); + } + let client_id = requested_client_id(request).map_err(|_| forbidden())?; + *self.selected_client.lock().map_err(|_| forbidden())? = Some(client_id); + Ok(response) + } +} + +fn requested_client_id(request: &Request) -> Result { + let mut client_id = None; + if let Some(query) = request.uri().query() { + for pair in query.split('&') { + let Some((key, value)) = pair.split_once('=') else { + continue; + }; + if key != "clientId" { + continue; + } + if client_id.replace(value.to_string()).is_some() { + bail!("clientId query parameter is duplicated"); + } + } + } + let client_id = client_id.unwrap_or_else(|| DEFAULT_CLIENT_ID.to_string()); + validate_client_id(&client_id)?; + Ok(client_id) +} + +fn authorize_upgrade(request: &Request, token: &str, expected_origin: &str) -> bool { + let origin_ok = request + .headers() + .get("origin") + .and_then(|value| value.to_str().ok()) + .is_some_and(|origin| origin == expected_origin || origin == format!("{expected_origin}/")); + let supplied_token = request.uri().query().and_then(|query| { + query.split('&').find_map(|pair| { + let (key, value) = pair.split_once('=')?; + (key == "token").then_some(value) + }) + }); + origin_ok && supplied_token.is_some_and(|supplied| constant_time_eq(supplied, token)) +} + +fn constant_time_eq(left: &str, right: &str) -> bool { + if left.len() != right.len() { + return false; + } + left.bytes() + .zip(right.bytes()) + .fold(0_u8, |difference, (left, right)| { + difference | (left ^ right) + }) + == 0 +} + +fn forbidden() -> ErrorResponse { + tungstenite::http::Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Some("Forbidden".to_string())) + .expect("valid static forbidden response") +} + +fn random_hex(bytes: usize) -> Result { + let mut random = vec![0_u8; bytes]; + File::open("/dev/urandom") + .context("failed to open /dev/urandom")? + .read_exact(&mut random) + .context("failed to read /dev/urandom")?; + Ok(random.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +#[cfg(test)] +#[path = "proxy_tests.rs"] +mod tests; diff --git a/native/chrome-extension-host/src/runtime/proxy_tests.rs b/native/chrome-extension-host/src/runtime/proxy_tests.rs new file mode 100644 index 0000000..053e954 --- /dev/null +++ b/native/chrome-extension-host/src/runtime/proxy_tests.rs @@ -0,0 +1,220 @@ +use super::*; +use crate::config::HostConfig; +use serde_json::json; +use std::{ + fs, + os::unix::fs::PermissionsExt, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; +use tungstenite::{ + client::IntoClientRequest, + http::{HeaderValue, Request as HttpRequest, header::ORIGIN}, +}; + +const EXTENSION_ID: &str = "hehggadaopoacecdllhhajmbjkdcmajg"; + +fn request(uri: &str, origin: &str) -> HttpRequest<()> { + HttpRequest::builder() + .uri(uri) + .header("origin", origin) + .body(()) + .unwrap() +} + +fn fixture() -> (PathBuf, HostConfig, PathBuf, PathBuf) { + let root = std::env::temp_dir().join(format!( + "codex-host-proxy-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&root).unwrap(); + let initialize_count = root.join("initialize-count"); + let process_count = root.join("process-count"); + let cli = root.join("codex"); + let script = format!( + "#!/bin/sh\n\ + [ \"$1\" = app-server ] || exit 42\n\ + [ \"$2\" = --analytics-default-enabled ] || exit 43\n\ + [ \"$CODEX_CLI_PATH\" = \"$0\" ] || exit 44\n\ + [ \"$CODEX_EXTENSION_ID\" = \"{EXTENSION_ID}\" ] || exit 45\n\ + [ -n \"$CODEX_APP_SERVER_PROXY_HOST\" ] || exit 46\n\ + [ -n \"$CODEX_APP_SERVER_PROXY_PORT\" ] || exit 47\n\ + printf 'p\\n' >> \"{}\"\n\ + while IFS= read -r line; do\n\ + id=$(printf '%s\\n' \"$line\" | sed -n 's/.*\"id\":\"\\([^\"]*\\)\".*/\\1/p')\n\ + case \"$line\" in\n\ + *'\"method\":\"initialize\"'*)\n\ + if [ -e \"{}\" ]; then\n\ + printf '{{\"id\":\"%s\",\"error\":{{\"message\":\"duplicate initialize\"}}}}\\n' \"$id\"\n\ + else\n\ + printf 'x\\n' >> \"{}\"\n\ + sleep 0.1\n\ + printf '{{\"id\":\"%s\",\"result\":{{\"server\":\"initialized\"}}}}\\n' \"$id\"\n\ + fi\n\ + ;;\n\ + *'\"method\":\"emitNotification\"'*)\n\ + printf '{{\"id\":\"%s\",\"result\":{{\"emitted\":true}}}}\\n' \"$id\"\n\ + printf '%s\\n' '{{\"method\":\"server/notice\",\"params\":{{\"sequence\":1}}}}'\n\ + ;;\n\ + *)\n\ + marker=$(printf '%s\\n' \"$line\" | sed -n 's/.*\"marker\":\"\\([^\"]*\\)\".*/\\1/p')\n\ + printf '{{\"id\":\"%s\",\"result\":{{\"marker\":\"%s\"}}}}\\n' \"$id\" \"$marker\"\n\ + ;;\n\ + esac\n\ + done\n", + process_count.display(), + initialize_count.display(), + initialize_count.display() + ); + fs::write(&cli, script).unwrap(); + fs::set_permissions(&cli, fs::Permissions::from_mode(0o700)).unwrap(); + let config = HostConfig { + schema_version: 1, + app_version: None, + browser_client_path: Some(cli.clone()), + channel: Some("prod".to_string()), + codex_cli_path: cli.clone(), + codex_home: None, + cli_version: None, + entry_id: None, + extension_id: Some(EXTENSION_ID.to_string()), + native_host_version: None, + node_module_dirs: Vec::new(), + node_path: cli.clone(), + node_repl_path: Some(cli), + proxy_host: "127.0.0.1".to_string(), + proxy_port: 0, + resources_path: None, + }; + (root, config, initialize_count, process_count) +} + +fn connect(url: &str, origin: &str) -> tungstenite::WebSocket { + let address = url + .strip_prefix("ws://") + .unwrap() + .split('/') + .next() + .unwrap(); + let stream = TcpStream::connect(address).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut request = url.into_client_request().unwrap(); + request + .headers_mut() + .insert(ORIGIN, HeaderValue::from_str(origin).unwrap()); + tungstenite::client(request, stream).unwrap().0 +} + +fn read_json(websocket: &mut tungstenite::WebSocket) -> Value { + serde_json::from_str(websocket.read().unwrap().to_text().unwrap()).unwrap() +} + +#[test] +fn upgrade_requires_matching_token_and_extension_origin() { + let origin = format!("chrome-extension://{EXTENSION_ID}"); + assert!(authorize_upgrade( + &request("/?token=secret&clientId=sidepanel-window-1", &origin), + "secret", + &origin + )); + assert!(!authorize_upgrade( + &request("/?token=wrong", &origin), + "secret", + &origin + )); + assert!(!authorize_upgrade( + &request("/?token=secret", "https://example.com"), + "secret", + &origin + )); +} + +#[test] +fn client_id_query_defaults_and_rejects_unsafe_values() { + assert_eq!( + requested_client_id(&request("/?token=secret", "ignored")).unwrap(), + DEFAULT_CLIENT_ID + ); + assert_eq!( + requested_client_id(&request( + "/?token=secret&clientId=sidepanel-window-9", + "ignored" + )) + .unwrap(), + "sidepanel-window-9" + ); + assert!(requested_client_id(&request("/?clientId=../escape", "ignored")).is_err()); +} + +#[test] +fn websocket_proxy_multiplexes_clients_and_replays_one_initialize() { + let (root, config, initialize_count, process_count) = fixture(); + let session = RuntimeSession::start(&config, EXTENSION_ID.to_string()).unwrap(); + let origin = format!("chrome-extension://{EXTENSION_ID}"); + let first_url = format!("{}&clientId=sidepanel-window-1", session.url()); + let second_url = format!("{}&clientId=sidepanel-window-2", session.url()); + let mut first = connect(&first_url, &origin); + let mut second = connect(&second_url, &origin); + first + .send(Message::text( + json!({"id":"init-first","method":"initialize"}).to_string(), + )) + .unwrap(); + second + .send(Message::text( + json!({"id":"init-second","method":"initialize"}).to_string(), + )) + .unwrap(); + let first_response = read_json(&mut first); + let second_response = read_json(&mut second); + assert_eq!(first_response["id"], "init-first"); + assert_eq!(second_response["id"], "init-second"); + assert_eq!(first_response["result"]["server"], "initialized"); + assert_eq!(second_response["result"]["server"], "initialized"); + + first + .send(Message::text( + json!({"id":"collision","method":"echo","params":{"marker":"first"}}).to_string(), + )) + .unwrap(); + second + .send(Message::text( + json!({"id":"collision","method":"echo","params":{"marker":"second"}}).to_string(), + )) + .unwrap(); + assert_eq!(read_json(&mut first)["result"]["marker"], "first"); + assert_eq!(read_json(&mut second)["result"]["marker"], "second"); + + first + .send(Message::text( + json!({"id":"emit","method":"emitNotification"}).to_string(), + )) + .unwrap(); + assert_eq!(read_json(&mut first)["id"], "emit"); + assert_eq!(read_json(&mut first)["method"], "server/notice"); + assert_eq!(read_json(&mut second)["method"], "server/notice"); + + first.close(None).unwrap(); + thread::sleep(Duration::from_millis(100)); + let mut reconnect = connect(&first_url, &origin); + reconnect + .send(Message::text( + json!({"id":"init-reconnect","method":"initialize"}).to_string(), + )) + .unwrap(); + assert_eq!(read_json(&mut reconnect)["id"], "init-reconnect"); + reconnect.close(None).unwrap(); + second.close(None).unwrap(); + thread::sleep(Duration::from_millis(100)); + + assert_eq!(fs::read_to_string(initialize_count).unwrap(), "x\n"); + assert_eq!(fs::read_to_string(process_count).unwrap(), "p\n"); + session.stop(); + fs::remove_dir_all(root).unwrap(); +} diff --git a/native/chrome-extension-host/src/uds.rs b/native/chrome-extension-host/src/uds.rs new file mode 100644 index 0000000..d8d1a18 --- /dev/null +++ b/native/chrome-extension-host/src/uds.rs @@ -0,0 +1,175 @@ +use anyhow::{Context, Result, bail}; +use std::{ + env, fs, io, + os::unix::{ + fs::{MetadataExt, PermissionsExt}, + io::AsRawFd, + net::{UnixListener, UnixStream}, + }, + path::{Path, PathBuf}, + process, + time::{SystemTime, UNIX_EPOCH}, +}; + +pub const SOCKET_DIR_ENV: &str = "CODEX_BROWSER_USE_SOCKET_DIR"; +pub const DEFAULT_SOCKET_DIR: &str = "/tmp/codex-browser-use"; + +pub struct SocketGuard { + path: PathBuf, +} + +impl SocketGuard { + pub fn bind() -> Result<(UnixListener, Self)> { + Self::bind_in(&socket_dir()) + } + + pub fn bind_in(directory: &Path) -> Result<(UnixListener, Self)> { + prepare_socket_dir(directory)?; + let path = socket_path(directory); + let listener = UnixListener::bind(&path) + .with_context(|| format!("failed to bind {}", path.display()))?; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) + .with_context(|| format!("failed to chmod {}", path.display()))?; + Ok((listener, Self { path })) + } + + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for SocketGuard { + fn drop(&mut self) { + if let Err(error) = fs::remove_file(&self.path) + && error.kind() != io::ErrorKind::NotFound + { + crate::log(format_args!( + "failed to remove socket {}: {error}", + self.path.display() + )); + } + } +} + +pub fn socket_dir() -> PathBuf { + env::var_os(SOCKET_DIR_ENV) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_SOCKET_DIR)) +} + +pub fn prepare_socket_dir(path: &Path) -> Result<()> { + fs::create_dir_all(path).with_context(|| format!("failed to create {}", path.display()))?; + let metadata = + fs::symlink_metadata(path).with_context(|| format!("failed to stat {}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!( + "socket directory is not a real directory: {}", + path.display() + ); + } + let effective_uid = unsafe { libc::geteuid() }; + if metadata.uid() != effective_uid { + bail!( + "socket directory is owned by uid {}, expected {effective_uid}: {}", + metadata.uid(), + path.display() + ); + } + if metadata.permissions().mode() & 0o777 != 0o700 { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .with_context(|| format!("failed to chmod {}", path.display()))?; + } + Ok(()) +} + +pub fn authorize_peer(stream: &UnixStream) -> Result<()> { + let credentials = peer_credentials(stream)?; + let effective_uid = unsafe { libc::geteuid() }; + if credentials.uid != effective_uid { + bail!( + "rejecting peer pid {} uid {}, expected uid {effective_uid}", + credentials.pid, + credentials.uid + ); + } + Ok(()) +} + +pub fn peer_credentials(stream: &UnixStream) -> Result { + let mut credentials = libc::ucred { + pid: 0, + uid: 0, + gid: 0, + }; + let expected = std::mem::size_of::(); + let mut length = expected as libc::socklen_t; + let result = unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + (&mut credentials as *mut libc::ucred).cast(), + &mut length, + ) + }; + if result != 0 { + return Err(io::Error::last_os_error()).context("failed to read peer credentials"); + } + if length as usize != expected { + bail!("SO_PEERCRED returned {} bytes; expected {expected}", length); + } + if credentials.pid <= 0 { + bail!("SO_PEERCRED returned invalid pid {}", credentials.pid); + } + Ok(credentials) +} + +fn socket_path(directory: &Path) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + directory.join(format!("extension-{}-{nonce}.sock", process::id())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unique_directory(label: &str) -> PathBuf { + env::temp_dir().join(format!( + "{label}-{}-{}", + process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } + + #[test] + fn socket_and_directory_are_private_and_removed_on_drop() { + let directory = unique_directory("codex-host-socket"); + let (_listener, guard) = SocketGuard::bind_in(&directory).unwrap(); + assert_eq!( + fs::metadata(&directory).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(guard.path()).unwrap().permissions().mode() & 0o777, + 0o600 + ); + let path = guard.path().to_path_buf(); + drop(guard); + assert!(!path.exists()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn unix_pair_has_same_uid_credentials() { + let (left, _right) = UnixStream::pair().unwrap(); + let credentials = peer_credentials(&left).unwrap(); + assert_eq!(credentials.uid, unsafe { libc::geteuid() }); + authorize_peer(&left).unwrap(); + } +} diff --git a/package.json b/package.json index 8167bba..0c99167 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "scripts": { "canary": "node scripts/canary.mjs", "smoke": "node scripts/smoke-artifacts.mjs", - "test": "node --test", + "test": "npm run test:node && npm run test:host", + "test:node": "node --test test/*.test.mjs", + "test:host": "cargo test --locked --manifest-path native/chrome-extension-host/Cargo.toml", "release:prod": "node scripts/release-channel.mjs --channel prod", "release:beta": "node scripts/release-channel.mjs --channel beta" }, diff --git a/runtime/webstrap/server.mjs b/runtime/webstrap/server.mjs index 00e050b..9e61f69 100644 --- a/runtime/webstrap/server.mjs +++ b/runtime/webstrap/server.mjs @@ -501,6 +501,9 @@ export function createAppHostModuleBody(rpcModulePath, webRoot) { const rpcModuleUrl = `/${path.relative(webRoot, rpcModulePath).split(path.sep).join("/")}`; return ` +// Importing the upstream bundle can patch Function.prototype.toString. Keep the +// browser's native method so semantic export discovery remains deterministic. +const functionToString = Function.prototype.toString; const rpcModulePromise = import(${JSON.stringify(rpcModuleUrl)}); function resolveCreateRpcPeer(rpcModule) { @@ -509,7 +512,7 @@ function resolveCreateRpcPeer(rpcModule) { return false; } - return Function.prototype.toString.call(value).includes("getRemoteMain"); + return functionToString.call(value).includes("getRemoteMain"); }); } @@ -532,6 +535,7 @@ const appUpdateState = { const appHostMain = { services: { appUpdates: { + setSparkleQueryParams() {}, installUpdate() {}, stateChanged(callback) { appUpdateSubscribers.add(callback); diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..b4be66a --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.95.0" +profile = "minimal" +targets = ["x86_64-unknown-linux-musl"] diff --git a/scripts/lib/build.mjs b/scripts/lib/build.mjs index ce4e95b..d691991 100644 --- a/scripts/lib/build.mjs +++ b/scripts/lib/build.mjs @@ -18,6 +18,8 @@ import { } from "./config.mjs"; import { patchUpstreamApp } from "./upstream-patches.mjs"; import { writeAurPackage } from "./aur.mjs"; +import { stageLinuxChromeExtensionHost } from "./chrome-extension-host.mjs"; +import { patchLinuxChromePluginResources } from "./chrome-plugin-patches.mjs"; const skippedLinuxResourceNames = new Set([ "app.asar", @@ -42,13 +44,16 @@ const primaryRuntime = { nodeEntry: "codex-primary-runtime/dependencies/node/bin/node", nodeReplEntry: "codex-primary-runtime/dependencies/bin/node_repl" }; -const codexCliRuntime = { +// Keep the Linux runtime aligned with the Codex binary embedded in the current +// ChatGPT desktop archive. The matching official release ships both Linux +// executables needed by the desktop app in one verified package. +export const codexCliRuntime = { url: process.env.CODEX_CLI_RUNTIME_URL || - "https://github.com/openai/codex/releases/download/rust-v0.144.0-alpha.4/codex-package-x86_64-unknown-linux-musl.tar.gz", + "https://github.com/openai/codex/releases/download/rust-v0.146.0-alpha.3.1/codex-package-x86_64-unknown-linux-musl.tar.gz", sha256: process.env.CODEX_CLI_RUNTIME_SHA256 || - "d445749123af97de7e2adf8d66fc52954c8c15d692d19f4cb3d6bd12aafa37ba", + "71696f571d99b83ca09ef482653315fe8b7bfc1c18253662da5406e8d3f17158", codexEntry: "bin/codex", codeModeHostEntry: "bin/codex-code-mode-host" }; @@ -87,6 +92,8 @@ export async function buildChannel({ ]); await patchUpstreamApp(paths.stageAppDir); await stagePackagedResources(appResourcesDir, paths.stageResourcesDir); + await patchLinuxChromePluginResources(paths.stageResourcesDir); + await stageLinuxChromeExtensionHost(paths.stageResourcesDir); await stageLinuxCodexCliRuntime(paths.stageResourcesDir); await stageLinuxNodeReplRuntime(paths.stageResourcesDir); diff --git a/scripts/lib/chrome-extension-constants.mjs b/scripts/lib/chrome-extension-constants.mjs new file mode 100644 index 0000000..f7a2c9a --- /dev/null +++ b/scripts/lib/chrome-extension-constants.mjs @@ -0,0 +1,3 @@ +export const CHROME_EXTENSION_HOST_CONTENT_VARIANT = "linux-extension-host-v3"; +export const CHROME_EXTENSION_HOST_TARGET = "x86_64-unknown-linux-musl"; +export const CHROME_EXTENSION_HOST_ARCH = "x64"; diff --git a/scripts/lib/chrome-extension-host.mjs b/scripts/lib/chrome-extension-host.mjs new file mode 100644 index 0000000..d539283 --- /dev/null +++ b/scripts/lib/chrome-extension-host.mjs @@ -0,0 +1,152 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { cacheRoot, projectRoot } from "./config.mjs"; +import { + CHROME_EXTENSION_HOST_ARCH, + CHROME_EXTENSION_HOST_CONTENT_VARIANT, + CHROME_EXTENSION_HOST_TARGET +} from "./chrome-extension-constants.mjs"; +import { writeRustDependencyLicenses } from "./rust-dependency-licenses.mjs"; + +const execFileAsync = promisify(execFile); +const crateDir = path.join(projectRoot, "native", "chrome-extension-host"); +const defaultCargoTargetDir = path.join(cacheRoot, "chrome-extension-host-target"); +const defaultLicensePath = path.join(crateDir, "LICENSES", "ilysenko-MIT.txt"); +const defaultNoticesPath = path.join(crateDir, "THIRD_PARTY_NOTICES.md"); + +export { CHROME_EXTENSION_HOST_CONTENT_VARIANT }; + +/** Build the vendored host as a static musl executable and verify the result. */ +export async function buildLinuxChromeExtensionHost({ + cargoTargetDir = defaultCargoTargetDir +} = {}) { + const manifestPath = path.join(crateDir, "Cargo.toml"); + + await execFileAsync( + "cargo", + [ + "build", + "--locked", + "--release", + "--manifest-path", + manifestPath, + "--target", + CHROME_EXTENSION_HOST_TARGET + ], + { + cwd: projectRoot, + env: { + ...process.env, + CARGO_TARGET_DIR: cargoTargetDir + }, + maxBuffer: 16 * 1024 * 1024 + } + ); + + const binaryPath = path.join( + cargoTargetDir, + CHROME_EXTENSION_HOST_TARGET, + "release", + "codex-chrome-extension-host" + ); + await assertStaticLinuxChromeExtensionHost(binaryPath); + return binaryPath; +} + +/** + * Replace any upstream host with the audited project build. Upstream has + * alternated between an empty directory and Darwin-only payloads, so the + * Linux artifact is always authoritative. + */ +export async function stageLinuxChromeExtensionHost( + resourcesDir, + { + sourcePath, + licensePath = defaultLicensePath, + noticesPath = defaultNoticesPath + } = {} +) { + const pluginRoot = chromePluginRoot(resourcesDir); + + if (!(await isDirectory(pluginRoot))) { + throw new Error(`Chrome plugin root is missing: ${pluginRoot}`); + } + + const hostSource = sourcePath || (await buildLinuxChromeExtensionHost()); + const targetPath = path.join( + pluginRoot, + "extension-host", + "linux", + CHROME_EXTENSION_HOST_ARCH, + "extension-host" + ); + const temporaryPath = `${targetPath}.installing-${process.pid}`; + const targetLicensePath = path.join( + pluginRoot, + "extension-host", + "linux", + "LICENSE.ilysenko-MIT.txt" + ); + const targetNoticesPath = path.join( + pluginRoot, + "extension-host", + "linux", + "THIRD_PARTY_NOTICES.md" + ); + const targetRustLicensesPath = path.join( + pluginRoot, + "extension-host", + "linux", + "RUST_DEPENDENCY_LICENSES.md" + ); + + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.copyFile(licensePath, targetLicensePath); + await fs.copyFile(noticesPath, targetNoticesPath); + await writeRustDependencyLicenses(targetRustLicensesPath); + try { + await fs.copyFile(hostSource, temporaryPath); + await fs.chmod(temporaryPath, 0o755); + await fs.rename(temporaryPath, targetPath); + } finally { + await fs.rm(temporaryPath, { force: true }); + } + + return targetPath; +} + +export async function assertStaticLinuxChromeExtensionHost(binaryPath) { + const { stdout } = await execFileAsync("file", ["-b", binaryPath]); + const fileType = stdout.trim(); + const staticExecutable = /\b(?:static-pie|statically) linked\b/i.test(fileType); + + if (!/\bELF\b/.test(fileType) || !/\bx86-64\b/.test(fileType) || !staticExecutable) { + throw new Error( + `Refusing non-static Linux x64 Chrome extension host ${binaryPath}: ${fileType}` + ); + } + + return fileType; +} + +export function chromePluginRoot(resourcesDir) { + return path.join( + resourcesDir, + "plugins", + "openai-bundled", + "plugins", + "chrome" + ); +} + +async function isDirectory(targetPath) { + try { + return (await fs.stat(targetPath)).isDirectory(); + } catch (error) { + if (error?.code === "ENOENT") return false; + throw error; + } +} diff --git a/scripts/lib/chrome-extension-patches.mjs b/scripts/lib/chrome-extension-patches.mjs new file mode 100644 index 0000000..a012750 --- /dev/null +++ b/scripts/lib/chrome-extension-patches.mjs @@ -0,0 +1,137 @@ +import { parse } from "acorn"; + +import { CHROME_EXTENSION_HOST_CONTENT_VARIANT } from "./chrome-extension-constants.mjs"; + +const contractName = "Chrome plugin content-variant"; +const materializerSignals = [ + ".browserSkillVariant", + ".computerUseSkillVariant", + ".pluginRoot", + ".codex-plugin" +]; + +export const linuxChromeExtensionHostContentVariantContract = { + name: "linux-chrome-extension-host-content-variant", + find: findContentVariantContract, + assertBefore(source) { + const match = findContentVariantContract(source); + if (match.status !== "patch") throw new Error("Linux Chrome content variant is already present"); + }, + apply: patchLinuxChromeExtensionHostContentVariant, + assertAfter(source) { + if (!hasLinuxChromeExtensionHostContentVariant(source)) { + throw new Error("Linux Chrome content variant was not applied"); + } + } +}; + +function findContentVariantContract(source) { + if (hasLinuxChromeExtensionHostContentVariant(source)) { + return { status: "patched" }; + } + return { status: "patch", ...findContentVariantMaterializer(source) }; +} + +/** + * Upstream rewrites bundledContentVariant immediately before materializing a + * plugin cache entry. Namespace Chrome's runtime value so a launcher update + * replaces stale same-version caches that were created without a Linux host. + */ +export function patchLinuxChromeExtensionHostContentVariant(source) { + if (hasLinuxChromeExtensionHostContentVariant(source)) return source; + + try { + const match = findContentVariantMaterializer(source); + const variantName = source.slice(match.property.value.start, match.property.value.end); + const replacement = + `${match.pluginParameter}.pluginName===\`chrome\`?` + + `\`\${${variantName}}-${CHROME_EXTENSION_HOST_CONTENT_VARIANT}\`:${variantName}`; + + return ( + source.slice(0, match.property.value.start) + + replacement + + source.slice(match.property.value.end) + ); + } catch (error) { + if (error?.message?.startsWith(`${contractName} contract changed:`)) { + throw error; + } + throw contractError(error instanceof Error ? error.message : String(error), error); + } +} + +export function hasLinuxChromeExtensionHostContentVariant(source) { + return ( + source.includes(CHROME_EXTENSION_HOST_CONTENT_VARIANT) && + /bundledContentVariant:[A-Za-z_$][\w$]*\.pluginName===`chrome`\?/.test(source) + ); +} + +function findContentVariantMaterializer(source) { + let ast; + try { + ast = parse(source, { ecmaVersion: "latest", sourceType: "module" }); + } catch (error) { + throw contractError(`main bundle is not valid JavaScript: ${error.message}`, error); + } + + const matches = []; + walk(ast, [], (node, ancestors) => { + if (!isBundledContentVariantProperty(node)) return; + + const fn = [...ancestors].reverse().find(isFunctionNode); + const pluginParameter = fn?.params?.[0]; + if (!fn || pluginParameter?.type !== "Identifier") return; + + const functionSource = source.slice(fn.start, fn.end); + if (!materializerSignals.every(signal => functionSource.includes(signal))) return; + + matches.push({ + property: node, + pluginParameter: pluginParameter.name + }); + }); + + if (matches.length !== 1) { + throw contractError(`expected one runtime materializer, found ${matches.length}`); + } + + return matches[0]; +} + +function isBundledContentVariantProperty(node) { + return ( + node?.type === "Property" && + !node.computed && + node.key?.type === "Identifier" && + node.key.name === "bundledContentVariant" && + node.value?.type === "Identifier" + ); +} + +function isFunctionNode(node) { + return [ + "FunctionDeclaration", + "FunctionExpression", + "ArrowFunctionExpression" + ].includes(node?.type); +} + +function walk(node, ancestors, visit) { + if (!node || typeof node !== "object") return; + visit(node, ancestors); + + const nextAncestors = [...ancestors, node]; + for (const [key, value] of Object.entries(node)) { + if (key === "start" || key === "end" || key === "loc") continue; + if (Array.isArray(value)) { + for (const child of value) walk(child, nextAncestors, visit); + } else if (value && typeof value.type === "string") { + walk(value, nextAncestors, visit); + } + } +} + +function contractError(message, cause) { + return new Error(`${contractName} contract changed: ${message}`, { cause }); +} diff --git a/scripts/lib/chrome-extension-smoke.mjs b/scripts/lib/chrome-extension-smoke.mjs new file mode 100644 index 0000000..5400698 --- /dev/null +++ b/scripts/lib/chrome-extension-smoke.mjs @@ -0,0 +1,402 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { CHROME_EXTENSION_HOST_ARCH } from "./chrome-extension-constants.mjs"; +import { chromePluginRoot } from "./chrome-extension-host.mjs"; +import { projectRoot } from "./config.mjs"; + +const extensionId = "hehggadaopoacecdllhhajmbjkdcmajg"; +const nativeHostName = "com.openai.codexextension"; +const smokeEntryId = "linux-managed-smoke"; + +/** Verify the packaged ELF and execute a real protocol-v2 hello exchange. */ +export async function assertLinuxChromeExtensionHost(resourcesDir, channelName) { + resourcesDir = path.resolve(resourcesDir); + const pluginRoot = chromePluginRoot(resourcesDir); + const hostPath = path.join( + pluginRoot, + "extension-host", + "linux", + CHROME_EXTENSION_HOST_ARCH, + "extension-host" + ); + + await fs.access(hostPath).catch(error => { + throw new Error(`Missing Chrome extension host: ${hostPath}`, { cause: error }); + }); + const [stat, fileType] = await Promise.all([ + fs.stat(hostPath), + runFileType(hostPath) + ]); + const artifact = evaluateLinuxChromeExtensionHostArtifact({ + fileType, + mode: stat.mode & 0o777 + }); + const protocol = await smokeLinuxChromeExtensionHostProtocol({ + channelName, + hostPath, + pluginRoot, + resourcesDir + }); + + return { + path: path.relative(resourcesDir, hostPath), + ...artifact, + ...protocol + }; +} + +export function evaluateLinuxChromeExtensionHostArtifact({ fileType, mode }) { + if ((mode & 0o111) === 0) { + throw new Error(`Chrome extension host must be executable (mode ${mode.toString(8)})`); + } + if (!/\bELF\b/.test(fileType) || !/\bx86-64\b/.test(fileType)) { + throw new Error(`Chrome extension host must be a Linux x86-64 ELF: ${fileType}`); + } + if (!/\b(?:static-pie|statically) linked\b/i.test(fileType)) { + throw new Error(`Chrome extension host must be statically linked: ${fileType}`); + } + + return { + executable: true, + static: true, + architecture: "x86-64" + }; +} + +async function smokeLinuxChromeExtensionHostProtocol({ + channelName, + hostPath, + pluginRoot, + resourcesDir +}) { + const temporaryDir = await fs.mkdtemp( + path.join(os.tmpdir(), "codex-chrome-host-smoke-") + ); + const temporaryHostPath = path.join(temporaryDir, "extension-host"); + const codexHome = path.join(temporaryDir, "codex-home"); + const stateHome = path.join(temporaryDir, "state"); + const socketDir = path.join(temporaryDir, "sockets"); + try { + await fs.copyFile(hostPath, temporaryHostPath); + await fs.chmod(temporaryHostPath, 0o755); + await Promise.all([ + fs.mkdir(codexHome, { recursive: true }), + fs.mkdir(stateHome, { recursive: true }), + fs.mkdir(socketDir, { recursive: true }) + ]); + const codexCliPath = path.join(resourcesDir, "codex"); + const nodePath = path.join(resourcesDir, "node"); + const registry = managedRegistry({ + channelName, + codexCliPath, + codexHome, + hostPath: temporaryHostPath, + nodePath, + pluginRoot, + resourcesDir + }); + await Promise.all([ + writeRegistry( + path.join(stateHome, "openai-codex", "chrome-native-hosts-v2.json"), + registry + ), + writeRegistry(path.join(codexHome, "chrome-native-hosts-v2.json"), registry) + ]); + const environment = { + ...process.env, + CODEX_BROWSER_USE_SOCKET_DIR: socketDir, + CODEX_HOME: codexHome, + HOME: temporaryDir, + XDG_STATE_HOME: stateHome + }; + const constraints = { + extensionBuildChannel: channelName, + extensionId, + extensionVersion: "smoke", + nativeHostName, + requiredAppServerProtocolVersion: 2, + requiredNativeHostProtocolVersion: 2 + }; + const helloRequest = nativeMessageFrame({ + jsonrpc: "2.0", + id: "smoke-hello", + method: "codexRuntime/hello", + params: { constraints } + }); + const helloResult = await runNativeMessage( + temporaryHostPath, + [`chrome-extension://${extensionId}/`], + helloRequest, + environment + ); + if (helloResult.code !== 0) { + throw new Error( + `Chrome extension host hello exited ${helloResult.code}: ${helloResult.stderr.slice(0, 1000)}` + ); + } + const ensureRequest = nativeMessageFrame({ + jsonrpc: "2.0", + id: "smoke-ensure", + method: "codexRuntime/ensure", + params: { clientId: "artifact-smoke", constraints } + }); + const ensureResult = await runNativeMessage( + temporaryHostPath, + [`chrome-extension://${extensionId}/`], + ensureRequest, + environment + ); + if (ensureResult.code !== 0) { + throw new Error( + `Chrome extension host ensure exited ${ensureResult.code}: ${ensureResult.stderr.slice(0, 1000)}` + ); + } + return { + hello: evaluateLinuxChromeExtensionHostHello( + parseNativeMessageFrame(helloResult.stdout) + ), + runtime: evaluateLinuxChromeExtensionHostEnsure( + parseNativeMessageFrame(ensureResult.stdout), + { channelName, codexCliPath, codexHome, nodePath } + ) + }; + } finally { + await fs.rm(temporaryDir, { recursive: true, force: true }); + } +} + +function managedRegistry({ + channelName, + codexCliPath, + codexHome, + hostPath, + nodePath, + pluginRoot, + resourcesDir +}) { + return { + schemaVersion: 2, + entries: [{ + schemaVersion: 2, + appServerProtocolVersion: 2, + appVersion: "artifact-smoke", + channel: channelName, + cliVersion: "artifact-smoke", + entryId: smokeEntryId, + extensionBuildChannels: [channelName], + extensionIds: [extensionId], + installId: "linux-managed-smoke-install", + nativeHostNames: [nativeHostName], + nativeHostProtocolVersion: 2, + nativeHostVersion: "artifact-smoke", + paths: { + browserClientPath: path.join(pluginRoot, "scripts", "browser-client.mjs"), + codexCliPath, + codexHome, + extensionHostPath: hostPath, + nodePath, + nodeModuleDirs: [], + nodeReplPath: path.join(resourcesDir, "node_repl"), + resourcesPath: resourcesDir + }, + presence: { + lastSeenAt: new Date().toISOString(), + pid: process.pid, + startedAt: new Date().toISOString() + }, + proxyHost: "127.0.0.1", + proxyPort: 0, + updatedAt: new Date().toISOString() + }] + }; +} + +async function writeRegistry(registryPath, registry) { + await fs.mkdir(path.dirname(registryPath), { recursive: true }); + await fs.writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`); +} + +function nativeMessageFrame(message) { + const body = Buffer.from(JSON.stringify(message)); + const frame = Buffer.allocUnsafe(4 + body.length); + frame.writeUInt32LE(body.length, 0); + body.copy(frame, 4); + return frame; +} + +export function parseNativeMessageFrame(frame) { + if (!Buffer.isBuffer(frame) || frame.length < 4) { + throw new Error("Chrome extension host returned a truncated native frame header"); + } + const bodyLength = frame.readUInt32LE(0); + if (frame.length !== bodyLength + 4) { + throw new Error( + `Chrome extension host returned a truncated or trailing native frame: expected ${bodyLength + 4} bytes, got ${frame.length}` + ); + } + try { + return JSON.parse(frame.subarray(4).toString("utf8")); + } catch (error) { + throw new Error("Chrome extension host returned invalid JSON", { cause: error }); + } +} + +export function evaluateLinuxChromeExtensionHostHello(message) { + if (message?.jsonrpc !== "2.0" || message?.id !== "smoke-hello") { + throw new Error("Chrome extension host returned an unexpected hello response ID"); + } + if (message.error) { + throw new Error(`Chrome extension host hello failed: ${JSON.stringify(message.error)}`); + } + const result = message.result; + if ( + result?.manifestSchemaVersion !== 2 || + result?.nativeHostProtocolVersion !== 2 || + !Array.isArray(result?.supportedProtocolVersions) || + !result.supportedProtocolVersions.includes(2) || + !Array.isArray(result?.supportedMethods) || + !result.supportedMethods.includes("codexRuntime/openLocalFile") || + typeof result?.nativeHostVersion !== "string" || + result.nativeHostVersion.length === 0 + ) { + throw new Error( + `Chrome extension host returned an incompatible hello: ${JSON.stringify(result)}` + ); + } + return { + protocolVersion: result.nativeHostProtocolVersion, + version: result.nativeHostVersion + }; +} + +export function evaluateLinuxChromeExtensionHostEnsure(message, { + channelName, + codexCliPath, + codexHome, + nodePath +}) { + if (message?.jsonrpc !== "2.0" || message?.id !== "smoke-ensure") { + throw new Error("Chrome extension host returned an unexpected ensure response ID"); + } + if (message.error) { + throw new Error(`Chrome extension host ensure failed: ${JSON.stringify(message.error)}`); + } + const result = message.result; + let runtimeUrl; + try { + runtimeUrl = new URL(result?.localAppServerUrl); + } catch { + throw new Error("Chrome extension host returned an invalid app-server URL"); + } + const agentModeDefaults = result?.runtimeConfig?.desktopAgentModeDefaults; + if ( + result?.entryId !== smokeEntryId || + typeof result?.runtimeSessionId !== "string" || + result.runtimeSessionId.length === 0 || + runtimeUrl.protocol !== "ws:" || + !["127.0.0.1", "[::1]", "localhost"].includes(runtimeUrl.hostname) || + !runtimeUrl.searchParams.has("token") || + result?.selected?.appServerProtocolVersion !== 2 || + result?.selected?.nativeHostProtocolVersion !== 2 || + result?.selected?.channel !== channelName || + result?.runtimeConfig?.platform !== "linux" || + result?.runtimeConfig?.codexCliPath !== codexCliPath || + result?.runtimeConfig?.codexHome !== codexHome || + result?.runtimeConfig?.nodePath !== nodePath || + !isJsonObject(agentModeDefaults) || + !isJsonObject(agentModeDefaults.agentModesByHostId) || + !isJsonObject(agentModeDefaults.preferredNonFullAccessModesByHostId) || + Object.hasOwn(result.runtimeConfig, "browserClientSha256") + ) { + throw new Error( + `Chrome extension host returned an incompatible managed runtime: ${JSON.stringify(result)}` + ); + } + return { + channel: result.selected.channel, + entryId: result.entryId, + protocolVersion: result.selected.nativeHostProtocolVersion + }; +} + +function isJsonObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function runFileType(hostPath) { + return new Promise((resolve, reject) => { + const child = spawn("file", ["-b", hostPath], { + cwd: projectRoot, + stdio: ["ignore", "pipe", "pipe"] + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", chunk => { + stdout += chunk; + }); + child.stderr.on("data", chunk => { + stderr += chunk; + }); + child.on("error", reject); + child.on("exit", code => { + if (code === 0) resolve(stdout.trim()); + else reject(new Error(`file exited ${code}: ${stderr}`)); + }); + }); +} + +function runNativeMessage(command, args, input, env = process.env) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: projectRoot, + env, + stdio: ["pipe", "pipe", "pipe"] + }); + const stdout = []; + let stdoutBytes = 0; + let stderr = ""; + let settled = false; + const finish = callback => value => { + if (settled) return; + settled = true; + clearTimeout(timer); + callback(value); + }; + const fail = finish(reject); + const timer = setTimeout(() => { + child.kill("SIGKILL"); + fail(new Error(`${command} native hello timed out after 5000ms`)); + }, 5000); + + child.stdout.on("data", chunk => { + stdoutBytes += chunk.length; + if (stdoutBytes > 2 * 1024 * 1024) { + child.kill("SIGKILL"); + fail(new Error(`${command} native hello exceeded 2MiB`)); + return; + } + stdout.push(chunk); + const output = Buffer.concat(stdout); + if (output.length >= 4 && output.length >= output.readUInt32LE(0) + 4) { + child.stdin.end(); + } + }); + child.stderr.on("data", chunk => { + stderr += chunk.toString(); + }); + child.on("error", fail); + child.stdin.on("error", error => { + if (error?.code !== "EPIPE") fail(error); + }); + child.on( + "exit", + finish(code => { + resolve({ code, stdout: Buffer.concat(stdout), stderr }); + }) + ); + child.stdin.write(input); + }); +} diff --git a/scripts/lib/chrome-plugin-patches.mjs b/scripts/lib/chrome-plugin-patches.mjs new file mode 100644 index 0000000..b4a697d --- /dev/null +++ b/scripts/lib/chrome-plugin-patches.mjs @@ -0,0 +1,105 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { parse } from "acorn"; + +import { chromePluginRoot } from "./chrome-extension-host.mjs"; + +const nativeManifestContract = "Linux native-host manifest diagnostics"; + +export async function patchLinuxChromePluginResources(resourcesDir) { + const scriptsDir = path.join(chromePluginRoot(resourcesDir), "scripts"); + // browser-client.mjs is SHA-pinned by the desktop runtime. Keep its bytes + // intact; changing its profile metadata would disable the trusted Node REPL. + const manifestCheckPath = path.join(scriptsDir, "check-native-host-manifest.js"); + const source = await fs.readFile(manifestCheckPath, "utf8").catch(error => { + throw new Error(`Required Chrome plugin script is missing: ${manifestCheckPath}`, { + cause: error + }); + }); + const patched = patchLinuxNativeHostManifestCheckSource(source); + if (patched !== source) await fs.writeFile(manifestCheckPath, patched); +} + +export function patchLinuxNativeHostManifestCheckSource(source) { + if (hasLinuxNativeHostManifestCheck(source)) return source; + + try { + const fn = findNamedFunction(source, "getNativeHostManifestLocation"); + const functionSource = source.slice(fn.start, fn.end); + const windowsBranch = functionSource.indexOf( + 'if (process.platform === "win32")' + ); + if (windowsBranch === -1) throw new Error("missing Windows manifest branch"); + + const insertion = `if (process.platform === "linux") { + return { + manifestPath: path.join( + os.homedir(), + ".config", + "google-chrome", + "NativeMessagingHosts", + \`\${expectedHostName}.json\`, + ), + registryKey: null, + registryManifestPath: null, + registryKeyExists: null, + }; + } + + `; + const insertionOffset = fn.start + windowsBranch; + let patched = + source.slice(0, insertionOffset) + insertion + source.slice(insertionOffset); + const oldSupport = "This script supports macOS and Windows."; + if (!patched.includes(oldSupport)) throw new Error("missing supported-platform message"); + patched = patched.replace(oldSupport, "This script supports macOS, Linux, and Windows."); + + if (!hasLinuxNativeHostManifestCheck(patched)) { + throw new Error("Linux manifest branch was not applied"); + } + return patched; + } catch (error) { + throw contractError(nativeManifestContract, error); + } +} + +function hasLinuxNativeHostManifestCheck(source) { + return ( + source.includes('process.platform === "linux"') && + source.includes('"NativeMessagingHosts"') && + source.includes("supports macOS, Linux, and Windows") + ); +} + +function findNamedFunction(source, name) { + const ast = parse(source, { ecmaVersion: "latest", sourceType: "script" }); + const matches = []; + walk(ast, node => { + if (node.type === "FunctionDeclaration" && node.id?.name === name) { + matches.push(node); + } + }); + if (matches.length !== 1) { + throw new Error(`expected function ${name} once, found ${matches.length}`); + } + return matches[0]; +} + +function walk(node, visit) { + if (!node || typeof node !== "object") return; + visit(node); + for (const [key, value] of Object.entries(node)) { + if (key === "start" || key === "end" || key === "loc") continue; + if (Array.isArray(value)) { + for (const child of value) walk(child, visit); + } else if (value && typeof value.type === "string") { + walk(value, visit); + } + } +} + +function contractError(name, error) { + if (error?.message?.startsWith(`${name} contract changed:`)) return error; + const message = error instanceof Error ? error.message : String(error); + return new Error(`${name} contract changed: ${message}`, { cause: error }); +} diff --git a/scripts/lib/config.mjs b/scripts/lib/config.mjs index 8d94284..19db1f5 100644 --- a/scripts/lib/config.mjs +++ b/scripts/lib/config.mjs @@ -12,7 +12,7 @@ export const defaultLauncherCommand = export const defaultReleaseRepo = process.env.CODEX_RELEASE_REPO || "cau1k/codex-app-linux"; export const defaultPackageRevision = Number( - process.env.CODEX_PACKAGE_REVISION || "34" + process.env.CODEX_PACKAGE_REVISION || "35" ); export const channels = { diff --git a/scripts/lib/rust-dependency-licenses.mjs b/scripts/lib/rust-dependency-licenses.mjs new file mode 100644 index 0000000..cfc1749 --- /dev/null +++ b/scripts/lib/rust-dependency-licenses.mjs @@ -0,0 +1,96 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { projectRoot } from "./config.mjs"; + +const execFileAsync = promisify(execFile); +const licenseFilePattern = /^(?:licen[cs]e|copying|unlicense|notice)(?:[-._].*)?$/i; +let renderedLicenses; + +/** Build a deterministic license bundle from the Cargo.lock dependency graph. */ +export async function writeRustDependencyLicenses( + outputPath, + manifestPath = path.join( + projectRoot, + "native", + "chrome-extension-host", + "Cargo.toml" + ) +) { + renderedLicenses ||= renderRustDependencyLicenses(manifestPath); + await fs.writeFile(outputPath, await renderedLicenses); +} + +async function renderRustDependencyLicenses(manifestPath) { + const { stdout } = await execFileAsync( + "cargo", + [ + "metadata", + "--locked", + "--filter-platform", + "x86_64-unknown-linux-musl", + "--format-version", + "1", + "--manifest-path", + manifestPath + ], + { cwd: projectRoot, maxBuffer: 16 * 1024 * 1024 } + ); + const metadata = JSON.parse(stdout); + const resolvedIds = new Set(metadata.resolve?.nodes?.map(node => node.id) || []); + const packages = metadata.packages + .filter(pkg => resolvedIds.has(pkg.id) && pkg.id !== metadata.resolve?.root) + .sort((left, right) => + left.name.localeCompare(right.name) || left.version.localeCompare(right.version) + ); + if (packages.length === 0) { + throw new Error("Cargo metadata returned no Rust dependencies to license"); + } + + const sections = await Promise.all(packages.map(renderPackageLicenses)); + return [ + "# Rust dependency licenses", + "", + "Generated from `native/chrome-extension-host/Cargo.lock`. The complete", + "license notices below ship beside the statically linked Linux host.", + "", + ...sections, + "" + ].join("\n"); +} + +async function renderPackageLicenses(pkg) { + const packageDir = path.dirname(pkg.manifest_path); + const entries = await fs.readdir(packageDir, { withFileTypes: true }); + const licensePaths = new Map(); + if (pkg.license_file) { + const licensePath = path.resolve(packageDir, pkg.license_file); + licensePaths.set(licensePath, path.basename(licensePath)); + } + for (const entry of entries) { + if (entry.isFile() && licenseFilePattern.test(entry.name)) { + licensePaths.set(path.join(packageDir, entry.name), entry.name); + } + } + if (licensePaths.size === 0) { + throw new Error(`Rust dependency ${pkg.name} ${pkg.version} has no packaged license file`); + } + + const notices = []; + for (const [licensePath, name] of [...licensePaths].sort((left, right) => + left[1].localeCompare(right[1]) + )) { + const text = (await fs.readFile(licensePath, "utf8")).trimEnd(); + notices.push(`### ${name}\n\n\`\`\`text\n${text}\n\`\`\``); + } + return [ + `## ${pkg.name} ${pkg.version}`, + "", + `SPDX: ${pkg.license || "not declared"}`, + ...(pkg.repository ? [`Source: ${pkg.repository}`] : []), + "", + notices.join("\n\n") + ].join("\n"); +} diff --git a/scripts/lib/upstream-patches.mjs b/scripts/lib/upstream-patches.mjs index 86a58d9..edb30d7 100644 --- a/scripts/lib/upstream-patches.mjs +++ b/scripts/lib/upstream-patches.mjs @@ -2,6 +2,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import { parse } from "acorn"; +import { linuxChromeExtensionHostContentVariantContract } from "./chrome-extension-patches.mjs"; + const linuxOpenTargetDefinitions = ({ openCommandName, executableResolverName }) => [ "var __codexLinuxOpenTargetGotoArgs=(e,t)=>t?[`--goto`,`${e}:${t.line}:${t.column}`]:[e]", "__codexLinuxOpenTargetColonArgs=(e,t)=>t?[`${e}:${t.line}:${t.column}`]:[e]", @@ -27,6 +29,8 @@ const owlFeatureBindingRegex = const owlNullableFeatureBindingRegex = /function ([A-Za-z_$][\w$]*)\(\)\{let ([A-Za-z_$][\w$]*)=process\._linkedBinding;if\(typeof \2!=`function`\)return null;let ([A-Za-z_$][\w$]*);try\{\3=\2\.call\(process,([A-Za-z_$][\w$]*)\)\}catch\(([A-Za-z_$][\w$]*)\)\{if\(([A-Za-z_$][\w$]*)\(\5\)\)return null;throw \5\}return ([A-Za-z_$][\w$]*)\.parse\(\3\)\}/; const owlFeatureFallbackMarker = "__codexLinuxOwlFeatureFallback"; +const chromeProfileRootResolverRegex = + /function ([A-Za-z_$][\w$]*)\(\{homeDir:([A-Za-z_$][\w$]*),localAppDataDir:([A-Za-z_$][\w$]*),platform:([A-Za-z_$][\w$]*)\}\)\{return \4===`darwin`\?(\(0,[A-Za-z_$][\w$]*\.join\)|[A-Za-z_$][\w$]*\.join)\(\2,`Library`,`Application Support`,`Google`,`Chrome`\):\4===`win32`\?\5\(\3\?\?\5\(\2,`AppData`,`Local`\),`Google`,`Chrome`,`User Data`\):/g; export class UpstreamPatchContractError extends Error { constructor(contractName, message, options = {}) { @@ -38,6 +42,13 @@ export class UpstreamPatchContractError extends Error { } } +export const linuxChromeExtensionDetectionContract = { + name: "linux-chrome-extension-detection", + find: findLinuxChromeExtensionDetectionPatch, + assertBefore: assertLinuxChromeExtensionDetectionBefore, + apply: patchLinuxChromeExtensionDetection, + assertAfter: assertLinuxChromeExtensionDetectionAfter +}; export const upstreamPatchContracts = [ // Why: upstream desktop only registers macOS open-in-editor targets; Linux // needs locally installed editors and terminal-backed Neovim. Contract: @@ -81,7 +92,17 @@ export const upstreamPatchContracts = [ assertBefore: assertLinuxWindowFocusableBefore, apply: patchLinuxWindowFocusable, assertAfter: assertLinuxWindowFocusableAfter - } + }, + // Why: older launcher builds cached the same upstream Chrome plugin version + // without a Linux native host. Upstream rewrites bundledContentVariant while + // materializing the cache, so the Linux host revision must be composed there. + linuxChromeExtensionHostContentVariantContract, + // Why: upstream only searches Chrome profiles on macOS and Windows, which + // makes the official extension look absent on Linux. Without detection, the + // desktop never installs the Chrome plugin or writes its native manifest. + // Contract: the main bundle still resolves the Google Chrome profile root + // from homeDir, localAppDataDir, and platform. + linuxChromeExtensionDetectionContract ]; export const owlFeatureBindingContract = { @@ -122,6 +143,10 @@ export function patchLinuxOpenTargetsSource(source) { return applyUpstreamPatchContract(source, upstreamPatchContracts[0]); } +export function patchLinuxChromeExtensionDetectionSource(source) { + return applyUpstreamPatchContract(source, linuxChromeExtensionDetectionContract); +} + export function patchDisableTransparencySource(source) { return applyUpstreamPatchContracts(source, upstreamPatchContracts.slice(1, 3)); } @@ -757,6 +782,124 @@ function isTrueExpression(node) { ); } +function findLinuxChromeExtensionDetectionPatch(source) { + if (hasNativeLinuxChromeExtensionDetection(source)) { + return { status: "patched" }; + } + + const matches = [...source.matchAll(chromeProfileRootResolverRegex)]; + + if (matches.length !== 1) { + throw new Error(`expected one Chrome profile root resolver, found ${matches.length}`); + } + + const match = matches[0]; + const homeDir = match[2]; + const platform = match[4]; + const join = match[5]; + const start = match.index + match[0].length; + const linuxRoot = `${platform}===\`linux\`?${join}(${homeDir},\`.config\`,\`google-chrome\`):null`; + + if (source.startsWith(`${linuxRoot}}`, start)) { + return { status: "patched" }; + } + + if (!source.startsWith("null}", start)) { + throw new Error("Chrome profile root resolver has an unknown fallback"); + } + + return { + status: "patch", + start, + end: start + "null".length, + replacement: linuxRoot + }; +} + +function hasNativeLinuxChromeExtensionDetection(source) { + const ast = parseJavaScript(source); + let foundNativeResolver = false; + + walkAst(ast, node => { + if (!isFunctionNode(node) || node.params.length !== 1) { + return; + } + + const bindings = objectPatternBindings(node.params[0]); + const platform = bindings.platform; + const homeDirectory = bindings.homeDirectory ?? bindings.homeDir; + const localAppData = bindings.localAppData ?? bindings.localAppDataDir; + + if (!platform || !homeDirectory || !localAppData) { + return; + } + + const commonLiterals = new Set(["darwin", "win32", "linux"]); + const directProfileLiterals = new Set([ + "Library", "Application Support", "Google", "Chrome", "User Data", ".config", "google-chrome" + ]); + const metadataProfileLiterals = new Set(["AppData", "Local"]); + let hasUserDataDirectorySegments = false; + let hasUserDataDirName = false; + + // The metadata resolver reads `userDataDirName` inside a `.map()` callback, + // so inspect its nested arrow body as part of the resolver contract. + walkAst(node, child => { + for (const literal of commonLiterals) { + if (isStringLiteral(child, literal)) { + commonLiterals.delete(literal); + } + } + for (const literal of directProfileLiterals) { + if (isStringLiteral(child, literal)) { + directProfileLiterals.delete(literal); + } + } + for (const literal of metadataProfileLiterals) { + if (isStringLiteral(child, literal)) { + metadataProfileLiterals.delete(literal); + } + } + hasUserDataDirectorySegments ||= isMemberPropertyNamed(child, "userDataDirectorySegments"); + hasUserDataDirName ||= isMemberPropertyNamed(child, "userDataDirName"); + }); + + const hasDirectProfiles = directProfileLiterals.size === 0; + const hasMetadataProfiles = + bindings.chromeConfigHome && + bindings.xdgConfigHome && + metadataProfileLiterals.size === 0 && + hasUserDataDirectorySegments && + hasUserDataDirName; + + foundNativeResolver ||= commonLiterals.size === 0 && (hasDirectProfiles || hasMetadataProfiles); + }); + + return foundNativeResolver; +} + +function patchLinuxChromeExtensionDetection(source) { + const patch = findLinuxChromeExtensionDetectionPatch(source); + + if (patch.status === "patched") { + return source; + } + + return source.slice(0, patch.start) + patch.replacement + source.slice(patch.end); +} + +function assertLinuxChromeExtensionDetectionBefore(source) { + if (findLinuxChromeExtensionDetectionPatch(source).status !== "patch") { + throw new Error("Linux Chrome profile root is not patchable"); + } +} + +function assertLinuxChromeExtensionDetectionAfter(source) { + if (findLinuxChromeExtensionDetectionPatch(source).status !== "patched") { + throw new Error("Linux Chrome profile root assertion failed"); + } +} + function parseJavaScript(source) { try { return parse(source, { diff --git a/scripts/smoke-artifacts.mjs b/scripts/smoke-artifacts.mjs index f5aa7c0..d849c7f 100644 --- a/scripts/smoke-artifacts.mjs +++ b/scripts/smoke-artifacts.mjs @@ -7,6 +7,7 @@ import { spawn, spawnSync } from "node:child_process"; import * as asar from "@electron/asar"; import { channelPaths, getChannel, parseArgs, projectRoot } from "./lib/config.mjs"; +import { assertLinuxChromeExtensionHost } from "./lib/chrome-extension-smoke.mjs"; import { hasLinuxWindowFocusableContractSource, hasUnguardedLinuxWindowFocusableSource, @@ -56,6 +57,9 @@ export async function smokeLinuxArtifacts({ await runCheck(summary, "linux-native-payloads", () => assertNoForeignNativePayloads(linuxDir) ); + await runCheck(summary, "chrome-extension-host", () => + assertLinuxChromeExtensionHost(resourcesDir, channelName || "prod") + ); await runCheck(summary, "node-runtime", () => assertCommandSuccess(path.join(resourcesDir, "node"), ["--version"], { expectStdout: /^v\d+\.\d+\.\d+/ @@ -267,7 +271,7 @@ export function evaluateBundledCodexLauncherSource(source) { } } -async function smokeBundledCodexDynamicTools(resourcesDir) { +export async function smokeBundledCodexDynamicTools(resourcesDir) { const executablePath = path.resolve(resourcesDir, "codex"); await accessFile(executablePath, "bundled Codex CLI"); const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), "codex-app-linux-dynamic-tools-")); @@ -729,18 +733,49 @@ async function smokeBrowserPage(url) { const splashOnly = body?.children.length <= 2 && document.querySelector("svg") && text.trim().length < 20; return Boolean(interactive) || (text.trim().length > 20 && !splashOnly); - }, { + }, undefined, { timeout: 45_000 }); if (fatalConsole.length > 0) { throw new Error(`fatal browser console output: ${fatalConsole.slice(0, 5).join("\n")}`); } + } catch (error) { + let bodyText = ""; + + try { + bodyText = await page.locator("body").innerText({ timeout: 1000 }); + } catch { + // The page may have crashed or closed. Console output and the original + // Playwright error still provide the primary failure evidence. + } + + throw new Error(formatBrowserSmokeFailure({ + error, + fatalConsole, + bodyText, + url + }), { cause: error }); } finally { await browser.close(); } } +export function formatBrowserSmokeFailure({ error, fatalConsole = [], bodyText = "", url }) { + const details = [`browser smoke failed for ${url}: ${toErrorMessage(error)}`]; + + if (fatalConsole.length > 0) { + details.push(`console: ${fatalConsole.slice(0, 5).join(" | ")}`); + } + + const renderedBody = String(bodyText).replace(/\s+/g, " ").trim().slice(0, 1000); + if (renderedBody) { + details.push(`body: ${renderedBody}`); + } + + return details.join("\n"); +} + async function assertCommandSuccess(command, args, options = {}) { const result = await runCommand(command, args, { capture: true, @@ -847,6 +882,7 @@ function runCommand(command, args, options = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd: projectRoot, + detached: process.platform !== "win32", env, stdio: capture ? ["pipe", "pipe", "pipe"] : "inherit" }); @@ -854,10 +890,13 @@ function runCommand(command, args, options = {}) { let stderr = ""; let timedOut = false; let timeoutMetadata = {}; + let forceKillTimer; const timer = setTimeout(() => { timedOut = true; timeoutMetadata = onTimeout?.() || {}; - child.kill("SIGTERM"); + signalProcessTree(child, "SIGTERM"); + forceKillTimer = setTimeout(() => signalProcessTree(child, "SIGKILL"), 2000); + forceKillTimer.unref(); }, timeoutMs); child.stdout?.on("data", chunk => { @@ -868,10 +907,16 @@ function runCommand(command, args, options = {}) { }); child.on("error", error => { clearTimeout(timer); + clearTimeout(forceKillTimer); reject(error); }); child.on("exit", code => { clearTimeout(timer); + clearTimeout(forceKillTimer); + if (timedOut) signalProcessTree(child, "SIGKILL"); + child.stdin?.destroy(); + child.stdout?.destroy(); + child.stderr?.destroy(); if (timedOut && !allowTimeout) { reject(new Error(`${command} timed out after ${timeoutMs}ms`)); @@ -893,6 +938,24 @@ function runCommand(command, args, options = {}) { }); } +export function runCommandForTest(command, args, options) { + return runCommand(command, args, options); +} + +function signalProcessTree(child, signal) { + if (child.pid == null) return false; + try { + if (process.platform !== "win32") { + process.kill(-child.pid, signal); + return true; + } + return child.kill(signal); + } catch (error) { + if (error?.code === "ESRCH") return false; + throw error; + } +} + function readX11WindowTree() { if (!process.env.DISPLAY) { return ""; diff --git a/test/appcast.test.mjs b/test/appcast.test.mjs index 9275558..d676842 100644 --- a/test/appcast.test.mjs +++ b/test/appcast.test.mjs @@ -36,10 +36,10 @@ test("npmVersionFor applies launcher revision suffixes", () => { buildNumber: "1041" }; - assert.equal(npmVersionFor("prod", upstream), "26.313.41514-launcher.34"); + assert.equal(npmVersionFor("prod", upstream), "26.313.41514-launcher.35"); assert.equal( npmVersionFor("beta", upstream), - "26.313.41514-beta.1041.launcher.34" + "26.313.41514-beta.1041.launcher.35" ); assert.equal(npmVersionFor("prod", upstream, 0), "26.313.41514"); assert.equal(npmVersionFor("beta", upstream, 0), "26.313.41514-beta.1041"); diff --git a/test/build.test.mjs b/test/build.test.mjs index f37cc25..7eb3b8d 100644 --- a/test/build.test.mjs +++ b/test/build.test.mjs @@ -5,12 +5,26 @@ import os from "node:os"; import path from "node:path"; import { + codexCliRuntime, installLinuxRuntimeExecutable, patchBetterSqlite3NativeSource, stagePackagedResources, writeLinuxAppPackageMetadata } from "../scripts/lib/build.mjs"; +test("Codex CLI runtime matches the current ChatGPT desktop bundle", () => { + assert.equal( + codexCliRuntime.url, + "https://github.com/openai/codex/releases/download/rust-v0.146.0-alpha.3.1/codex-package-x86_64-unknown-linux-musl.tar.gz" + ); + assert.equal( + codexCliRuntime.sha256, + "71696f571d99b83ca09ef482653315fe8b7bfc1c18253662da5406e8d3f17158" + ); + assert.equal(codexCliRuntime.codexEntry, "bin/codex"); + assert.equal(codexCliRuntime.codeModeHostEntry, "bin/codex-code-mode-host"); +}); + test("stagePackagedResources preserves Linux-safe upstream resources", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-app-linux-build-test-")); const resourcesDir = path.join(root, "Resources"); diff --git a/test/chrome-extension-host.test.mjs b/test/chrome-extension-host.test.mjs new file mode 100644 index 0000000..363e281 --- /dev/null +++ b/test/chrome-extension-host.test.mjs @@ -0,0 +1,101 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { + CHROME_EXTENSION_HOST_CONTENT_VARIANT, + stageLinuxChromeExtensionHost +} from "../scripts/lib/chrome-extension-host.mjs"; + +test("stageLinuxChromeExtensionHost installs the project host over an empty upstream directory", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-linux-chrome-host-")); + const resourcesDir = path.join(root, "resources"); + const pluginDir = path.join( + resourcesDir, + "plugins", + "openai-bundled", + "plugins", + "chrome" + ); + const sourcePath = path.join(root, "extension-host"); + + await fs.mkdir(path.join(pluginDir, "extension-host"), { recursive: true }); + await fs.copyFile("/bin/true", sourcePath); + + const installedPath = await stageLinuxChromeExtensionHost(resourcesDir, { + sourcePath + }); + + assert.equal( + installedPath, + path.join(pluginDir, "extension-host", "linux", "x64", "extension-host") + ); + assert.deepEqual(await fs.readFile(installedPath), await fs.readFile(sourcePath)); + assert.equal((await fs.stat(installedPath)).mode & 0o777, 0o755); + assert.match( + await fs.readFile( + path.join(pluginDir, "extension-host", "linux", "LICENSE.ilysenko-MIT.txt"), + "utf8" + ), + /MIT License/ + ); + assert.match( + await fs.readFile( + path.join(pluginDir, "extension-host", "linux", "THIRD_PARTY_NOTICES.md"), + "utf8" + ), + /ddcd9b559274abc7d4c8ae8ba54813a8acbcb982/ + ); + assert.match( + await fs.readFile( + path.join(pluginDir, "extension-host", "linux", "RUST_DEPENDENCY_LICENSES.md"), + "utf8" + ), + /tungstenite 0\.29\.0[\s\S]*MIT License/ + ); +}); + +test("stageLinuxChromeExtensionHost replaces an untrusted upstream Linux host", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-linux-chrome-host-")); + const resourcesDir = path.join(root, "resources"); + const targetPath = path.join( + resourcesDir, + "plugins", + "openai-bundled", + "plugins", + "chrome", + "extension-host", + "linux", + "x64", + "extension-host" + ); + const sourcePath = path.join(root, "extension-host"); + + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, "unknown-upstream-binary"); + await fs.copyFile("/bin/true", sourcePath); + + await stageLinuxChromeExtensionHost(resourcesDir, { sourcePath }); + + assert.deepEqual(await fs.readFile(targetPath), await fs.readFile(sourcePath)); +}); + +test("stageLinuxChromeExtensionHost fails closed when the Chrome plugin layout drifts", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-linux-chrome-host-")); + const resourcesDir = path.join(root, "resources"); + const sourcePath = path.join(root, "extension-host"); + + await fs.mkdir(resourcesDir, { recursive: true }); + await fs.copyFile("/bin/true", sourcePath); + + await assert.rejects( + stageLinuxChromeExtensionHost(resourcesDir, { sourcePath }), + /Chrome plugin root is missing/ + ); +}); + +test("Linux host cache revision is explicit and stable", () => { + assert.equal(CHROME_EXTENSION_HOST_CONTENT_VARIANT, "linux-extension-host-v3"); +}); diff --git a/test/chrome-extension-patches.test.mjs b/test/chrome-extension-patches.test.mjs new file mode 100644 index 0000000..53687c0 --- /dev/null +++ b/test/chrome-extension-patches.test.mjs @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + hasLinuxChromeExtensionHostContentVariant, + linuxChromeExtensionHostContentVariantContract, + patchLinuxChromeExtensionHostContentVariant +} from "../scripts/lib/chrome-extension-patches.mjs"; +import { applyUpstreamPatchContract } from "../scripts/lib/upstream-patches.mjs"; + +const upstreamMaterializer = [ + "async function Za(e){", + "let t,n=[],r=qa.get(e.pluginName);", + "if(r==null?e.pluginName===`computer-use`&&(t=e.computerUseSkillVariant):(t=e.browserSkillVariant),t==null)return;", + "let i=join(e.pluginRoot,`.codex-plugin`,`plugin.json`),a=await schema.parseAsync(JSON.parse(await fs.readFile(i,`utf8`)));", + "await fs.writeFile(i,`${JSON.stringify({...a,bundledContentVariant:t},null,2)}\\n`,`utf8`)", + "}" +].join(""); + +test("patchLinuxChromeExtensionHostContentVariant revises only the Chrome cache identity", () => { + const patched = patchLinuxChromeExtensionHostContentVariant(upstreamMaterializer); + + assert.match( + patched, + /bundledContentVariant:e\.pluginName===`chrome`\?`\$\{t\}-linux-extension-host-v3`:t/ + ); + assert.equal(hasLinuxChromeExtensionHostContentVariant(patched), true); +}); + +test("patchLinuxChromeExtensionHostContentVariant is idempotent", () => { + const patched = patchLinuxChromeExtensionHostContentVariant(upstreamMaterializer); + + assert.equal(patchLinuxChromeExtensionHostContentVariant(patched), patched); +}); + +test("content-variant contract runner accepts an already patched bundle", () => { + const patched = applyUpstreamPatchContract( + upstreamMaterializer, + linuxChromeExtensionHostContentVariantContract + ); + + assert.equal( + applyUpstreamPatchContract(patched, linuxChromeExtensionHostContentVariantContract), + patched + ); +}); + +test("patchLinuxChromeExtensionHostContentVariant fails on upstream contract drift", () => { + assert.throws( + () => patchLinuxChromeExtensionHostContentVariant("async function unrelated(){}"), + /Chrome plugin content-variant contract changed/ + ); +}); diff --git a/test/chrome-plugin-patches.test.mjs b/test/chrome-plugin-patches.test.mjs new file mode 100644 index 0000000..e067842 --- /dev/null +++ b/test/chrome-plugin-patches.test.mjs @@ -0,0 +1,63 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { + patchLinuxChromePluginResources, + patchLinuxNativeHostManifestCheckSource +} from "../scripts/lib/chrome-plugin-patches.mjs"; + +const nativeHostManifestSource = ` +function getNativeHostManifestLocation() { + if (process.platform === "darwin") return { manifestPath: "mac", registryKey: null }; + if (process.platform === "win32") return { manifestPath: "win", registryKey: "key" }; + throw new Error( + \`Unsupported platform for native host manifest check: \${process.platform}. This script supports macOS and Windows.\`, + ); +} +`; + +test("native host diagnostics resolve Chrome's Linux manifest", () => { + const patched = patchLinuxNativeHostManifestCheckSource(nativeHostManifestSource); + + assert.match(patched, /process\.platform === "linux"/); + assert.match(patched, /"\.config",\s*"google-chrome",\s*"NativeMessagingHosts"/); + assert.match(patched, /supports macOS, Linux, and Windows/); + assert.equal(patchLinuxNativeHostManifestCheckSource(patched), patched); +}); + +test("native host diagnostics fail closed when the upstream contract drifts", () => { + assert.throws( + () => patchLinuxNativeHostManifestCheckSource("function unrelated() {}"), + /Linux native-host manifest diagnostics contract changed/ + ); +}); + +test("resource patching preserves the trusted browser client bytes", async t => { + const resourcesDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-chrome-plugin-")); + t.after(() => fs.rm(resourcesDir, { recursive: true, force: true })); + + const scriptsDir = path.join( + resourcesDir, + "plugins", + "openai-bundled", + "plugins", + "chrome", + "scripts" + ); + await fs.mkdir(scriptsDir, { recursive: true }); + await fs.writeFile( + path.join(scriptsDir, "check-native-host-manifest.js"), + nativeHostManifestSource + ); + const browserClientSource = + 'var Xd=dH(pH(),fH()==="win32"?"AppData\\\\Local\\\\Google\\\\Chrome\\\\User Data":"Library/Application Support/Google/Chrome");'; + const browserClientPath = path.join(scriptsDir, "browser-client.mjs"); + await fs.writeFile(browserClientPath, browserClientSource); + + await patchLinuxChromePluginResources(resourcesDir); + + assert.equal(await fs.readFile(browserClientPath, "utf8"), browserClientSource); +}); diff --git a/test/electron-builder-config.test.mjs b/test/electron-builder-config.test.mjs new file mode 100644 index 0000000..8ceaf8b --- /dev/null +++ b/test/electron-builder-config.test.mjs @@ -0,0 +1,16 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +test("electron-builder preserves the upstream package entry point", async () => { + const config = await fs.readFile( + path.join(__dirname, "..", "electron-builder.config.mjs"), + "utf8" + ); + + assert.doesNotMatch(config, /extraMetadata\s*:\s*\{\s*main\s*:/); +}); diff --git a/test/package-manifest.test.mjs b/test/package-manifest.test.mjs index d9ad9a3..bab773a 100644 --- a/test/package-manifest.test.mjs +++ b/test/package-manifest.test.mjs @@ -14,7 +14,12 @@ test("repo package manifest matches publisher toolchain", async () => { assert.equal(manifest.name, "codex-app-linux-publisher"); assert.equal(manifest.private, true); assert.equal(manifest.type, "module"); - assert.equal(manifest.scripts.test, "node --test"); + assert.equal(manifest.scripts.test, "npm run test:node && npm run test:host"); + assert.equal(manifest.scripts["test:node"], "node --test test/*.test.mjs"); + assert.equal( + manifest.scripts["test:host"], + "cargo test --locked --manifest-path native/chrome-extension-host/Cargo.toml" + ); assert.equal(manifest.scripts["release:prod"], "node scripts/release-channel.mjs --channel prod"); assert.equal(manifest.scripts["release:beta"], "node scripts/release-channel.mjs --channel beta"); assert.equal(manifest.devDependencies.electron, "42.1.0"); diff --git a/test/release-workflow.test.mjs b/test/release-workflow.test.mjs index 926a215..9fb68ed 100644 --- a/test/release-workflow.test.mjs +++ b/test/release-workflow.test.mjs @@ -40,6 +40,20 @@ test("release workflow runs canary and smoke before publish mutations", async () assert.ok(betaSmoke < betaRelease); }); +test("release workflow installs the static Linux Chrome host toolchain", async () => { + const workflow = await fs.readFile(".github/workflows/release.yml", "utf8"); + const prodJob = workflow.slice( + workflow.indexOf(" publish-prod:"), + workflow.indexOf(" publish-beta:") + ); + const betaJob = workflow.slice(workflow.indexOf(" publish-beta:")); + + for (const job of [prodJob, betaJob]) { + assert.match(job, /rustup target add x86_64-unknown-linux-musl/); + assert.match(job, /musl-tools/); + } +}); + test("upstream canary workflow reports scheduled failures without publish permissions", async () => { const workflow = await fs.readFile(".github/workflows/upstream-canary.yml", "utf8"); const canaryJob = workflow.slice( diff --git a/test/smoke-artifacts.test.mjs b/test/smoke-artifacts.test.mjs index c1c1d97..d94eb56 100644 --- a/test/smoke-artifacts.test.mjs +++ b/test/smoke-artifacts.test.mjs @@ -1,11 +1,67 @@ import test from "node:test"; import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { evaluateBundledCodexLauncherSource, evaluateDesktopBootResult, - evaluateLinuxWindowFocusableContractSources + evaluateLinuxWindowFocusableContractSources, + formatBrowserSmokeFailure, + runCommandForTest, + smokeBundledCodexDynamicTools } from "../scripts/smoke-artifacts.mjs"; +import { + evaluateLinuxChromeExtensionHostArtifact, + evaluateLinuxChromeExtensionHostEnsure, + evaluateLinuxChromeExtensionHostHello, + parseNativeMessageFrame +} from "../scripts/lib/chrome-extension-smoke.mjs"; + +test("browser smoke failures preserve console and rendered-page diagnostics", () => { + const message = formatBrowserSmokeFailure({ + error: new Error("page.waitForFunction: Timeout 45000ms exceeded"), + fatalConsole: ["TypeError: missing app host RPC"], + bodyText: " Loading ChatGPT\n\nUpdate required ", + url: "http://127.0.0.1:3000/" + }); + + assert.match(message, /browser smoke failed for http:\/\/127\.0\.0\.1:3000\//); + assert.match(message, /page\.waitForFunction: Timeout 45000ms exceeded/); + assert.match(message, /TypeError: missing app host RPC/); + assert.match(message, /body: Loading ChatGPT Update required/); +}); + +test("bundled Codex dynamic-tool smoke runs its app-server protocol", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-app-linux-smoke-test-")); + const resourcesDir = path.join(root, "resources"); + const executablePath = path.join(resourcesDir, "codex"); + + try { + await fs.mkdir(resourcesDir); + await fs.writeFile(executablePath, `#!/usr/bin/env node +const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +lines.on("line", line => { + const message = JSON.parse(line); + if (message.id === 1) console.log(JSON.stringify({ id: 1, result: {} })); + if (message.id === 2) console.log(JSON.stringify({ id: 2, result: { thread: { id: "canonical" } } })); + if (message.id === 3) console.log(JSON.stringify({ id: 3, result: { thread: { id: "legacy" } } })); + if (message.id === 4) console.log(JSON.stringify({ id: 4, error: { message: "dynamic tools must use either canonical or legacy format consistently" } })); +}); +`); + await fs.chmod(executablePath, 0o755); + + assert.deepEqual(await smokeBundledCodexDynamicTools(resourcesDir), { + canonicalThreadId: "canonical", + legacyThreadId: "legacy", + hybridRejected: true + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); test("desktop boot smoke accepts a silent process still alive at timeout", () => { assert.deepEqual( @@ -61,6 +117,26 @@ test("desktop boot smoke rejects native failed-start dialogs", () => { ); }); +test("timed-out smoke commands terminate descendants holding stdio open", { + skip: process.platform === "win32" +}, async () => { + const startedAt = Date.now(); + const result = await runCommandForTest( + process.execPath, + [ + "-e", + `require("node:child_process").spawn(process.execPath, ["-e", "setInterval(() => {}, 60000)"], { + stdio: ["ignore", "inherit", "inherit"] + }); + setInterval(() => {}, 60000);` + ], + { allowTimeout: true, capture: true, timeoutMs: 100 } + ); + + assert.equal(result.timedOut, true); + assert.ok(Date.now() - startedAt < 2000); +}); + test("bundled Codex launcher smoke rejects PATH-first wrappers", () => { assert.throws( () => evaluateBundledCodexLauncherSource(`#!/bin/sh @@ -130,3 +206,131 @@ test("Linux window focusable smoke accepts patched and legacy-safe defaults", () } ); }); + +test("Chrome extension host smoke accepts an executable static PIE x64 host", () => { + assert.deepEqual( + evaluateLinuxChromeExtensionHostArtifact({ + fileType: + "ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), static-pie linked, stripped", + mode: 0o755 + }), + { + executable: true, + static: true, + architecture: "x86-64" + } + ); +}); + +test("Chrome extension host smoke rejects dynamic and non-executable hosts", () => { + assert.throws( + () => + evaluateLinuxChromeExtensionHostArtifact({ + fileType: + "ELF 64-bit LSB pie executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2", + mode: 0o755 + }), + /must be statically linked/ + ); + assert.throws( + () => + evaluateLinuxChromeExtensionHostArtifact({ + fileType: + "ELF 64-bit LSB pie executable, x86-64, static-pie linked, stripped", + mode: 0o644 + }), + /must be executable/ + ); +}); + +test("Chrome extension host smoke decodes and verifies the protocol-v2 hello", () => { + const message = { + jsonrpc: "2.0", + id: "smoke-hello", + result: { + manifestSchemaVersion: 2, + nativeHostProtocolVersion: 2, + nativeHostVersion: "0.1.0", + supportedProtocolVersions: [2], + supportedMethods: ["codexRuntime/openLocalFile"] + } + }; + const body = Buffer.from(JSON.stringify(message)); + const frame = Buffer.alloc(4 + body.length); + frame.writeUInt32LE(body.length); + body.copy(frame, 4); + + assert.deepEqual(parseNativeMessageFrame(frame), message); + assert.deepEqual(evaluateLinuxChromeExtensionHostHello(message), { + protocolVersion: 2, + version: "0.1.0" + }); + assert.throws( + () => evaluateLinuxChromeExtensionHostHello({ ...message, id: "wrong" }), + /unexpected hello response ID/ + ); + assert.throws(() => parseNativeMessageFrame(frame.subarray(0, -1)), /truncated/); +}); + +test("Chrome extension host smoke verifies desktop-managed runtime selection", () => { + const message = { + jsonrpc: "2.0", + id: "smoke-ensure", + result: { + entryId: "linux-managed-smoke", + localAppServerUrl: "ws://127.0.0.1:4567/?token=secret", + runtimeSessionId: "session", + selected: { + appServerProtocolVersion: 2, + channel: "prod", + nativeHostProtocolVersion: 2 + }, + runtimeConfig: { + platform: "linux", + codexCliPath: "/opt/codex/resources/codex", + codexHome: "/tmp/codex-home", + desktopAgentModeDefaults: { + agentModesByHostId: {}, + preferredNonFullAccessModesByHostId: {} + }, + nodePath: "/opt/codex/resources/node" + } + } + }; + + assert.deepEqual( + evaluateLinuxChromeExtensionHostEnsure(message, { + channelName: "prod", + codexCliPath: "/opt/codex/resources/codex", + codexHome: "/tmp/codex-home", + nodePath: "/opt/codex/resources/node" + }), + { + channel: "prod", + entryId: "linux-managed-smoke", + protocolVersion: 2 + } + ); + assert.throws( + () => evaluateLinuxChromeExtensionHostEnsure({ ...message, id: "wrong" }, {}), + /unexpected ensure response ID/ + ); + assert.throws( + () => evaluateLinuxChromeExtensionHostEnsure({ + ...message, + result: { + ...message.result, + runtimeConfig: { + ...message.result.runtimeConfig, + desktopAgentModeDefaults: null + } + } + }, { + channelName: "prod", + codexCliPath: "/opt/codex/resources/codex", + codexHome: "/tmp/codex-home", + nodePath: "/opt/codex/resources/node" + }), + /incompatible managed runtime/ + ); +}); diff --git a/test/upstream-patches.test.mjs b/test/upstream-patches.test.mjs index 2855f8c..6ebb39b 100644 --- a/test/upstream-patches.test.mjs +++ b/test/upstream-patches.test.mjs @@ -9,6 +9,7 @@ import { hasUnguardedOwlFeatureBindingSource, patchDisableTransparencySource, patchLinuxOwlFeatureBindingSource, + patchLinuxChromeExtensionDetectionSource, patchLinuxWindowFocusableSource, patchLinuxOpenTargetsSource, upstreamPatchContracts @@ -18,6 +19,68 @@ const openTargetResolverSource = "function W(e){let t=which.default.sync(e,{nothrow:!0});return typeof t==`string`&&fs.existsSync(t)?t:null}"; const withOpenTargetResolver = parts => [openTargetResolverSource, ...parts].join(";"); +test("patchLinuxChromeExtensionDetectionSource finds stable Chrome profiles", () => { + const source = + "function o({homeDir:e,localAppDataDir:t,platform:n}){return n===`darwin`?p.join(e,`Library`,`Application Support`,`Google`,`Chrome`):n===`win32`?p.join(t??p.join(e,`AppData`,`Local`),`Google`,`Chrome`,`User Data`):null};globalThis.resolveChromeRoot=o"; + const patched = patchLinuxChromeExtensionDetectionSource(source); + const context = { + globalThis: {}, + p: { join: (...parts) => parts.join("/") } + }; + + vm.runInNewContext(patched, context); + + assert.equal( + context.globalThis.resolveChromeRoot({ + homeDir: "/home/zero", + platform: "linux" + }), + "/home/zero/.config/google-chrome" + ); + assert.equal( + context.globalThis.resolveChromeRoot({ + homeDir: "/Users/zero", + platform: "darwin" + }), + "/Users/zero/Library/Application Support/Google/Chrome" + ); + assert.equal(patchLinuxChromeExtensionDetectionSource(patched), patched); +}); + +test("patchLinuxChromeExtensionDetectionSource accepts upstream native Linux profiles", () => { + const source = + "function Nu({platform:e,homeDirectory:t,localAppData:n}){return e===`darwin`?[p.join(t,`Library`,`Application Support`,`Google`,`Chrome`)]:e===`win32`?n==null?[]:[p.join(n,`Google`,`Chrome`,`User Data`)]:e===`linux`?[p.join(t,`.config`,`google-chrome`),p.join(t,`.config`,`google-chrome-beta`),p.join(t,`.config`,`google-chrome-canary`),p.join(t,`.config`,`chromium`)]:[]}globalThis.resolveChromeRoots=Nu"; + const patched = patchLinuxChromeExtensionDetectionSource(source); + const context = { + globalThis: {}, + p: { join: (...parts) => parts.join("/") } + }; + + vm.runInNewContext(patched, context); + + assert.equal(patched, source); + assert.deepEqual( + Array.from(context.globalThis.resolveChromeRoots({ + homeDirectory: "/home/zero", + platform: "linux" + })), + [ + "/home/zero/.config/google-chrome", + "/home/zero/.config/google-chrome-beta", + "/home/zero/.config/google-chrome-canary", + "/home/zero/.config/chromium" + ] + ); +}); + +test("patchLinuxChromeExtensionDetectionSource accepts metadata-driven Linux profiles", () => { + const source = + "function Ts({extensionId:e,chromeConfigHome:t=process.env.CHROME_CONFIG_HOME,homeDir:n=h.homedir(),localAppDataDir:r=process.env.LOCALAPPDATA,platform:i=process.platform,xdgConfigHome:a=process.env.XDG_CONFIG_HOME}){let o=js(e);return Ns({homeDir:n,chromeConfigHome:t,localAppDataDir:r,platform:i,xdgConfigHome:a}).some(e=>Ms(e,o))}" + + "function Ns({chromeConfigHome:e,homeDir:t,localAppDataDir:r,platform:i,xdgConfigHome:a}){if(i===`darwin`)return[p.join(t,...xs.macos.userDataDirectorySegments)];if(i===`win32`)return[p.join(r??p.join(t,`AppData`,`Local`),...xs.windows.userDataDirectorySegments)];if(i===`linux`){let r=n.On({chromeConfigHome:e,homeDir:t,xdgConfigHome:a});return n.Dn.map(e=>p.join(r,e.userDataDirName))}return[]}"; + + assert.equal(patchLinuxChromeExtensionDetectionSource(source), source); +}); + test("patchLinuxOpenTargetsSource adds Linux editor targets and exposes app paths", () => { const source = withOpenTargetResolver([ "prefix", @@ -172,7 +235,9 @@ test("upstream patch contracts declare required contract surface", () => { "open-target-dispatcher", "linux-window-background", "linux-window-transparency", - "linux-window-focusable-default" + "linux-window-focusable-default", + "linux-chrome-extension-host-content-variant", + "linux-chrome-extension-detection" ] ); }); diff --git a/test/web-assets.test.mjs b/test/web-assets.test.mjs index 53c4c91..917de52 100644 --- a/test/web-assets.test.mjs +++ b/test/web-assets.test.mjs @@ -116,11 +116,22 @@ test("createAppHostModuleBody resolves RPC peer constructor semantically", () => ); assert.match(body, /const rpcModulePromise = import/); + assert.match(body, /const functionToString = Function\.prototype\.toString;\s+const rpcModulePromise = import/); assert.match(body, /\[rpcModule\.tC, rpcModule\.V, rpcModule\.E, \.\.\.Object\.values\(rpcModule\)\]\.find/); - assert.match(body, /getRemoteMain/); + assert.match(body, /functionToString\.call\(value\)\.includes\("getRemoteMain"\)/); + assert.doesNotMatch(body, /Function\.prototype\.toString\.call\(value\)/); assert.doesNotMatch(body, /import \{ E as createRpcPeer \}/); }); +test("createAppHostModuleBody exposes Sparkle query params RPC", () => { + const body = createAppHostModuleBody( + "/tmp/codex-web/webview/assets/rpc-new.js", + "/tmp/codex-web/webview" + ); + + assert.match(body, /appUpdates:\s*\{[\s\S]*?setSparkleQueryParams\(\)\s*\{\s*\}/); +}); + test("findAppHostRpcModulePath accepts upstream rpc facade layout", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-app-linux-rpc-assets-")); const assetsDir = path.join(root, "assets");