diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index cbee57c2ac5d..95acfb73a715 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1915,6 +1915,9 @@ dependencies = [ "codex-backend-client", "codex-chatgpt", "codex-cloud-config", + "codex-code-bridge-client", + "codex-code-bridge-protocol", + "codex-code-bridge-service", "codex-config", "codex-core", "codex-core-plugins", diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index cd6c7e47b6a9..ca395f888cde 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -39,8 +39,13 @@ use ts_rs::TS; pub(crate) const GENERATED_TS_HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"]; const JSON_V1_ALLOWLIST: &[&str] = &["InitializeParams", "InitializeResponse"]; -const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] = - &["RemoteControlClient", "RemoteControlClientsListOrder"]; +const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] = &[ + "CodeBridgeAvailability", + "CodeBridgeServiceStatus", + "CodeBridgeUnavailableReason", + "RemoteControlClient", + "RemoteControlClientsListOrder", +]; const SPECIAL_DEFINITIONS: &[&str] = &[ "ClientNotification", "ClientRequest", diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index ce3af231986e..8056dbaa2668 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -873,6 +873,12 @@ client_request_definitions! { serialization: global_shared_read("remote-control"), response: v2::RemoteControlStatusReadResponse, }, + #[experimental("codeBridge/status/read")] + CodeBridgeStatusRead => "codeBridge/status/read" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global_shared_read("code-bridge-status"), + response: v2::CodeBridgeStatusReadResponse, + }, #[experimental("remoteControl/pairing/start")] RemoteControlPairingStart => "remoteControl/pairing/start" { params: v2::RemoteControlPairingStartParams, @@ -2064,6 +2070,16 @@ mod tests { "remote-control-pairing" )) ); + let code_bridge_status_read = ClientRequest::CodeBridgeStatusRead { + request_id: request_id(), + params: None, + }; + assert_eq!( + code_bridge_status_read.serialization_scope(), + Some(ClientRequestSerializationScope::GlobalSharedRead( + "code-bridge-status" + )) + ); let remote_control_clients_list = ClientRequest::RemoteControlClientsList { request_id: request_id(), params: v2::RemoteControlClientsListParams::default(), @@ -2468,6 +2484,7 @@ mod tests { cli_version: "0.0.0".to_string(), source: v2::SessionSource::Exec, thread_source: None, + session_provenance: None, agent_nickname: None, agent_role: None, git_info: None, @@ -3139,6 +3156,16 @@ mod tests { assert_eq!(reason, Some("mock/experimentalMethod")); } + #[test] + fn code_bridge_status_read_is_marked_experimental() { + let request = ClientRequest::CodeBridgeStatusRead { + request_id: RequestId::Integer(1), + params: None, + }; + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request); + assert_eq!(reason, Some("codeBridge/status/read")); + } + #[test] fn environment_add_is_marked_experimental() { let request = ClientRequest::EnvironmentAdd { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/code_bridge.rs b/codex-rs/app-server-protocol/src/protocol/v2/code_bridge.rs new file mode 100644 index 000000000000..1f3c6ff22c0f --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/code_bridge.rs @@ -0,0 +1,43 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CodeBridgeStatusReadResponse { + pub status: CodeBridgeAvailability, + pub service: Option, + pub unavailable_reason: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum CodeBridgeAvailability { + Available, + Unavailable, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum CodeBridgeUnavailableReason { + DescriptorMissing, + DescriptorInvalid, + UnsupportedEndpoint, + ServiceUnreachable, + StatusInvalid, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CodeBridgeServiceStatus { + pub protocol_version: String, + pub connected_producer_count: usize, + pub connected_subscriber_count: usize, + pub uptime_ms: u64, + pub last_event_time_unix_ms: Option, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs index b5fa9fdc6590..a75934a30b59 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -3,6 +3,7 @@ mod shared; mod account; mod apps; mod attestation; +mod code_bridge; mod collaboration_mode; mod command_exec; mod config; @@ -29,6 +30,7 @@ mod windows_sandbox; pub use account::*; pub use apps::*; pub use attestation::*; +pub use code_bridge::*; pub use collaboration_mode::*; pub use command_exec::*; pub use config::*; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 4a683be9904d..bb38f84e096f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -154,6 +154,7 @@ fn thread_resume_response_round_trips_initial_turns_page() { cli_version: "0.0.0".to_string(), source: SessionSource::Exec, thread_source: None, + session_provenance: None, agent_nickname: None, agent_role: None, git_info: None, diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 864de121a057..366482e215fe 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -34,6 +34,8 @@ codex-analytics = { workspace = true } codex-arg0 = { workspace = true } codex-auto-review = { workspace = true } codex-cloud-config = { workspace = true } +codex-code-bridge-client = { workspace = true } +codex-code-bridge-protocol = { workspace = true } codex-config = { workspace = true } codex-core = { workspace = true } codex-core-plugins = { workspace = true } @@ -109,6 +111,7 @@ axum = { workspace = true, default-features = false, features = [ ] } base64 = { workspace = true } codex-model-provider-info = { workspace = true } +codex-code-bridge-service = { workspace = true } codex-utils-cargo-bin = { workspace = true } core_test_support = { workspace = true } flate2 = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d344b7dc1084..e4064802bdd3 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -264,6 +264,7 @@ Example with notification opt-out: - `remoteControl/enable` — experimental; enable remote control for the current app-server process and return the current remote-control status snapshot. The caller is responsible for persisting the desired setting outside app-server. - `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices. - `remoteControl/status/read` — experimental; read the current remote-control status snapshot. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. +- `codeBridge/status/read` — experimental; read whether a local Code Bridge service is discoverable and responsive. This method reads the existing local bridge descriptor and calls the bridge `/status` endpoint only; it does not start Code Bridge, subscribe to events, proxy telemetry, request screenshots, or change `remoteControl/*` behavior. When the service is absent or unreachable, the request succeeds with `status: "unavailable"` and an `unavailableReason`. - `remoteControl/pairing/start` — experimental; start a short-lived remote-control pairing artifact for the current app-server process. Pass `manualCode: true` to also request a manual pairing code. Returns `pairingCode`, `manualPairingCode`, `environmentId`, and Unix-seconds `expiresAt`; app-server intentionally does not expose the backend `serverId`. - `remoteControl/pairing/status` — experimental; poll whether a remote-control `pairingCode` or `manualPairingCode` has been claimed. Pass exactly one of the two fields. Returns `claimed`. - `remoteControl/client/list` — experimental; list controller devices granted access to an environment. Pass `environmentId` and optional `cursor`, `limit`, and `order`; returns picker-oriented client metadata plus `nextCursor`. This signed-in account-management operation works while the local relay is disabled or unenrolled. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 9347c55b818b..0172b67eaced 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -19,6 +19,7 @@ use crate::outgoing_message::RequestContext; use crate::request_processors::AccountRequestProcessor; use crate::request_processors::AppsRequestProcessor; use crate::request_processors::CatalogRequestProcessor; +use crate::request_processors::CodeBridgeRequestProcessor; use crate::request_processors::CommandExecRequestProcessor; use crate::request_processors::ConfigRequestProcessor; use crate::request_processors::EnvironmentRequestProcessor; @@ -166,6 +167,7 @@ pub(crate) struct MessageProcessor { account_processor: AccountRequestProcessor, apps_processor: AppsRequestProcessor, catalog_processor: CatalogRequestProcessor, + code_bridge_processor: CodeBridgeRequestProcessor, command_exec_processor: CommandExecRequestProcessor, process_exec_processor: ProcessExecRequestProcessor, config_processor: ConfigRequestProcessor, @@ -414,6 +416,8 @@ impl MessageProcessor { workspace_settings_cache, ); let remote_control_processor = RemoteControlRequestProcessor::new(remote_control_handle); + let code_bridge_processor = + CodeBridgeRequestProcessor::new(config.codex_home.to_path_buf()); let search_processor = SearchRequestProcessor::new(outgoing.clone()); let thread_goal_processor = ThreadGoalRequestProcessor::new( Arc::clone(&thread_manager), @@ -497,6 +501,7 @@ impl MessageProcessor { account_processor, apps_processor, catalog_processor, + code_bridge_processor, command_exec_processor, process_exec_processor, config_processor, @@ -922,6 +927,10 @@ impl MessageProcessor { .remote_control_processor .status_read() .map(|response| Some(response.into())), + ClientRequest::CodeBridgeStatusRead { .. } => { + let response = self.code_bridge_processor.status_read().await; + Ok(Some(response.into())) + } ClientRequest::RemoteControlPairingStart { params, .. } => self .remote_control_processor .pairing_start(params, app_server_client_name.as_deref()) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index b335f7b94680..b9b5751d96e6 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -478,6 +478,7 @@ use codex_app_server_protocol::ServerRequest; mod account_processor; mod apps_processor; mod catalog_processor; +mod code_bridge_processor; mod command_exec_processor; mod config_processor; mod environment_processor; @@ -501,6 +502,7 @@ mod windows_sandbox_processor; pub(crate) use account_processor::AccountRequestProcessor; pub(crate) use apps_processor::AppsRequestProcessor; pub(crate) use catalog_processor::CatalogRequestProcessor; +pub(crate) use code_bridge_processor::CodeBridgeRequestProcessor; pub(crate) use command_exec_processor::CommandExecRequestProcessor; pub(crate) use config_processor::ConfigRequestProcessor; pub(crate) use environment_processor::EnvironmentRequestProcessor; diff --git a/codex-rs/app-server/src/request_processors/code_bridge_processor.rs b/codex-rs/app-server/src/request_processors/code_bridge_processor.rs new file mode 100644 index 000000000000..db64e813c3bc --- /dev/null +++ b/codex-rs/app-server/src/request_processors/code_bridge_processor.rs @@ -0,0 +1,187 @@ +use codex_app_server_protocol::CodeBridgeAvailability; +use codex_app_server_protocol::CodeBridgeServiceStatus as ApiCodeBridgeServiceStatus; +use codex_app_server_protocol::CodeBridgeStatusReadResponse; +use codex_app_server_protocol::CodeBridgeUnavailableReason; +use codex_code_bridge_client::CodeBridgeClient; +use codex_code_bridge_client::CodeBridgeClientError; +use codex_code_bridge_protocol::BridgeServiceStatus; +use codex_code_bridge_protocol::DESCRIPTOR_RELATIVE_PATH; +use std::io; +use std::path::PathBuf; +use tokio::time::Duration; +use tokio::time::timeout; + +const STATUS_READ_TIMEOUT: Duration = Duration::from_secs(2); + +#[derive(Clone)] +pub(crate) struct CodeBridgeRequestProcessor { + descriptor_path: PathBuf, +} + +impl CodeBridgeRequestProcessor { + pub(crate) fn new(codex_home: PathBuf) -> Self { + Self { + descriptor_path: codex_home.join(DESCRIPTOR_RELATIVE_PATH), + } + } + + pub(crate) async fn status_read(&self) -> CodeBridgeStatusReadResponse { + let client = match CodeBridgeClient::from_descriptor_path(&self.descriptor_path) { + Ok(client) => client, + Err(error) => return unavailable(map_descriptor_error(&error)), + }; + match timeout(STATUS_READ_TIMEOUT, client.status()).await { + Ok(Ok(status)) => available(status), + Ok(Err(error)) => unavailable(map_status_error(&error)), + Err(_) => unavailable(CodeBridgeUnavailableReason::ServiceUnreachable), + } + } +} + +fn available(status: BridgeServiceStatus) -> CodeBridgeStatusReadResponse { + CodeBridgeStatusReadResponse { + status: CodeBridgeAvailability::Available, + service: Some(ApiCodeBridgeServiceStatus { + protocol_version: status.protocol_version, + connected_producer_count: status.connected_producer_count, + connected_subscriber_count: status.connected_subscriber_count, + uptime_ms: status.uptime_ms, + last_event_time_unix_ms: status.last_event_time_unix_ms, + }), + unavailable_reason: None, + } +} + +fn unavailable(reason: CodeBridgeUnavailableReason) -> CodeBridgeStatusReadResponse { + CodeBridgeStatusReadResponse { + status: CodeBridgeAvailability::Unavailable, + service: None, + unavailable_reason: Some(reason), + } +} + +fn map_descriptor_error(error: &CodeBridgeClientError) -> CodeBridgeUnavailableReason { + match error { + CodeBridgeClientError::ReadDescriptor { source, .. } + if source.kind() == io::ErrorKind::NotFound => + { + CodeBridgeUnavailableReason::DescriptorMissing + } + CodeBridgeClientError::ReadDescriptor { .. } + | CodeBridgeClientError::ParseDescriptor { .. } + | CodeBridgeClientError::InvalidDescriptor(_) + | CodeBridgeClientError::InvalidEndpointUrl(_) => { + CodeBridgeUnavailableReason::DescriptorInvalid + } + CodeBridgeClientError::UnsupportedEndpoint => { + CodeBridgeUnavailableReason::UnsupportedEndpoint + } + _ => CodeBridgeUnavailableReason::DescriptorInvalid, + } +} + +fn map_status_error(error: &CodeBridgeClientError) -> CodeBridgeUnavailableReason { + match error { + CodeBridgeClientError::Http(error) if error.is_decode() => { + CodeBridgeUnavailableReason::StatusInvalid + } + CodeBridgeClientError::HttpStatus(status) if matches!(status.as_u16(), 401 | 403) => { + CodeBridgeUnavailableReason::DescriptorInvalid + } + _ => CodeBridgeUnavailableReason::ServiceUnreachable, + } +} + +#[cfg(test)] +mod code_bridge_processor_tests { + use super::*; + use codex_code_bridge_protocol::BridgeDescriptor; + use codex_code_bridge_protocol::BridgeEndpoint; + use codex_code_bridge_protocol::PROTOCOL_VERSION; + use codex_code_bridge_protocol::validate_descriptor; + use codex_code_bridge_service::BridgeServiceConfig; + use tempfile::TempDir; + use tokio::net::TcpListener; + + #[tokio::test] + async fn status_read_reports_missing_descriptor_as_unavailable() { + let codex_home = TempDir::new().expect("temp home"); + let processor = CodeBridgeRequestProcessor::new(codex_home.path().to_path_buf()); + + let response = processor.status_read().await; + + assert_eq!(response.status, CodeBridgeAvailability::Unavailable); + assert_eq!( + response.unavailable_reason, + Some(CodeBridgeUnavailableReason::DescriptorMissing) + ); + assert_eq!(response.service, None); + } + + #[tokio::test] + async fn status_read_reports_running_service_as_available() { + let codex_home = TempDir::new().expect("temp home"); + let service = codex_code_bridge_service::start(BridgeServiceConfig::new( + codex_home.path().to_path_buf(), + )) + .await + .expect("start service"); + let processor = CodeBridgeRequestProcessor::new(codex_home.path().to_path_buf()); + + let response = processor.status_read().await; + + assert_eq!(response.status, CodeBridgeAvailability::Available); + assert_eq!(response.unavailable_reason, None); + let service_status = response.service.expect("service status"); + assert_eq!( + service_status.protocol_version, + codex_code_bridge_protocol::PROTOCOL_VERSION + ); + assert_eq!(service_status.connected_producer_count, 0); + assert_eq!(service_status.connected_subscriber_count, 0); + + service.shutdown().await; + } + + #[tokio::test] + async fn status_read_times_out_hung_descriptor_endpoint() { + let codex_home = TempDir::new().expect("temp home"); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind listener"); + let local_addr = listener.local_addr().expect("local addr"); + let descriptor = BridgeDescriptor { + protocol_version: PROTOCOL_VERSION.to_string(), + endpoint: BridgeEndpoint::LoopbackHttp { + url: format!("http://{local_addr}"), + }, + auth_secret: "test-secret".to_string(), + pid: None, + }; + validate_descriptor(&descriptor).expect("valid descriptor"); + let descriptor_path = codex_home.path().join(DESCRIPTOR_RELATIVE_PATH); + std::fs::create_dir_all(descriptor_path.parent().expect("descriptor parent")) + .expect("create descriptor parent"); + std::fs::write( + &descriptor_path, + serde_json::to_vec(&descriptor).expect("serialize descriptor"), + ) + .expect("write descriptor"); + let accept_task = tokio::spawn(async move { + let (_socket, _addr) = listener.accept().await.expect("accept connection"); + tokio::time::sleep(STATUS_READ_TIMEOUT * 2).await; + }); + let processor = CodeBridgeRequestProcessor::new(codex_home.path().to_path_buf()); + + let response = processor.status_read().await; + + assert_eq!(response.status, CodeBridgeAvailability::Unavailable); + assert_eq!( + response.unavailable_reason, + Some(CodeBridgeUnavailableReason::ServiceUnreachable) + ); + assert_eq!(response.service, None); + accept_task.abort(); + let _ = accept_task.await; + } +} diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index 9134852decd5..1369ce089efc 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -650,6 +650,12 @@ impl TestAppServer { .await } + /// Send a `codeBridge/status/read` JSON-RPC request. + pub async fn send_code_bridge_status_read_request(&mut self) -> anyhow::Result { + self.send_request("codeBridge/status/read", /*params*/ None) + .await + } + /// Send a `remoteControl/pairing/start` JSON-RPC request. pub async fn send_remote_control_pairing_start_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/code_bridge.rs b/codex-rs/app-server/tests/suite/v2/code_bridge.rs new file mode 100644 index 000000000000..33e733aaa977 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/code_bridge.rs @@ -0,0 +1,68 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::TestAppServer; +use app_test_support::to_response; +use codex_app_server_protocol::CodeBridgeAvailability; +use codex_app_server_protocol::CodeBridgeStatusReadResponse; +use codex_app_server_protocol::CodeBridgeUnavailableReason; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_code_bridge_protocol::PROTOCOL_VERSION; +use codex_code_bridge_service::BridgeServiceConfig; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn code_bridge_status_read_reports_missing_descriptor() -> Result<()> { + let codex_home = TempDir::new()?; + let mut app_server = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??; + + let request_id = app_server.send_code_bridge_status_read_request().await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: CodeBridgeStatusReadResponse = to_response(response)?; + + assert_eq!(received.status, CodeBridgeAvailability::Unavailable); + assert_eq!( + received.unavailable_reason, + Some(CodeBridgeUnavailableReason::DescriptorMissing) + ); + assert_eq!(received.service, None); + Ok(()) +} + +#[tokio::test] +async fn code_bridge_status_read_reports_running_service() -> Result<()> { + let codex_home = TempDir::new()?; + let service = + codex_code_bridge_service::start(BridgeServiceConfig::new(codex_home.path().to_path_buf())) + .await?; + let mut app_server = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??; + + let request_id = app_server.send_code_bridge_status_read_request().await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: CodeBridgeStatusReadResponse = to_response(response)?; + + assert_eq!(received.status, CodeBridgeAvailability::Available); + assert_eq!(received.unavailable_reason, None); + let bridge_status = received.service.expect("service status"); + assert_eq!(bridge_status.protocol_version, PROTOCOL_VERSION); + assert_eq!(bridge_status.connected_producer_count, 0); + assert_eq!(bridge_status.connected_subscriber_count, 0); + + service.shutdown().await; + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 9aef40aadeb7..283b8b2f8e2d 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -3,6 +3,7 @@ mod analytics; mod app_list; mod attestation; mod client_metadata; +mod code_bridge; mod collaboration_mode_list; #[cfg(unix)] mod command_exec; diff --git a/codex-rs/code-bridge-client/src/lib.rs b/codex-rs/code-bridge-client/src/lib.rs index 09fd96299570..50ff210cbac0 100644 --- a/codex-rs/code-bridge-client/src/lib.rs +++ b/codex-rs/code-bridge-client/src/lib.rs @@ -5,6 +5,7 @@ use codex_code_bridge_protocol::BridgeEnvelope; use codex_code_bridge_protocol::BridgeEvent; use codex_code_bridge_protocol::BridgeMessageResponse; use codex_code_bridge_protocol::BridgePayload; +use codex_code_bridge_protocol::BridgeServiceStatus; use codex_code_bridge_protocol::BridgeSseMessage; pub use codex_code_bridge_protocol::CLIENT_SESSION_HEADER; use codex_code_bridge_protocol::CapabilitySet; @@ -156,6 +157,19 @@ impl CodeBridgeClient { self.endpoint_url.as_str() } + pub async fn status(&self) -> Result { + let response = self + .http + .get(self.endpoint_path(&["status"])) + .bearer_auth(&self.auth_secret) + .send() + .await?; + if !response.status().is_success() { + return Err(CodeBridgeClientError::HttpStatus(response.status())); + } + Ok(response.json::().await?) + } + pub async fn hello( &self, client_id: impl Into, @@ -682,6 +696,28 @@ mod tests { service.shutdown().await; } + #[tokio::test] + async fn descriptor_client_reads_service_status() { + let temp = TempDir::new().expect("temp home"); + let mut config = BridgeServiceConfig::new(temp.path().to_path_buf()); + config.stale_client_timeout = Duration::from_secs(30); + config.stale_client_sweep_interval = Duration::from_secs(30); + let service = codex_code_bridge_service::start(config) + .await + .expect("start service"); + + let client = CodeBridgeClient::from_descriptor_path(service.descriptor_path()) + .expect("descriptor client"); + let status = client.status().await.expect("status"); + + assert_eq!(status.protocol_version, PROTOCOL_VERSION); + assert_eq!(status.connected_producer_count, 0); + assert_eq!(status.connected_subscriber_count, 0); + assert_eq!(status.last_event_time_unix_ms, None); + + service.shutdown().await; + } + #[tokio::test] async fn descriptor_client_publishes_typed_events() { let temp = TempDir::new().expect("temp home"); diff --git a/codex-rs/code-bridge-protocol/src/lib.rs b/codex-rs/code-bridge-protocol/src/lib.rs index ab46017292be..42dd1fd57942 100644 --- a/codex-rs/code-bridge-protocol/src/lib.rs +++ b/codex-rs/code-bridge-protocol/src/lib.rs @@ -55,6 +55,16 @@ pub struct BridgeSseMessage { pub envelope: BridgeEnvelope, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BridgeServiceStatus { + pub protocol_version: String, + pub connected_producer_count: usize, + pub connected_subscriber_count: usize, + pub uptime_ms: u64, + pub last_event_time_unix_ms: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "kind", content = "body", rename_all = "camelCase")] pub enum BridgeEndpoint { diff --git a/codex-rs/code-bridge-service/src/lib.rs b/codex-rs/code-bridge-service/src/lib.rs index 7a10d4f4ef7e..a3df070d9aa4 100644 --- a/codex-rs/code-bridge-service/src/lib.rs +++ b/codex-rs/code-bridge-service/src/lib.rs @@ -33,6 +33,7 @@ use codex_code_bridge_protocol::BridgeEnvelope; use codex_code_bridge_protocol::BridgeEvent; use codex_code_bridge_protocol::BridgeLimits; use codex_code_bridge_protocol::BridgePayload; +use codex_code_bridge_protocol::BridgeServiceStatus; use codex_code_bridge_protocol::CLIENT_SESSION_HEADER; use codex_code_bridge_protocol::CapabilitySet; use codex_code_bridge_protocol::ClientRole; @@ -54,8 +55,6 @@ use codex_code_bridge_protocol::validate_envelope; use codex_code_bridge_protocol::validate_event_capabilities; use constant_time_eq::constant_time_eq; use rand::RngCore; -use serde::Deserialize; -use serde::Serialize; use std::collections::HashMap; use std::collections::VecDeque; use std::convert::Infallible; @@ -168,16 +167,6 @@ impl BridgeServiceHandle { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct BridgeServiceStatus { - pub protocol_version: String, - pub connected_producer_count: usize, - pub connected_subscriber_count: usize, - pub uptime_ms: u64, - pub last_event_time_unix_ms: Option, -} - #[derive(Debug, Error)] pub enum BridgeServiceError { #[error("Code Bridge service only supports loopback listeners, got {0}")]