diff --git a/UPGRADING.md b/UPGRADING.md index a622475..6bf0a17 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -22,7 +22,7 @@ the context the patches were being applied in. there are a few places to update next. -1. `appVersion` in default.nix and `hash` in `codexZip`. +1. `version` and `hash` in `nix/codex-zip/default.nix`. 2. `APP_VERSION` in ./scripts/prepare then temporarily comment out the patch lines in ./scripts/prepare_asar and run diff --git a/default.nix b/default.nix index 76984d9..4f13c5b 100644 --- a/default.nix +++ b/default.nix @@ -16,19 +16,13 @@ flake-utils.lib.eachSystem systems ( system: let pkgs = import nixpkgs { inherit system; }; - appVersion = "26.513.20950"; - codexZip = pkgs.fetchurl { - url = "https://persistent.oaistatic.com/codex-app-prod/Codex-darwin-arm64-${appVersion}.zip"; - hash = "sha256-zSlRaoUJc4eRFbe08qS/oyqaBbfW2Epjj3hlbEmA6Cw="; - }; - codex = self.packages.${system}.codex; in { devShells.default = pkgs.mkShell { - HOSTED_CODEX_APP_ZIP = codexZip; + HOSTED_CODEX_APP_ZIP = self.packages.${system}.codexZip; packages = [ - codex + self.packages.${system}.codex pkgs.nodejs pkgs.unzip pkgs.patch @@ -89,7 +83,7 @@ flake-utils.lib.eachSystem systems ( in { default = pkgs.buildNpmPackage { - HOSTED_CODEX_APP_ZIP = codexZip; + HOSTED_CODEX_APP_ZIP = self.packages.${system}.codexZip; pname = "codex-web"; version = "1.0.0"; @@ -111,6 +105,11 @@ flake-utils.lib.eachSystem systems ( patchShebangs scripts ''; + postBuild = '' + substituteInPlace src/server/main.js \ + --replace-fail '@resourcesPath@' '${self.packages.${system}.codex_resources}' + ''; + preInstall = '' # npm pack always runs the package prepare lifecycle. Nix already ran # the explicit build script above, so remove prepare in the sandbox. diff --git a/flake.nix b/flake.nix index 372597e..deff901 100644 --- a/flake.nix +++ b/flake.nix @@ -24,6 +24,10 @@ inputs@{ flake-utils, ... }: flake-utils.lib.meld inputs [ ./nix/codex + ./nix/codex-zip + ./nix/codex-primary-runtime + ./nix/codex-resources + ./src/chrome-extension-host ./default.nix ./nix/fmt.nix ./scripts/fetch_updates diff --git a/nix/codex-primary-runtime/default.nix b/nix/codex-primary-runtime/default.nix new file mode 100644 index 0000000..c6257d4 --- /dev/null +++ b/nix/codex-primary-runtime/default.nix @@ -0,0 +1,50 @@ +{ + flake-utils, + nixpkgs, + ... +}: +flake-utils.lib.eachSystem [ "x86_64-linux" ] ( + system: + let + pkgs = import nixpkgs { inherit system; }; + version = "26.426.12240"; + in + { + packages.codex-primary-runtime = pkgs.stdenvNoCC.mkDerivation { + pname = "codex-primary-runtime"; + inherit version; + + # this one random version of codex-primary-runtime has a linux build with + # node_repl mcp. considered using the macOS binary and emulating with + # darling but ran into numerous issues with darling's isolation + # requirements + src = pkgs.fetchurl { + url = "https://persistent.oaistatic.com/codex-primary-runtime/${version}/codex-primary-runtime-linux-x64-${version}.tar.xz"; + hash = "sha256-21Yk6276NrZuxvbdBIjO+5ZuSWNoYqq2IJpDNsHKkMQ="; + }; + + sourceRoot = "codex-primary-runtime"; + + nativeBuildInputs = [ pkgs.autoPatchelfHook ]; + + buildInputs = [ + pkgs.glibc + pkgs.libxcrypt-legacy + pkgs.stdenv.cc.cc.lib + pkgs.zlib + ]; + + dontConfigure = true; + dontBuild = true; + + installPhase = '' + runHook preInstall + + mkdir -p "$out" + cp -R . "$out"/ + + runHook postInstall + ''; + }; + } +) diff --git a/nix/codex-resources/default.nix b/nix/codex-resources/default.nix new file mode 100644 index 0000000..aed6d23 --- /dev/null +++ b/nix/codex-resources/default.nix @@ -0,0 +1,183 @@ +{ + self, + flake-utils, + nixpkgs, + ... +}: +let + systems = [ + "aarch64-darwin" + "x86_64-darwin" + "aarch64-linux" + "x86_64-linux" + ]; +in +flake-utils.lib.eachSystem systems ( + system: + let + pkgs = import nixpkgs { inherit system; }; + linuxOpenChromeWindow = + let + chromeExtensionId = "hehggadaopoacecdllhhajmbjkdcmajg"; + chromeNativeHostName = "com.openai.codexextension"; + chromeExtensionUpdateUrl = "https://clients2.google.com/service/update2/crx"; + chromeNativeHostManifest = pkgs.writeText "codex-chrome-native-host-manifest.json" ( + builtins.toJSON { + name = chromeNativeHostName; + description = "Codex chrome native messaging host"; + type = "stdio"; + path = "${self.packages.${system}.codex_chrome_extension_host}/bin/codex-chrome-extension-host"; + allowed_origins = [ "chrome-extension://${chromeExtensionId}/" ]; + } + ); + linuxProfileRootTemplate = pkgs.linkFarm "codex-brave-profile-root-template" [ + { + name = "xdg-config/BraveSoftware/Brave-Browser/NativeMessagingHosts/${chromeNativeHostName}.json"; + path = chromeNativeHostManifest; + } + { + name = "user-data/External Extensions/${chromeExtensionId}.json"; + path = pkgs.writeText "codex-chrome-extension.json" ( + builtins.toJSON { + external_update_url = chromeExtensionUpdateUrl; + } + ); + } + { + name = "user-data/policies/managed/codex.json"; + path = pkgs.writeText "codex-chrome-policy.json" ( + builtins.toJSON { + ExtensionInstallForcelist = [ "${chromeExtensionId};${chromeExtensionUpdateUrl}" ]; + AudioCaptureAllowed = false; + VideoCaptureAllowed = false; + DefaultClipboardSetting = 2; + DefaultWebUsbGuardSetting = 2; + DefaultSerialGuardSetting = 2; + } + ); + } + { + name = "user-data/Codex/Preferences"; + path = pkgs.writeText "codex-chrome-preferences.json" ( + builtins.toJSON { + profile = { + name = "Codex"; + }; + extensions = { + settings = { + "${chromeExtensionId}" = { + external_update_url = chromeExtensionUpdateUrl; + }; + }; + }; + } + ); + } + ]; + in + pkgs.writeShellScriptBin "codex-open-chrome-window" '' + set -euo pipefail + + if [[ "$#" -gt 0 ]]; then + echo "Usage: scripts/open-chrome-window" >&2 + exit 2 + fi + + profile_root="$(mktemp -d -t codex-brave-profile.XXXXXX)" + + home_dir="$profile_root/home" + xdg_config_home="$profile_root/xdg-config" + xdg_cache_home="$profile_root/xdg-cache" + user_data_dir="$profile_root/user-data" + profile_name="Codex" + + cp -RL --no-preserve=mode,ownership,timestamps ${linuxProfileRootTemplate}/. "$profile_root" + + mkdir -p \ + "$home_dir" \ + "$xdg_cache_home" \ + "$xdg_config_home" \ + "$user_data_dir" + + chmod -R u+w "$profile_root" + + log_file="$profile_root/brave.log" + HOME="$home_dir" XDG_CONFIG_HOME="$xdg_config_home" XDG_CACHE_HOME="$xdg_cache_home" \ + ${pkgs.xvfb-run}/bin/xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \ + ${pkgs.brave}/bin/brave \ + --user-data-dir="$user_data_dir" \ + --profile-directory="$profile_name" \ + --no-first-run \ + --no-default-browser-check \ + --disable-dev-shm-usage \ + --force-device-scale-factor=1 \ + --start-maximized \ + --window-position=0,0 \ + --window-size=1920,1080 \ + --new-window about:blank + ''; + in + { + packages.codex_resources = pkgs.stdenvNoCC.mkDerivation { + pname = "codex-resources"; + version = self.packages.${system}.codexZip.version; + + src = self.packages.${system}.codexZip; + + nativeBuildInputs = [ + pkgs.patch + pkgs.unzip + ]; + + dontConfigure = true; + dontBuild = true; + + unpackPhase = '' + runHook preUnpack + + unzip -q "$src" + + runHook postUnpack + ''; + + installPhase = '' + runHook preInstall + + mkdir -p "$out" + cp -R Codex.app/Contents/Resources/plugins "$out/plugins" + + chromeManifestScript="$out/plugins/openai-bundled/plugins/chrome/scripts/installManifest.mjs" + chromeExtensionHost="${ + self.packages.${system}.codex_chrome_extension_host + }/bin/codex-chrome-extension-host" + substituteInPlace "$chromeManifestScript" \ + --replace-fail 'let t=a(o);' "let t=\"$chromeExtensionHost\";" \ + --replace-fail 'path:a(o)' "path:\"$chromeExtensionHost\"" + '' + + pkgs.lib.optionalString (system == "x86_64-linux") '' + chromePluginRoot="$out/plugins/openai-bundled/plugins/chrome" + patch --batch --forward --strip 1 --directory "$chromePluginRoot" < ${./patches/chrome-linux-brave-skill.patch} + substituteInPlace "$chromePluginRoot/skills/chrome/SKILL.md" \ + --replace-fail '@openChromeWindow@' '${linuxOpenChromeWindow}/bin/codex-open-chrome-window' + rm "$chromePluginRoot/scripts/open-chrome-window.js" + '' + + pkgs.lib.optionalString (system == "aarch64-darwin") '' + install -m755 Codex.app/Contents/Resources/node "$out/node" + install -m755 Codex.app/Contents/Resources/node_repl "$out/node_repl" + '' + + pkgs.lib.optionalString (system == "x86_64-linux") '' + install -m755 ${pkgs.nodejs}/bin/node "$out/node" + install -m755 ${ + self.packages.${system}.codex-primary-runtime + }/dependencies/bin/node_repl "$out/node_repl" + '' + + pkgs.lib.optionalString (system != "aarch64-darwin" && system != "x86_64-linux") '' + echo "codex resources are only packaged for aarch64-darwin and x86_64-linux" >&2 + exit 1 + '' + + '' + runHook postInstall + ''; + }; + } +) diff --git a/nix/codex-resources/patches/chrome-linux-brave-skill.patch b/nix/codex-resources/patches/chrome-linux-brave-skill.patch new file mode 100644 index 0000000..1ab0a52 --- /dev/null +++ b/nix/codex-resources/patches/chrome-linux-brave-skill.patch @@ -0,0 +1,206 @@ +--- a/skills/chrome/SKILL.md ++++ b/skills/chrome/SKILL.md +@@ -11,89 +11,21 @@ + + - Use Chrome directly for browser automation requests and for Chrome setup, detection, repair, or profile checks. + - For bare or general `@chrome` requests, do not ask a clarification question just because the request is ambiguous. Proceed with browser automation in this skill using the `chrome` backend. +-- If communication with the Codex Chrome Extension ultimately fails, even after checks, do not attempt to complete the user's request using applescript, bash commands or any other scripting methods. +-- Do not install or repair the native host yourself. If native host setup appears broken, tell the user to reinstall the Chrome plugin from the Codex plugin UI. + + Before using this skill for the first time in the current conversation context, read the entire `SKILL.md` file in one read. Do not use a partial range such as `sed -n '1,220p'`; read through the end of the file. Do not mention this internal skill-loading step to the user. + +-## Chrome Extension Checks ++## Running Chrome + +-On the first Chrome-backed browser task in a session, try a lightweight browser-client call such as listing open tabs after bootstrap. If the call fails, wait 2 seconds and retry the same lightweight browser-client call once. Any non-error response means the extension is installed and working. ++To launch Chrome, run the following script + +-If browser-client still reports that it cannot communicate with Chrome after that retry, confirm that Chrome is installed, running and that the extension is present in the selected Chrome profile: +- +-From the plugin root, use `node_repl` to run: +- + ``` +-scripts/chrome-is-running.js --check +-scripts/installed-browsers.js --check +-scripts/check-extension-installed.js --json +-scripts/check-native-host-manifest.js --json ++@openChromeWindow@ + ``` + +-Depending on the outcome follow the following checks. Be sure to ask the user permission when required, if it is stated in the check. +- +- +-### 1. Chrome is not installed +- +-Keep the first response short and non-technical unless the user asks for more information. +- +-If Chrome is not installed, then inform the user that this plugin only works with the Chrome browser. +- +- +-### 2. Chrome is not running +- +-Keep the first response short and non-technical unless the user asks for more information. +- +-If Chrome is not running then ALWAYS ask the User if they would like to launch Chrome. ALWAYS wait for a user response before taking action. +- +- +-### 3. The native host manifest is not installed, or is invalid +- +-Keep the first response short and non-technical unless the user asks for more information. +- +-Do not install or repair the native host yourself. If native host setup appears broken, tell the user to reinstall the Chrome plugin from the Codex plugin UI. +- +- +-### 4. The Codex Chrome Extension is not installed +- +-Keep the first response short and non-technical unless the user asks for more information. +- +-If the Codex Chrome Extension is missing, tell the user: +- +-`Cannot communicate with the Codex Chrome Extension. Confirm that the extension is installed and enabled in Chrome.` +- +-Ask the User if you can open the Codex Chrome Extension webstore page so they can verify that the extension is installed. ALWAYS wait for a user response before taking action. ALWAYS refer to the extension as the [Codex Chrome Extension](https://chromewebstore.google.com/detail/codex/<>), and not by it's extension ID. +- +-You can construct the URL of the Codex Chrome extension webstore page by appending the `extensionId` from `scripts/extension-id.json` to `https://chromewebstore.google.com/detail/codex/`. +- +- +-### 4. The Codex Chrome Extension is not enabled +- +-Keep the first response short and non-technical unless the user asks for more information. +- +-If the Codex Chrome Extension is not enabled ask the User if you can open the Google Chrome Extension Manager so they can verify that the extension is enabled. ALWAYS wait for a user response before taking action. Always refer to the Google Chrome Extension Manager as [Google Chrome Extension Manager](chrome://extensions/). +- +- +-### 5. Codex Extension is installed and enabled, the manifest file is installed, but communication still fails +- +-Keep the first response short and non-technical unless the user asks for more information. +- +-If Chrome is running and the extension/native-host checks pass, ask the User if you can open a Chrome window for the selected Chrome profile and retry the connection. ALWAYS wait for a user response before taking action. +- +-If the User agrees, run: +- +-``` +-scripts/open-chrome-window.js +-``` +- + Then wait 2 seconds and retry the browser-client setup once. + +-After one successful setup check in a session, do not repeat extension detection unless browser-client reports an extension connection failure. ++This script opens a browser window for the selected Chrome automation profile. Run this process from a long-running background terminal/tool session tied to the current Codex session, and keep that session alive until browser work is finished. + +-If the issue is specifically the native host or extension-backed install path, or if communication still fails after opening a Chrome window and retrying setup once, tell the user to reinstall the Chrome plugin from the Codex plugin UI. Never import or run `scripts/installManifest.mjs` yourself. +- +- + ## Chrome Error handling + + ### File upload errors +@@ -104,91 +36,6 @@ + + `To enable file upload, go to chrome://extensions in Chrome, click Details under the Codex extension, and enable "Allow access to file URLs." See [here](https://developers.openai.com/codex/app/chrome-extension#upload-files) for details.` + +- +-## Commands +- +-### installed-browsers.js +- +-This script reports which browsers are installed. +- +-From the plugin root, use `node_repl` to run: +- +-``` +-scripts/installed-browsers.js +-``` +- +-Use JSON output when another tool or script needs structured data: +- +-``` +-scripts/installed-browsers.js --json +-``` +- +-### chrome-is-running.js +- +-This script checks whether Google Chrome is actively running. It exits `0` when Chrome is running, `1` when Chrome is not running, and `2` for usage or runtime errors. +- +-From the plugin root, use `node_repl` to run: +- +-``` +-scripts/chrome-is-running.js --check +-``` +- +-Use JSON output when another tool or script needs structured data: +- +-``` +-scripts/chrome-is-running.js --json +-``` +- +-### open-chrome-window.js +- +-This script opens `about:blank` in a Google Chrome window for the same selected Chrome profile used by `check-extension-installed.js`. Use it only after the User gives permission. +- +-From the plugin root, use `node_repl` to run: +- +-``` +-scripts/open-chrome-window.js +-``` +- +-Use dry-run JSON output when another tool or script needs to verify the selected launch command without opening Chrome: +- +-``` +-scripts/open-chrome-window.js --dry-run --json +-``` +- +-### check-extension-installed.js +- +-This script checks whether the selected Google Chrome profile has the configured extension registered and present, either from installed version directories or a registered unpacked extension path. It exits `0` when installed and enabled, `1` when installed but not enabled, `2` when not installed, and `3` for usage or runtime errors. +- +-From the plugin root, use `node_repl` to run: +- +-``` +-scripts/check-extension-installed.js +-``` +- +-Use JSON output when another tool or script needs structured data: +- +-``` +-scripts/check-extension-installed.js --json +-``` +- +-The check reads the configured extension ID from `scripts/extension-id.json`. It detects the Chrome profile from `Local State`, then falls back to the highest-numbered `Profile X` or `Default` directory with `Preferences`. For debugging or tests, override profile selection with `CODEX_CHROME_USER_DATA_DIR=/path/to/chrome-root` or `CODEX_CHROME_PREFERENCES_PATH=/path/to/Profile/Preferences`. +- +-### check-native-host-manifest.js +- +-This script checks whether the Chrome Native Messaging Host manifest exists for the configured native host name and allows the Chrome extension ID from `scripts/extension-id.json`. On Windows it also checks the Chrome NativeMessagingHosts registry key. It exits `0` when correct, `1` when missing or incorrect, and `2` for usage or runtime errors. +- +-From the plugin root, use `node_repl` to run: +- +-``` +-scripts/check-native-host-manifest.js +-``` +- +-Use JSON output when another tool or script needs structured data: +- +-``` +-scripts/check-native-host-manifest.js --json +-``` +- + ## Chrome Safety + + - Do not inspect browser cookies, local storage, profiles, passwords, or session stores. +@@ -749,7 +596,7 @@ + interface CUAAPI { + click(options: ClickOptions): Promise; // Click at a coordinate in the current viewport. + double_click(options: DoubleClickOptions): Promise; // Double click at a coordinate in the current viewport. +- ++ + drag(options: DragOptions): Promise; // Drag from a point to a point by the provided path. + keypress(options: KeypressOptions): Promise; // Press control characters at the current focused element (focus it first via click/dblclick). + move(options: MoveOptions): Promise; // Move the mouse to a point by the provided x and y coordinates. +@@ -760,7 +607,7 @@ + interface DomCUAAPI { + click(options: DomClickOptions): Promise; // Click a DOM node by its id from the visible DOM snapshot. + double_click(options: DomClickOptions): Promise; // Double-click a DOM node by its id. +- ++ + get_visible_dom(): Promise; // Return a filtered DOM with node ids for interactable elements. + keypress(options: DomKeypressOptions): Promise; // Press control characters at the currently focused element (focus it first via click/dblclick). + scroll(options: DomScrollOptions): Promise; // Scroll either the page or a specific node (if node_id provided) by deltas. diff --git a/nix/codex-zip/default.nix b/nix/codex-zip/default.nix new file mode 100644 index 0000000..d637f9e --- /dev/null +++ b/nix/codex-zip/default.nix @@ -0,0 +1,30 @@ +{ + flake-utils, + nixpkgs, + ... +}: +let + systems = [ + "aarch64-darwin" + "x86_64-darwin" + "aarch64-linux" + "x86_64-linux" + ]; +in +flake-utils.lib.eachSystem systems ( + system: + let + pkgs = import nixpkgs { inherit system; }; + version = "26.513.20950"; + in + { + packages.codexZip = pkgs.fetchurl { + name = "codex-darwin-arm64-${version}.zip"; + url = "https://persistent.oaistatic.com/codex-app-prod/Codex-darwin-arm64-${version}.zip"; + hash = "sha256-zSlRaoUJc4eRFbe08qS/oyqaBbfW2Epjj3hlbEmA6Cw="; + passthru = { + inherit version; + }; + }; + } +) diff --git a/patches/browser-use-resources-path-runtime.patch b/patches/browser-use-resources-path-runtime.patch new file mode 100644 index 0000000..e670114 --- /dev/null +++ b/patches/browser-use-resources-path-runtime.patch @@ -0,0 +1,14 @@ +--- a/.vite/build/main-kSlb32Yb.js ++++ b/.vite/build/main-kSlb32Yb.js +@@ -968,7 +968,10 @@ + resolveBundledPath: a, + resourcesPath: o, + }) { +- if (t || n === `win32`) return a(o); ++ if (o != null) { ++ let s = a(o); ++ if (t || n === `win32` || Gt(s)) return s; ++ } else if (t || n === `win32`) return a(o); + let s = i.default.join(r, ...e); + return Gt(s) ? s : null; + } diff --git a/scripts/prepare_asar b/scripts/prepare_asar index b1782aa..5fed81b 100755 --- a/scripts/prepare_asar +++ b/scripts/prepare_asar @@ -35,6 +35,7 @@ patch --batch --forward --strip 1 --directory scratch/asar < patches/webview-ele patch --batch --forward --strip 1 --directory scratch/asar < patches/webview-prosemirror-inputmode.patch patch --batch --forward --strip 1 --directory scratch/asar < patches/webview-use-atfs-for-local-files.patch patch --batch --forward --strip 1 --directory scratch/asar < patches/webview-prompt-search-param.patch +patch --batch --forward --strip 1 --directory scratch/asar < patches/browser-use-resources-path-runtime.patch patch --batch --forward --strip 1 --directory scratch/asar < patches/sentry-disable-shell.patch patch --batch --forward --strip 1 --directory scratch/asar < patches/sentry-disable-webview.patch rm -rf scratch/asar/node_modules/better-sqlite3 diff --git a/src/chrome-extension-host/Cargo.lock b/src/chrome-extension-host/Cargo.lock new file mode 100644 index 0000000..1e26fcc --- /dev/null +++ b/src/chrome-extension-host/Cargo.lock @@ -0,0 +1,119 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "codex-chrome-extension-host" +version = "0.2.3-linux-alpha1" +dependencies = [ + "anyhow", + "libc", + "serde_json", +] + +[[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.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[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.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[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.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/src/chrome-extension-host/Cargo.toml b/src/chrome-extension-host/Cargo.toml new file mode 100644 index 0000000..1274449 --- /dev/null +++ b/src/chrome-extension-host/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "codex-chrome-extension-host" +version = "0.2.3-linux-alpha1" +edition = "2021" + +[[bin]] +name = "codex-chrome-extension-host" +path = "src/bin/codex-chrome-extension-host.rs" + +[dependencies] +anyhow = "=1.0.102" +libc = "=0.2.183" +serde_json = "=1.0.149" diff --git a/src/chrome-extension-host/default.nix b/src/chrome-extension-host/default.nix new file mode 100644 index 0000000..fbb863c --- /dev/null +++ b/src/chrome-extension-host/default.nix @@ -0,0 +1,32 @@ +{ + flake-utils, + nixpkgs, + ... +}: + +flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + packages.codex_chrome_extension_host = pkgs.rustPlatform.buildRustPackage { + pname = "codex-chrome-extension-host"; + version = "0.2.3-linux-alpha1"; + + src = ./.; + + cargoLock = { + lockFile = ./Cargo.lock; + }; + + meta = { + description = "Native messaging host for the Codex Chrome extension"; + homepage = "https://github.com/ilysenko/codex-desktop-linux"; + license = pkgs.lib.licenses.mit; + mainProgram = "codex-chrome-extension-host"; + platforms = pkgs.lib.platforms.unix; + }; + }; + } +) diff --git a/src/chrome-extension-host/src/bin/codex-chrome-extension-host.rs b/src/chrome-extension-host/src/bin/codex-chrome-extension-host.rs new file mode 100644 index 0000000..801071d --- /dev/null +++ b/src/chrome-extension-host/src/bin/codex-chrome-extension-host.rs @@ -0,0 +1,1219 @@ +use anyhow::{bail, Context, Result}; +use serde_json::{json, Value}; +use std::{ + collections::HashMap, + env, fs, + fs::File, + io::{self, BufRead, BufReader, ErrorKind, Read, Seek, SeekFrom, Write}, + net::Shutdown, + os::unix::{ + fs::{MetadataExt, PermissionsExt}, + io::AsRawFd, + net::{UnixListener, UnixStream}, + }, + path::{Path, PathBuf}, + process, + sync::{Arc, Mutex}, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +const HOST_NAME: &str = "com.openai.codexextension"; +const SOCKET_DIR_ENV: &str = "CODEX_BROWSER_USE_SOCKET_DIR"; +const SESSIONS_DIR_ENV: &str = "CODEX_BROWSER_USE_SESSIONS_DIR"; +const DEFAULT_SOCKET_DIR: &str = "/tmp/codex-browser-use"; +const ROLLOUT_POLL_INTERVAL: Duration = Duration::from_millis(500); +const OBSERVED_TURN_TTL: Duration = Duration::from_secs(6 * 60 * 60); +const ROLLOUT_SEARCH_MAX_DEPTH: usize = 5; + +type SharedState = Arc>; +type SharedClientWriter = Arc>; + +#[derive(Clone)] +struct Client { + writer: SharedClientWriter, +} + +struct PendingChromeRequest { + client_id: usize, + client_request_id: Value, + fallback_extension_info: bool, +} + +#[derive(Clone)] +struct PendingClientRequest { + client_id: usize, + chrome_request_id: Value, +} + +struct PeerCredentials { + pid: Option, + uid: libc::uid_t, +} + +#[derive(Debug, PartialEq, Eq)] +enum ChromeClientRouteError { + NoClients, + MultipleClients, +} + +impl ChromeClientRouteError { + fn message(&self) -> &'static str { + match self { + Self::NoClients => "No Codex browser client is connected", + Self::MultipleClients => { + "Multiple Codex browser clients are connected; Chrome requests require exactly one" + } + } + } +} + +struct HostState { + stdout: Arc>, + rollout_tracker: RolloutTracker, + extension_id: Option, + clients: HashMap, + pending_chrome_requests: HashMap, + pending_client_requests: HashMap, + next_client_id: usize, + next_chrome_id: u64, + next_client_request_id: u64, +} + +impl HostState { + fn new( + stdout: Arc>, + rollout_tracker: RolloutTracker, + extension_id: Option, + ) -> Self { + Self { + stdout, + rollout_tracker, + extension_id, + clients: HashMap::new(), + pending_chrome_requests: HashMap::new(), + pending_client_requests: HashMap::new(), + next_client_id: 1, + next_chrome_id: 1, + next_client_request_id: 1, + } + } + + fn replace_with_client(&mut self, writer: SharedClientWriter) -> (usize, Vec<(usize, Client)>) { + let evicted_clients = self.clients.drain().collect::>(); + if !evicted_clients.is_empty() { + self.pending_chrome_requests.clear(); + self.pending_client_requests.clear(); + } + + let id = self.next_client_id; + self.next_client_id += 1; + self.clients.insert(id, Client { writer }); + (id, evicted_clients) + } + + fn remove_client(&mut self, client_id: usize) { + self.clients.remove(&client_id); + remove_pending_requests_for_client( + &mut self.pending_chrome_requests, + &mut self.pending_client_requests, + client_id, + ); + } + + fn send_chrome(&self, message: &Value) { + let mut stdout = self.stdout.lock().expect("stdout mutex poisoned"); + if let Err(error) = write_frame(&mut *stdout, message) { + log(&format!("native stdout error: {error}")); + process::exit(1); + } + } + + fn send_client(&self, client_id: usize, message: &Value) { + let Some(client) = self.clients.get(&client_id) else { + return; + }; + + let mut writer = client.writer.lock().expect("client writer mutex poisoned"); + if let Err(error) = write_frame(&mut *writer, message) { + log(&format!("client socket write error: {error}")); + } + } + + fn broadcast_clients(&self, message: &Value) { + for client_id in self.clients.keys().copied().collect::>() { + self.send_client(client_id, message); + } + } +} + +#[derive(Clone)] +struct RolloutTracker { + inner: Arc>, + stdout: Arc>, + sessions_root: Option, +} + +struct RolloutTrackerState { + observed: HashMap, +} + +struct ObservedTurn { + session_id: String, + turn_id: String, + path: Option, + offset: u64, + created_at: Instant, +} + +impl RolloutTracker { + fn new(stdout: Arc>) -> Self { + let tracker = Self { + inner: Arc::new(Mutex::new(RolloutTrackerState { + observed: HashMap::new(), + })), + stdout, + sessions_root: sessions_root(), + }; + + let worker = tracker.clone(); + if let Err(error) = thread::Builder::new() + .name("codex-rollout-tracker".to_string()) + .spawn(move || worker.watch_loop()) + { + log(&format!("extension-host: rollout watcher error: {error}")); + } + + tracker + } + + fn observe_request(&self, message: &Value) { + let Some((session_id, turn_id)) = session_turn_from_message(message) else { + return; + }; + + let key = observed_turn_key(&session_id, &turn_id); + let mut state = self.inner.lock().expect("rollout watcher mutex poisoned"); + if state.observed.contains_key(&key) { + return; + } + + let (path, offset) = self + .sessions_root + .as_deref() + .and_then(|root| find_rollout_path(root, &session_id)) + .map(|path| { + let offset = file_len(&path).unwrap_or_default(); + (Some(path), offset) + }) + .unwrap_or((None, 0)); + + state.observed.insert( + key, + ObservedTurn { + session_id, + turn_id, + path, + offset, + created_at: Instant::now(), + }, + ); + } + + fn watch_loop(self) { + loop { + thread::sleep(ROLLOUT_POLL_INTERVAL); + if let Err(error) = self.process_rollouts() { + log(&format!("extension-host: rollout watcher error: {error}")); + } + } + } + + fn process_rollouts(&self) -> Result<()> { + let Some(sessions_root) = self.sessions_root.as_deref() else { + return Ok(()); + }; + + let mut completed = Vec::new(); + let mut expired = Vec::new(); + { + let mut state = self.inner.lock().expect("tracker mutex poisoned"); + for (key, observed) in &mut state.observed { + if observed.created_at.elapsed() >= OBSERVED_TURN_TTL { + expired.push(key.clone()); + continue; + } + + if observed.path.is_none() { + if let Some(path) = find_rollout_path(sessions_root, &observed.session_id) { + observed.offset = 0; + observed.path = Some(path); + } + } + + let Some(path) = observed.path.as_ref() else { + continue; + }; + + let (offset, is_complete) = + drain_rollout_file(path, observed.offset, &observed.turn_id).with_context( + || format!("failed to drain rollout file {}", path.display()), + )?; + observed.offset = offset; + if is_complete { + completed.push(( + key.clone(), + observed.session_id.clone(), + observed.turn_id.clone(), + )); + } + } + + for key in expired { + state.observed.remove(&key); + } + for (key, _, _) in &completed { + state.observed.remove(key); + } + } + + for (_, session_id, turn_id) in completed { + self.emit_turn_ended(&session_id, &turn_id); + } + + Ok(()) + } + + fn emit_turn_ended(&self, session_id: &str, turn_id: &str) { + let message = json!({ + "jsonrpc": "2.0", + "id": format!("native-turn-ended:{session_id}:{turn_id}"), + "method": "turnEnded", + "params": { + "session_id": session_id, + "turn_id": turn_id + } + }); + + let mut stdout = self.stdout.lock().expect("stdout writer mutex poisoned"); + if let Err(error) = write_frame(&mut *stdout, &message) { + log(&format!( + "extension-host: failed to emit turnEnded for session {session_id}: {error}" + )); + } + } +} + +fn main() -> Result<()> { + let socket_dir = socket_dir(); + prepare_socket_dir(&socket_dir)?; + let socket_path = socket_path(&socket_dir); + remove_socket_if_present(&socket_path)?; + + let listener = UnixListener::bind(&socket_path) + .with_context(|| format!("failed to bind {}", socket_path.display()))?; + fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600)) + .with_context(|| format!("failed to chmod {}", socket_path.display()))?; + + let stdout = Arc::new(Mutex::new(io::stdout())); + let rollout_tracker = RolloutTracker::new(Arc::clone(&stdout)); + let extension_id = extension_id_from_args(); + let state = Arc::new(Mutex::new(HostState::new( + stdout, + rollout_tracker, + extension_id, + ))); + + log(&format!("listening on {}", socket_path.display())); + + { + let state = Arc::clone(&state); + thread::spawn(move || accept_clients(listener, state)); + } + + let result = read_chrome_messages(Arc::clone(&state)); + remove_socket_if_present(&socket_path)?; + result +} + +fn socket_dir() -> PathBuf { + env::var_os(SOCKET_DIR_ENV) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_SOCKET_DIR)) +} + +fn sessions_root() -> Option { + if let Some(path) = env::var_os(SESSIONS_DIR_ENV).map(PathBuf::from) { + return Some(path); + } + + if let Some(path) = env::var_os("CODEX_HOME").map(PathBuf::from) { + return Some(path.join("sessions")); + } + + env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join(".codex").join("sessions")) +} + +fn extension_id_from_args() -> Option { + env::args().skip(1).find_map(|arg| { + arg.strip_prefix("chrome-extension://") + .and_then(|value| value.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')) +} + +fn socket_path(socket_dir: &Path) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + socket_dir.join(format!("extension-{}-{nonce}.sock", process::id())) +} + +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_dir() { + bail!( + "unix socket directory path is not a directory: {}", + path.display() + ); + } + + let effective_uid = unsafe { libc::geteuid() }; + if metadata.uid() != effective_uid { + bail!( + "unix socket directory is owned by uid {}, expected {}: {}", + metadata.uid(), + effective_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(()) +} + +fn remove_socket_if_present(path: &Path) -> Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).with_context(|| format!("failed to remove {}", path.display())), + } +} + +fn accept_clients(listener: UnixListener, state: SharedState) { + for stream in listener.incoming() { + let stream = match stream { + Ok(stream) => stream, + Err(error) => { + log(&format!("platform accept error: {error}")); + continue; + } + }; + + match authorize_peer(&stream) { + Ok(true) => {} + Ok(false) => continue, + Err(error) => { + log(&format!("peer authorization error: {error}")); + continue; + } + } + + let writer = match stream.try_clone() { + Ok(stream) => Arc::new(Mutex::new(stream)), + Err(error) => { + log(&format!("client socket clone error: {error}")); + continue; + } + }; + + let (client_id, evicted_clients) = { + let mut state = state.lock().expect("host state mutex poisoned"); + state.replace_with_client(writer) + }; + for (evicted_id, evicted_client) in evicted_clients { + log(&format!( + "evicting stale browser client {evicted_id} after a newer client connected" + )); + close_client_socket(&evicted_client); + } + + let state = Arc::clone(&state); + thread::spawn(move || read_client_messages(state, client_id, stream)); + } +} + +fn close_client_socket(client: &Client) { + match client.writer.lock() { + Ok(writer) => { + let _ = writer.shutdown(Shutdown::Both); + } + Err(error) => log(&format!("client socket close lock error: {error}")), + } +} + +fn authorize_peer(stream: &UnixStream) -> Result { + let credentials = peer_credentials(stream)?; + let effective_uid = unsafe { libc::geteuid() }; + + if credentials.uid != effective_uid { + let pid = credentials + .pid + .map(|pid| pid.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + log(&format!( + "rejecting peer pid {} uid {}, expected uid {}", + pid, credentials.uid, effective_uid + )); + return Ok(false); + } + + Ok(true) +} + +#[cfg(target_os = "linux")] +fn peer_credentials(stream: &UnixStream) -> Result { + let mut credentials = libc::ucred { + pid: 0, + uid: 0, + gid: 0, + }; + let mut length = std::mem::size_of::() 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"); + } + + Ok(PeerCredentials { + pid: Some(credentials.pid), + uid: credentials.uid, + }) +} + +#[cfg(target_os = "macos")] +fn peer_credentials(stream: &UnixStream) -> Result { + let mut uid: libc::uid_t = 0; + let mut gid: libc::gid_t = 0; + let result = unsafe { libc::getpeereid(stream.as_raw_fd(), &mut uid, &mut gid) }; + + if result != 0 { + return Err(io::Error::last_os_error()).context("failed to read peer credentials"); + } + + Ok(PeerCredentials { pid: None, uid }) +} + +fn read_chrome_messages(state: SharedState) -> Result<()> { + let stdin = io::stdin(); + let mut reader = stdin.lock(); + while let Some(message) = + read_frame(&mut reader).context("extension-host: platform reader error")? + { + handle_chrome_message(&state, message); + } + Ok(()) +} + +fn read_client_messages(state: SharedState, client_id: usize, stream: UnixStream) { + let mut stream = stream; + loop { + match read_frame(&mut stream) { + Ok(Some(message)) => handle_client_message(&state, client_id, message), + Ok(None) => break, + Err(error) => { + log(&format!("client socket read error: {error}")); + break; + } + } + } + + let mut state = state.lock().expect("host state mutex poisoned"); + state.remove_client(client_id); +} + +fn handle_client_message(state: &SharedState, client_id: usize, message: Value) { + { + let state = state.lock().expect("host state mutex poisoned"); + if !state.clients.contains_key(&client_id) { + return; + } + } + + if is_response(&message) { + let Some(id) = message_id_as_str(&message) else { + return; + }; + + let mut state = state.lock().expect("host state mutex poisoned"); + let Some(pending) = state.pending_client_requests.get(id).cloned() else { + return; + }; + if pending.client_id != client_id { + return; + } + state.pending_client_requests.remove(id); + + state.send_chrome(&with_id(message, pending.chrome_request_id)); + return; + } + + if !is_request(&message) { + let state = state.lock().expect("host state mutex poisoned"); + if state.clients.contains_key(&client_id) { + state.send_chrome(&message); + } + return; + } + + { + let tracker = { + let state = state.lock().expect("host state mutex poisoned"); + state.rollout_tracker.clone() + }; + tracker.observe_request(&message); + } + + if message.get("method").and_then(Value::as_str) == Some("ping") { + let Some(id) = message.get("id").cloned() else { + return; + }; + let state = state.lock().expect("host state mutex poisoned"); + state.send_client( + client_id, + &json!({ "jsonrpc": "2.0", "id": id, "result": "pong" }), + ); + return; + } + + let Some(client_request_id) = message.get("id").cloned() else { + return; + }; + let fallback_extension_info = message.get("method").and_then(Value::as_str) == Some("getInfo"); + + let mut state = state.lock().expect("host state mutex poisoned"); + if !state.clients.contains_key(&client_id) { + return; + } + let chrome_id = format!("linux-{}-{}", process::id(), state.next_chrome_id); + state.next_chrome_id += 1; + state.pending_chrome_requests.insert( + chrome_id.clone(), + PendingChromeRequest { + client_id, + client_request_id, + fallback_extension_info, + }, + ); + state.send_chrome(&with_id(message, Value::String(chrome_id))); +} + +fn handle_chrome_message(state: &SharedState, message: Value) { + if is_response(&message) { + let Some(id) = message_id_as_str(&message) else { + return; + }; + + let mut state = state.lock().expect("host state mutex poisoned"); + let Some(pending) = state.pending_chrome_requests.remove(id) else { + return; + }; + + // chrome.runtime.getVersion() is available in Chrome/Chromium 143+. + // Keep forwarding getInfo for browsers that support it, and only + // synthesize discovery metadata for this older-runtime compatibility + // failure. + if pending.fallback_extension_info && is_missing_chrome_runtime_get_version_error(&message) + { + state.send_client( + pending.client_id, + &extension_info_response(pending.client_request_id, state.extension_id.as_deref()), + ); + return; + } + + state.send_client( + pending.client_id, + &with_id(message, pending.client_request_id), + ); + return; + } + + if !is_request(&message) { + let state = state.lock().expect("host state mutex poisoned"); + state.broadcast_clients(&message); + return; + } + + let chrome_request_id = message.get("id").cloned().unwrap_or(Value::Null); + let mut state = state.lock().expect("host state mutex poisoned"); + let client_id = match select_single_client_id(&state.clients) { + Ok(client_id) => client_id, + Err(error) => { + state.send_chrome(&json!({ + "jsonrpc": "2.0", + "id": chrome_request_id, + "error": { + "code": -32000, + "message": error.message() + } + })); + return; + } + }; + + let client_request_id = format!("chrome-{}-{}", process::id(), state.next_client_request_id); + state.next_client_request_id += 1; + state.pending_client_requests.insert( + client_request_id.clone(), + PendingClientRequest { + client_id, + chrome_request_id, + }, + ); + state.send_client( + client_id, + &with_id(message, Value::String(client_request_id)), + ); +} + +fn select_single_client_id( + clients: &HashMap, +) -> std::result::Result { + match clients.len() { + 0 => Err(ChromeClientRouteError::NoClients), + 1 => Ok(*clients.keys().next().expect("one client id")), + _ => Err(ChromeClientRouteError::MultipleClients), + } +} + +fn remove_pending_requests_for_client( + pending_chrome_requests: &mut HashMap, + pending_client_requests: &mut HashMap, + client_id: usize, +) { + pending_chrome_requests.retain(|_, pending| pending.client_id != client_id); + pending_client_requests.retain(|_, pending| pending.client_id != client_id); +} + +fn is_request(message: &Value) -> bool { + message.get("id").is_some() && message.get("method").and_then(Value::as_str).is_some() +} + +fn is_response(message: &Value) -> bool { + message.get("id").is_some() && message.get("method").and_then(Value::as_str).is_none() +} + +fn message_id_as_str(message: &Value) -> Option<&str> { + message.get("id").and_then(Value::as_str) +} + +fn with_id(mut message: Value, id: Value) -> Value { + if let Value::Object(ref mut object) = message { + object.insert("id".to_string(), id); + } + message +} + +fn is_missing_chrome_runtime_get_version_error(message: &Value) -> bool { + 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")) +} + +fn extension_info_response(id: Value, extension_id: Option<&str>) -> Value { + let mut metadata = serde_json::Map::new(); + if let Some(extension_id) = extension_id { + metadata.insert( + "extensionId".to_string(), + Value::String(extension_id.to_string()), + ); + } + + json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "name": "Chrome", + "version": "unknown", + "type": "extension", + "capabilities": { + "tab": [ + { + "id": "pageAssets", + "description": "List assets already observed in the current page state and bundle selected assets into a temporary local artifact." + } + ] + }, + "metadata": Value::Object(metadata) + } + }) +} + +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 value = value.as_str()?.trim(); + (!value.is_empty()).then_some(value) +} + +fn observed_turn_key(session_id: &str, turn_id: &str) -> String { + format!("{session_id}\n{turn_id}") +} + +fn file_len(path: &Path) -> io::Result { + Ok(fs::metadata(path)?.len()) +} + +fn find_rollout_path(root: &Path, session_id: &str) -> Option { + let mut stack = vec![(root.to_path_buf(), 0_usize)]; + let mut best: Option<(SystemTime, PathBuf)> = None; + + while let Some((dir, depth)) = stack.pop() { + let entries = fs::read_dir(&dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + + if file_type.is_dir() { + if depth < ROLLOUT_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 len = file.metadata()?.len(); + file.seek(SeekFrom::Start(offset.min(len)))?; + + let mut reader = BufReader::new(file); + let mut line = String::new(); + let mut is_complete = false; + + loop { + line.clear(); + if reader.read_line(&mut line)? == 0 { + break; + } + if line_marks_turn_complete(&line, turn_id) { + is_complete = true; + } + } + + Ok((reader.stream_position()?, is_complete)) +} + +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); + let payload_type = payload.get("type").and_then(Value::as_str); + let payload_turn_id = payload.get("turn_id").and_then(Value::as_str); + if payload_type == Some("task_complete") && payload_turn_id == Some(turn_id) { + return true; + } + + let top_level_type = value.get("type").and_then(Value::as_str); + let kind = value.get("kind").and_then(Value::as_str); + top_level_type == Some("turn") + && matches!(kind, Some("end" | "completed" | "complete")) + && value.get("turn_id").and_then(Value::as_str) == Some(turn_id) +} + +fn read_frame(reader: &mut impl Read) -> io::Result> { + loop { + let mut header = [0_u8; 4]; + match reader.read_exact(&mut header) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::UnexpectedEof => return Ok(None), + Err(error) => return Err(error), + } + + let length = u32::from_ne_bytes(header) as usize; + let mut body = vec![0_u8; length]; + reader.read_exact(&mut body)?; + + match serde_json::from_slice(&body) { + Ok(message) => return Ok(Some(message)), + Err(error) => log(&format!("dropping invalid JSON frame: {error}")), + } + } +} + +fn write_frame(writer: &mut impl Write, message: &Value) -> io::Result<()> { + let body = serde_json::to_vec(message).map_err(io::Error::other)?; + if body.len() > u32::MAX as usize { + return Err(io::Error::new( + ErrorKind::InvalidInput, + "message too large for 4-byte length prefix", + )); + } + + writer.write_all(&(body.len() as u32).to_ne_bytes())?; + writer.write_all(&body)?; + writer.flush() +} + +fn log(message: &str) { + let _ = writeln!(io::stderr(), "[{HOST_NAME}] {message}"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frame_round_trip_uses_native_length_prefix() { + let message = json!({ "jsonrpc": "2.0", "id": "1", "method": "ping" }); + let mut encoded = Vec::new(); + write_frame(&mut encoded, &message).unwrap(); + + let length = u32::from_ne_bytes(encoded[..4].try_into().unwrap()) as usize; + assert_eq!(length, encoded.len() - 4); + + let mut cursor = io::Cursor::new(encoded); + assert_eq!(read_frame(&mut cursor).unwrap(), Some(message)); + } + + #[test] + fn id_replacement_preserves_other_fields() { + let message = json!({ "jsonrpc": "2.0", "id": 1, "method": "getTabs" }); + assert_eq!( + with_id(message, Value::String("linux-1-1".to_string())), + json!({ "jsonrpc": "2.0", "id": "linux-1-1", "method": "getTabs" }) + ); + } + + #[test] + fn extracts_session_turn_from_browser_request() { + let message = json!({ + "jsonrpc": "2.0", + "id": "request-1", + "method": "getTabs", + "params": { + "session_id": "session-1", + "turn_id": "turn-1" + } + }); + + assert_eq!( + session_turn_from_message(&message), + Some(("session-1".to_string(), "turn-1".to_string())) + ); + } + + #[test] + fn recognizes_task_complete_rollout_line() { + let line = r#"{"timestamp":"2026-05-09T12:00:00Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1"}}"#; + assert!(line_marks_turn_complete(line, "turn-1")); + assert!(!line_marks_turn_complete(line, "turn-2")); + } + + #[test] + fn finds_nested_rollout_path_by_session_id() { + let root = unique_test_dir("codex-rollout-path"); + let nested = root.join("2026").join("05").join("09"); + fs::create_dir_all(&nested).unwrap(); + let path = nested.join("rollout-2026-05-09T12-00-00-session-1.jsonl"); + fs::write(&path, "{}\n").unwrap(); + + assert_eq!(find_rollout_path(&root, "session-1"), Some(path)); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn drains_rollout_file_from_offset() { + let root = unique_test_dir("codex-rollout-drain"); + fs::create_dir_all(&root).unwrap(); + let path = root.join("rollout-session-1.jsonl"); + fs::write( + &path, + "{\"type\":\"event_msg\",\"payload\":{\"type\":\"other\"}}\n", + ) + .unwrap(); + let offset = file_len(&path).unwrap(); + + let complete = + r#"{"type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1"}}"#; + writeln!( + fs::OpenOptions::new().append(true).open(&path).unwrap(), + "ignored\n{complete}" + ) + .unwrap(); + let (new_offset, is_complete) = drain_rollout_file(&path, offset, "turn-1").unwrap(); + + assert!(new_offset >= offset); + assert!(is_complete); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn late_discovered_rollout_file_scans_existing_content() { + let root = unique_test_dir("codex-rollout-late"); + let nested = root.join("2026").join("05").join("09"); + fs::create_dir_all(&nested).unwrap(); + let path = nested.join("rollout-session-1.jsonl"); + let complete = + r#"{"type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1"}}"#; + writeln!(File::create(&path).unwrap(), "{complete}").unwrap(); + + let discovered = find_rollout_path(&root, "session-1").unwrap(); + let (_, is_complete) = drain_rollout_file(&discovered, 0, "turn-1").unwrap(); + + assert!(is_complete); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn rejects_chrome_request_routing_without_exactly_one_client() { + let clients = HashMap::new(); + assert_eq!( + select_single_client_id(&clients), + Err(ChromeClientRouteError::NoClients) + ); + + let mut clients = HashMap::new(); + clients.insert(7, test_client()); + assert_eq!(select_single_client_id(&clients), Ok(7)); + + clients.insert(8, test_client()); + assert_eq!( + select_single_client_id(&clients), + Err(ChromeClientRouteError::MultipleClients) + ); + } + + #[test] + fn replacing_browser_client_evicts_stale_clients_and_pending_requests() { + let mut state = test_host_state(); + + let (first_client_id, evicted_clients) = + state.replace_with_client(test_client().writer.clone()); + assert!(evicted_clients.is_empty()); + assert!(state.clients.contains_key(&first_client_id)); + + state.pending_chrome_requests.insert( + "chrome-request".to_string(), + PendingChromeRequest { + client_id: first_client_id, + client_request_id: json!("client-request-1"), + fallback_extension_info: false, + }, + ); + state.pending_client_requests.insert( + "client-request".to_string(), + PendingClientRequest { + client_id: first_client_id, + chrome_request_id: json!("chrome-request-1"), + }, + ); + + let (second_client_id, evicted_clients) = + state.replace_with_client(test_client().writer.clone()); + + assert_ne!(first_client_id, second_client_id); + assert_eq!(evicted_clients.len(), 1); + assert_eq!(evicted_clients[0].0, first_client_id); + assert!(!state.clients.contains_key(&first_client_id)); + assert!(state.clients.contains_key(&second_client_id)); + assert!(state.pending_chrome_requests.is_empty()); + assert!(state.pending_client_requests.is_empty()); + } + + #[test] + fn evicted_client_requests_are_ignored() { + let state = Arc::new(Mutex::new(test_host_state())); + + handle_client_message( + &state, + 99, + json!({ "jsonrpc": "2.0", "id": 1, "method": "getTabs" }), + ); + + let state = state.lock().unwrap(); + assert!(state.pending_chrome_requests.is_empty()); + assert_eq!(state.next_chrome_id, 1); + } + + #[test] + fn get_info_falls_back_when_runtime_get_version_is_missing() { + let (client_writer, mut client_reader) = UnixStream::pair().unwrap(); + let mut state = test_host_state(); + state.clients.insert( + 1, + Client { + writer: Arc::new(Mutex::new(client_writer)), + }, + ); + state.pending_chrome_requests.insert( + "linux-1-1".to_string(), + PendingChromeRequest { + client_id: 1, + client_request_id: json!("info-1"), + fallback_extension_info: true, + }, + ); + state.extension_id = Some("abcdefghijklmnopabcdefghijklmnop".to_string()); + let state = Arc::new(Mutex::new(state)); + + handle_chrome_message( + &state, + json!({ + "jsonrpc": "2.0", + "id": "linux-1-1", + "error": { + "code": 1, + "message": "chrome.runtime.getVersion is not a function" + } + }), + ); + + let message = read_frame(&mut client_reader).unwrap().unwrap(); + assert_eq!(message["id"], "info-1"); + assert_eq!(message["result"]["type"], "extension"); + assert_eq!(message["result"]["version"], "unknown"); + assert_eq!( + message["result"]["metadata"]["extensionId"], + "abcdefghijklmnopabcdefghijklmnop" + ); + assert!(state.lock().unwrap().pending_chrome_requests.is_empty()); + } + + #[test] + fn disconnect_cleanup_removes_pending_state_for_client() { + let mut pending_chrome = HashMap::from([ + ( + "keep".to_string(), + PendingChromeRequest { + client_id: 1, + client_request_id: json!("chrome-request-1"), + fallback_extension_info: false, + }, + ), + ( + "drop".to_string(), + PendingChromeRequest { + client_id: 2, + client_request_id: json!("chrome-request-2"), + fallback_extension_info: false, + }, + ), + ]); + let mut pending_client = HashMap::from([ + ( + "keep".to_string(), + PendingClientRequest { + client_id: 1, + chrome_request_id: json!("client-request-1"), + }, + ), + ( + "drop".to_string(), + PendingClientRequest { + client_id: 2, + chrome_request_id: json!("client-request-2"), + }, + ), + ]); + + remove_pending_requests_for_client(&mut pending_chrome, &mut pending_client, 2); + + assert!(pending_chrome.contains_key("keep")); + assert!(!pending_chrome.contains_key("drop")); + assert!(pending_client.contains_key("keep")); + assert!(!pending_client.contains_key("drop")); + } + + fn test_client() -> Client { + let (stream, _peer) = UnixStream::pair().unwrap(); + Client { + writer: Arc::new(Mutex::new(stream)), + } + } + + fn test_host_state() -> HostState { + let stdout = Arc::new(Mutex::new(io::stdout())); + HostState::new( + Arc::clone(&stdout), + RolloutTracker { + inner: Arc::new(Mutex::new(RolloutTrackerState { + observed: HashMap::new(), + })), + stdout, + sessions_root: None, + }, + Some("abcdefghijklmnopabcdefghijklmnop".to_string()), + ) + } + + fn unique_test_dir(prefix: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + env::temp_dir().join(format!("{prefix}-{}-{nonce}", process::id())) + } +} diff --git a/src/server/main.ts b/src/server/main.ts index a78e8aa..d948e2b 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -243,10 +243,7 @@ function ensureElectronLikeProcessContext(): void { resourcesPath?: string; type?: string; }; - processWithElectronFields.resourcesPath ??= path.resolve( - __dirname, - "../../scratch/asar", - ); + processWithElectronFields.resourcesPath ??= "@resourcesPath@"; processWithElectronFields.type ??= "browser"; }