diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index cc12faef..96aed35b 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -18,6 +18,8 @@ struct LauncherHooks { core: Arc, data: Arc, runtime: Arc, + app_dir: Arc>>, + bridge_watchdog: Arc>>>, } impl Default for LauncherHooks { @@ -29,6 +31,8 @@ impl Default for LauncherHooks { 9229, default_user_script_manager(), )), + app_dir: Arc::new(Mutex::new(None)), + bridge_watchdog: Arc::new(tokio::sync::Mutex::new(None)), } } } @@ -333,6 +337,9 @@ impl LaunchHooks for LauncherHooks { app_dir: &Path, ) -> anyhow::Result> { self.runtime.set_debug_port(debug_port); + if let Ok(mut current) = self.app_dir.lock() { + *current = Some(app_dir.to_path_buf()); + } Ok(Some(BridgeContext::core_with_data_and_app_dir( self.runtime.clone(), self.data.clone(), @@ -353,6 +360,84 @@ impl LaunchHooks for LauncherHooks { self.core.inject(debug_port, helper_port).await } + async fn start_bridge_watchdog(&self, debug_port: u16, helper_port: u16) -> anyhow::Result<()> { + let mut current = self.bridge_watchdog.lock().await; + if let Some(task) = current.take() { + task.abort(); + } + let hooks = self.clone(); + let task = tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(5)); + loop { + interval.tick().await; + let healthy = match launcher_bridge_health_ok(debug_port).await { + Ok(healthy) => healthy, + Err(error) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "bridge.health_check_failed", + json!({ + "debug_port": debug_port, + "helper_port": helper_port, + "message": error.to_string() + }), + ); + false + } + }; + if healthy { + continue; + } + + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "bridge.reinject_start", + json!({ + "debug_port": debug_port, + "helper_port": helper_port + }), + ); + let ctx = match hooks + .app_dir + .lock() + .ok() + .and_then(|app_dir| app_dir.clone()) + { + Some(app_dir) => BridgeContext::core_with_data_and_app_dir( + hooks.runtime.clone(), + hooks.data.clone(), + app_dir, + ), + None => { + BridgeContext::core_with_data(hooks.runtime.clone(), hooks.data.clone()) + } + }; + match inject_with_context(debug_port, helper_port, ctx, hooks.runtime.clone()).await + { + Ok(()) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "bridge.reinject_ok", + json!({ + "debug_port": debug_port, + "helper_port": helper_port + }), + ); + } + Err(error) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "bridge.reinject_failed", + json!({ + "debug_port": debug_port, + "helper_port": helper_port, + "message": error.to_string() + }), + ); + } + } + } + }); + *current = Some(task); + Ok(()) + } + async fn start_computer_use_guard_watchdog( &self, settings: &codex_plus_core::settings::BackendSettings, @@ -372,6 +457,9 @@ impl LaunchHooks for LauncherHooks { } async fn shutdown_helper(&self, helper_port: u16) { + if let Some(task) = self.bridge_watchdog.lock().await.take() { + task.abort(); + } self.core.shutdown_helper(helper_port).await; } @@ -656,6 +744,27 @@ impl BridgeRuntimeService for LauncherRuntimeService { } } +async fn launcher_bridge_health_ok(debug_port: u16) -> anyhow::Result { + let targets = codex_plus_core::cdp::list_targets(debug_port).await?; + let target = codex_plus_core::cdp::pick_injectable_codex_page_target(&targets)?; + let websocket_url = target + .web_socket_debugger_url + .as_deref() + .ok_or_else(|| anyhow::anyhow!("selected CDP target has no websocket URL"))?; + let result = codex_plus_core::bridge::evaluate_script_with_await_promise( + websocket_url, + codex_plus_core::bridge::bridge_health_check_script(), + true, + ) + .await?; + Ok(result + .get("result") + .and_then(|result| result.get("result")) + .and_then(|result| result.get("value")) + .and_then(Value::as_bool) + .unwrap_or(false)) +} + async fn inject_with_context( debug_port: u16, helper_port: u16, @@ -830,6 +939,16 @@ mod tests { assert!(source.contains(".start_computer_use_guard_watchdog(settings)")); } + #[test] + fn launcher_hooks_install_bridge_watchdog_for_reinjection() { + let source = include_str!("main.rs"); + + assert!(source.contains("async fn start_bridge_watchdog")); + assert!(source.contains("launcher_bridge_health_ok")); + assert!(source.contains("bridge.reinject_start")); + assert!(source.contains("inject_with_context(debug_port, helper_port, ctx")); + } + #[test] fn manager_update_prompt_uses_sidecar_manager_binary_name() { let path = manager_exe_path(); diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index fcdeb046..a8254fe9 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -1043,11 +1043,11 @@ async fn handle_helper_connection( ); let response = if method == "OPTIONS" { format!( - "HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, Authorization\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + "HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, Authorization\r\nAccess-Control-Allow-Private-Network: true\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" ) } else { format!( - "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, Authorization\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, Authorization\r\nAccess-Control-Allow-Private-Network: true\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() ) }; @@ -1424,7 +1424,7 @@ async fn write_http_response( body: &[u8], ) -> anyhow::Result<()> { let response = format!( - "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, Authorization\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, Authorization\r\nAccess-Control-Allow-Private-Network: true\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() ); stream.write_all(response.as_bytes()).await?; @@ -1438,7 +1438,7 @@ async fn write_http_stream_headers( content_type: &str, ) -> anyhow::Result<()> { let response = format!( - "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nCache-Control: no-cache\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, Authorization\r\nConnection: close\r\n\r\n" + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nCache-Control: no-cache\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, Authorization\r\nAccess-Control-Allow-Private-Network: true\r\nConnection: close\r\n\r\n" ); stream.write_all(response.as_bytes()).await?; Ok(()) diff --git a/crates/codex-plus-core/tests/launcher.rs b/crates/codex-plus-core/tests/launcher.rs index 26e2c571..39b764d2 100644 --- a/crates/codex-plus-core/tests/launcher.rs +++ b/crates/codex-plus-core/tests/launcher.rs @@ -526,10 +526,37 @@ async fn default_helper_serves_backend_status_over_http() { .await .unwrap(); assert!(response.status().is_success()); + assert_eq!( + response + .headers() + .get("access-control-allow-private-network") + .and_then(|value| value.to_str().ok()), + Some("true") + ); let payload: serde_json::Value = response.json().await.unwrap(); assert_eq!(payload["status"], "ok"); assert_eq!(payload["transport"], "http-helper"); + let preflight_response = client + .request( + reqwest::Method::OPTIONS, + format!("http://127.0.0.1:{port}/backend/status"), + ) + .header("Origin", "app://-") + .header("Access-Control-Request-Method", "POST") + .header("Access-Control-Request-Private-Network", "true") + .send() + .await + .unwrap(); + assert!(preflight_response.status().is_success()); + assert_eq!( + preflight_response + .headers() + .get("access-control-allow-private-network") + .and_then(|value| value.to_str().ok()), + Some("true") + ); + let repair_response = client .post(format!("http://127.0.0.1:{port}/backend/repair")) .json(&serde_json::json!({}))