diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 40c3daae38..c9aac49c48 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1440,6 +1440,7 @@ dependencies = [ "codex-utils-cli", "codex-utils-json-to-toml", "codex-utils-pty", + "codex-utils-rustls-provider", "core_test_support", "futures", "opentelemetry", diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 0d62c8f133..c24de62f2d 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -47,6 +47,7 @@ codex-rmcp-client = { workspace = true } codex-state = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-json-to-toml = { workspace = true } +codex-utils-rustls-provider = { workspace = true } chrono = { workspace = true } clap = { workspace = true, features = ["derive"] } futures = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 67bc310da1..46dd1e40ec 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -2,6 +2,8 @@ `codex app-server` is the interface Codex uses to power rich interfaces such as the [Codex VS Code extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt). +For remote-control-only deployments, use `codexd`. It runs the same app-server runtime in a headless daemon mode, connects outbound to the ChatGPT remote control server using ChatGPT auth, and does not expose a local stdio or websocket transport. + ## Table of Contents - [Protocol](#protocol) diff --git a/codex-rs/app-server/src/app_server_tracing.rs b/codex-rs/app-server/src/app_server_tracing.rs index 564b2eb2d6..1f25d509d4 100644 --- a/codex-rs/app-server/src/app_server_tracing.rs +++ b/codex-rs/app-server/src/app_server_tracing.rs @@ -86,6 +86,7 @@ fn transport_name(transport: AppServerTransport) -> &'static str { match transport { AppServerTransport::Stdio => "stdio", AppServerTransport::WebSocket { .. } => "websocket", + AppServerTransport::Headless => "headless", } } diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index f2b19882f1..6a4d4d3908 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -13,6 +13,7 @@ use std::collections::HashMap; use std::collections::HashSet; use std::io::ErrorKind; use std::io::Result as IoResult; +use std::io::Write; use std::sync::Arc; use std::sync::RwLock; use std::sync::atomic::AtomicBool; @@ -30,6 +31,7 @@ use crate::transport::route_outgoing_envelope; use crate::transport::start_remote_control; use crate::transport::start_stdio_connection; use crate::transport::start_websocket_acceptor; +use crate::transport::validate_remote_control_auth; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCMessage; @@ -89,6 +91,37 @@ enum LogFormat { Json, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct TransportRuntimeMode { + single_client_mode: bool, + shutdown_when_no_connections: bool, + graceful_ctrl_c_restart_enabled: bool, + ctrl_c_shutdown_enabled: bool, +} + +fn transport_runtime_mode(transport: AppServerTransport) -> TransportRuntimeMode { + match transport { + AppServerTransport::Stdio => TransportRuntimeMode { + single_client_mode: true, + shutdown_when_no_connections: true, + graceful_ctrl_c_restart_enabled: false, + ctrl_c_shutdown_enabled: false, + }, + AppServerTransport::WebSocket { .. } => TransportRuntimeMode { + single_client_mode: false, + shutdown_when_no_connections: false, + graceful_ctrl_c_restart_enabled: true, + ctrl_c_shutdown_enabled: false, + }, + AppServerTransport::Headless => TransportRuntimeMode { + single_client_mode: false, + shutdown_when_no_connections: false, + graceful_ctrl_c_restart_enabled: false, + ctrl_c_shutdown_enabled: true, + }, + } +} + type StderrLogLayer = Box + Send + Sync + 'static>; /// Control-plane messages from the processor/transport side to the outbound router task. @@ -511,12 +544,12 @@ pub async fn run_main_with_transport( let transport_shutdown_token = CancellationToken::new(); let mut transport_accept_handles = Vec::>::new(); + let runtime_mode = transport_runtime_mode(transport); - let single_client_mode = match transport { + match transport { AppServerTransport::Stdio => { start_stdio_connection(transport_event_tx.clone(), &mut transport_accept_handles) .await?; - true } AppServerTransport::WebSocket { bind_address } => { let accept_handle = start_websocket_acceptor( @@ -526,11 +559,12 @@ pub async fn run_main_with_transport( ) .await?; transport_accept_handles.push(accept_handle); - false } - }; - let shutdown_when_no_connections = single_client_mode; - let graceful_signal_restart_enabled = !single_client_mode; + AppServerTransport::Headless => {} + } + let shutdown_when_no_connections = runtime_mode.shutdown_when_no_connections; + let graceful_ctrl_c_restart_enabled = runtime_mode.graceful_ctrl_c_restart_enabled; + let graceful_signal_restart_enabled = runtime_mode.graceful_ctrl_c_restart_enabled; let auth_manager = AuthManager::shared( config.codex_home.clone(), @@ -540,6 +574,20 @@ pub async fn run_main_with_transport( auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); let remote_control_url = config.experimental_app_server_remote_control_url.clone(); + if matches!(transport, AppServerTransport::Headless) { + let remote_control_url = remote_control_url.as_deref().ok_or_else(|| { + std::io::Error::new( + ErrorKind::InvalidInput, + "headless app-server transport requires a remote control URL", + ) + })?; + validate_remote_control_auth(auth_manager.as_ref()).await?; + info!("starting codexd remote-control daemon using `{remote_control_url}`"); + let mut stderr = std::io::stderr().lock(); + let _ = writeln!(stderr, "codexd remote-control daemon"); + let _ = writeln!(stderr, " control server: {remote_control_url}"); + let _ = writeln!(stderr, " auth: ChatGPT"); + } if let Some(remote_control_url) = remote_control_url { let accept_handle = start_remote_control( remote_control_url, @@ -660,6 +708,24 @@ pub async fn run_main_with_transport( let running_turn_count = *running_turn_count_rx.borrow(); shutdown_state.on_signal(connections.len(), running_turn_count); } + ctrl_c_result = tokio::signal::ctrl_c(), if runtime_mode.ctrl_c_shutdown_enabled => { + if let Err(err) = ctrl_c_result { + warn!("failed to listen for Ctrl-C during daemon shutdown: {err}"); + } + info!("received Ctrl-C; shutting down codexd remote-control daemon"); + transport_shutdown_token.cancel(); + let _ = outbound_control_tx + .send(OutboundControlEvent::DisconnectAll) + .await; + break; + } + ctrl_c_result = tokio::signal::ctrl_c(), if graceful_ctrl_c_restart_enabled && !shutdown_state.forced() => { + if let Err(err) = ctrl_c_result { + warn!("failed to listen for Ctrl-C during graceful restart drain: {err}"); + } + let running_turn_count = *running_turn_count_rx.borrow(); + shutdown_state.on_signal(connections.len(), running_turn_count); + } changed = running_turn_count_rx.changed(), if graceful_signal_restart_enabled && shutdown_state.requested() => { if changed.is_err() { warn!("running-turn watcher closed during graceful restart drain"); @@ -856,6 +922,9 @@ pub async fn run_main_with_transport( #[cfg(test)] mod tests { use super::LogFormat; + use super::TransportRuntimeMode; + use super::transport_runtime_mode; + use crate::AppServerTransport; use pretty_assertions::assert_eq; #[test] @@ -872,4 +941,17 @@ mod tests { assert_eq!(LogFormat::from_env_value(Some("text")), LogFormat::Default); assert_eq!(LogFormat::from_env_value(Some("jsonl")), LogFormat::Default); } + + #[test] + fn headless_transport_runtime_mode_uses_daemon_shutdown_behavior() { + assert_eq!( + transport_runtime_mode(AppServerTransport::Headless), + TransportRuntimeMode { + single_client_mode: false, + shutdown_when_no_connections: false, + graceful_ctrl_c_restart_enabled: false, + ctrl_c_shutdown_enabled: true, + } + ); + } } diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index 19bf16357a..9e0683b114 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -19,6 +19,7 @@ use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::ServerRequest; use codex_core::AuthManager; use codex_core::default_client::build_reqwest_client; +use codex_utils_rustls_provider::ensure_rustls_crypto_provider; use futures::SinkExt; use futures::StreamExt; use owo_colors::OwoColorize; @@ -133,6 +134,7 @@ async fn websocket_upgrade_handler( pub enum AppServerTransport { Stdio, WebSocket { bind_address: SocketAddr }, + Headless, } #[derive(Debug, Clone, Eq, PartialEq)] @@ -1275,6 +1277,10 @@ async fn load_remote_control_auth( }) } +pub(crate) async fn validate_remote_control_auth(auth_manager: &AuthManager) -> IoResult<()> { + load_remote_control_auth(auth_manager).await.map(|_| ()) +} + async fn enroll_remote_control_server( remote_control_target: &RemoteControlTarget, auth: &RemoteControlConnectionAuth, @@ -1380,6 +1386,8 @@ async fn connect_remote_control_websocket( auth_manager: &AuthManager, enrollment: &mut Option, ) -> IoResult>> { + ensure_rustls_crypto_provider(); + if remote_control_target.enroll_url.is_none() { return connect_async(remote_control_target.websocket_url.as_str()) .await @@ -1675,6 +1683,20 @@ mod tests { ); } + #[tokio::test] + async fn validate_remote_control_auth_rejects_api_key_auth() { + let auth_manager = auth_manager_from_auth(CodexAuth::from_api_key("sk-test")); + + let err = validate_remote_control_auth(auth_manager.as_ref()) + .await + .expect_err("API key auth should be rejected"); + + assert_eq!( + err.to_string(), + "remote control requires ChatGPT authentication; API key auth is not supported" + ); + } + #[tokio::test] async fn enqueue_incoming_request_returns_overload_error_when_queue_is_full() { let connection_id = ConnectionId(42); diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index a7e88cd1b4..409d445037 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -8,6 +8,10 @@ license.workspace = true name = "codex" path = "src/main.rs" +[[bin]] +name = "codexd" +path = "src/bin/codexd.rs" + [lib] name = "codex_cli" path = "src/lib.rs" diff --git a/codex-rs/cli/src/bin/codexd.rs b/codex-rs/cli/src/bin/codexd.rs new file mode 100644 index 0000000000..4e321b7746 --- /dev/null +++ b/codex-rs/cli/src/bin/codexd.rs @@ -0,0 +1,129 @@ +use clap::Parser; +use codex_app_server::AppServerTransport; +use codex_app_server::run_main_with_transport; +use codex_arg0::Arg0DispatchPaths; +use codex_arg0::arg0_dispatch_or_else; +use codex_core::config::Config; +use codex_core::config::ConfigBuilder; +use codex_core::config_loader::LoaderOverrides; +use codex_utils_cli::CliConfigOverrides; +use std::io::ErrorKind; +use toml::Value; + +#[derive(Debug, Parser)] +#[clap( + author, + version, + bin_name = "codexd", + override_usage = "codexd [OPTIONS]" +)] +struct CodexdCli { + #[clap(flatten)] + config_overrides: CliConfigOverrides, +} + +fn default_remote_control_url(chatgpt_base_url: &str) -> String { + let chatgpt_base_url = chatgpt_base_url.trim_end_matches('/'); + if chatgpt_base_url.contains("/backend-api") { + format!("{chatgpt_base_url}/wham") + } else { + format!("{chatgpt_base_url}/backend-api/wham") + } +} + +fn main() -> anyhow::Result<()> { + arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { + let cli = CodexdCli::parse(); + let cli_kv_overrides = cli.config_overrides.parse_overrides().map_err(|err| { + std::io::Error::new( + ErrorKind::InvalidInput, + format!("error parsing -c overrides: {err}"), + ) + })?; + let config = match ConfigBuilder::default() + .cli_overrides(cli_kv_overrides.clone()) + .loader_overrides(LoaderOverrides::default()) + .build() + .await + { + Ok(config) => config, + Err(_err) => Config::load_default_with_cli_overrides(cli_kv_overrides).map_err( + |fallback_err| { + std::io::Error::new( + ErrorKind::InvalidData, + format!("error loading default config after config error: {fallback_err}"), + ) + }, + )?, + }; + let mut config_overrides = cli.config_overrides.clone(); + if config.experimental_app_server_remote_control_url.is_none() { + config_overrides.raw_overrides.push(format!( + "experimental_app_server_remote_control_url={}", + Value::String(default_remote_control_url(&config.chatgpt_base_url)) + )); + } + + run_main_with_transport( + arg0_paths, + config_overrides, + LoaderOverrides::default(), + false, + AppServerTransport::Headless, + ) + .await?; + Ok(()) + }) +} + +#[cfg(test)] +mod tests { + use super::CodexdCli; + use super::default_remote_control_url; + use clap::Parser; + use pretty_assertions::assert_eq; + + #[test] + fn codexd_parses_root_config_overrides() { + let cli = CodexdCli::try_parse_from([ + "codexd", + "-c", + "chatgpt_base_url=\"http://localhost:10000\"", + "-c", + "model=\"gpt-5.1\"", + ]) + .expect("codexd args should parse"); + + assert_eq!( + cli.config_overrides.raw_overrides, + vec![ + "chatgpt_base_url=\"http://localhost:10000\"".to_string(), + "model=\"gpt-5.1\"".to_string(), + ] + ); + } + + #[test] + fn default_remote_control_url_adds_backend_api_wham_for_chatgpt_roots() { + assert_eq!( + default_remote_control_url("https://chatgpt.com"), + "https://chatgpt.com/backend-api/wham" + ); + assert_eq!( + default_remote_control_url("http://localhost:10000"), + "http://localhost:10000/backend-api/wham" + ); + } + + #[test] + fn default_remote_control_url_keeps_existing_backend_api_prefixes() { + assert_eq!( + default_remote_control_url("https://chatgpt.com/backend-api"), + "https://chatgpt.com/backend-api/wham" + ); + assert_eq!( + default_remote_control_url("https://chatgpt.com/backend-api/"), + "https://chatgpt.com/backend-api/wham" + ); + } +}