From 327a1622f2835cdb494d3130f198d089215be5fa Mon Sep 17 00:00:00 2001 From: Nicholas Oh Date: Sun, 24 May 2026 15:27:49 +0800 Subject: [PATCH 1/2] Handle missing Chrome extension version helper --- .../src/bin/codex-chrome-extension-host.rs | 129 +++++++++++++++++- 1 file changed, 127 insertions(+), 2 deletions(-) diff --git a/computer-use-linux/src/bin/codex-chrome-extension-host.rs b/computer-use-linux/src/bin/codex-chrome-extension-host.rs index 1dd8c057..987242d6 100644 --- a/computer-use-linux/src/bin/codex-chrome-extension-host.rs +++ b/computer-use-linux/src/bin/codex-chrome-extension-host.rs @@ -37,6 +37,7 @@ struct Client { struct PendingChromeRequest { client_id: usize, client_request_id: Value, + fallback_extension_info: bool, } #[derive(Clone)] @@ -65,6 +66,7 @@ impl ChromeClientRouteError { struct HostState { stdout: Arc>, rollout_tracker: RolloutTracker, + extension_id: Option, clients: HashMap, pending_chrome_requests: HashMap, pending_client_requests: HashMap, @@ -74,10 +76,15 @@ struct HostState { } impl HostState { - fn new(stdout: Arc>, rollout_tracker: RolloutTracker) -> Self { + 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(), @@ -305,7 +312,12 @@ fn main() -> Result<()> { let stdout = Arc::new(Mutex::new(io::stdout())); let rollout_tracker = RolloutTracker::new(Arc::clone(&stdout)); - let state = Arc::new(Mutex::new(HostState::new(stdout, rollout_tracker))); + 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())); @@ -339,6 +351,19 @@ fn sessions_root() -> Option { .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) @@ -561,6 +586,7 @@ fn handle_client_message(state: &SharedState, client_id: usize, message: Value) 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) { @@ -573,6 +599,7 @@ fn handle_client_message(state: &SharedState, client_id: usize, message: Value) PendingChromeRequest { client_id, client_request_id, + fallback_extension_info, }, ); state.send_chrome(&with_id(message, Value::String(chrome_id))); @@ -589,6 +616,19 @@ fn handle_chrome_message(state: &SharedState, message: Value) { 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), @@ -672,6 +712,43 @@ fn with_id(mut message: Value, id: Value) -> Value { 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")?)?; @@ -957,6 +1034,7 @@ mod tests { PendingChromeRequest { client_id: first_client_id, client_request_id: json!("client-request-1"), + fallback_extension_info: false, }, ); state.pending_client_requests.insert( @@ -994,6 +1072,50 @@ mod tests { 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([ @@ -1002,6 +1124,7 @@ mod tests { PendingChromeRequest { client_id: 1, client_request_id: json!("chrome-request-1"), + fallback_extension_info: false, }, ), ( @@ -1009,6 +1132,7 @@ mod tests { PendingChromeRequest { client_id: 2, client_request_id: json!("chrome-request-2"), + fallback_extension_info: false, }, ), ]); @@ -1055,6 +1179,7 @@ mod tests { stdout, sessions_root: None, }, + Some("abcdefghijklmnopabcdefghijklmnop".to_string()), ) } From a536803386af1380aa34ce5fcdb463c0a42365a8 Mon Sep 17 00:00:00 2001 From: Nicholas Oh Date: Sun, 24 May 2026 15:27:56 +0800 Subject: [PATCH 2/2] Add opt-in Thorium Chrome plugin feature --- launcher/start.sh.template | 25 +- .../thorium-chrome-plugin/README.md | 29 ++ .../thorium-chrome-plugin/feature.json | 10 + .../patch-chrome-plugin.js | 359 ++++++++++++++++++ linux-features/thorium-chrome-plugin/patch.js | 44 +++ linux-features/thorium-chrome-plugin/stage.sh | 17 + linux-features/thorium-chrome-plugin/test.js | 224 +++++++++++ tests/scripts_smoke.sh | 13 +- 8 files changed, 713 insertions(+), 8 deletions(-) create mode 100644 linux-features/thorium-chrome-plugin/README.md create mode 100644 linux-features/thorium-chrome-plugin/feature.json create mode 100755 linux-features/thorium-chrome-plugin/patch-chrome-plugin.js create mode 100644 linux-features/thorium-chrome-plugin/patch.js create mode 100755 linux-features/thorium-chrome-plugin/stage.sh create mode 100644 linux-features/thorium-chrome-plugin/test.js diff --git a/launcher/start.sh.template b/launcher/start.sh.template index b366da43..f7e79064 100644 --- a/launcher/start.sh.template +++ b/launcher/start.sh.template @@ -628,7 +628,7 @@ write_chrome_native_host_manifests() { local host_path="$1" local plugin_dir="$2" - python3 - "$host_path" "$HOME" "$plugin_dir" <<'PY' + python3 - "$host_path" "$HOME" "$plugin_dir" "$SCRIPT_DIR" <<'PY' import json import pathlib import re @@ -637,6 +637,7 @@ import sys host_path = sys.argv[1] home = pathlib.Path(sys.argv[2]) plugin_dir = pathlib.Path(sys.argv[3]) +app_dir = pathlib.Path(sys.argv[4]) scripts_dir = plugin_dir / "scripts" extension_id = None @@ -674,11 +675,29 @@ manifest = { } text = json.dumps(manifest, separators=(",", ":")) -for relative in ( +manifest_locations = [ ".config/google-chrome/NativeMessagingHosts", ".config/BraveSoftware/Brave-Browser/NativeMessagingHosts", ".config/chromium/NativeMessagingHosts", -): +] + +extra_locations = app_dir / ".codex-linux" / "chrome-native-host-manifest-paths" +try: + for line in extra_locations.read_text(encoding="utf-8").splitlines(): + relative = line.strip() + if ( + not relative + or relative.startswith("#") + or pathlib.PurePosixPath(relative).is_absolute() + or ".." in pathlib.PurePosixPath(relative).parts + ): + continue + if relative not in manifest_locations: + manifest_locations.append(relative) +except OSError: + pass + +for relative in manifest_locations: directory = home / relative directory.mkdir(parents=True, exist_ok=True) path = directory / manifest_name diff --git a/linux-features/thorium-chrome-plugin/README.md b/linux-features/thorium-chrome-plugin/README.md new file mode 100644 index 00000000..a426c139 --- /dev/null +++ b/linux-features/thorium-chrome-plugin/README.md @@ -0,0 +1,29 @@ +# Thorium Chrome Plugin Support + +This optional Linux feature extends the bundled Chrome plugin to recognize +Thorium as a Chromium-family browser. + +It is disabled by default because Thorium is a narrower browser variant that the +core Linux port does not regularly test. Enable it by adding the feature id to +`linux-features/features.json` before building or installing: + +```json +{ + "enabled": [ + "thorium-chrome-plugin" + ] +} +``` + +When enabled, the feature: + +- adds Thorium native-messaging manifest locations for the generated launcher +- patches the staged Chrome plugin scripts to detect Thorium installs, profiles, + running processes, default-browser desktop IDs, and launch commands +- adds Thorium to the Electron-side Chrome extension settings/status helper + +Run the focused tests with: + +```bash +node --test linux-features/thorium-chrome-plugin/test.js +``` diff --git a/linux-features/thorium-chrome-plugin/feature.json b/linux-features/thorium-chrome-plugin/feature.json new file mode 100644 index 00000000..750924d5 --- /dev/null +++ b/linux-features/thorium-chrome-plugin/feature.json @@ -0,0 +1,10 @@ +{ + "id": "thorium-chrome-plugin", + "title": "Thorium Chrome Plugin Support", + "description": "Adds disabled-by-default Thorium support to the bundled Chrome plugin on Linux.", + "defaultEnabled": false, + "entrypoints": { + "patches": "./patch.js", + "stageHook": "./stage.sh" + } +} diff --git a/linux-features/thorium-chrome-plugin/patch-chrome-plugin.js b/linux-features/thorium-chrome-plugin/patch-chrome-plugin.js new file mode 100755 index 00000000..109909aa --- /dev/null +++ b/linux-features/thorium-chrome-plugin/patch-chrome-plugin.js @@ -0,0 +1,359 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +function warn(message) { + process.stderr.write(`WARN: ${message}\n`); +} + +function sourceIncludesAny(source, texts) { + return (Array.isArray(texts) ? texts : [texts]).some( + (text) => typeof text === "string" && text.length > 0 && source.includes(text), + ); +} + +function patchFile(filePath, patches) { + let source; + try { + source = fs.readFileSync(filePath, "utf8"); + } catch (error) { + warn(`Could not read ${filePath}: ${error.message}`); + return; + } + + let changed = false; + for (const { label, oldText, newText, alreadyText = newText } of patches) { + if (source.includes(newText) || sourceIncludesAny(source, alreadyText)) { + console.log(`${path.basename(filePath)} already patched: ${label}`); + continue; + } + if (!source.includes(oldText)) { + warn(`${path.basename(filePath)} missing patch target for ${label}`); + continue; + } + source = source.replace(oldText, newText); + changed = true; + console.log(`Patched ${path.basename(filePath)}: ${label}`); + } + + if (changed) { + fs.writeFileSync(filePath, source, "utf8"); + } +} + +function patchFileFirstMatch(filePath, { label, oldTexts, newText, alreadyText = newText }) { + let source; + try { + source = fs.readFileSync(filePath, "utf8"); + } catch (error) { + warn(`Could not read ${filePath}: ${error.message}`); + return; + } + + if ((typeof newText === "string" && source.includes(newText)) || sourceIncludesAny(source, alreadyText)) { + console.log(`${path.basename(filePath)} already patched: ${label}`); + return; + } + + const match = oldTexts + .map((candidate) => typeof candidate === "string" ? { oldText: candidate, newText } : candidate) + .find((candidate) => source.includes(candidate.oldText)); + if (!match) { + warn(`${path.basename(filePath)} missing patch target for ${label}`); + return; + } + + fs.writeFileSync(filePath, source.replace(match.oldText, match.newText ?? newText), "utf8"); + console.log(`Patched ${path.basename(filePath)}: ${label}`); +} + +const pluginDir = process.argv[2]; +if (!pluginDir) { + throw new Error("Usage: patch-chrome-plugin.js /path/to/chrome/plugin"); +} + +const scriptsDir = path.resolve(pluginDir, "scripts"); + +const nativeHostManifestFallback = ` if (process.platform === "linux") { + const manifestPaths = [ + path.join( + os.homedir(), + ".config", + "google-chrome", + "NativeMessagingHosts", + \`\${expectedHostName}.json\`, + ), + path.join( + os.homedir(), + ".config", + "BraveSoftware", + "Brave-Browser", + "NativeMessagingHosts", + \`\${expectedHostName}.json\`, + ), + path.join( + os.homedir(), + ".config", + "chromium", + "NativeMessagingHosts", + \`\${expectedHostName}.json\`, + ), + path.join( + os.homedir(), + ".config", + "thorium", + "NativeMessagingHosts", + \`\${expectedHostName}.json\`, + ), + ]; + + return { + manifestPath: + manifestPaths.find((candidate) => fs.existsSync(candidate)) || + manifestPaths[0], + registryKey: null, + registryManifestPath: null, + registryKeyExists: null, + }; + }`; + +const nativeHostManifestFallbackWithoutThorium = nativeHostManifestFallback.replace( + ` path.join( + os.homedir(), + ".config", + "thorium", + "NativeMessagingHosts", + \`\${expectedHostName}.json\`, + ), +`, + "", +); + +const extensionAwareUserDataFallback = ` const linuxChromeUserDataDirectory = path.join(os.homedir(), ".config", "google-chrome"); + const linuxChromiumUserDataDirectory = path.join(os.homedir(), ".config", "chromium"); + const linuxThoriumUserDataDirectory = path.join(os.homedir(), ".config", "thorium"); + const linuxBraveUserDataDirectory = path.join( + os.homedir(), + ".config", + "BraveSoftware", + "Brave-Browser", + ); + const linuxUserDataCandidates = [ + linuxBraveUserDataDirectory, + linuxChromeUserDataDirectory, + linuxChromiumUserDataDirectory, + linuxThoriumUserDataDirectory, + ].filter((candidate) => fs.existsSync(candidate)); + const linuxCandidateWithInstalledExtension = linuxUserDataCandidates.find( + (candidate) => { + try { + const extensionId = loadRemoteChromeExtensionId(); + return findLatestChromeProfile(candidate) != null && + fs.existsSync( + path.join( + candidate, + resolveChromeProfileDirectory(candidate), + "Extensions", + extensionId, + ), + ); + } catch { + return false; + } + }, + ); + if (linuxCandidateWithInstalledExtension) { + return linuxCandidateWithInstalledExtension; + } + + if (linuxUserDataCandidates.length > 0) return linuxUserDataCandidates[0]; + + return linuxChromeUserDataDirectory;`; + +const extensionAwareUserDataFallbackWithoutThorium = extensionAwareUserDataFallback + .replace(' const linuxThoriumUserDataDirectory = path.join(os.homedir(), ".config", "thorium");\n', "") + .replace(" linuxThoriumUserDataDirectory,\n", ""); + +const defaultBrowserUserDataFallback = ` const linuxChromeUserDataDirectory = path.join(os.homedir(), ".config", "google-chrome"); + const linuxChromiumUserDataDirectory = path.join(os.homedir(), ".config", "chromium"); + const linuxThoriumUserDataDirectory = path.join(os.homedir(), ".config", "thorium"); + const linuxBraveUserDataDirectory = path.join( + os.homedir(), + ".config", + "BraveSoftware", + "Brave-Browser", + ); + const defaultBrowser = runCommand(["xdg-settings", "get", "default-web-browser"]); + if ( + defaultBrowser === "brave-browser.desktop" && + fs.existsSync(linuxBraveUserDataDirectory) + ) { + return linuxBraveUserDataDirectory; + } + if ( + ["chromium.desktop", "chromium-browser.desktop"].includes(defaultBrowser) && + fs.existsSync(linuxChromiumUserDataDirectory) + ) { + return linuxChromiumUserDataDirectory; + } + if ( + ["thorium-browser.desktop", "thorium-browser-avx2.desktop"].includes(defaultBrowser) && + fs.existsSync(linuxThoriumUserDataDirectory) + ) { + return linuxThoriumUserDataDirectory; + } + + if (fs.existsSync(linuxBraveUserDataDirectory)) return linuxBraveUserDataDirectory; + if (fs.existsSync(linuxChromeUserDataDirectory)) return linuxChromeUserDataDirectory; + if (fs.existsSync(linuxChromiumUserDataDirectory)) return linuxChromiumUserDataDirectory; + if (fs.existsSync(linuxThoriumUserDataDirectory)) return linuxThoriumUserDataDirectory; + + return linuxChromeUserDataDirectory;`; + +const defaultBrowserUserDataFallbackWithoutThorium = defaultBrowserUserDataFallback + .replace(' const linuxThoriumUserDataDirectory = path.join(os.homedir(), ".config", "thorium");\n', "") + .replace(` if ( + ["thorium-browser.desktop", "thorium-browser-avx2.desktop"].includes(defaultBrowser) && + fs.existsSync(linuxThoriumUserDataDirectory) + ) { + return linuxThoriumUserDataDirectory; + } +`, "") + .replace(" if (fs.existsSync(linuxThoriumUserDataDirectory)) return linuxThoriumUserDataDirectory;\n", ""); + +patchFileFirstMatch(path.join(scriptsDir, "installManifest.mjs"), { + label: "Thorium native host manifest location", + oldTexts: [ + 'linux:[".config/google-chrome/NativeMessagingHosts",".config/BraveSoftware/Brave-Browser/NativeMessagingHosts",".config/chromium/NativeMessagingHosts"]', + ], + newText: + 'linux:[".config/google-chrome/NativeMessagingHosts",".config/BraveSoftware/Brave-Browser/NativeMessagingHosts",".config/chromium/NativeMessagingHosts",".config/thorium/NativeMessagingHosts"]', +}); + +patchFile(path.join(scriptsDir, "check-native-host-manifest.js"), [ + { + label: "Thorium native host manifest fallback", + oldText: nativeHostManifestFallbackWithoutThorium, + newText: nativeHostManifestFallback, + alreadyText: '"thorium",\n "NativeMessagingHosts"', + }, +]); + +patchFileFirstMatch(path.join(scriptsDir, "browser-client.mjs"), { + label: "Thorium Chrome profile roots", + oldTexts: [ + { + oldText: String.raw`codexLinuxChromeUserDataDirectories=()=>WF()==="linux"?[GF(VF(),".config","BraveSoftware","Brave-Browser"),GF(VF(),".config","google-chrome"),GF(VF(),".config","chromium")]:[Tc]`, + newText: String.raw`codexLinuxChromeUserDataDirectories=()=>WF()==="linux"?[GF(VF(),".config","BraveSoftware","Brave-Browser"),GF(VF(),".config","google-chrome"),GF(VF(),".config","chromium"),GF(VF(),".config","thorium")]:[Tc]`, + }, + { + oldText: String.raw`codexLinuxChromeUserDataDirectories=()=>rO()==="linux"?[eO(tO(),".config","BraveSoftware","Brave-Browser"),eO(tO(),".config","google-chrome"),eO(tO(),".config","chromium")]:[Ic]`, + newText: String.raw`codexLinuxChromeUserDataDirectories=()=>rO()==="linux"?[eO(tO(),".config","BraveSoftware","Brave-Browser"),eO(tO(),".config","google-chrome"),eO(tO(),".config","chromium"),eO(tO(),".config","thorium")]:[Ic]`, + }, + { + oldText: String.raw`codexLinuxChromeUserDataDirectories=()=>X5()==="linux"?[Y5(Z5(),".config","BraveSoftware","Brave-Browser"),Y5(Z5(),".config","google-chrome"),Y5(Z5(),".config","chromium")]:[hl]`, + newText: String.raw`codexLinuxChromeUserDataDirectories=()=>X5()==="linux"?[Y5(Z5(),".config","BraveSoftware","Brave-Browser"),Y5(Z5(),".config","google-chrome"),Y5(Z5(),".config","chromium"),Y5(Z5(),".config","thorium")]:[hl]`, + }, + { + oldText: String.raw`var hl=Y5(Z5(),X5()==="win32"?"AppData\\Local\\Google\\Chrome\\User Data":"Library/Application Support/Google/Chrome");`, + newText: String.raw`var hl=Y5(Z5(),X5()==="win32"?"AppData\\Local\\Google\\Chrome\\User Data":"Library/Application Support/Google/Chrome"),codexLinuxChromeUserDataDirectories=()=>X5()==="linux"?[Y5(Z5(),".config","BraveSoftware","Brave-Browser"),Y5(Z5(),".config","google-chrome"),Y5(Z5(),".config","chromium"),Y5(Z5(),".config","thorium")]:[hl];`, + }, + ], + alreadyText: '".config","thorium"', +}); + +patchFileFirstMatch(path.join(scriptsDir, "browser-client.mjs"), { + label: "Thorium Chrome profile metadata lookup", + oldTexts: [ + { + oldText: String.raw`var mT=async(e,t)=>{let r=rh(hl,e,"Local Extension Settings",t);if(!n9(r))return null;let n=await r9(rh(o9(),"codex"));await t9(r,n,{recursive:!0}),await fT(rh(n,"LOCK"));let o=new Q5(n,{createIfMissing:!1,keyEncoding:"utf8",valueEncoding:"utf8"});try{await o.open();let i=await o.get("extensionInstanceId");if(!i)return null;let s=JSON.parse(i);return typeof s!="string"?null:s}finally{await o.close(),await fT(n,{force:!0,recursive:!0})}}`, + newText: String.raw`var mT=async(e,t,r=hl)=>{let n=rh(r,e,"Local Extension Settings",t);if(!n9(n))return null;let o=await r9(rh(o9(),"codex"));await t9(n,o,{recursive:!0}),await fT(rh(o,"LOCK"));let i=new Q5(o,{createIfMissing:!1,keyEncoding:"utf8",valueEncoding:"utf8"});try{await i.open();let s=await i.get("extensionInstanceId");if(!s)return null;let a=JSON.parse(s);return typeof a!="string"?null:a}finally{await i.close(),await fT(o,{force:!0,recursive:!0})}}`, + }, + ], + alreadyText: "async(e,t,r=hl)", +}); + +patchFileFirstMatch(path.join(scriptsDir, "browser-client.mjs"), { + label: "Thorium Chrome profile instance matching", + oldTexts: [ + { + oldText: String.raw`a9=async(e,t)=>(await u9(e)).find(o=>o.instanceId===t)||null,u9=async e=>{let t=await c9();return await Promise.all(t.map(async r=>({...r,instanceId:await mT(r.id,e).catch(n=>(ne(n),null))})))},c9=async()=>{let e=s9(hl,"Local State"),t=JSON.parse(await i9(e,"utf8"));return t.profile.profiles_order.map((r,n)=>{let o=t.profile.info_cache[r];return o?{id:r,name:o.name,isLastUsed:t.profile.last_used===r,orderingIndex:n,avatarUrl:o.avatar_icon}:null}).filter(r=>!!r)}`, + newText: String.raw`a9=async(e,t)=>{let r=(await u9(e)).filter(n=>n.instanceId===t);return r.length===1?r[0]:null},u9=async e=>{let t=[];for(let r of codexLinuxChromeUserDataDirectories())try{let n=await c9(r);t.push(...await Promise.all(n.map(async o=>({...o,userDataDir:r,instanceId:await mT(o.id,e,r).catch(i=>(ne(i),null))}))))}catch(n){ne(n)}return t},c9=async r=>{let n=s9(r,"Local State"),o=JSON.parse(await i9(n,"utf8"));return o.profile.profiles_order.map((i,s)=>{let a=o.profile.info_cache[i];return a?{id:i,name:a.name,isLastUsed:o.profile.last_used===i,orderingIndex:s,avatarUrl:a.avatar_icon}:null}).filter(i=>!!i)}`, + }, + ], + alreadyText: "r.length===1?r[0]:null},u9=async", +}); + +patchFile(path.join(scriptsDir, "installed-browsers.js"), [ + { + label: "Thorium browser inventory", + oldText: ` { + name: "Chromium", + bundleIds: ["org.chromium.Chromium"], + appNames: ["Chromium.app"], + commands: ["chromium", "chromium-browser"], + windowsExecutable: "chrome.exe", + }, +];`, + newText: ` { + name: "Chromium", + bundleIds: ["org.chromium.Chromium"], + appNames: ["Chromium.app"], + commands: ["chromium", "chromium-browser"], + windowsExecutable: "chrome.exe", + }, + { + name: "Thorium", + bundleIds: ["org.chromium.Thorium"], + appNames: ["Thorium.app"], + commands: ["thorium-browser-avx2", "thorium-browser", "thorium"], + windowsExecutable: "chrome.exe", + }, +];`, + alreadyText: '"Thorium"', + }, +]); + +patchFile(path.join(scriptsDir, "chrome-is-running.js"), [ + { + label: "Thorium running-process detection", + oldText: ` linux: new Set(["chrome", "google-chrome", "brave", "brave-browser", "chromium", "chromium-browser"]),`, + newText: ` linux: new Set(["chrome", "google-chrome", "brave", "brave-browser", "chromium", "chromium-browser", "thorium", "thorium-browser", "thorium-browser-avx2"]),`, + alreadyText: "thorium-browser-avx2", + }, +]); + +patchFileFirstMatch(path.join(scriptsDir, "check-extension-installed.js"), { + label: "Thorium extension-aware browser profile fallback", + oldTexts: [extensionAwareUserDataFallbackWithoutThorium], + newText: extensionAwareUserDataFallback, + alreadyText: "linuxThoriumUserDataDirectory", +}); + +patchFileFirstMatch(path.join(scriptsDir, "open-chrome-window.js"), { + label: "Thorium default-browser profile fallback", + oldTexts: [defaultBrowserUserDataFallbackWithoutThorium], + newText: defaultBrowserUserDataFallback, + alreadyText: "linuxThoriumUserDataDirectory", +}); + +patchFile(path.join(scriptsDir, "open-chrome-window.js"), [ + { + label: "Thorium browser window command", + oldText: ` } else if (linuxUserDataDirectory.includes(path.join(".config", "chromium"))) { + linuxCommand = commandPath("chromium") || commandPath("chromium-browser") || "chromium"; + } + + return {`, + newText: ` } else if (linuxUserDataDirectory.includes(path.join(".config", "chromium"))) { + linuxCommand = commandPath("chromium") || commandPath("chromium-browser") || "chromium"; + } else if (linuxUserDataDirectory.includes(path.join(".config", "thorium"))) { + linuxCommand = commandPath("thorium-browser-avx2") || commandPath("thorium-browser") || commandPath("thorium") || "thorium-browser"; + } + + return {`, + alreadyText: 'commandPath("thorium-browser-avx2")', + }, +]); diff --git a/linux-features/thorium-chrome-plugin/patch.js b/linux-features/thorium-chrome-plugin/patch.js new file mode 100644 index 00000000..290be3c7 --- /dev/null +++ b/linux-features/thorium-chrome-plugin/patch.js @@ -0,0 +1,44 @@ +"use strict"; + +function warn(message) { + console.warn(`WARN: ${message} — skipping Thorium Chrome plugin settings patch`); +} + +function applyThoriumChromeExtensionStatusPatch(source) { + let patched = source; + + patched = patched.replace( + /(\(0,([A-Za-z_$][\w$]*)\.join\)\(([A-Za-z_$][\w$]*),`\.config`,`chromium`\))\]:\[\]/g, + "$1,(0,$2.join)($3,`.config`,`thorium`)]:[]", + ); + patched = patched.replace( + /`chromium-browser`,`chromium`\]/g, + "`chromium-browser`,`chromium`,`thorium-browser-avx2`,`thorium-browser`,`thorium`]", + ); + patched = patched.replace( + /Google Chrome, Brave, or Chromium is not installed/g, + "Google Chrome, Brave, Chromium, or Thorium is not installed", + ); + + if ( + patched === source && + source.includes("codexLinuxChromeProfileRoots") && + !source.includes("`thorium`") + ) { + warn("Could not find Linux Chrome extension status helper shape"); + } + return patched; +} + +module.exports = { + patches: [ + { + id: "chrome-extension-status", + phase: "main-bundle", + order: 20500, + ciPolicy: "optional", + apply: applyThoriumChromeExtensionStatusPatch, + }, + ], + applyThoriumChromeExtensionStatusPatch, +}; diff --git a/linux-features/thorium-chrome-plugin/stage.sh b/linux-features/thorium-chrome-plugin/stage.sh new file mode 100755 index 00000000..bd058794 --- /dev/null +++ b/linux-features/thorium-chrome-plugin/stage.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +chrome_plugin="$INSTALL_DIR/resources/plugins/openai-bundled/plugins/chrome" +patcher="$SCRIPT_DIR/linux-features/thorium-chrome-plugin/patch-chrome-plugin.js" +manifest_paths_dir="$INSTALL_DIR/.codex-linux" +manifest_paths_file="$manifest_paths_dir/chrome-native-host-manifest-paths" + +mkdir -p "$manifest_paths_dir" +printf '%s\n' ".config/thorium/NativeMessagingHosts" > "$manifest_paths_file" + +if [ ! -d "$chrome_plugin" ]; then + echo "WARN: Chrome plugin not found; skipping Thorium Chrome plugin patch" >&2 + exit 0 +fi + +node "$patcher" "$chrome_plugin" >&2 diff --git a/linux-features/thorium-chrome-plugin/test.js b/linux-features/thorium-chrome-plugin/test.js new file mode 100644 index 00000000..e1bc59c6 --- /dev/null +++ b/linux-features/thorium-chrome-plugin/test.js @@ -0,0 +1,224 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { + enabledLinuxFeatureIds, + enabledLinuxFeatureStageHooks, + loadLinuxFeaturePatchDescriptors, +} = require("../../scripts/lib/linux-features.js"); +const { applyThoriumChromeExtensionStatusPatch } = require("./patch.js"); + +const repoRoot = path.resolve(__dirname, "..", ".."); + +function withTempFeatureRoot(enabled, fn) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-thorium-feature-root-")); + try { + fs.writeFileSync(path.join(root, "features.example.json"), JSON.stringify({ enabled: [] }, null, 2)); + fs.writeFileSync(path.join(root, "features.json"), JSON.stringify({ enabled }, null, 2)); + fs.cpSync(__dirname, path.join(root, "thorium-chrome-plugin"), { recursive: true }); + return fn(root); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +function writeFakeChromePlugin(pluginDir) { + const scriptsDir = path.join(pluginDir, "scripts"); + fs.mkdirSync(scriptsDir, { recursive: true }); + fs.writeFileSync( + path.join(scriptsDir, "installManifest.mjs"), + 'var n={extensionId:"hehggadaopoacecdllhhajmbjkdcmajg",extensionHostName:"com.openai.codexextension"};var p=o=>{let t=`${o.extensionHostName}.json`,r={darwin:["Library/Application Support/Google/Chrome/NativeMessagingHosts"],linux:[".config/google-chrome/NativeMessagingHosts"],win32:["AppData/Local/OpenAI/extension"]}[m.platform()];return r.map(s=>l.resolve(m.homedir(),s,t))};\n', + ); + fs.writeFileSync( + path.join(scriptsDir, "browser-client.mjs"), + 'import{resolve as GF}from"path";import{homedir as VF,platform as WF}from"os";var Tc=GF(VF(),WF()==="win32"?"AppData\\\\Local\\\\Google\\\\Chrome\\\\User Data":"Library/Application Support/Google/Chrome");var IS=async(t,e)=>{let r=Gf(Tc,t,"Local Extension Settings",e);if(!XF(r))return null;let n=await JF(Gf(QF(),"codex"));await ZF(r,n,{recursive:!0}),await kS(Gf(n,"LOCK"));let o=new KF(n,{createIfMissing:!1,keyEncoding:"utf8",valueEncoding:"utf8"});try{await o.open();let i=await o.get("extensionInstanceId");if(!i)return null;let s=JSON.parse(i);return typeof s!="string"?null:s}finally{await o.close(),await kS(n,{force:!0,recursive:!0})}};var AS=async t=>t,rO=async(t,e)=>(await nO(t)).find(o=>o.instanceId===e)||null,nO=async t=>{let e=await oO();return await Promise.all(e.map(async r=>({...r,instanceId:await IS(r.id,t).catch(n=>(ee(n),null))})))},oO=async()=>{let t=tO(Tc,"Local State"),e=JSON.parse(await eO(t,"utf8"));return e.profile.profiles_order.map((r,n)=>{let o=e.profile.info_cache[r];return o?{id:r,name:o.name,isLastUsed:e.profile.last_used===r,orderingIndex:n,avatarUrl:o.avatar_icon}:null}).filter(r=>!!r)};\n', + ); + fs.writeFileSync( + path.join(scriptsDir, "check-native-host-manifest.js"), + `function getNativeHostManifestLocation() { + if (process.platform === "win32") { + const registryKey = \`\${WINDOWS_NATIVE_HOST_REGISTRY_KEY_PREFIX}\\\\\${expectedHostName}\`; + const registryManifestPath = readWindowsRegistryDefaultValue(registryKey); + + return { + manifestPath: registryManifestPath || getDefaultWindowsManifestPath(), + registryKey, + registryManifestPath, + registryKeyExists: registryManifestPath != null, + }; + } + + throw new Error( + \`Unsupported platform for native host manifest check: \${process.platform}. This script supports macOS and Windows.\`, + ); +} +`, + ); + fs.writeFileSync( + path.join(scriptsDir, "installed-browsers.js"), + `const KNOWN_BROWSERS = [ + { + name: "Google Chrome", + bundleIds: ["com.google.Chrome"], + appNames: ["Google Chrome.app"], + commands: ["google-chrome", "chrome"], + windowsExecutable: "chrome.exe", + }, +]; +`, + ); + fs.writeFileSync( + path.join(scriptsDir, "chrome-is-running.js"), + `const CHROME_PROCESS_NAMES_BY_PLATFORM = { + darwin: new Set(["Google Chrome", "Google Chrome Helper"]), + win32: new Set(["chrome.exe"]), +}; +`, + ); + fs.writeFileSync( + path.join(scriptsDir, "check-extension-installed.js"), + `function resolveChromeUserDataDirectory() { + return path.join(os.homedir(), ".config", "google-chrome"); +} +`, + ); + fs.writeFileSync( + path.join(scriptsDir, "open-chrome-window.js"), + `function resolveChromeUserDataDirectory() { + return path.join(os.homedir(), ".config", "google-chrome"); +} + +function getOpenChromeCommand(profileDirectory) { + const chromeArgs = [ + \`--profile-directory=\${profileDirectory}\`, + "--new-window", + ABOUT_BLANK_URL, + ]; + + return { + command: "google-chrome", + args: chromeArgs, + }; +} +`, + ); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { encoding: "utf8", ...options }); + assert.equal( + result.status, + 0, + `${command} ${args.join(" ")} failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + return result; +} + +test("Thorium Chrome plugin feature stays disabled until listed in features.json", () => { + withTempFeatureRoot([], (root) => { + assert.deepEqual(enabledLinuxFeatureIds({ featuresRoot: root }), []); + assert.deepEqual(enabledLinuxFeatureStageHooks({ featuresRoot: root }), []); + assert.deepEqual(loadLinuxFeaturePatchDescriptors({ featuresRoot: root }), []); + }); +}); + +test("Thorium Chrome plugin feature exposes its patch and stage hook when enabled", () => { + withTempFeatureRoot(["thorium-chrome-plugin"], (root) => { + assert.deepEqual(enabledLinuxFeatureIds({ featuresRoot: root }), ["thorium-chrome-plugin"]); + assert.equal(enabledLinuxFeatureStageHooks({ featuresRoot: root }).length, 1); + assert.equal(loadLinuxFeaturePatchDescriptors({ featuresRoot: root }).length, 1); + }); +}); + +test("Thorium settings patch extends the core Linux Chrome status helper", () => { + const source = + "function codexLinuxChromeProfileRoots({homeDir:e,platform:t}){return t===`linux`?[(0,p.join)(e,`.config`,`BraveSoftware`,`Brave-Browser`),(0,p.join)(e,`.config`,`google-chrome`),(0,p.join)(e,`.config`,`google-chrome-beta`),(0,p.join)(e,`.config`,`google-chrome-unstable`),(0,p.join)(e,`.config`,`chromium`)]:[]}function codexLinuxChromeCommand(){for(let t of[`brave-browser`,`brave`,`google-chrome`,`google-chrome-stable`,`chromium-browser`,`chromium`]){}}throw Error(`Google Chrome, Brave, or Chromium is not installed`)"; + const patched = applyThoriumChromeExtensionStatusPatch(source); + + assert.match(patched, /`\.config`,`thorium`/); + assert.match(patched, /`thorium-browser-avx2`/); + assert.match(patched, /Google Chrome, Brave, Chromium, or Thorium is not installed/); +}); + +test("Thorium stage hook upgrades a core Linux-patched Chrome plugin", () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "codex-thorium-stage-")); + try { + const installDir = path.join(workspace, "install"); + const workDir = path.join(workspace, "work"); + const chromePlugin = path.join(installDir, "resources", "plugins", "openai-bundled", "plugins", "chrome"); + const featuresConfig = path.join(workspace, "features.json"); + + fs.mkdirSync(workDir, { recursive: true }); + writeFakeChromePlugin(chromePlugin); + fs.writeFileSync(featuresConfig, JSON.stringify({ enabled: ["thorium-chrome-plugin"] }, null, 2)); + + run("node", [path.join(repoRoot, "scripts", "lib", "patch-chrome-plugin.js"), chromePlugin]); + run("bash", [ + "-lc", + [ + "source \"$LINUX_FEATURES_RUNNER\"", + "info(){ echo \"$*\" >&2; }", + "warn(){ echo \"$*\" >&2; }", + "SCRIPT_DIR=\"$REPO_ROOT\"", + "INSTALL_DIR=\"$INSTALL_DIR\"", + "WORK_DIR=\"$WORK_DIR\"", + "ARCH=x86_64", + "run_linux_feature_stage_hooks", + ].join("\n"), + ], { + env: { + ...process.env, + CODEX_LINUX_FEATURES_CONFIG: featuresConfig, + LINUX_FEATURES_RUNNER: path.join(repoRoot, "scripts", "lib", "linux-features.sh"), + REPO_ROOT: repoRoot, + INSTALL_DIR: installDir, + WORK_DIR: workDir, + }, + }); + + const scriptsDir = path.join(chromePlugin, "scripts"); + assert.match(fs.readFileSync(path.join(scriptsDir, "installManifest.mjs"), "utf8"), /thorium\/NativeMessagingHosts/); + assert.match(fs.readFileSync(path.join(scriptsDir, "check-native-host-manifest.js"), "utf8"), /"thorium"/); + assert.match(fs.readFileSync(path.join(scriptsDir, "browser-client.mjs"), "utf8"), /"\.config","thorium"/); + assert.match(fs.readFileSync(path.join(scriptsDir, "installed-browsers.js"), "utf8"), /Thorium/); + assert.match(fs.readFileSync(path.join(scriptsDir, "chrome-is-running.js"), "utf8"), /thorium-browser-avx2/); + assert.match(fs.readFileSync(path.join(scriptsDir, "check-extension-installed.js"), "utf8"), /linuxThoriumUserDataDirectory/); + assert.match(fs.readFileSync(path.join(scriptsDir, "open-chrome-window.js"), "utf8"), /commandPath\("thorium-browser-avx2"\)/); + assert.equal( + fs.readFileSync(path.join(installDir, ".codex-linux", "chrome-native-host-manifest-paths"), "utf8").trim(), + ".config/thorium/NativeMessagingHosts", + ); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test("Thorium patcher handles the current browser-client metadata shape", () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "codex-thorium-current-browser-client-")); + try { + const chromePlugin = path.join(workspace, "chrome"); + const scriptsDir = path.join(chromePlugin, "scripts"); + fs.mkdirSync(scriptsDir, { recursive: true }); + fs.writeFileSync( + path.join(scriptsDir, "browser-client.mjs"), + 'import{readFile as i9}from"fs/promises";import{resolve as s9}from"path";import{resolve as Y5}from"path";import{homedir as Z5,platform as X5}from"os";var hl=Y5(Z5(),X5()==="win32"?"AppData\\\\Local\\\\Google\\\\Chrome\\\\User Data":"Library/Application Support/Google/Chrome");import{ClassicLevel as Q5}from"./node_modules/classic-level.mjs";import{resolve as rh}from"path";import{tmpdir as e9}from"os";import{cp as t9,mkdtemp as r9,rm as fT}from"fs/promises";import{existsSync as n9}from"fs";var mT=async(e,t)=>{let r=rh(hl,e,"Local Extension Settings",t);if(!n9(r))return null;let n=await r9(rh(o9(),"codex"));await t9(r,n,{recursive:!0}),await fT(rh(n,"LOCK"));let o=new Q5(n,{createIfMissing:!1,keyEncoding:"utf8",valueEncoding:"utf8"});try{await o.open();let i=await o.get("extensionInstanceId");if(!i)return null;let s=JSON.parse(i);return typeof s!="string"?null:s}finally{await o.close(),await fT(n,{force:!0,recursive:!0})}},o9=()=>"nodeRepl"in globalThis&&globalThis.nodeRepl?globalThis.nodeRepl.tmpDir:e9();var hT=async e=>e,a9=async(e,t)=>(await u9(e)).find(o=>o.instanceId===t)||null,u9=async e=>{let t=await c9();return await Promise.all(t.map(async r=>({...r,instanceId:await mT(r.id,e).catch(n=>(ne(n),null))})))},c9=async()=>{let e=s9(hl,"Local State"),t=JSON.parse(await i9(e,"utf8"));return t.profile.profiles_order.map((r,n)=>{let o=t.profile.info_cache[r];return o?{id:r,name:o.name,isLastUsed:t.profile.last_used===r,orderingIndex:n,avatarUrl:o.avatar_icon}:null}).filter(r=>!!r)};\n', + ); + + run("node", [path.join(repoRoot, "linux-features", "thorium-chrome-plugin", "patch-chrome-plugin.js"), chromePlugin]); + + const patched = fs.readFileSync(path.join(scriptsDir, "browser-client.mjs"), "utf8"); + assert.match(patched, /codexLinuxChromeUserDataDirectories/); + assert.match(patched, /"\.config","thorium"/); + assert.match(patched, /async\(e,t,r=hl\)/); + assert.match(patched, /r\.length===1\?r\[0\]:null/); + assert.match(patched, /instanceId:await mT\(o\.id,e,r\)/); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); diff --git a/tests/scripts_smoke.sh b/tests/scripts_smoke.sh index f8672e79..3e1cbd38 100755 --- a/tests/scripts_smoke.sh +++ b/tests/scripts_smoke.sh @@ -2689,28 +2689,30 @@ test_chrome_native_host_manifest_writer() { local workspace="$TMP_DIR/chrome-native-host-manifest" local plugin_dir="$workspace/plugin" local home_dir="$workspace/home" + local app_dir="$workspace/app" local host_path="$workspace/extension-host" local manifest_path - mkdir -p "$plugin_dir/scripts" "$home_dir" "$(dirname "$host_path")" + mkdir -p "$plugin_dir/scripts" "$home_dir" "$app_dir/.codex-linux" "$(dirname "$host_path")" printf '#!/bin/sh\n' > "$host_path" chmod +x "$host_path" cat > "$plugin_dir/scripts/extension-id.json" <<'JSON' {"extensionId":"abcdefghijklmnopabcdefghijklmnop","extensionHostName":"com.example.codextest"} JSON + printf '%s\n' ".config/example-browser/NativeMessagingHosts" > "$app_dir/.codex-linux/chrome-native-host-manifest-paths" - python3 - "$REPO_DIR/launcher/start.sh.template" "$host_path" "$home_dir" "$plugin_dir" <<'PY' + python3 - "$REPO_DIR/launcher/start.sh.template" "$host_path" "$home_dir" "$plugin_dir" "$app_dir" <<'PY' import subprocess import sys from pathlib import Path source = Path(sys.argv[1]).read_text(encoding="utf-8") -marker = "python3 - \"$host_path\" \"$HOME\" \"$plugin_dir\" <<'PY'\n" +marker = "python3 - \"$host_path\" \"$HOME\" \"$plugin_dir\" \"$SCRIPT_DIR\" <<'PY'\n" start = source.index(marker) + len(marker) end = source.index("\nPY\n", start) script = source[start:end] subprocess.run( - ["python3", "-", sys.argv[2], sys.argv[3], sys.argv[4]], + ["python3", "-", sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]], input=script, text=True, check=True, @@ -2720,7 +2722,8 @@ PY for relative in \ ".config/google-chrome/NativeMessagingHosts" \ ".config/BraveSoftware/Brave-Browser/NativeMessagingHosts" \ - ".config/chromium/NativeMessagingHosts"; do + ".config/chromium/NativeMessagingHosts" \ + ".config/example-browser/NativeMessagingHosts"; do manifest_path="$home_dir/$relative/com.example.codextest.json" assert_file_exists "$manifest_path" assert_contains "$manifest_path" "com.example.codextest"