mirror of
https://github.com/openai/codex.git
synced 2026-05-18 02:02:30 +00:00
codexd
This commit is contained in:
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -86,6 +86,7 @@ fn transport_name(transport: AppServerTransport) -> &'static str {
|
||||
match transport {
|
||||
AppServerTransport::Stdio => "stdio",
|
||||
AppServerTransport::WebSocket { .. } => "websocket",
|
||||
AppServerTransport::Headless => "headless",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
129
codex-rs/cli/src/bin/codexd.rs
Normal file
129
codex-rs/cli/src/bin/codexd.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user