diff --git a/native-host/Cargo.lock b/native-host/Cargo.lock index e9708c0..6021fc3 100644 --- a/native-host/Cargo.lock +++ b/native-host/Cargo.lock @@ -34,6 +34,7 @@ name = "codex-firefox-bridge" version = "1.4.10" dependencies = [ "base64", + "libc", "regex", "serde_json", "tempfile", diff --git a/native-host/Cargo.toml b/native-host/Cargo.toml index bf1ff56..3886ff3 100644 --- a/native-host/Cargo.toml +++ b/native-host/Cargo.toml @@ -12,6 +12,9 @@ regex = "1.11" serde_json = "1.0" tempfile = "3.20" +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [profile.release] lto = true codegen-units = 1 diff --git a/native-host/src/main.rs b/native-host/src/main.rs index b72faca..0cb42e9 100644 --- a/native-host/src/main.rs +++ b/native-host/src/main.rs @@ -7,8 +7,8 @@ use std::io::{self, Read, Seek, SeekFrom, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::process::{ChildStderr, ChildStdin, ChildStdout, Command, Stdio}; -use std::sync::atomic::{AtomicU16, Ordering}; -use std::sync::{Arc, OnceLock}; +use std::sync::atomic::{AtomicBool, AtomicU16, Ordering}; +use std::sync::{mpsc, Arc, OnceLock}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tempfile::TempDir; @@ -17,7 +17,8 @@ const HOST_NAME: &str = "com.openai.codexextension"; const OFFICIAL_CHROME_ORIGIN: &str = "chrome-extension://hehggadaopoacecdllhhajmbjkdcmajg/"; const OFFICIAL_CHROME_EXTENSION_ID: &str = "hehggadaopoacecdllhhajmbjkdcmajg"; const FIREFOX_EXTENSION_ID: &str = "codex-computer-use-firefox-zen@sunkenintime"; -const MAX_NATIVE_MESSAGE_BYTES: usize = 1024 * 1024 * 1024; +const MAX_NATIVE_INPUT_MESSAGE_BYTES: usize = 1024 * 1024 * 1024; +const MAX_NATIVE_OUTPUT_MESSAGE_BYTES: usize = 1024 * 1024; const CODEX_VERSION_TIMEOUT: Duration = Duration::from_secs(2); #[derive(Debug, PartialEq)] @@ -28,6 +29,12 @@ struct AppServerRuntime { node_repl: PathBuf, } +#[derive(Clone, Debug, PartialEq)] +enum NativeOutputOutcome { + CleanEof, + Fatal(Option), +} + fn main() { let argument = env::args().nth(1); if argument.as_deref() == Some("--version") { @@ -92,15 +99,66 @@ fn run() -> Result> { .ok_or("original host stderr is unavailable")?; let input_thread = thread::spawn(move || forward_stdin(child_stdin)); - let output_thread = - thread::spawn(move || forward_native_messages(child_stdout, relay_port, upstream_port)); - let error_thread = thread::spawn(move || forward_stderr(child_stderr)); + let fatal_output = Arc::new(AtomicBool::new(false)); + let (output_outcome_tx, output_outcome_rx) = mpsc::channel(); + let output_fatal_state = Arc::clone(&fatal_output); + let output_thread = thread::spawn(move || { + let outcome = + forward_native_messages(child_stdout, relay_port, upstream_port, output_fatal_state); + let _ = output_outcome_tx.send(outcome.clone()); + outcome + }); + let error_thread = thread::spawn(move || forward_stderr(child_stderr, fatal_output)); - let status = child.wait()?; - let _ = output_thread.join(); - let _ = error_thread.join(); + let mut observed_output_outcome = None; + let status = loop { + if observed_output_outcome.is_none() { + match output_outcome_rx.try_recv() { + Ok(outcome @ NativeOutputOutcome::Fatal(_)) => { + observed_output_outcome = Some(outcome); + let _ = child.kill(); + break child.wait()?; + } + Ok(NativeOutputOutcome::CleanEof) => { + observed_output_outcome = Some(NativeOutputOutcome::CleanEof); + } + Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => { + observed_output_outcome = Some(NativeOutputOutcome::Fatal(None)); + let _ = child.kill(); + break child.wait()?; + } + } + } + if let Some(status) = child.try_wait()? { + break status; + } + thread::sleep(Duration::from_millis(10)); + }; + let joined_output_outcome = output_thread + .join() + .unwrap_or(NativeOutputOutcome::Fatal(None)); + let fatal_diagnostic = match observed_output_outcome.as_ref() { + Some(NativeOutputOutcome::Fatal(diagnostic)) => diagnostic.clone(), + _ => match &joined_output_outcome { + NativeOutputOutcome::Fatal(diagnostic) => diagnostic.clone(), + NativeOutputOutcome::CleanEof => None, + }, + }; + let fatal_output_observed = + matches!(observed_output_outcome, Some(NativeOutputOutcome::Fatal(_))) + || matches!(joined_output_outcome, NativeOutputOutcome::Fatal(_)); + if !fatal_output_observed { + let _ = error_thread.join(); + } drop(input_thread); drop(fallback_registry); + if fatal_output_observed { + if let Some(diagnostic) = fatal_diagnostic { + eprintln!("{diagnostic}"); + } + return Ok(1); + } Ok(status.code().unwrap_or(1)) } @@ -418,7 +476,7 @@ fn forward_stdin(mut output: ChildStdin) { } } let length = u32::from_le_bytes(header) as usize; - if length > MAX_NATIVE_MESSAGE_BYTES { + if length > MAX_NATIVE_INPUT_MESSAGE_BYTES { eprintln!("[codex-firefox-bridge] native input message is too large: {length}"); return; } @@ -477,34 +535,82 @@ fn rewrite_firefox_extension_id(value: &mut Value) -> bool { changed } -fn forward_stderr(mut input: ChildStderr) { - let _ = io::copy(&mut input, &mut io::stderr().lock()); +fn forward_stderr(mut input: ChildStderr, fatal_output: Arc) { + let mut buffer = [0_u8; 8 * 1024]; + loop { + let Ok(read) = input.read(&mut buffer) else { + return; + }; + if read == 0 { + return; + } + if fatal_output.load(Ordering::Acquire) { + return; + } + let mut output = io::stderr().lock(); + if fatal_output.load(Ordering::Acquire) { + return; + } + if output + .write_all(&buffer[..read]) + .and_then(|_| output.flush()) + .is_err() + { + return; + } + } } -fn forward_native_messages(mut input: ChildStdout, relay_port: u16, upstream_port: Arc) { +fn forward_native_messages( + mut input: ChildStdout, + relay_port: u16, + upstream_port: Arc, + fatal_output: Arc, +) -> NativeOutputOutcome { let mut output = io::stdout().lock(); loop { let mut header = [0_u8; 4]; match read_exact_or_eof(&mut input, &mut header) { - Ok(false) => return, + Ok(false) => return NativeOutputOutcome::CleanEof, Ok(true) => {} Err(error) => { - eprintln!("[codex-firefox-bridge] native header read failed: {error}"); - return; + fatal_output.store(true, Ordering::Release); + return NativeOutputOutcome::Fatal(Some(format!( + "[codex-firefox-bridge] native header read failed: {error}" + ))); } } let length = u32::from_le_bytes(header) as usize; - if length > MAX_NATIVE_MESSAGE_BYTES { - eprintln!("[codex-firefox-bridge] native message is too large: {length}"); - return; + if length > MAX_NATIVE_OUTPUT_MESSAGE_BYTES { + fatal_output.store(true, Ordering::Release); + return NativeOutputOutcome::Fatal(Some(format!( + "[codex-firefox-bridge] native output message is too large: {length}" + ))); } let mut payload = vec![0_u8; length]; if let Err(error) = input.read_exact(&mut payload) { - eprintln!("[codex-firefox-bridge] native payload read failed: {error}"); - return; + fatal_output.store(true, Ordering::Release); + return NativeOutputOutcome::Fatal(Some(format!( + "[codex-firefox-bridge] native payload read failed: {error}" + ))); + } + let enriched = match enrich_native_message(payload, relay_port, &upstream_port) { + Ok(enriched) => enriched, + Err(projected_length) => { + fatal_output.store(true, Ordering::Release); + return NativeOutputOutcome::Fatal(Some(format!( + "[codex-firefox-bridge] native output message is too large: {projected_length}" + ))); + } + }; + if enriched.len() > MAX_NATIVE_OUTPUT_MESSAGE_BYTES { + fatal_output.store(true, Ordering::Release); + return NativeOutputOutcome::Fatal(Some(format!( + "[codex-firefox-bridge] native output message is too large: {}", + enriched.len() + ))); } - let enriched = enrich_native_message(payload, relay_port, &upstream_port); let output_header = (enriched.len() as u32).to_le_bytes(); if output .write_all(&output_header) @@ -512,7 +618,8 @@ fn forward_native_messages(mut input: ChildStdout, relay_port: u16, upstream_por .and_then(|_| output.flush()) .is_err() { - return; + fatal_output.store(true, Ordering::Release); + return NativeOutputOutcome::Fatal(None); } } } @@ -535,17 +642,63 @@ fn read_exact_or_eof(reader: &mut impl Read, buffer: &mut [u8]) -> io::Result, relay_port: u16, upstream_port: &AtomicU16) -> Vec { +fn enrich_native_message( + payload: Vec, + relay_port: u16, + upstream_port: &AtomicU16, +) -> Result, u128> { + enrich_native_message_with_pre_read_hook(payload, relay_port, upstream_port, || {}) +} + +fn enrich_native_message_with_pre_read_hook( + payload: Vec, + relay_port: u16, + upstream_port: &AtomicU16, + pre_read_hook: impl FnOnce(), +) -> Result, u128> { let Ok(mut value) = serde_json::from_slice::(&payload) else { - return payload; + return Ok(payload); }; + let mut pending_files = Vec::new(); let changed = enrich_bridge_version(&mut value) - | enrich_commands(&mut value) + | enrich_commands(&mut value, &mut pending_files) | rewrite_websocket_urls(&mut value, relay_port, upstream_port); if !changed { - return payload; + return Ok(payload); } - serde_json::to_vec(&value).unwrap_or(payload) + + let projected_without_file_data = + serde_json::to_vec(&value).unwrap_or_else(|_| payload.clone()); + let projected_length = pending_files.iter().fold( + projected_without_file_data.len() as u128, + |length, pending| length + base64_encoded_length(pending.byte_len), + ); + if projected_length > MAX_NATIVE_OUTPUT_MESSAGE_BYTES as u128 { + return Err(projected_length); + } + + pre_read_hook(); + let mut encoded_files = Vec::with_capacity(pending_files.len()); + for pending in pending_files { + let byte_len = usize::try_from(pending.byte_len) + .expect("a preflight-approved file length must fit in usize"); + let mut data = Vec::with_capacity(byte_len); + let read = open_regular_file(&pending.path) + .ok_or_else(|| io::Error::other("upload path is not a readable regular file")) + .and_then(|file| file.take(pending.byte_len).read_to_end(&mut data)); + if read.is_err() { + encoded_files.push(None); + continue; + } + encoded_files.push(Some(base64::engine::general_purpose::STANDARD.encode(data))); + } + apply_file_payload_data(&mut value, &mut encoded_files.into_iter()); + + let enriched = serde_json::to_vec(&value).unwrap_or(payload); + if enriched.len() > MAX_NATIVE_OUTPUT_MESSAGE_BYTES { + return Err(enriched.len() as u128); + } + Ok(enriched) } fn enrich_bridge_version(value: &mut Value) -> bool { @@ -562,11 +715,26 @@ fn enrich_bridge_version(value: &mut Value) -> bool { true } -fn enrich_commands(value: &mut Value) -> bool { +struct PendingFilePayload { + path: PathBuf, + byte_len: u64, +} + +fn base64_encoded_length(byte_len: u64) -> u128 { + u128::from(byte_len).div_ceil(3) * 4 +} + +fn enrich_commands(value: &mut Value, pending_files: &mut Vec) -> bool { let mut changed = false; match value { Value::Object(object) => { if object.get("method").and_then(Value::as_str) == Some("DOM.setFileInputFiles") { + for key in ["commandParams", "params"] { + let Some(Value::Object(parameters)) = object.get_mut(key) else { + continue; + }; + changed |= parameters.remove("_firefoxFilePayloads").is_some(); + } for key in ["commandParams", "params"] { let Some(Value::Object(parameters)) = object.get_mut(key) else { continue; @@ -574,11 +742,14 @@ fn enrich_commands(value: &mut Value) -> bool { let Some(Value::Array(files)) = parameters.get("files") else { continue; }; - let payloads: Vec = files - .iter() - .filter_map(Value::as_str) - .filter_map(file_payload) - .collect(); + let mut payloads = Vec::new(); + for path in files.iter().filter_map(Value::as_str) { + let Some((payload, pending)) = pending_file_payload(path) else { + continue; + }; + payloads.push(payload); + pending_files.push(pending); + } if !payloads.is_empty() { parameters.insert("_firefoxFilePayloads".into(), Value::Array(payloads)); changed = true; @@ -587,12 +758,12 @@ fn enrich_commands(value: &mut Value) -> bool { } } for child in object.values_mut() { - changed |= enrich_commands(child); + changed |= enrich_commands(child, pending_files); } } Value::Array(array) => { for child in array { - changed |= enrich_commands(child); + changed |= enrich_commands(child, pending_files); } } _ => {} @@ -600,26 +771,85 @@ fn enrich_commands(value: &mut Value) -> bool { changed } -fn file_payload(path: &str) -> Option { - let path = Path::new(path); - let metadata = fs::metadata(path).ok()?; - if !metadata.is_file() { - return None; - } - let data = fs::read(path).ok()?; +fn pending_file_payload(path: &str) -> Option<(Value, PendingFilePayload)> { + let source_path = Path::new(path); + let path = source_path.canonicalize().ok()?; + let file = open_regular_file(&path)?; + let metadata = file.metadata().ok()?; + let byte_len = metadata.len(); let modified = metadata .modified() .ok() .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) .map(|duration| duration.as_millis() as u64) .unwrap_or(0); - Some(json!({ - "path": path.canonicalize().unwrap_or_else(|_| path.to_path_buf()).to_string_lossy(), + let payload = json!({ + "path": path.to_string_lossy(), "name": path.file_name()?.to_string_lossy(), "type": mime_type(path.extension().and_then(|value| value.to_str()).unwrap_or("")), "lastModified": modified, - "data": base64::engine::general_purpose::STANDARD.encode(data) - })) + "data": "" + }); + Some((payload, PendingFilePayload { path, byte_len })) +} + +#[cfg(unix)] +fn open_regular_file(path: &Path) -> Option { + use std::os::unix::fs::OpenOptionsExt; + + let file = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW) + .open(path) + .ok()?; + file.metadata().ok()?.is_file().then_some(file) +} + +#[cfg(not(unix))] +fn open_regular_file(path: &Path) -> Option { + let file = fs::File::open(path).ok()?; + file.metadata().ok()?.is_file().then_some(file) +} + +fn apply_file_payload_data( + value: &mut Value, + encoded_files: &mut impl Iterator>, +) { + match value { + Value::Object(object) => { + if object.get("method").and_then(Value::as_str) == Some("DOM.setFileInputFiles") { + for key in ["commandParams", "params"] { + let Some(Value::Object(parameters)) = object.get_mut(key) else { + continue; + }; + let Some(Value::Array(payloads)) = parameters.get_mut("_firefoxFilePayloads") + else { + continue; + }; + payloads.retain_mut(|payload| { + let Some(encoded) = encoded_files.next().flatten() else { + return false; + }; + payload["data"] = Value::String(encoded); + true + }); + if payloads.is_empty() { + parameters.remove("_firefoxFilePayloads"); + } + break; + } + } + for child in object.values_mut() { + apply_file_payload_data(child, encoded_files); + } + } + Value::Array(array) => { + for child in array { + apply_file_payload_data(child, encoded_files); + } + } + _ => {} + } } fn mime_type(extension: &str) -> &'static str { @@ -938,6 +1168,387 @@ fn bundled_app_host_candidates_for( mod tests { use super::*; + fn file_input_message(paths: &[&Path]) -> Vec { + serde_json::to_vec(&json!({ + "jsonrpc": "2.0", + "id": "file-input", + "method": "DOM.setFileInputFiles", + "params": { + "files": paths + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect::>(), + } + })) + .unwrap() + } + + fn expected_file_payload(path: &Path) -> Value { + let metadata = fs::metadata(path).unwrap(); + let modified = metadata + .modified() + .unwrap() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + json!({ + "path": path.canonicalize().unwrap().to_string_lossy(), + "name": path.file_name().unwrap().to_string_lossy(), + "type": "text/plain", + "lastModified": modified, + "data": base64::engine::general_purpose::STANDARD.encode(fs::read(path).unwrap()), + }) + } + + fn expected_enriched_file_input(paths: &[&Path]) -> Vec { + let mut value: Value = serde_json::from_slice(&file_input_message(paths)).unwrap(); + value["params"]["_firefoxFilePayloads"] = Value::Array( + paths + .iter() + .map(|path| expected_file_payload(path)) + .collect(), + ); + serde_json::to_vec(&value).unwrap() + } + + #[test] + fn rejects_an_oversized_file_upload_with_the_exact_projected_output_length() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("oversized.txt"); + fs::write(&file, vec![b'x'; 786_432]).unwrap(); + let payload = file_input_message(&[&file]); + let projected_length = expected_enriched_file_input(&[&file]).len(); + assert!(projected_length > MAX_NATIVE_OUTPUT_MESSAGE_BYTES); + + let upstream = AtomicU16::new(0); + let error = enrich_native_message(payload, 54321, &upstream).expect_err( + "an upload that would exceed Firefox's 1 MiB native-message limit must be rejected before its contents are loaded or base64-encoded", + ); + + assert_eq!(error, projected_length as u128); + } + + #[test] + fn rejects_file_uploads_when_their_cumulative_payload_exceeds_the_remaining_budget() { + let directory = tempfile::tempdir().unwrap(); + let first = directory.path().join("first.txt"); + let second = directory.path().join("second.txt"); + fs::write(&first, vec![b'a'; 524_288]).unwrap(); + fs::write(&second, vec![b'b'; 524_288]).unwrap(); + let projected_first_length = expected_enriched_file_input(&[&first]).len(); + let projected_total_length = expected_enriched_file_input(&[&first, &second]).len(); + assert!(projected_first_length <= MAX_NATIVE_OUTPUT_MESSAGE_BYTES); + assert!(projected_total_length > MAX_NATIVE_OUTPUT_MESSAGE_BYTES); + + let upstream = AtomicU16::new(0); + let error = enrich_native_message(file_input_message(&[&first, &second]), 54321, &upstream) + .expect_err( + "the second file must be checked against the remaining native-message budget", + ); + + assert_eq!(error, projected_total_length as u128); + } + + #[test] + fn enriches_a_small_file_upload_with_its_firefox_payload() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("small.txt"); + fs::write(&file, b"small Firefox upload").unwrap(); + let expected: Value = + serde_json::from_slice(&expected_enriched_file_input(&[&file])).unwrap(); + + let upstream = AtomicU16::new(0); + let enriched = enrich_native_message(file_input_message(&[&file]), 54321, &upstream) + .expect("a small upload within Firefox's native-message limit must be enriched"); + + assert_eq!( + serde_json::from_slice::(&enriched).unwrap(), + expected + ); + } + + #[test] + fn removes_a_forged_file_payload_before_enriching_a_later_file_command() { + let directory = tempfile::tempdir().unwrap(); + let valid_file = directory.path().join("valid.txt"); + fs::write(&valid_file, b"valid command data").unwrap(); + + let forged_command = json!({ + "method": "DOM.setFileInputFiles", + "params": { + "files": [], + "_firefoxFilePayloads": [{ + "path": "/attacker-controlled.txt", + "name": "attacker-controlled.txt", + "data": "attacker-controlled-data" + }] + } + }); + let valid_command: Value = + serde_json::from_slice(&file_input_message(&[&valid_file])).unwrap(); + let payload = serde_json::to_vec(&json!({ + "commands": [forged_command, valid_command] + })) + .unwrap(); + + let upstream = AtomicU16::new(0); + let enriched: Value = + serde_json::from_slice(&enrich_native_message(payload, 54321, &upstream).unwrap()) + .unwrap(); + + assert!(enriched["commands"][0]["params"] + .get("_firefoxFilePayloads") + .is_none()); + assert_eq!( + enriched["commands"][1]["params"]["_firefoxFilePayloads"], + Value::Array(vec![expected_file_payload(&valid_file)]) + ); + } + + #[test] + fn associates_each_nested_file_command_with_only_its_own_file_payloads() { + let directory = tempfile::tempdir().unwrap(); + let first = directory.path().join("first.txt"); + let second = directory.path().join("second.txt"); + fs::write(&first, b"first command data").unwrap(); + fs::write(&second, b"second command data").unwrap(); + let first_command: Value = serde_json::from_slice(&file_input_message(&[&first])).unwrap(); + let second_command: Value = + serde_json::from_slice(&file_input_message(&[&second])).unwrap(); + let payload = serde_json::to_vec(&json!({ + "responses": [ + first_command, + { "nested": { "command": second_command } } + ] + })) + .unwrap(); + + let upstream = AtomicU16::new(0); + let enriched: Value = + serde_json::from_slice(&enrich_native_message(payload, 54321, &upstream).unwrap()) + .unwrap(); + + assert_eq!( + enriched["responses"][0]["params"]["_firefoxFilePayloads"], + Value::Array(vec![expected_file_payload(&first)]) + ); + assert_eq!( + enriched["responses"][1]["nested"]["command"]["params"]["_firefoxFilePayloads"], + Value::Array(vec![expected_file_payload(&second)]) + ); + } + + #[test] + fn enriches_many_small_file_uploads_without_losing_any_payload() { + let directory = tempfile::tempdir().unwrap(); + let files: Vec = (0..2048) + .map(|index| { + let file = directory.path().join(format!("small-{index}.txt")); + fs::write(&file, b"x").unwrap(); + file + }) + .collect(); + let references: Vec<&Path> = files.iter().map(PathBuf::as_path).collect(); + + let upstream = AtomicU16::new(0); + let enriched: Value = serde_json::from_slice( + &enrich_native_message(file_input_message(&references), 54321, &upstream).unwrap(), + ) + .unwrap(); + + let payloads = enriched["params"]["_firefoxFilePayloads"] + .as_array() + .unwrap(); + assert_eq!(payloads.len(), files.len()); + for (payload, file) in payloads.iter().zip(&files) { + assert_eq!(payload, &expected_file_payload(file)); + } + } + + #[test] + fn reads_only_the_metadata_time_file_length_after_the_pre_read_hook_grows_the_file() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("snapshot.txt"); + fs::write(&file, b"metadata-time contents").unwrap(); + let expected: Value = + serde_json::from_slice(&expected_enriched_file_input(&[&file])).unwrap(); + let hook_runs = std::sync::atomic::AtomicUsize::new(0); + let upstream = AtomicU16::new(0); + + let enriched = enrich_native_message_with_pre_read_hook( + file_input_message(&[&file]), + 54321, + &upstream, + || { + hook_runs.fetch_add(1, Ordering::SeqCst); + let mut grown = fs::read(&file).unwrap(); + grown.extend(vec![b'g'; MAX_NATIVE_OUTPUT_MESSAGE_BYTES + 1]); + fs::write(&file, grown).unwrap(); + }, + ) + .expect( + "a file that grows after metadata capture must not fabricate a 1,048,577-byte overflow", + ); + + assert_eq!(hook_runs.load(Ordering::SeqCst), 1); + assert_eq!( + serde_json::from_slice::(&enriched).unwrap(), + expected + ); + } + + #[test] + fn preserves_file_payload_named_application_data_outside_file_input_parameters() { + let application_payload = json!({ + "source": "application", + "items": [{ "id": "unrelated" }] + }); + let nested_application_payload = json!(["unrelated", { "keep": true }]); + let payload = serde_json::to_vec(&json!({ + "applicationState": { + "_firefoxFilePayloads": application_payload, + "nested": { + "_firefoxFilePayloads": nested_application_payload + } + }, + "command": { + "method": "DOM.setFileInputFiles", + "params": { + "files": [], + "_firefoxFilePayloads": [{ "forged": true }], + "applicationData": { + "_firefoxFilePayloads": { "keep": "this value" } + } + } + } + })) + .unwrap(); + + let upstream = AtomicU16::new(0); + let enriched: Value = + serde_json::from_slice(&enrich_native_message(payload, 54321, &upstream).unwrap()) + .unwrap(); + + assert_eq!( + enriched["applicationState"]["_firefoxFilePayloads"], + application_payload + ); + assert_eq!( + enriched["applicationState"]["nested"]["_firefoxFilePayloads"], + nested_application_payload + ); + assert_eq!( + enriched["command"]["params"]["applicationData"]["_firefoxFilePayloads"], + json!({ "keep": "this value" }) + ); + assert!(enriched["command"]["params"] + .get("_firefoxFilePayloads") + .is_none()); + } + + #[cfg(unix)] + fn create_fifo(path: &Path) { + let status = Command::new("mkfifo").arg(path).status().unwrap(); + assert!(status.success(), "mkfifo failed for {}", path.display()); + } + + #[cfg(unix)] + #[test] + fn skips_a_fifo_upload_without_blocking_enrichment() { + let directory = tempfile::tempdir().unwrap(); + let fifo = directory.path().join("upload.fifo"); + create_fifo(&fifo); + let payload = file_input_message(&[&fifo]); + let (result_tx, result_rx) = mpsc::channel(); + let worker = thread::spawn(move || { + let upstream = AtomicU16::new(0); + result_tx + .send(enrich_native_message(payload, 54321, &upstream)) + .unwrap(); + }); + + let first_result = result_rx.recv_timeout(Duration::from_millis(250)); + let timed_out = matches!(first_result, Err(mpsc::RecvTimeoutError::Timeout)); + if timed_out { + let writer = fs::OpenOptions::new().write(true).open(&fifo).unwrap(); + drop(writer); + } + let result = match first_result { + Ok(result) => result, + Err(mpsc::RecvTimeoutError::Timeout) => result_rx + .recv_timeout(Duration::from_secs(1)) + .expect("FIFO cleanup did not release the blocked enrichment worker"), + Err(mpsc::RecvTimeoutError::Disconnected) => { + panic!("the FIFO enrichment worker exited before reporting a result") + } + }; + worker.join().unwrap(); + + assert!( + !timed_out, + "a FIFO reference must be rejected or omitted without waiting for a writer" + ); + if let Ok(enriched) = result { + let enriched: Value = serde_json::from_slice(&enriched).unwrap(); + assert!(enriched["params"].get("_firefoxFilePayloads").is_none()); + } + } + + #[cfg(unix)] + #[test] + fn skips_a_regular_upload_replaced_with_a_fifo_before_the_read_phase_without_blocking() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("replaced.txt"); + fs::write(&file, b"metadata-time contents").unwrap(); + let payload = file_input_message(&[&file]); + let replacement_path = file.clone(); + let cleanup_fifo = file.clone(); + let (hook_tx, hook_rx) = mpsc::channel(); + let (result_tx, result_rx) = mpsc::channel(); + let worker = thread::spawn(move || { + let upstream = AtomicU16::new(0); + let result = + enrich_native_message_with_pre_read_hook(payload, 54321, &upstream, || { + fs::remove_file(&replacement_path).unwrap(); + create_fifo(&replacement_path); + hook_tx.send(()).unwrap(); + }); + result_tx.send(result).unwrap(); + }); + + hook_rx + .recv_timeout(Duration::from_secs(1)) + .expect("the pre-read hook did not replace the prepared regular file"); + let first_result = result_rx.recv_timeout(Duration::from_millis(250)); + let timed_out = matches!(first_result, Err(mpsc::RecvTimeoutError::Timeout)); + if timed_out { + let writer = fs::OpenOptions::new() + .write(true) + .open(&cleanup_fifo) + .unwrap(); + drop(writer); + } + let result = match first_result { + Ok(result) => result, + Err(mpsc::RecvTimeoutError::Timeout) => result_rx + .recv_timeout(Duration::from_secs(1)) + .expect("FIFO cleanup did not release the replacement-file enrichment worker"), + Err(mpsc::RecvTimeoutError::Disconnected) => { + panic!("the replacement-file enrichment worker exited before reporting a result") + } + }; + worker.join().unwrap(); + + assert!( + !timed_out, + "a file replaced with a FIFO before reading must not wait for a writer" + ); + if let Ok(enriched) = result { + let enriched: Value = serde_json::from_slice(&enriched).unwrap(); + assert!(enriched["params"].get("_firefoxFilePayloads").is_none()); + } + } + #[test] fn rewrites_nested_websocket_urls() { let mut value = json!({"nested": {"url": "ws://localhost:45678/path?token=test"}}); @@ -960,7 +1571,8 @@ mod tests { .unwrap(); let upstream = AtomicU16::new(0); let enriched: Value = - serde_json::from_slice(&enrich_native_message(payload, 54321, &upstream)).unwrap(); + serde_json::from_slice(&enrich_native_message(payload, 54321, &upstream).unwrap()) + .unwrap(); assert_eq!(enriched["_firefoxBridgeVersion"], env!("CARGO_PKG_VERSION")); } @@ -1039,12 +1651,8 @@ mod tests { fn discovers_linux_chatgpt_app_resources() { let paths = chatgpt_resource_candidates_for("linux", Some(Path::new("/home/test"))); assert!(paths.contains(&PathBuf::from("/usr/lib/chatgpt/resources"))); - assert!(paths.contains(&PathBuf::from( - "/home/test/.local/opt/chatgpt/resources" - ))); - assert!(paths.contains(&PathBuf::from( - "/home/test/.local/share/chatgpt/resources" - ))); + assert!(paths.contains(&PathBuf::from("/home/test/.local/opt/chatgpt/resources"))); + assert!(paths.contains(&PathBuf::from("/home/test/.local/share/chatgpt/resources"))); } #[test] diff --git a/tests/NativeHostFixture.cs b/tests/NativeHostFixture.cs index 81b5427..a7e6e21 100644 --- a/tests/NativeHostFixture.cs +++ b/tests/NativeHostFixture.cs @@ -1,21 +1,220 @@ using System; +using System.Diagnostics; using System.IO; using System.Text; +using System.Threading; +using System.Threading.Tasks; internal static class NativeHostFixture { + private const int OutputLimit = 1024 * 1024; + private const int LingeringHostMilliseconds = 3000; + private static int Main() { + string mode = Environment.GetEnvironmentVariable("CHATGPT_FIREFOX_FIXTURE_MODE") ?? "file-upload"; + if (mode == "output-at-limit") + { + WriteSizedMessage("output-at-limit", OutputLimit); + return 0; + } + if (mode == "output-above-limit") + { + WriteSizedMessage("output-above-limit", OutputLimit + 1); + return 0; + } + if (mode == "output-above-limit-then-normal") + { + WriteSizedMessage("output-above-limit", OutputLimit + 1); + WriteFrame(Encoding.UTF8.GetBytes("{\"kind\":\"normal-after-oversize\"}")); + return 0; + } + if (mode == "output-above-limit-then-wait") + { + WriteSizedMessage("output-above-limit", OutputLimit + 1); + Thread.Sleep(LingeringHostMilliseconds); + File.WriteAllText(Environment.GetEnvironmentVariable("CHATGPT_FIREFOX_FIXTURE_COMPLETION_MARKER"), "completed"); + return 0; + } + if (mode == "output-above-limit-header-then-wait") + { + WriteHeader(OutputLimit + 1); + Thread.Sleep(LingeringHostMilliseconds); + File.WriteAllText(Environment.GetEnvironmentVariable("CHATGPT_FIREFOX_FIXTURE_COMPLETION_MARKER"), "completed"); + return 0; + } + if (mode == "output-above-limit-header-then-stderr-descendant") + { + SpawnDescendantThatRetainsStderr(); + WriteHeader(OutputLimit + 1); + return 0; + } + if (mode == "write-completion-marker-after-wait") + { + Thread.Sleep(LingeringHostMilliseconds); + File.WriteAllText(Environment.GetEnvironmentVariable("CHATGPT_FIREFOX_FIXTURE_COMPLETION_MARKER"), "completed"); + return 0; + } + if (mode == "enrichment-overflow") + { + WriteEnrichmentOverflow(); + return 0; + } + if (mode == "oversized-file-upload") + { + WriteOversizedFileUpload(); + return 0; + } + if (mode == "verify-truncated-input") + { + string result = ReceivesInputWithin(100) + ? "{\"kind\":\"truncated-input-forwarded\"}" + : "{\"kind\":\"truncated-input-not-forwarded\"}"; + WriteFrame(Encoding.UTF8.GetBytes(result)); + return 0; + } + if (mode == "echo-input") + { + byte[] input = ReadInputWithin(100); + if (input.Length >= 4 && input.Length == BitConverter.ToUInt32(input, 0) + 4) + { + byte[] payload = new byte[input.Length - 4]; + Buffer.BlockCopy(input, 4, payload, 0, payload.Length); + WriteFrame(payload); + } + else + { + WriteFrame(Encoding.UTF8.GetBytes("{\"kind\":\"invalid-echo-input\"}")); + } + return 0; + } + if (mode == "verify-large-input") + { + byte[] input = ReadInputWithin(3000); + bool valid = input.Length >= 4 + && input.Length == BitConverter.ToUInt32(input, 0) + 4 + && HasOnlyInputBytes(input, 4); + string result = "{\"kind\":\"" + (valid ? "large-input-received" : "large-input-invalid") + "\",\"receivedLength\":" + input.Length + "}"; + WriteFrame(Encoding.UTF8.GetBytes(result)); + return 0; + } + string file = Environment.GetEnvironmentVariable("CHATGPT_FIREFOX_TEST_FILE") ?? String.Empty; string escaped = file.Replace("\\", "\\\\").Replace("\"", "\\\""); string nested = "{\\\"localAppServerUrl\\\":\\\"ws://localhost:45678?clientId=nested\\\"}"; string json = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"executeCdp\",\"appServerUrl\":\"ws://127.0.0.1:45678?token=test\",\"serializedResult\":\"" + nested + "\",\"params\":{\"method\":\"DOM.setFileInputFiles\",\"commandParams\":{\"files\":[\"" + escaped + "\"]}}}"; - byte[] payload = Encoding.UTF8.GetBytes(json); + WriteFrame(Encoding.UTF8.GetBytes(json)); + return 0; + } + + private static void WriteSizedMessage(string kind, int length) + { + byte[] prefix = Encoding.UTF8.GetBytes("{\"kind\":\"" + kind + "\",\"data\":\""); + byte[] suffix = Encoding.UTF8.GetBytes("\"}"); + WriteSizedPayload(prefix, suffix, length); + } + + private static void WriteEnrichmentOverflow() + { + byte[] prefix = Encoding.UTF8.GetBytes("{\"method\":\"getInfo\",\"padding\":\""); + byte[] suffix = Encoding.UTF8.GetBytes("\"}"); + WriteSizedPayload(prefix, suffix, OutputLimit); + } + + private static void WriteOversizedFileUpload() + { + string file = Environment.GetEnvironmentVariable("CHATGPT_FIREFOX_TEST_FILE") ?? String.Empty; + string escaped = file.Replace("\\", "\\\\").Replace("\"", "\\\""); + string json = "{\"method\":\"DOM.setFileInputFiles\",\"params\":{\"files\":[\"" + escaped + "\"]}}"; + WriteFrame(Encoding.UTF8.GetBytes(json)); + } + + private static void WriteSizedPayload(byte[] prefix, byte[] suffix, int length) + { + byte[] payload = new byte[length]; + Buffer.BlockCopy(prefix, 0, payload, 0, prefix.Length); + for (int index = prefix.Length; index < length - suffix.Length; index += 1) + { + payload[index] = (byte)'x'; + } + Buffer.BlockCopy(suffix, 0, payload, length - suffix.Length, suffix.Length); + WriteFrame(payload); + } + + private static void WriteFrame(byte[] payload) + { Stream output = Console.OpenStandardOutput(); byte[] header = BitConverter.GetBytes(payload.Length); output.Write(header, 0, header.Length); output.Write(payload, 0, payload.Length); output.Flush(); - return 0; + } + + private static void WriteHeader(int length) + { + Stream output = Console.OpenStandardOutput(); + byte[] header = BitConverter.GetBytes(length); + output.Write(header, 0, header.Length); + output.Flush(); + } + + private static void SpawnDescendantThatRetainsStderr() + { + ProcessStartInfo start = new ProcessStartInfo + { + FileName = Process.GetCurrentProcess().MainModule.FileName, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = false + }; + start.EnvironmentVariables["CHATGPT_FIREFOX_FIXTURE_MODE"] = "write-completion-marker-after-wait"; + Process.Start(start); + } + + private static bool HasOnlyInputBytes(byte[] input, int offset) + { + for (int index = offset; index < input.Length; index += 1) + { + if (input[index] != (byte)'i') + { + return false; + } + } + return true; + } + + private static bool ReceivesInputWithin(int milliseconds) + { + byte[] buffer = new byte[1]; + Task read = Console.OpenStandardInput().ReadAsync(buffer, 0, buffer.Length); + return read.Wait(milliseconds) && read.Result > 0; + } + + private static byte[] ReadInputWithin(int milliseconds) + { + Stream input = Console.OpenStandardInput(); + byte[] chunk = new byte[4096]; + using (MemoryStream buffer = new MemoryStream()) + { + DateTime deadline = DateTime.UtcNow.AddMilliseconds(milliseconds); + while (DateTime.UtcNow < deadline) + { + int remaining = Math.Max(1, (int)(deadline - DateTime.UtcNow).TotalMilliseconds); + Task read = input.ReadAsync(chunk, 0, chunk.Length); + if (!read.Wait(remaining) || read.Result == 0) + { + break; + } + buffer.Write(chunk, 0, read.Result); + byte[] bytes = buffer.ToArray(); + if (bytes.Length >= 4 && bytes.Length >= BitConverter.ToUInt32(bytes, 0) + 4) + { + return bytes; + } + } + return buffer.ToArray(); + } } } diff --git a/tests/native-host-fixture.mjs b/tests/native-host-fixture.mjs index e040764..91dd255 100755 --- a/tests/native-host-fixture.mjs +++ b/tests/native-host-fixture.mjs @@ -1,22 +1,157 @@ #!/usr/bin/env node +import fs from "node:fs"; +import { spawn } from "node:child_process"; + +const outputLimit = 1024 * 1024; +const lingeringHostMilliseconds = 3000; const file = process.env.CHATGPT_FIREFOX_TEST_FILE ?? ""; -const message = { - jsonrpc: "2.0", - id: 1, - method: "executeCdp", - appServerUrl: "ws://127.0.0.1:45678?token=test", - serializedResult: JSON.stringify({ - localAppServerUrl: "ws://localhost:45678?clientId=nested" - }), - params: { +const mode = process.env.CHATGPT_FIREFOX_FIXTURE_MODE ?? "file-upload"; + +function writeFrame(payload) { + const header = Buffer.alloc(4); + header.writeUInt32LE(payload.length, 0); + process.stdout.write(Buffer.concat([header, payload])); +} + +function writeHeader(length) { + const header = Buffer.alloc(4); + header.writeUInt32LE(length, 0); + process.stdout.write(header); +} + +function spawnStderrDescendantThenWriteHeader(length) { + const descendant = spawn(process.execPath, ["-e", [ + 'const fs = require("node:fs");', + `setTimeout(() => fs.writeFileSync(process.env.CHATGPT_FIREFOX_FIXTURE_COMPLETION_MARKER, "completed", "utf8"), ${lingeringHostMilliseconds});` + ].join(" ")], { + detached: true, + stdio: ["ignore", "ignore", "inherit"] + }); + descendant.once("error", (error) => { + throw error; + }); + descendant.once("spawn", () => { + descendant.unref(); + const header = Buffer.alloc(4); + header.writeUInt32LE(length, 0); + process.stdout.write(header, () => process.exit(0)); + }); +} + +function writeFrameAndExit(payload) { + const header = Buffer.alloc(4); + header.writeUInt32LE(payload.length, 0); + process.stdout.write(Buffer.concat([header, payload]), () => process.exit(0)); +} + +function writeSizedPayload(prefix, suffix, length) { + const payload = Buffer.concat([prefix, Buffer.alloc(length - prefix.length - suffix.length, "x"), suffix]); + if (payload.length !== length) { + throw new Error(`Fixture message length mismatch: expected ${length}, got ${payload.length}`); + } + writeFrame(payload); +} + +function writeSizedMessage(kind, length) { + writeSizedPayload(Buffer.from(`{"kind":"${kind}","data":"`, "utf8"), Buffer.from('"}', "utf8"), length); +} + +function writeEnrichmentOverflow() { + writeSizedPayload(Buffer.from('{"method":"getInfo","padding":"', "utf8"), Buffer.from('"}', "utf8"), outputLimit); +} + +function writeOversizedFileUpload() { + writeFrameAndExit(Buffer.from(JSON.stringify({ method: "DOM.setFileInputFiles", - commandParams: { - files: [file] + params: { files: [file] } + }), "utf8")); +} + +function reportTruncatedInputDelivery() { + let received = false; + process.stdin.once("data", () => { + received = true; + }); + setTimeout(() => { + writeFrameAndExit(Buffer.from(JSON.stringify(received + ? { kind: "truncated-input-forwarded" } + : { kind: "truncated-input-not-forwarded" }), "utf8")); + }, 100); +} + +function echoInput() { + const chunks = []; + process.stdin.on("data", (chunk) => chunks.push(chunk)); + process.stdin.on("end", () => { + const input = Buffer.concat(chunks); + if (input.length >= 4 && input.length === input.readUInt32LE(0) + 4) { + writeFrameAndExit(input.subarray(4)); + return; } - } -}; -const payload = Buffer.from(JSON.stringify(message), "utf8"); -const header = Buffer.alloc(4); -header.writeUInt32LE(payload.length, 0); -process.stdout.write(Buffer.concat([header, payload])); + writeFrameAndExit(Buffer.from(JSON.stringify({ kind: "invalid-echo-input" }), "utf8")); + }); +} + +function verifyLargeInput() { + const chunks = []; + process.stdin.on("data", (chunk) => chunks.push(chunk)); + process.stdin.on("end", () => { + const input = Buffer.concat(chunks); + const valid = input.length >= 4 + && input.length === input.readUInt32LE(0) + 4 + && input.subarray(4).every((byte) => byte === "i".charCodeAt(0)); + writeFrameAndExit(Buffer.from(JSON.stringify({ + kind: valid ? "large-input-received" : "large-input-invalid", + receivedLength: input.length + }), "utf8")); + }); +} + +if (mode === "output-at-limit") { + writeSizedMessage("output-at-limit", outputLimit); +} else if (mode === "output-above-limit") { + writeSizedMessage("output-above-limit", outputLimit + 1); +} else if (mode === "output-above-limit-then-normal") { + writeSizedMessage("output-above-limit", outputLimit + 1); + writeFrame(Buffer.from('{"kind":"normal-after-oversize"}', "utf8")); +} else if (mode === "output-above-limit-then-wait") { + writeSizedMessage("output-above-limit", outputLimit + 1); + setTimeout(() => { + fs.writeFileSync(process.env.CHATGPT_FIREFOX_FIXTURE_COMPLETION_MARKER, "completed", "utf8"); + }, lingeringHostMilliseconds); +} else if (mode === "output-above-limit-header-then-wait") { + writeHeader(outputLimit + 1); + setTimeout(() => { + fs.writeFileSync(process.env.CHATGPT_FIREFOX_FIXTURE_COMPLETION_MARKER, "completed", "utf8"); + }, lingeringHostMilliseconds); +} else if (mode === "output-above-limit-header-then-stderr-descendant") { + spawnStderrDescendantThenWriteHeader(outputLimit + 1); +} else if (mode === "enrichment-overflow") { + writeEnrichmentOverflow(); +} else if (mode === "oversized-file-upload") { + writeOversizedFileUpload(); +} else if (mode === "verify-truncated-input") { + reportTruncatedInputDelivery(); +} else if (mode === "echo-input") { + echoInput(); +} else if (mode === "verify-large-input") { + verifyLargeInput(); +} else { + const message = { + jsonrpc: "2.0", + id: 1, + method: "executeCdp", + appServerUrl: "ws://127.0.0.1:45678?token=test", + serializedResult: JSON.stringify({ + localAppServerUrl: "ws://localhost:45678?clientId=nested" + }), + params: { + method: "DOM.setFileInputFiles", + commandParams: { + files: [file] + } + } + }; + writeFrame(Buffer.from(JSON.stringify(message), "utf8")); +} diff --git a/tests/test-native-host.mjs b/tests/test-native-host.mjs index 75cadc3..d398e27 100644 --- a/tests/test-native-host.mjs +++ b/tests/test-native-host.mjs @@ -2,21 +2,104 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const temp = fs.mkdtempSync(path.join(os.tmpdir(), "chatgpt-firefox-native-test-")); const upload = path.join(temp, "firefox-upload.txt"); const isWindows = process.platform === "win32"; +const outputLimit = 1024 * 1024; +const lingeringHostMilliseconds = 3000; +const supervisorTerminationMaximumMilliseconds = 1500; +const smallUploadContents = "Firefox file upload parity\n"; +const bridgeVersion = JSON.parse(fs.readFileSync(path.join(root, "version.json"), "utf8")).version; +const enrichmentBytes = Buffer.byteLength(`,"_firefoxBridgeVersion":"${bridgeVersion}"`, "utf8"); +const firefoxExtensionId = "codex-computer-use-firefox-zen@sunkenintime"; +const chromeExtensionId = "hehggadaopoacecdllhhajmbjkdcmajg"; +const selectedCase = process.env.CHATGPT_FIREFOX_TEST_CASE; const cargoCandidates = [ process.env.CARGO, path.join(os.homedir(), ".cargo", "bin", isWindows ? "cargo.exe" : "cargo"), "cargo" ].filter(Boolean); +function requireWindowsDotNetFramework45() { + const releaseKey = "HKLM\\SOFTWARE\\Microsoft\\NET Framework Setup\\NDP\\v4\\Full"; + const query = spawnSync("reg", ["query", releaseKey, "/v", "Release"], { encoding: "utf8" }); + const output = `${query.stdout ?? ""}${query.stderr ?? ""}`; + assert.equal( + query.status, + 0, + `.NET Framework 4.5+ is required to compile the Windows native-host fixture (Release >= 378389). reg query failed: ${output}` + ); + const match = output.match(/Release\s+REG_DWORD\s+(0x[\da-f]+|\d+)/i); + assert.ok( + match, + `.NET Framework 4.5+ is required to compile the Windows native-host fixture (Release >= 378389). The Release registry value was not found.` + ); + const release = Number.parseInt(match[1], /^0x/i.test(match[1]) ? 16 : 10); + assert.ok( + release >= 378389, + `.NET Framework 4.5+ is required to compile the Windows native-host fixture (Release >= 378389). Found Release ${release}.` + ); +} + +function base64EncodedLength(byteLength) { + return 4 * Math.ceil(byteLength / 3); +} + +function rustWindowsExtendedLengthPath(canonicalPath) { + if (canonicalPath.startsWith("\\\\?\\")) { + return canonicalPath; + } + if (canonicalPath.startsWith("\\\\")) { + return `\\\\?\\UNC\\${canonicalPath.slice(2)}`; + } + if (/^[a-z]:[\\/]/i.test(canonicalPath)) { + return `\\\\?\\${canonicalPath.replaceAll("/", "\\")}`; + } + return canonicalPath; +} + +function rustCanonicalPath(file) { + const canonicalPath = fs.realpathSync(file); + return isWindows ? rustWindowsExtendedLengthPath(canonicalPath) : canonicalPath; +} + +function assertWindowsCanonicalPathOracle() { + assert.equal( + rustWindowsExtendedLengthPath("C:\\native\\fixture.txt"), + "\\\\?\\C:\\native\\fixture.txt" + ); + assert.equal( + rustWindowsExtendedLengthPath("\\\\server\\share\\fixture.txt"), + "\\\\?\\UNC\\server\\share\\fixture.txt" + ); +} + +function projectedOversizedFileUploadLength(file) { + const metadata = fs.statSync(file); + const projected = { + method: "DOM.setFileInputFiles", + params: { + files: [file], + _firefoxFilePayloads: [{ + path: rustCanonicalPath(file), + name: path.basename(file), + type: "text/plain", + lastModified: Math.floor(metadata.mtimeMs), + data: "A".repeat(base64EncodedLength(metadata.size)) + }] + } + }; + return Buffer.byteLength(JSON.stringify(projected), "utf8"); +} + function createFixture() { if (isWindows) { + requireWindowsDotNetFramework45(); + assertWindowsCanonicalPathOracle(); const csc = path.join(process.env.WINDIR, "Microsoft.NET", "Framework64", "v4.0.30319", "csc.exe"); const fixture = path.join(temp, "fixture.exe"); const compilation = spawnSync(csc, [ @@ -39,10 +122,7 @@ function createFixture() { return fixture; } -try { - fs.writeFileSync(upload, "Firefox file upload parity\n", "utf8"); - const fixture = createFixture(); - +function buildBridge() { const cargo = cargoCandidates.find((candidate) => { const result = spawnSync(candidate, ["--version"], { encoding: "utf8" }); return result.status === 0; @@ -55,42 +135,286 @@ try { path.join(root, "native-host", "Cargo.toml") ], { encoding: "utf8", cwd: root }); assert.equal(build.status, 0, build.stderr || build.stdout); - const proxy = path.join( + return path.join( root, "native-host", "target", "debug", isWindows ? "codex-firefox-bridge.exe" : "codex-firefox-bridge" ); +} - const run = spawnSync(proxy, [], { - encoding: null, - env: { - ...process.env, - CHATGPT_FIREFOX_ORIGINAL_HOST: fixture, - CHATGPT_FIREFOX_TEST_FILE: upload - }, - timeout: 10_000 +function runBridge(proxy, fixture, mode, input = Buffer.alloc(0), environment = {}) { + return new Promise((resolve, reject) => { + const startedAt = Date.now(); + const child = spawn(proxy, [], { + env: { + ...process.env, + CHATGPT_FIREFOX_ORIGINAL_HOST: fixture, + CHATGPT_FIREFOX_TEST_FILE: upload, + CHATGPT_FIREFOX_FIXTURE_MODE: mode, + ...environment + }, + stdio: ["pipe", "pipe", "pipe"] + }); + const stdout = []; + const stderr = []; + let outputBytes = 0; + const outputMaximum = (2 * outputLimit) + 4096; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill(); + }, 10_000); + child.stdout.on("data", (chunk) => { + outputBytes += chunk.length; + if (outputBytes > outputMaximum) { + child.kill(); + reject(new Error("The bridge exceeded the bounded test output capture.")); + return; + } + stdout.push(chunk); + }); + child.stderr.on("data", (chunk) => stderr.push(chunk)); + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.once("close", (status, signal) => { + clearTimeout(timer); + resolve({ + durationMilliseconds: Date.now() - startedAt, + signal, + status, + stderr: Buffer.concat(stderr), + stdout: Buffer.concat(stdout), + timedOut + }); + }); + child.stdin.end(input); }); - assert.equal(run.status, 0, run.stderr?.toString("utf8")); - assert.ok(run.stdout.length >= 4, "The native adapter returned no framed message."); - const length = run.stdout.readUInt32LE(0); - assert.equal(run.stdout.length, length + 4, "The native message frame length is invalid."); - const message = JSON.parse(run.stdout.subarray(4).toString("utf8")); - const payloads = message.params.commandParams._firefoxFilePayloads; - assert.equal(payloads.length, 1); - assert.equal(payloads[0].name, "firefox-upload.txt"); - assert.equal(Buffer.from(payloads[0].data, "base64").toString("utf8"), "Firefox file upload parity\n"); - const rewrittenUrl = new URL(message.appServerUrl); - assert.equal(rewrittenUrl.hostname, "127.0.0.1"); - assert.notEqual(rewrittenUrl.port, "45678"); - assert.equal(rewrittenUrl.searchParams.get("token"), "test"); - const serializedResult = JSON.parse(message.serializedResult); - const nestedUrl = new URL(serializedResult.localAppServerUrl); - assert.equal(nestedUrl.hostname, "127.0.0.1"); - assert.equal(nestedUrl.port, rewrittenUrl.port); - assert.equal(nestedUrl.searchParams.get("clientId"), "nested"); - console.log(JSON.stringify({ ok: true, nativeMessaging: true, fileUploadBridge: true, webSocketOriginRelay: true }, null, 2)); +} + +function readFrames(stdout) { + const frames = []; + let offset = 0; + while (offset < stdout.length) { + assert.ok(stdout.length - offset >= 4, "The native message header is truncated."); + const length = stdout.readUInt32LE(offset); + offset += 4; + assert.ok(stdout.length - offset >= length, "The native message payload is truncated."); + frames.push(JSON.parse(stdout.subarray(offset, offset + length).toString("utf8"))); + offset += length; + } + return frames; +} + +function shouldRun(name) { + return selectedCase == null || selectedCase === name; +} + +function assertFatalBridgeRun(run) { + assert.equal(run.signal, null, "A protocol violation must not crash or signal-terminate the bridge."); + assert.equal(Number.isInteger(run.status), true, "A protocol violation must produce an integer bridge exit status."); + assert.notEqual(run.status, 0, "A protocol violation must make the bridge exit nonzero."); +} + +try { + fs.writeFileSync(upload, smallUploadContents, "utf8"); + const fixture = createFixture(); + const proxy = buildBridge(); + + if (shouldRun("file-upload")) { + const run = await runBridge(proxy, fixture, "file-upload"); + assert.equal(run.status, 0, run.stderr?.toString("utf8")); + const [message] = readFrames(run.stdout); + assert.ok(message, "The native adapter returned no framed message."); + const payloads = message.params.commandParams._firefoxFilePayloads; + assert.equal(payloads.length, 1); + assert.equal(payloads[0].name, "firefox-upload.txt"); + assert.equal(Buffer.from(payloads[0].data, "base64").toString("utf8"), smallUploadContents); + const rewrittenUrl = new URL(message.appServerUrl); + assert.equal(rewrittenUrl.hostname, "127.0.0.1"); + assert.notEqual(rewrittenUrl.port, "45678"); + assert.equal(rewrittenUrl.searchParams.get("token"), "test"); + const serializedResult = JSON.parse(message.serializedResult); + const nestedUrl = new URL(serializedResult.localAppServerUrl); + assert.equal(nestedUrl.hostname, "127.0.0.1"); + assert.equal(nestedUrl.port, rewrittenUrl.port); + assert.equal(nestedUrl.searchParams.get("clientId"), "nested"); + } + + if (shouldRun("output-at-limit")) { + const run = await runBridge(proxy, fixture, "output-at-limit"); + assert.equal(run.status, 0, run.stderr?.toString("utf8")); + assert.ok(run.stdout.length >= 4, "The native adapter returned no framed message."); + assert.equal(run.stdout.readUInt32LE(0), outputLimit); + const [message] = readFrames(run.stdout); + assert.equal(message.kind, "output-at-limit"); + } + + if (shouldRun("output-above-limit")) { + const run = await runBridge(proxy, fixture, "output-above-limit"); + assertFatalBridgeRun(run); + assert.equal( + run.stderr.toString("utf8"), + `[codex-firefox-bridge] native output message is too large: ${outputLimit + 1}\n` + ); + assert.equal(run.stdout.length, 0, "An oversized host message must not be emitted to Firefox."); + } + + if (shouldRun("output-above-limit-then-normal")) { + const run = await runBridge(proxy, fixture, "output-above-limit-then-normal"); + assertFatalBridgeRun(run); + assert.equal( + run.stderr.toString("utf8"), + `[codex-firefox-bridge] native output message is too large: ${outputLimit + 1}\n` + ); + assert.equal(run.stdout.length, 0, "The frame after an oversized host message must not reach Firefox."); + } + + if (shouldRun("output-above-limit-then-wait")) { + const completionMarker = path.join(temp, "violating-host-completed"); + const run = await runBridge(proxy, fixture, "output-above-limit-then-wait", Buffer.alloc(0), { + CHATGPT_FIREFOX_FIXTURE_COMPLETION_MARKER: completionMarker + }); + assert.equal(run.timedOut, false, "The test harness deadline terminated the bridge instead of its supervisor."); + assert.ok( + run.durationMilliseconds < supervisorTerminationMaximumMilliseconds, + `The bridge waited ${run.durationMilliseconds}ms for an oversized-message host that remains alive for ${lingeringHostMilliseconds}ms.` + ); + assertFatalBridgeRun(run); + assert.equal( + run.stderr.toString("utf8"), + `[codex-firefox-bridge] native output message is too large: ${outputLimit + 1}\n` + ); + assert.equal(run.stdout.length, 0, "The violating host must not emit data to Firefox."); + await new Promise((resolve) => setTimeout(resolve, lingeringHostMilliseconds + 250)); + assert.equal(fs.existsSync(completionMarker), false, "The violating host survived the bridge after its termination deadline."); + } + + if (shouldRun("output-above-limit-header-then-wait")) { + const completionMarker = path.join(temp, "header-only-violating-host-completed"); + const run = await runBridge(proxy, fixture, "output-above-limit-header-then-wait", Buffer.alloc(0), { + CHATGPT_FIREFOX_FIXTURE_COMPLETION_MARKER: completionMarker + }); + assert.equal(run.timedOut, false, "The test harness deadline terminated the bridge instead of its supervisor."); + assert.ok( + run.durationMilliseconds < supervisorTerminationMaximumMilliseconds, + `The bridge waited ${run.durationMilliseconds}ms for a host that declared an oversized message but withheld its payload for ${lingeringHostMilliseconds}ms.` + ); + assertFatalBridgeRun(run); + assert.equal( + run.stderr.toString("utf8"), + `[codex-firefox-bridge] native output message is too large: ${outputLimit + 1}\n` + ); + assert.equal(run.stdout.length, 0, "A header-only oversized message must not emit data to Firefox."); + await new Promise((resolve) => setTimeout(resolve, lingeringHostMilliseconds + 250)); + assert.equal(fs.existsSync(completionMarker), false, "The header-only violating host survived the bridge after its termination deadline."); + } + + if (shouldRun("output-above-limit-header-then-stderr-descendant")) { + const completionMarker = path.join(temp, "stderr-descendant-completed"); + const run = await runBridge(proxy, fixture, "output-above-limit-header-then-stderr-descendant", Buffer.alloc(0), { + CHATGPT_FIREFOX_FIXTURE_COMPLETION_MARKER: completionMarker + }); + assert.equal(run.timedOut, false, "The test harness deadline terminated the bridge instead of its supervisor."); + assert.ok( + run.durationMilliseconds < supervisorTerminationMaximumMilliseconds, + `The bridge waited ${run.durationMilliseconds}ms for a violating host descendant that retained stderr for ${lingeringHostMilliseconds}ms.` + ); + assertFatalBridgeRun(run); + assert.equal( + run.stderr.toString("utf8"), + `[codex-firefox-bridge] native output message is too large: ${outputLimit + 1}\n` + ); + assert.equal(run.stdout.length, 0, "A violating host with a stderr-retaining descendant must not emit data to Firefox."); + await new Promise((resolve) => setTimeout(resolve, lingeringHostMilliseconds + 250)); + assert.equal( + fs.existsSync(completionMarker), + true, + "The fixture descendant did not remain alive long enough to retain the inherited stderr pipe beyond the bridge deadline." + ); + } + + if (shouldRun("enrichment-overflow")) { + const run = await runBridge(proxy, fixture, "enrichment-overflow"); + assertFatalBridgeRun(run); + assert.equal( + run.stderr.toString("utf8"), + `[codex-firefox-bridge] native output message is too large: ${outputLimit + enrichmentBytes}\n` + ); + assert.equal(run.stdout.length, 0, "Enrichment must not make an oversized message reach Firefox."); + } + + if (shouldRun("oversized-file-upload")) { + try { + fs.writeFileSync(upload, Buffer.alloc(786_432, "x")); + const projectedLength = projectedOversizedFileUploadLength(upload); + assert.ok(projectedLength > outputLimit, "The oversized fixture must exceed Firefox's native-message limit after enrichment."); + const run = await runBridge(proxy, fixture, "oversized-file-upload"); + assert.equal(run.timedOut, false, "The test harness deadline terminated the bridge instead of a fatal enrichment rejection."); + assertFatalBridgeRun(run); + assert.equal( + run.stderr.toString("utf8"), + `[codex-firefox-bridge] native output message is too large: ${projectedLength}\n` + ); + assert.equal(run.stdout.length, 0, "An oversized file upload must not emit a Firefox native-message frame."); + } finally { + fs.writeFileSync(upload, smallUploadContents, "utf8"); + } + } + + if (shouldRun("truncated-input")) { + const declaredLength = 32; + const input = Buffer.alloc(4 + 2); + input.writeUInt32LE(declaredLength, 0); + input.write("{}", 4, "utf8"); + const run = await runBridge(proxy, fixture, "verify-truncated-input", input); + assert.equal(run.status, 0, run.stderr?.toString("utf8")); + const [message] = readFrames(run.stdout); + assert.deepEqual(message, { kind: "truncated-input-not-forwarded" }); + } + + if (shouldRun("firefox-extension-id-rewrite")) { + const request = { + extensionId: firefoxExtensionId, + metadata: { + extensionId: firefoxExtensionId, + geckoExtensionId: firefoxExtensionId + }, + params: { + extensionId: firefoxExtensionId + } + }; + const payload = Buffer.from(JSON.stringify(request), "utf8"); + const input = Buffer.alloc(payload.length + 4); + input.writeUInt32LE(payload.length, 0); + payload.copy(input, 4); + const run = await runBridge(proxy, fixture, "echo-input", input); + assert.equal(run.status, 0, run.stderr?.toString("utf8")); + const [message] = readFrames(run.stdout); + assert.equal(message.extensionId, chromeExtensionId); + assert.equal(message.metadata.extensionId, chromeExtensionId); + assert.equal(message.params.extensionId, chromeExtensionId); + assert.equal(message.metadata.geckoExtensionId, firefoxExtensionId); + } + + if (shouldRun("input-above-output-limit")) { + const payload = Buffer.alloc(outputLimit + 1, "i"); + const input = Buffer.alloc(payload.length + 4); + input.writeUInt32LE(payload.length, 0); + payload.copy(input, 4); + const run = await runBridge(proxy, fixture, "verify-large-input", input); + assert.equal(run.status, 0, run.stderr?.toString("utf8")); + const [message] = readFrames(run.stdout); + assert.deepEqual(message, { + kind: "large-input-received", + receivedLength: input.length + }); + } + + console.log(JSON.stringify({ ok: true, nativeMessaging: true, selectedCase: selectedCase ?? "all" }, null, 2)); } finally { fs.rmSync(temp, { recursive: true, force: true }); }