This commit is contained in:
Anton Panasenko
2026-03-05 21:20:09 -08:00
parent 6853ce9136
commit f62fbddb64
8 changed files with 248 additions and 6 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -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",

View File

@@ -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 }

View File

@@ -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)

View File

@@ -86,6 +86,7 @@ fn transport_name(transport: AppServerTransport) -> &'static str {
match transport {
AppServerTransport::Stdio => "stdio",
AppServerTransport::WebSocket { .. } => "websocket",
AppServerTransport::Headless => "headless",
}
}

View File

@@ -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<dyn Layer<Registry> + 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::<JoinHandle<()>>::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,
}
);
}
}

View File

@@ -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<RemoteControlEnrollment>,
) -> IoResult<WebSocketStream<MaybeTlsStream<TcpStream>>> {
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);

View File

@@ -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"

View File

@@ -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"
);
}
}