diff --git a/crates/harness/runner/src/cli.rs b/crates/harness/runner/src/cli.rs index 4376cb8620..b04ae668fd 100644 --- a/crates/harness/runner/src/cli.rs +++ b/crates/harness/runner/src/cli.rs @@ -16,6 +16,10 @@ pub struct Cli { /// Subnet to assign harness network interfaces. #[arg(long, default_value = "10.250.0.0/24", env = "SUBNET")] pub subnet: Ipv4Net, + /// Run browser in headed mode (visible window) for debugging. + /// Works with both X11 and Wayland. + #[arg(long)] + pub headed: bool, } #[derive(Subcommand)] diff --git a/crates/harness/runner/src/executor.rs b/crates/harness/runner/src/executor.rs index 87820ef611..50e3494057 100644 --- a/crates/harness/runner/src/executor.rs +++ b/crates/harness/runner/src/executor.rs @@ -28,6 +28,9 @@ pub struct Executor { ns: Namespace, config: ExecutorConfig, target: Target, + /// Display environment variables for headed mode (X11/Wayland). + /// Empty means headless mode. + display_env: Vec, state: State, } @@ -49,11 +52,17 @@ impl State { } impl Executor { - pub fn new(ns: Namespace, config: ExecutorConfig, target: Target) -> Self { + pub fn new( + ns: Namespace, + config: ExecutorConfig, + target: Target, + display_env: Vec, + ) -> Self { Self { ns, config, target, + display_env, state: State::Init, } } @@ -120,23 +129,49 @@ impl Executor { let tmp = duct::cmd!("mktemp", "-d").read()?; let tmp = tmp.trim(); - let process = duct::cmd!( - "sudo", - "ip", - "netns", - "exec", - self.ns.name(), - chrome_path, - format!("--remote-debugging-port={PORT_BROWSER}"), - "--headless", - "--disable-dev-shm-usage", - "--disable-gpu", - "--disable-cache", - "--disable-application-cache", - "--no-sandbox", + let headed = !self.display_env.is_empty(); + + // Build command args based on headed/headless mode + let mut args: Vec = vec![ + "ip".into(), + "netns".into(), + "exec".into(), + self.ns.name().into(), + ]; + + if headed { + // For headed mode: drop back to the current user and pass display env vars + // This allows the browser to connect to X11/Wayland while in the namespace + let user = + std::env::var("USER").context("USER environment variable not set")?; + args.extend(["sudo".into(), "-E".into(), "-u".into(), user, "env".into()]); + args.extend(self.display_env.clone()); + } + + args.push(chrome_path.to_string_lossy().into()); + args.push(format!("--remote-debugging-port={PORT_BROWSER}")); + + if headed { + // Headed mode: no headless, add flags to suppress first-run dialogs + args.extend(["--no-first-run".into(), "--no-default-browser-check".into()]); + } else { + // Headless mode: original flags + args.extend([ + "--headless".into(), + "--disable-dev-shm-usage".into(), + "--disable-gpu".into(), + "--disable-cache".into(), + "--disable-application-cache".into(), + ]); + } + + args.extend([ + "--no-sandbox".into(), format!("--user-data-dir={tmp}"), - format!("--allowed-ips=10.250.0.1"), - ); + "--allowed-ips=10.250.0.1".into(), + ]); + + let process = duct::cmd("sudo", &args); let process = if !cfg!(feature = "debug") { process.stderr_capture().stdout_capture().start()? diff --git a/crates/harness/runner/src/lib.rs b/crates/harness/runner/src/lib.rs index dcaffc6ea9..4f9b95d166 100644 --- a/crates/harness/runner/src/lib.rs +++ b/crates/harness/runner/src/lib.rs @@ -105,14 +105,46 @@ struct Runner { started: bool, } +/// Collects display-related environment variables for headed browser mode. +/// Works with both X11 and Wayland by collecting whichever vars are present. +fn collect_display_env_vars() -> Vec { + const DISPLAY_VARS: &[&str] = &[ + "DISPLAY", // X11 + "XAUTHORITY", // X11 auth + "WAYLAND_DISPLAY", // Wayland + "XDG_RUNTIME_DIR", // Wayland runtime dir + ]; + + DISPLAY_VARS + .iter() + .filter_map(|&var| { + std::env::var(var) + .ok() + .map(|val| format!("{}={}", var, val)) + }) + .collect() +} + impl Runner { fn new(cli: &Cli) -> Result { - let Cli { target, subnet, .. } = cli; + let Cli { + target, + subnet, + headed, + .. + } = cli; let current_path = std::env::current_exe().unwrap(); let fixture_path = current_path.parent().unwrap().join("server-fixture"); let network_config = NetworkConfig::new(*subnet); let network = Network::new(network_config.clone())?; + // Collect display env vars once if headed mode is enabled + let display_env = if *headed { + collect_display_env_vars() + } else { + Vec::new() + }; + let server_fixture = ServerFixture::new(fixture_path, network.ns_app().clone(), network_config.app); let wasm_server = WasmServer::new( @@ -130,6 +162,7 @@ impl Runner { .network_config(network_config.clone()) .build(), *target, + display_env.clone(), ); let exec_v = Executor::new( network.ns_1().clone(), @@ -139,6 +172,7 @@ impl Runner { .network_config(network_config.clone()) .build(), Target::Native, + Vec::new(), // Verifier doesn't need display env ); Ok(Self { @@ -173,6 +207,12 @@ pub async fn main() -> Result<()> { tracing_subscriber::fmt::init(); let cli = Cli::parse(); + + // Validate --headed requires --target browser + if cli.headed && cli.target != Target::Browser { + anyhow::bail!("--headed can only be used with --target browser"); + } + let mut runner = Runner::new(&cli)?; let mut exit_code = 0;