diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 194dbbfa0d..bf808662ad 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2514,7 +2514,6 @@ dependencies = [ "codex-app-server-protocol", "codex-app-server-test-client", "codex-arg0", - "codex-aws-auth", "codex-build-info", "codex-chatgpt", "codex-cloud-config", @@ -2524,6 +2523,7 @@ dependencies = [ "codex-core-plugins", "codex-exec", "codex-exec-server", + "codex-exec-server-cli", "codex-execpolicy", "codex-extension-api", "codex-features", @@ -3313,6 +3313,46 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-exec-server-cli" +version = "0.0.0" +dependencies = [ + "anyhow", + "assert_matches", + "clap", + "codex-api", + "codex-app-server", + "codex-arg0", + "codex-aws-auth", + "codex-build-info", + "codex-cloud-config", + "codex-config", + "codex-core", + "codex-exec-server", + "codex-http-client", + "codex-login", + "codex-model-provider", + "codex-protocol", + "codex-sandboxing", + "codex-utils-absolute-path", + "codex-utils-cargo-bin", + "codex-utils-cli", + "codex-utils-process", + "codex-websocket-auth", + "http 1.4.0", + "insta", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "tikv-jemallocator", + "tokio", + "toml 0.9.11+spec-1.1.0", + "tracing", + "tracing-subscriber", + "url", +] + [[package]] name = "codex-exec-server-protocol" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 5583fde4ff..a0edc45337 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -59,6 +59,7 @@ members = [ "file-system", "exec-server-protocol", "exec-server", + "exec-server-cli", "exec-server/tests/support", "execpolicy", "ext/agent", @@ -232,6 +233,7 @@ codex-exec = { path = "exec" } codex-file-system = { path = "file-system" } codex-exec-server-protocol = { path = "exec-server-protocol" } codex-exec-server = { path = "exec-server" } +codex-exec-server-cli = { path = "exec-server-cli" } codex-exec-server-test-support = { path = "exec-server/tests/support" } codex-execpolicy = { path = "execpolicy" } codex-extension-api = { path = "ext/extension-api" } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 8cc0bce5d8..ffff79d331 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -39,7 +39,6 @@ codex-app-server-test-client = { workspace = true } codex-arg0 = { workspace = true } codex-build-info = { workspace = true } codex-api = { workspace = true } -codex-aws-auth = { workspace = true } codex-chatgpt = { workspace = true } codex-cloud-config = { workspace = true } codex-cloud-tasks = { path = "../cloud-tasks" } @@ -52,6 +51,7 @@ codex-home = { workspace = true } codex-http-client = { workspace = true } codex-exec = { workspace = true } codex-exec-server = { workspace = true } +codex-exec-server-cli = { workspace = true } codex-execpolicy = { workspace = true } codex-extension-api = { workspace = true } codex-features = { workspace = true } diff --git a/codex-rs/cli/src/config_override_args.rs b/codex-rs/cli/src/config_override_args.rs index 3046e2da22..26f86a8e6b 100644 --- a/codex-rs/cli/src/config_override_args.rs +++ b/codex-rs/cli/src/config_override_args.rs @@ -1,10 +1,9 @@ //! Preserve config overrides across command boundaries without changing typed child arguments. use crate::MultitoolCli; -use clap::Command; use clap::CommandFactory; use clap::FromArgMatches; -use codex_utils_cli::CliConfigOverrides; +use codex_utils_cli::collect_config_overrides; use std::ffi::OsString; pub(super) fn try_parse_from( @@ -20,34 +19,11 @@ pub(super) fn try_parse_from( return Ok(cli); } - // Clap propagates the deepest global argument's values instead of appending - // values from each scope. Reparse with local config arguments to recover them. - let matches = localize_config_arg(MultitoolCli::command()).try_get_matches_from(args)?; - - // Keep child copies unchanged: existing dispatch merges apply them after - // generated feature overrides, and clearing them would change precedence. - cli.config_overrides.raw_overrides = std::iter::successors(Some(&matches), |scope| { - scope.subcommand().map(|(_, child)| child) - }) - .filter_map(|scope| scope.get_many::("raw_overrides")) - .flatten() - .cloned() - .collect(); + // Keep child copies unchanged: dispatch merges apply them after feature overrides. + cli.config_overrides = collect_config_overrides(MultitoolCli::command(), args)?; Ok(cli) } -fn localize_config_arg(mut command: Command) -> Command { - if !command - .get_arguments() - .any(|arg| arg.get_id() == "raw_overrides") - { - command = command.args(CliConfigOverrides::command().get_arguments().cloned()); - } - command - .mut_arg("raw_overrides", |arg| arg.global(false)) - .mut_subcommands(localize_config_arg) -} - #[cfg(test)] #[path = "config_override_args_tests.rs"] mod tests; diff --git a/codex-rs/cli/src/exec_server_args_tests.rs b/codex-rs/cli/src/exec_server_args_tests.rs index 3292ed4be3..fad5701e6a 100644 --- a/codex-rs/cli/src/exec_server_args_tests.rs +++ b/codex-rs/cli/src/exec_server_args_tests.rs @@ -1,21 +1,8 @@ -use clap::error::ErrorKind; -use pretty_assertions::assert_eq; +#![cfg(test)] use super::*; -use crate::exec_server_command::ExecServerRemoteTransport; - -fn exec_server_from_args(args: &[&str]) -> ExecServerCommand { - let cli = MultitoolCli::try_parse_from( - ["codex", "exec-server"] - .into_iter() - .chain(args.iter().copied()), - ) - .expect("parse executor arguments"); - let Some(Subcommand::ExecServer(command)) = cli.subcommand else { - panic!("expected executor command"); - }; - command -} +use clap::error::ErrorKind; +use pretty_assertions::assert_eq; #[test] fn exec_server_help_documents_remote_options() { @@ -40,280 +27,3 @@ fn exec_server_help_documents_remote_options() { .join("\n"); insta::assert_snapshot!(help_text); } - -#[test] -fn exec_server_defaults_preserve_local_and_noise_modes() { - for args in [ - vec![], - vec!["--listen", "stdio"], - vec![ - "--remote", - "https://registry.example.com", - "--environment-id", - "env-1", - ], - vec![ - "--remote", - "https://registry.example.com", - "--environment-id", - "env-1", - "--remote-transport", - "noise", - "--use-agent-identity-auth", - ], - ] { - let command = exec_server_from_args(&args); - assert_eq!(command.remote_transport, ExecServerRemoteTransport::Noise); - assert!(!command.aws_sigv4); - assert!(!command.executor_credential_stdin); - command - .validate_remote_transport() - .expect("valid existing mode"); - } -} - -#[test] -fn remote_forwarder_skips_cloud_config() { - let command = exec_server_from_args(&[ - "forward", - "--connect", - "ws://localhost:8765", - "--remote", - "https://registry.example.com", - "--environment-id", - "env-1", - ]); - assert!(!command.should_load_cloud_config()); -} - -#[test] -fn exec_server_parses_direct_aws_options() { - for (options, expected) in [ - (vec![], (None, None, "execute-api")), - ( - vec![ - "--aws-profile", - "development", - "--aws-region", - "us-west-2", - "--aws-service", - "bedrock-mantle", - ], - (Some("development"), Some("us-west-2"), "bedrock-mantle"), - ), - ] { - let mut args = vec![ - "--remote", - "https://registry.example.com", - "--environment-id", - "env-1", - "--remote-transport", - "direct", - "--aws-sigv4", - ]; - args.extend(options); - let command = exec_server_from_args(&args); - - assert_eq!( - command.remote.as_deref(), - Some("https://registry.example.com") - ); - assert_eq!(command.environment_id.as_deref(), Some("env-1")); - assert_eq!(command.remote_transport, ExecServerRemoteTransport::Direct); - assert!(command.aws_sigv4); - assert!(!command.should_load_cloud_config()); - assert_eq!( - ( - command.aws_profile.as_deref(), - command.aws_region.as_deref(), - command.aws_service.as_str() - ), - expected, - ); - command - .validate_remote_transport() - .expect("valid Direct mode"); - } -} - -#[test] -fn exec_server_parses_executor_credential_options() { - let args = [ - "--remote", - "https://registry.example.com", - "--environment-id", - "env-1", - ]; - for options in [ - vec!["--executor-credential-stdin"], - vec!["--executor-credential-stdin", "--exit-on-stdin-close"], - ] { - let options: Vec<_> = args.iter().copied().chain(options).collect(); - let command = exec_server_from_args(&options); - assert!(command.executor_credential_stdin); - command - .validate_remote_transport() - .expect("valid scoped mode"); - } -} - -#[test] -fn exec_server_rejects_missing_or_conflicting_remote_options() { - for (options, expected) in [ - ( - vec!["--remote-transport", "direct"], - ErrorKind::MissingRequiredArgument, - ), - ( - vec!["--aws-profile", "development"], - ErrorKind::MissingRequiredArgument, - ), - ( - vec!["--aws-region", "us-west-2"], - ErrorKind::MissingRequiredArgument, - ), - ( - vec!["--aws-service", "execute-api"], - ErrorKind::MissingRequiredArgument, - ), - ( - vec![ - "--remote-transport", - "direct", - "--aws-sigv4", - "--use-agent-identity-auth", - ], - ErrorKind::ArgumentConflict, - ), - ( - vec!["--executor-credential-stdin", "--use-agent-identity-auth"], - ErrorKind::ArgumentConflict, - ), - ( - vec![ - "--executor-credential-stdin", - "--remote-transport", - "direct", - "--aws-sigv4", - ], - ErrorKind::ArgumentConflict, - ), - ( - vec!["--remote-transport", "unsupported"], - ErrorKind::InvalidValue, - ), - ] { - let error = MultitoolCli::try_parse_from( - [ - "codex", - "exec-server", - "--remote", - "https://registry.example.com", - "--environment-id", - "env-1", - ] - .into_iter() - .chain(options), - ) - .expect_err("reject invalid remote arguments"); - assert_eq!(error.kind(), expected); - } -} - -#[test] -fn exec_server_transport_and_aws_options_require_registration_arguments() { - for options in [ - vec!["--remote-transport", "noise"], - vec!["--remote-transport", "direct", "--aws-sigv4"], - vec!["--aws-sigv4"], - vec!["--executor-credential-stdin"], - vec![ - "--remote", - "https://registry.example.com", - "--remote-transport", - "direct", - "--aws-sigv4", - ], - ] { - let error = - MultitoolCli::try_parse_from(["codex", "exec-server"].into_iter().chain(options)) - .expect_err("require remote URL and environment ID"); - assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument); - } -} - -#[tokio::test] -async fn exec_server_sigv4_does_not_enable_aws_auth_for_noise() { - for options in [vec![], vec!["--remote-transport", "noise"]] { - let mut args = vec![ - "--remote", - "https://registry.example.com", - "--environment-id", - "env-1", - "--aws-sigv4", - ]; - args.extend(options); - let command = exec_server_from_args(&args); - // Unset runtime paths prove validation runs before startup or credential loading. - let error = command - .run( - &Arg0DispatchPaths::default(), - &CliConfigOverrides::default(), - ) - .await - .expect_err("Noise auth is unchanged"); - assert_eq!( - error.to_string(), - "--aws-sigv4 requires --remote-transport direct" - ); - } -} - -#[tokio::test] -async fn exec_server_direct_forwarding_remains_rejected() { - let command = exec_server_from_args(&[ - "forward", - "--connect", - "ws://127.0.0.1:8765", - "--remote", - "https://registry.example.com", - "--environment-id", - "env-1", - "--remote-transport", - "direct", - "--aws-sigv4", - "--aws-profile", - "development", - "--aws-region", - "us-west-2", - "--aws-service", - "bedrock-mantle", - ]); - assert_eq!( - ( - command.remote_transport, - command.aws_sigv4, - command.aws_profile.as_deref(), - command.aws_region.as_deref(), - command.aws_service.as_str() - ), - ( - ExecServerRemoteTransport::Direct, - true, - Some("development"), - Some("us-west-2"), - "bedrock-mantle" - ), - ); - let error = command - .run( - &Arg0DispatchPaths::default(), - &CliConfigOverrides::default(), - ) - .await - .expect_err("Direct forwarding is unsupported before startup"); - assert_eq!( - error.to_string(), - "direct exec-server transport does not support forwarding" - ); -} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 591d056387..8a578c38a7 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -65,10 +65,6 @@ mod exec_args_tests; #[cfg(test)] #[path = "exec_server_args_tests.rs"] mod exec_server_args_tests; -mod exec_server_auth; -mod exec_server_command; -mod exec_server_credential; -mod exec_server_telemetry; mod marketplace_cmd; mod mcp_cmd; mod mcp_login; @@ -84,12 +80,12 @@ mod state_db_recovery; #[cfg(not(windows))] mod wsl_paths; -use crate::exec_server_command::ExecServerCommand; use crate::mcp_cmd::McpCli; use crate::plugin_cmd::PluginCli; use crate::plugin_cmd::PluginSubcommand; use crate::queue_cmd::QueueCommand; use crate::remote_control_cmd::RemoteControlCommand; +use codex_exec_server_cli::ExecServerCommand; use doctor::DoctorCommand; use state_db_recovery as local_state_db; @@ -2823,11 +2819,7 @@ fn print_completion(cmd: CompletionCommand) { #[cfg(test)] mod tests { use super::*; - use crate::exec_server_command::ExecServerSubcommand; - use crate::exec_server_command::is_supported_exec_server_remote_auth; - use crate::exec_server_command::validate_api_key_remote_host; use assert_matches::assert_matches; - use codex_login::CodexAuth; use codex_protocol::ThreadId; use codex_tui::TokenUsage; use pretty_assertions::assert_eq; @@ -2968,63 +2960,6 @@ enabled = true ); } - #[test] - fn exec_server_remote_auth_accepts_api_key_auth() { - let auth = CodexAuth::from_api_key("sk-test"); - - assert!(is_supported_exec_server_remote_auth(&auth)); - } - - #[test] - fn exec_server_remote_api_key_auth_accepts_https_openai_domains() { - for base_url in [ - "https://openai.com/api", - "https://service.openai.com/api", - "https://openai.org/api", - "https://service.openai.org/api", - ] { - assert!(validate_api_key_remote_host(base_url).is_ok()); - } - } - - #[test] - fn exec_server_remote_api_key_auth_accepts_http_loopback() { - for base_url in [ - "http://localhost:8098/api", - "http://127.0.0.1:8098/api", - "http://[::1]:8098/api", - ] { - assert!(validate_api_key_remote_host(base_url).is_ok()); - } - } - - #[test] - fn exec_server_remote_api_key_auth_rejects_http_openai_domain() { - for base_url in [ - "http://service.openai.com/api", - "http://service.openai.org/api", - ] { - let error = validate_api_key_remote_host(base_url) - .expect_err("reject plaintext OpenAI destination"); - - assert_eq!( - error.to_string(), - "remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts" - ); - } - } - - #[test] - fn exec_server_remote_api_key_auth_rejects_suffix_spoof() { - let error = validate_api_key_remote_host("https://service.openai.org.evil.example/api") - .expect_err("reject suffix spoof"); - - assert_eq!( - error.to_string(), - "remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts" - ); - } - fn finalize_resume_from_args(args: &[&str]) -> TuiCli { let cli = MultitoolCli::try_parse_from(args).expect("parse"); let MultitoolCli { @@ -4422,15 +4357,13 @@ enabled = true .expect("parse forward"); assert!(cli.remote.remote.is_none()); assert!(!cli.interactive.strict_config); - assert_matches!(cli.subcommand, Some(Subcommand::ExecServer(ExecServerCommand { - command: Some(ExecServerSubcommand::Forward { connect }), - remote: Some(remote), - environment_id: Some(environment_id), - name: Some(name), - strict_config: true, - use_agent_identity_auth: true, - .. - })) if connect == "ws://127.0.0.1:8765" && remote == "https://example.openai.com" && environment_id == "env-1" && name == "forwarded"); + assert_matches!( + cli.subcommand, + Some(Subcommand::ExecServer(ExecServerCommand { + strict_config: true, + .. + })) + ); } #[test] diff --git a/codex-rs/exec-server-cli/BUILD.bazel b/codex-rs/exec-server-cli/BUILD.bazel new file mode 100644 index 0000000000..5d31d3ad0d --- /dev/null +++ b/codex-rs/exec-server-cli/BUILD.bazel @@ -0,0 +1,11 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "exec-server-cli", + binaries_with_build_commit = ["codex-exec-server"], + crate_name = "codex_exec_server_cli", + extra_binaries = ["//codex-rs/bwrap:bwrap"], + test_data_extra = glob(["src/**/snapshots/**"]), + # The standalone integration test launches native OS sandboxes itself. + test_tags = ["no-sandbox"], +) diff --git a/codex-rs/exec-server-cli/Cargo.toml b/codex-rs/exec-server-cli/Cargo.toml new file mode 100644 index 0000000000..2f066185a7 --- /dev/null +++ b/codex-rs/exec-server-cli/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "codex-exec-server-cli" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "codex-exec-server" +path = "src/main.rs" + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive", "env"] } +codex-api = { workspace = true } +codex-app-server = { workspace = true } +codex-arg0 = { workspace = true } +codex-aws-auth = { workspace = true } +codex-build-info = { workspace = true } +codex-cloud-config = { workspace = true } +codex-config = { workspace = true } +codex-core = { workspace = true } +codex-exec-server = { workspace = true } +codex-http-client = { workspace = true } +codex-login = { workspace = true } +codex-model-provider = { workspace = true } +codex-sandboxing = { workspace = true } +codex-utils-absolute-path = { workspace = true } +codex-utils-cli = { workspace = true } +codex-websocket-auth = { workspace = true } +http = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal", "sync", "time"] } +toml = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +url = { workspace = true } + +[target.'cfg(all(target_os = "linux", target_env = "musl", any(target_arch = "x86_64", target_arch = "aarch64")))'.dependencies] +tikv-jemallocator = { workspace = true } + +[dev-dependencies] +assert_matches = { workspace = true } +codex-protocol = { workspace = true } +codex-utils-cargo-bin = { workspace = true } +codex-utils-process = { workspace = true } +insta = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["io-util", "process"] } diff --git a/codex-rs/cli/src/exec_server_command.rs b/codex-rs/exec-server-cli/src/command.rs similarity index 68% rename from codex-rs/cli/src/exec_server_command.rs rename to codex-rs/exec-server-cli/src/command.rs index 52440873be..9f96d48815 100644 --- a/codex-rs/cli/src/exec_server_command.rs +++ b/codex-rs/exec-server-cli/src/command.rs @@ -1,39 +1,29 @@ -//! Command-line startup for `codex exec-server`. +//! Command-line startup shared by `codex exec-server` and `codex-exec-server`. //! //! Transport, configuration, authentication, and shutdown are kept together. use std::sync::Arc; use clap::Parser; -use codex_app_server::in_process::EmbeddedNetworkPolicy; use codex_arg0::Arg0DispatchPaths; -use codex_cloud_config::chatgpt_config_bundle_loader_for_storage; -use codex_config::LoaderOverrides; use codex_core::config::Config; -use codex_core::config::ConfigBuilder; -use codex_core::config::ConfigLoadOptions; -use codex_core::config::bootstrap_auth_config; -use codex_core::config::find_codex_home; -use codex_core::config::load_config_toml_with_layer_stack; use codex_exec_server::ExecServerRuntimeOptions; use codex_http_client::HttpClientFactory; use codex_http_client::OutboundProxyPolicy; -use codex_login::AuthManager; -use codex_login::CodexAuth; -use codex_login::read_codex_access_token_from_env; -use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; use codex_websocket_auth::WebsocketAuthArgs; +use crate::config::load_exec_server_config; +use crate::config::load_exec_server_remote_auth_provider; use crate::exec_server_auth; use crate::exec_server_credential::ExecutorCredentialPipe; use crate::exec_server_telemetry; /// [EXPERIMENTAL] Run the standalone exec-server service. #[derive(Debug, Parser)] -pub(super) struct ExecServerCommand { +pub struct ExecServerCommand { #[command(subcommand)] - pub(super) command: Option, + command: Option, /// Error out when config.toml contains fields that are not recognized by this version of Codex. #[arg( @@ -42,7 +32,7 @@ pub(super) struct ExecServerCommand { default_value_t = false, global = true )] - pub(super) strict_config: bool, + pub strict_config: bool, /// Linux PID namespace: isolate (default) or inherit. Inherit allows signals to /// other same-UID processes; enable only when provisioning a dedicated environment. @@ -86,7 +76,7 @@ pub(super) struct ExecServerCommand { requires = "environment_id", global = true )] - pub(super) remote: Option, + remote: Option, /// Transport used for the remote executor connection. #[arg( @@ -97,15 +87,15 @@ pub(super) struct ExecServerCommand { requires_if("direct", "aws_sigv4"), global = true )] - pub(super) remote_transport: ExecServerRemoteTransport, + remote_transport: ExecServerRemoteTransport, /// Environment id to attach to when registering remotely. #[arg(long = "environment-id", value_name = "ID", global = true)] - pub(super) environment_id: Option, + environment_id: Option, /// Human-readable environment name. #[arg(long = "name", value_name = "NAME", global = true)] - pub(super) name: Option, + name: Option, /// Use Agent Identity auth from CODEX_ACCESS_TOKEN for remote registration. #[arg( @@ -114,7 +104,7 @@ pub(super) struct ExecServerCommand { conflicts_with = "aws_sigv4", global = true )] - pub(super) use_agent_identity_auth: bool, + use_agent_identity_auth: bool, /// Read rotating executor credentials from stdin; exit when the pipe closes. #[arg( @@ -123,11 +113,11 @@ pub(super) struct ExecServerCommand { conflicts_with_all = ["use_agent_identity_auth", "aws_sigv4"], global = true )] - pub(super) executor_credential_stdin: bool, + executor_credential_stdin: bool, /// Sign Direct registration and WebSocket handshake requests with AWS SigV4. #[arg(long = "aws-sigv4", requires = "exec_server_remote", global = true)] - pub(super) aws_sigv4: bool, + aws_sigv4: bool, /// AWS profile used for SigV4 authentication. #[arg( @@ -136,7 +126,7 @@ pub(super) struct ExecServerCommand { requires = "aws_sigv4", global = true )] - pub(super) aws_profile: Option, + aws_profile: Option, /// AWS signing region. Uses the SDK region chain when omitted. #[arg( @@ -145,7 +135,7 @@ pub(super) struct ExecServerCommand { requires = "aws_sigv4", global = true )] - pub(super) aws_region: Option, + aws_region: Option, /// AWS signing service. #[arg( @@ -155,7 +145,7 @@ pub(super) struct ExecServerCommand { requires = "aws_sigv4", global = true )] - pub(super) aws_service: String, + aws_service: String, /// Exit when the parent-owned standard-input pipe closes. #[arg( @@ -169,7 +159,7 @@ pub(super) struct ExecServerCommand { impl ExecServerCommand { /// Starts the executor with the given runtime paths and configuration overrides. - pub(super) async fn run( + pub async fn run( mut self, arg0_paths: &Arg0DispatchPaths, root_config_overrides: &CliConfigOverrides, @@ -348,7 +338,7 @@ impl ExecServerCommand { } } - pub(super) fn should_load_cloud_config(&self) -> bool { + fn should_load_cloud_config(&self) -> bool { self.remote.is_some() && self.command.is_none() && !self.executor_credential_stdin @@ -356,7 +346,7 @@ impl ExecServerCommand { && !self.use_agent_identity_auth } - pub(super) fn validate_remote_transport(&self) -> anyhow::Result<()> { + fn validate_remote_transport(&self) -> anyhow::Result<()> { match (self.remote_transport, self.aws_sigv4) { (ExecServerRemoteTransport::Noise, true) => { anyhow::bail!("--aws-sigv4 requires --remote-transport direct"); @@ -380,14 +370,14 @@ impl ExecServerCommand { } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)] -pub(super) enum ExecServerRemoteTransport { +enum ExecServerRemoteTransport { #[default] Noise, Direct, } #[derive(Debug, clap::Subcommand)] -pub(super) enum ExecServerSubcommand { +enum ExecServerSubcommand { /// Register an existing WebSocket exec-server as a remote environment. Forward { /// Destination exec-server WebSocket URL. @@ -396,150 +386,6 @@ pub(super) enum ExecServerSubcommand { }, } -async fn load_exec_server_remote_auth_provider( - config: &codex_core::config::Config, - base_url: &str, - use_agent_identity_auth: bool, -) -> anyhow::Result { - if use_agent_identity_auth { - read_codex_access_token_from_env().ok_or_else(|| { - anyhow::anyhow!("CODEX_ACCESS_TOKEN is required when --use-agent-identity-auth is set") - })?; - let auth = AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false) - .await? - .auth() - .await - .ok_or_else(|| anyhow::anyhow!("Agent Identity authentication is unavailable"))?; - if !matches!(auth, CodexAuth::AgentIdentity(_)) { - anyhow::bail!( - "CODEX_ACCESS_TOKEN did not provide permitted Agent Identity authentication" - ); - } - return Ok(codex_model_provider::auth_provider_from_auth(&auth)); - } - - let (auth_manager, auth) = load_exec_server_remote_auth( - config, - "remote exec-server registration requires ChatGPT authentication or API key authentication; run `codex login` or set CODEX_API_KEY", - ) - .await?; - - if !is_supported_exec_server_remote_auth(&auth) { - anyhow::bail!( - "remote exec-server registration requires ChatGPT authentication or API key authentication; Agent Identity auth requires --use-agent-identity-auth" - ); - } - - if auth.is_api_key_auth() { - validate_api_key_remote_host(base_url)?; - } - - if auth.is_chatgpt_auth() || auth_manager.is_workload_identity_selected() { - Ok(codex_model_provider::auth_provider_from_auth_manager( - auth_manager, - &auth, - )) - } else { - Ok(codex_model_provider::auth_provider_from_auth(&auth)) - } -} - -pub(super) fn is_supported_exec_server_remote_auth(auth: &CodexAuth) -> bool { - auth.is_chatgpt_auth() || auth.is_api_key_auth() -} - -pub(super) fn validate_api_key_remote_host(base_url: &str) -> anyhow::Result<()> { - let url = url::Url::parse(base_url) - .map_err(|err| anyhow::anyhow!("invalid remote exec-server registration URL: {err}"))?; - let host = url.host().ok_or_else(|| { - anyhow::anyhow!("remote exec-server registration URL must include a host") - })?; - - let is_loopback = match &host { - url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"), - url::Host::Ipv4(ip) => ip.is_loopback(), - url::Host::Ipv6(ip) => ip.is_loopback(), - }; - let is_openai_host = match &host { - url::Host::Domain(host) => ["openai.com", "openai.org"].into_iter().any(|domain| { - host.eq_ignore_ascii_case(domain) - || host.to_ascii_lowercase().ends_with(&format!(".{domain}")) - }), - _ => false, - }; - let is_allowed = match url.scheme() { - "https" => is_loopback || is_openai_host, - "http" => is_loopback, - _ => false, - }; - - if !is_allowed { - anyhow::bail!( - "remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts" - ); - } - - Ok(()) -} - -async fn load_exec_server_config( - root_config_overrides: &CliConfigOverrides, - strict_config: bool, - enable_cloud_config: bool, -) -> anyhow::Result { - let cli_kv_overrides = root_config_overrides - .parse_overrides() - .map_err(anyhow::Error::msg)?; - let bootstrap_cli_overrides = cli_kv_overrides.clone(); - let mut builder = ConfigBuilder::default() - .cli_overrides(cli_kv_overrides) - .strict_config(strict_config); - if enable_cloud_config { - let codex_home = find_codex_home()?; - let loader_overrides = LoaderOverrides::default(); - let network_policy = EmbeddedNetworkPolicy::load(&loader_overrides).await; - let bootstrap_cwd = AbsolutePathBuf::current_dir()?; - let bootstrap_config = load_config_toml_with_layer_stack( - &codex_home, - Some(&bootstrap_cwd), - bootstrap_cli_overrides, - ConfigLoadOptions { - loader_overrides, - strict_config, - cloud_config_bundle: Default::default(), - }, - ) - .await?; - let bootstrap_auth_config = bootstrap_auth_config(&codex_home, &bootstrap_config)?; - let cloud_config_bundle = chatgpt_config_bundle_loader_for_storage( - network_policy.bind_bootstrap_auth(bootstrap_auth_config), - /*enable_codex_api_key_env*/ true, - ) - .await?; - builder = builder.cloud_config_bundle(cloud_config_bundle); - } - let mut config = builder.build().await?; - EmbeddedNetworkPolicy::default().activate(&mut config); - Ok(config) -} - -async fn load_exec_server_remote_auth( - config: &codex_core::config::Config, - missing_auth_error: &'static str, -) -> anyhow::Result<(Arc, codex_login::CodexAuth)> { - let auth_manager = - AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await?; - - let auth = match auth_manager.auth().await { - Some(auth) => auth, - None => { - auth_manager.reload().await; - auth_manager - .auth() - .await - .ok_or_else(|| anyhow::anyhow!(missing_auth_error))? - } - }; - - Ok((auth_manager, auth)) -} +#[cfg(test)] +#[path = "exec_server_args_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server-cli/src/config.rs b/codex-rs/exec-server-cli/src/config.rs new file mode 100644 index 0000000000..8ef9b87073 --- /dev/null +++ b/codex-rs/exec-server-cli/src/config.rs @@ -0,0 +1,168 @@ +//! Loads executor startup configuration and remote authentication without changing CLI policy. + +use codex_app_server::in_process::EmbeddedNetworkPolicy; +use codex_cloud_config::chatgpt_config_bundle_loader_for_storage; +use codex_config::LoaderOverrides; +use codex_core::config::ConfigBuilder; +use codex_core::config::ConfigLoadOptions; +use codex_core::config::bootstrap_auth_config; +use codex_core::config::find_codex_home; +use codex_core::config::load_config_toml_with_layer_stack; +use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_login::read_codex_access_token_from_env; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_cli::CliConfigOverrides; +use std::sync::Arc; + +pub(super) async fn load_exec_server_remote_auth_provider( + config: &codex_core::config::Config, + base_url: &str, + use_agent_identity_auth: bool, +) -> anyhow::Result { + if use_agent_identity_auth { + read_codex_access_token_from_env().ok_or_else(|| { + anyhow::anyhow!("CODEX_ACCESS_TOKEN is required when --use-agent-identity-auth is set") + })?; + let auth = AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false) + .await? + .auth() + .await + .ok_or_else(|| anyhow::anyhow!("Agent Identity authentication is unavailable"))?; + if !matches!(auth, CodexAuth::AgentIdentity(_)) { + anyhow::bail!( + "CODEX_ACCESS_TOKEN did not provide permitted Agent Identity authentication" + ); + } + return Ok(codex_model_provider::auth_provider_from_auth(&auth)); + } + + let (auth_manager, auth) = load_exec_server_remote_auth( + config, + "remote exec-server registration requires ChatGPT authentication or API key authentication; run `codex login` or set CODEX_API_KEY", + ) + .await?; + + if !is_supported_exec_server_remote_auth(&auth) { + anyhow::bail!( + "remote exec-server registration requires ChatGPT authentication or API key authentication; Agent Identity auth requires --use-agent-identity-auth" + ); + } + + if auth.is_api_key_auth() { + validate_api_key_remote_host(base_url)?; + } + + if auth.is_chatgpt_auth() || auth_manager.is_workload_identity_selected() { + Ok(codex_model_provider::auth_provider_from_auth_manager( + auth_manager, + &auth, + )) + } else { + Ok(codex_model_provider::auth_provider_from_auth(&auth)) + } +} + +pub(super) fn is_supported_exec_server_remote_auth(auth: &CodexAuth) -> bool { + auth.is_chatgpt_auth() || auth.is_api_key_auth() +} + +pub(super) fn validate_api_key_remote_host(base_url: &str) -> anyhow::Result<()> { + let url = url::Url::parse(base_url) + .map_err(|err| anyhow::anyhow!("invalid remote exec-server registration URL: {err}"))?; + let host = url.host().ok_or_else(|| { + anyhow::anyhow!("remote exec-server registration URL must include a host") + })?; + + let is_loopback = match &host { + url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"), + url::Host::Ipv4(ip) => ip.is_loopback(), + url::Host::Ipv6(ip) => ip.is_loopback(), + }; + let is_openai_host = match &host { + url::Host::Domain(host) => ["openai.com", "openai.org"].into_iter().any(|domain| { + host.eq_ignore_ascii_case(domain) + || host.to_ascii_lowercase().ends_with(&format!(".{domain}")) + }), + _ => false, + }; + let is_allowed = match url.scheme() { + "https" => is_loopback || is_openai_host, + "http" => is_loopback, + _ => false, + }; + + if !is_allowed { + anyhow::bail!( + "remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts" + ); + } + + Ok(()) +} + +pub(super) async fn load_exec_server_config( + root_config_overrides: &CliConfigOverrides, + strict_config: bool, + enable_cloud_config: bool, +) -> anyhow::Result { + let cli_kv_overrides = root_config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + let bootstrap_cli_overrides = cli_kv_overrides.clone(); + let mut builder = ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .strict_config(strict_config); + if enable_cloud_config { + let codex_home = find_codex_home()?; + let loader_overrides = LoaderOverrides::default(); + let network_policy = EmbeddedNetworkPolicy::load(&loader_overrides).await; + let bootstrap_cwd = AbsolutePathBuf::current_dir()?; + let bootstrap_config = load_config_toml_with_layer_stack( + &codex_home, + Some(&bootstrap_cwd), + bootstrap_cli_overrides, + ConfigLoadOptions { + loader_overrides, + strict_config, + cloud_config_bundle: Default::default(), + }, + ) + .await?; + let bootstrap_auth_config = bootstrap_auth_config(&codex_home, &bootstrap_config)?; + let cloud_config_bundle = chatgpt_config_bundle_loader_for_storage( + network_policy.bind_bootstrap_auth(bootstrap_auth_config), + /*enable_codex_api_key_env*/ true, + ) + .await?; + builder = builder.cloud_config_bundle(cloud_config_bundle); + } + let mut config = builder.build().await?; + EmbeddedNetworkPolicy::default().activate(&mut config); + Ok(config) +} + +async fn load_exec_server_remote_auth( + config: &codex_core::config::Config, + missing_auth_error: &'static str, +) -> anyhow::Result<(Arc, codex_login::CodexAuth)> { + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await?; + + let auth = match auth_manager.auth().await { + Some(auth) => auth, + None => { + auth_manager.reload().await; + auth_manager + .auth() + .await + .ok_or_else(|| anyhow::anyhow!(missing_auth_error))? + } + }; + + Ok((auth_manager, auth)) +} + +#[cfg(test)] +#[path = "config_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server-cli/src/config_tests.rs b/codex-rs/exec-server-cli/src/config_tests.rs new file mode 100644 index 0000000000..8ca9411242 --- /dev/null +++ b/codex-rs/exec-server-cli/src/config_tests.rs @@ -0,0 +1,61 @@ +#![cfg(test)] + +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn exec_server_remote_auth_accepts_api_key_auth() { + let auth = CodexAuth::from_api_key("sk-test"); + + assert!(is_supported_exec_server_remote_auth(&auth)); +} + +#[test] +fn exec_server_remote_api_key_auth_accepts_https_openai_domains() { + for base_url in [ + "https://openai.com/api", + "https://service.openai.com/api", + "https://openai.org/api", + "https://service.openai.org/api", + ] { + assert!(validate_api_key_remote_host(base_url).is_ok()); + } +} + +#[test] +fn exec_server_remote_api_key_auth_accepts_http_loopback() { + for base_url in [ + "http://localhost:8098/api", + "http://127.0.0.1:8098/api", + "http://[::1]:8098/api", + ] { + assert!(validate_api_key_remote_host(base_url).is_ok()); + } +} + +#[test] +fn exec_server_remote_api_key_auth_rejects_http_openai_domain() { + for base_url in [ + "http://service.openai.com/api", + "http://service.openai.org/api", + ] { + let error = validate_api_key_remote_host(base_url) + .expect_err("reject plaintext OpenAI destination"); + + assert_eq!( + error.to_string(), + "remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts" + ); + } +} + +#[test] +fn exec_server_remote_api_key_auth_rejects_suffix_spoof() { + let error = validate_api_key_remote_host("https://service.openai.org.evil.example/api") + .expect_err("reject suffix spoof"); + + assert_eq!( + error.to_string(), + "remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts" + ); +} diff --git a/codex-rs/exec-server-cli/src/exec_server_args_tests.rs b/codex-rs/exec-server-cli/src/exec_server_args_tests.rs new file mode 100644 index 0000000000..eb7fdf5b8b --- /dev/null +++ b/codex-rs/exec-server-cli/src/exec_server_args_tests.rs @@ -0,0 +1,339 @@ +#![cfg(test)] + +use assert_matches::assert_matches; +use clap::error::ErrorKind; +use pretty_assertions::assert_eq; + +use super::*; +use crate::ExecServerCli; + +fn exec_server_from_args(args: &[&str]) -> ExecServerCommand { + let cli = ExecServerCli::try_parse_from( + ["codex-exec-server"] + .into_iter() + .chain(args.iter().copied()), + ) + .expect("parse executor arguments"); + cli.command +} + +#[test] +fn exec_server_defaults_preserve_local_and_noise_modes() { + for args in [ + vec![], + vec!["--listen", "stdio"], + vec![ + "--remote", + "https://registry.example.com", + "--environment-id", + "env-1", + ], + vec![ + "--remote", + "https://registry.example.com", + "--environment-id", + "env-1", + "--remote-transport", + "noise", + "--use-agent-identity-auth", + ], + ] { + let command = exec_server_from_args(&args); + assert_eq!(command.remote_transport, ExecServerRemoteTransport::Noise); + assert!(!command.aws_sigv4); + assert!(!command.executor_credential_stdin); + command + .validate_remote_transport() + .expect("valid existing mode"); + } +} + +#[test] +fn remote_forwarder_skips_cloud_config() { + let command = exec_server_from_args(&[ + "forward", + "--connect", + "ws://localhost:8765", + "--remote", + "https://registry.example.com", + "--environment-id", + "env-1", + ]); + assert!(!command.should_load_cloud_config()); +} + +#[test] +fn exec_server_parses_direct_aws_options() { + for (options, expected) in [ + (vec![], (None, None, "execute-api")), + ( + vec![ + "--aws-profile", + "development", + "--aws-region", + "us-west-2", + "--aws-service", + "bedrock-mantle", + ], + (Some("development"), Some("us-west-2"), "bedrock-mantle"), + ), + ] { + let mut args = vec![ + "--remote", + "https://registry.example.com", + "--environment-id", + "env-1", + "--remote-transport", + "direct", + "--aws-sigv4", + ]; + args.extend(options); + let command = exec_server_from_args(&args); + + assert_eq!( + command.remote.as_deref(), + Some("https://registry.example.com") + ); + assert_eq!(command.environment_id.as_deref(), Some("env-1")); + assert_eq!(command.remote_transport, ExecServerRemoteTransport::Direct); + assert!(!command.should_load_cloud_config()); + assert!(command.aws_sigv4); + assert_eq!( + ( + command.aws_profile.as_deref(), + command.aws_region.as_deref(), + command.aws_service.as_str() + ), + expected, + ); + command + .validate_remote_transport() + .expect("valid Direct mode"); + } +} + +#[test] +fn exec_server_parses_executor_credential_options() { + let args = [ + "--remote", + "https://registry.example.com", + "--environment-id", + "env-1", + ]; + for options in [ + vec!["--executor-credential-stdin"], + vec!["--executor-credential-stdin", "--exit-on-stdin-close"], + ] { + let options: Vec<_> = args.iter().copied().chain(options).collect(); + let command = exec_server_from_args(&options); + assert!(command.executor_credential_stdin); + command + .validate_remote_transport() + .expect("valid scoped mode"); + } +} + +#[test] +fn exec_server_rejects_missing_or_conflicting_remote_options() { + for (options, expected) in [ + ( + vec!["--remote-transport", "direct"], + ErrorKind::MissingRequiredArgument, + ), + ( + vec!["--aws-profile", "development"], + ErrorKind::MissingRequiredArgument, + ), + ( + vec!["--aws-region", "us-west-2"], + ErrorKind::MissingRequiredArgument, + ), + ( + vec!["--aws-service", "execute-api"], + ErrorKind::MissingRequiredArgument, + ), + ( + vec![ + "--remote-transport", + "direct", + "--aws-sigv4", + "--use-agent-identity-auth", + ], + ErrorKind::ArgumentConflict, + ), + ( + vec!["--executor-credential-stdin", "--use-agent-identity-auth"], + ErrorKind::ArgumentConflict, + ), + ( + vec![ + "--executor-credential-stdin", + "--remote-transport", + "direct", + "--aws-sigv4", + ], + ErrorKind::ArgumentConflict, + ), + ( + vec!["--remote-transport", "unsupported"], + ErrorKind::InvalidValue, + ), + ] { + let error = ExecServerCli::try_parse_from( + [ + "codex-exec-server", + "--remote", + "https://registry.example.com", + "--environment-id", + "env-1", + ] + .into_iter() + .chain(options), + ) + .expect_err("reject invalid remote arguments"); + assert_eq!(error.kind(), expected); + } +} + +#[test] +fn exec_server_transport_and_aws_options_require_registration_arguments() { + for options in [ + vec!["--remote-transport", "noise"], + vec!["--remote-transport", "direct", "--aws-sigv4"], + vec!["--aws-sigv4"], + vec!["--executor-credential-stdin"], + vec![ + "--remote", + "https://registry.example.com", + "--remote-transport", + "direct", + "--aws-sigv4", + ], + ] { + let error = ExecServerCli::try_parse_from(["codex-exec-server"].into_iter().chain(options)) + .expect_err("require remote URL and environment ID"); + assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument); + } +} + +#[tokio::test] +async fn exec_server_sigv4_does_not_enable_aws_auth_for_noise() { + for options in [vec![], vec!["--remote-transport", "noise"]] { + let mut args = vec![ + "--remote", + "https://registry.example.com", + "--environment-id", + "env-1", + "--aws-sigv4", + ]; + args.extend(options); + let command = exec_server_from_args(&args); + // Unset runtime paths prove validation runs before startup or credential loading. + let error = command + .run( + &Arg0DispatchPaths::default(), + &CliConfigOverrides::default(), + ) + .await + .expect_err("Noise auth is unchanged"); + assert_eq!( + error.to_string(), + "--aws-sigv4 requires --remote-transport direct" + ); + } +} + +#[tokio::test] +async fn exec_server_direct_forwarding_remains_rejected() { + let command = exec_server_from_args(&[ + "forward", + "--connect", + "ws://127.0.0.1:8765", + "--remote", + "https://registry.example.com", + "--environment-id", + "env-1", + "--remote-transport", + "direct", + "--aws-sigv4", + "--aws-profile", + "development", + "--aws-region", + "us-west-2", + "--aws-service", + "bedrock-mantle", + ]); + assert_eq!( + ( + command.remote_transport, + command.aws_sigv4, + command.aws_profile.as_deref(), + command.aws_region.as_deref(), + command.aws_service.as_str() + ), + ( + ExecServerRemoteTransport::Direct, + true, + Some("development"), + Some("us-west-2"), + "bedrock-mantle" + ), + ); + let error = command + .run( + &Arg0DispatchPaths::default(), + &CliConfigOverrides::default(), + ) + .await + .expect_err("Direct forwarding is unsupported before startup"); + assert_eq!( + error.to_string(), + "direct exec-server transport does not support forwarding" + ); +} + +#[test] +fn exec_server_forward_parses_shared_remote_options() { + let cli = ExecServerCli::try_parse_from([ + "codex-exec-server", + "forward", + "--connect", + "ws://127.0.0.1:8765", + "--remote", + "https://example.openai.com", + "--environment-id", + "env-1", + "--name", + "forwarded", + "--strict-config", + "--use-agent-identity-auth", + ]) + .expect("parse forward"); + assert_matches!(cli.command, ExecServerCommand { + command: Some(ExecServerSubcommand::Forward { connect }), + remote: Some(remote), + environment_id: Some(environment_id), + name: Some(name), + strict_config: true, + use_agent_identity_auth: true, + .. + } if connect == "ws://127.0.0.1:8765" && remote == "https://example.openai.com" && environment_id == "env-1" && name == "forwarded"); +} + +#[test] +fn standalone_help_documents_executor_options() { + use clap::CommandFactory; + let help = ExecServerCli::command() + .term_width(80) + .mut_arg("exit_on_stdin_close", |arg| arg.hide_env_values(true)) + .mut_arg("proxy_private_ips_via_upstream", |arg| { + arg.hide_env_values(true) + }) + .render_long_help() + .to_string() + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + insta::assert_snapshot!(help); +} diff --git a/codex-rs/cli/src/exec_server_auth.rs b/codex-rs/exec-server-cli/src/exec_server_auth.rs similarity index 100% rename from codex-rs/cli/src/exec_server_auth.rs rename to codex-rs/exec-server-cli/src/exec_server_auth.rs diff --git a/codex-rs/cli/src/exec_server_auth_tests.rs b/codex-rs/exec-server-cli/src/exec_server_auth_tests.rs similarity index 99% rename from codex-rs/cli/src/exec_server_auth_tests.rs rename to codex-rs/exec-server-cli/src/exec_server_auth_tests.rs index 2271d6e09b..00985ce28f 100644 --- a/codex-rs/cli/src/exec_server_auth_tests.rs +++ b/codex-rs/exec-server-cli/src/exec_server_auth_tests.rs @@ -1,3 +1,5 @@ +#![cfg(test)] + use std::time::Duration; use codex_aws_auth::AwsAccessKeys; diff --git a/codex-rs/cli/src/exec_server_credential.rs b/codex-rs/exec-server-cli/src/exec_server_credential.rs similarity index 100% rename from codex-rs/cli/src/exec_server_credential.rs rename to codex-rs/exec-server-cli/src/exec_server_credential.rs diff --git a/codex-rs/cli/src/exec_server_credential_tests.rs b/codex-rs/exec-server-cli/src/exec_server_credential_tests.rs similarity index 99% rename from codex-rs/cli/src/exec_server_credential_tests.rs rename to codex-rs/exec-server-cli/src/exec_server_credential_tests.rs index 5a59e8bf26..fd166100f1 100644 --- a/codex-rs/cli/src/exec_server_credential_tests.rs +++ b/codex-rs/exec-server-cli/src/exec_server_credential_tests.rs @@ -1,3 +1,5 @@ +#![cfg(test)] + use std::io::PipeWriter; use std::io::Write; diff --git a/codex-rs/cli/src/exec_server_telemetry.rs b/codex-rs/exec-server-cli/src/exec_server_telemetry.rs similarity index 98% rename from codex-rs/cli/src/exec_server_telemetry.rs rename to codex-rs/exec-server-cli/src/exec_server_telemetry.rs index c20895b513..001c84fafa 100644 --- a/codex-rs/cli/src/exec_server_telemetry.rs +++ b/codex-rs/exec-server-cli/src/exec_server_telemetry.rs @@ -1,3 +1,5 @@ +//! Executor telemetry and shutdown coordination shared by both entry points. + use std::future::Future; use tracing_subscriber::EnvFilter; diff --git a/codex-rs/cli/src/exec_server_telemetry_tests.rs b/codex-rs/exec-server-cli/src/exec_server_telemetry_tests.rs similarity index 98% rename from codex-rs/cli/src/exec_server_telemetry_tests.rs rename to codex-rs/exec-server-cli/src/exec_server_telemetry_tests.rs index bcf75a7994..7ae881a5ec 100644 --- a/codex-rs/cli/src/exec_server_telemetry_tests.rs +++ b/codex-rs/exec-server-cli/src/exec_server_telemetry_tests.rs @@ -1,3 +1,5 @@ +#![cfg(test)] + use std::time::Duration; use super::ShutdownBehavior; diff --git a/codex-rs/exec-server-cli/src/lib.rs b/codex-rs/exec-server-cli/src/lib.rs new file mode 100644 index 0000000000..4c2aff039c --- /dev/null +++ b/codex-rs/exec-server-cli/src/lib.rs @@ -0,0 +1,35 @@ +//! Startup shared by the full Codex CLI and the standalone executor. +//! Internal sandbox and filesystem re-exec modes always dispatch before argument parsing. + +use clap::CommandFactory; +use clap::Parser; +use codex_utils_cli::CliConfigOverrides; +use codex_utils_cli::collect_config_overrides; + +mod command; +mod config; +mod exec_server_auth; +mod exec_server_credential; +mod exec_server_telemetry; + +pub use command::ExecServerCommand; + +#[derive(Debug, Parser)] +#[command(name = "codex-exec-server", bin_name = "codex-exec-server", version)] +struct ExecServerCli { + #[command(flatten)] + config_overrides: CliConfigOverrides, + #[command(flatten)] + command: ExecServerCommand, +} + +/// Starts the executor after dispatching internal helper modes and preparing runtime paths. +pub fn run_standalone() -> anyhow::Result<()> { + codex_arg0::arg0_dispatch_or_else(|paths| async move { + let args: Vec<_> = std::env::args_os().collect(); + let mut cli = ExecServerCli::parse_from(&args); + cli.config_overrides = collect_config_overrides(ExecServerCli::command(), args) + .unwrap_or_else(|error| error.exit()); + cli.command.run(&paths, &cli.config_overrides).await + }) +} diff --git a/codex-rs/exec-server-cli/src/main.rs b/codex-rs/exec-server-cli/src/main.rs new file mode 100644 index 0000000000..2eb376457d --- /dev/null +++ b/codex-rs/exec-server-cli/src/main.rs @@ -0,0 +1,14 @@ +//! Standalone executor entry point; startup and sandbox dispatch are shared with Codex. + +#[cfg(all( + target_os = "linux", + target_env = "musl", + any(target_arch = "x86_64", target_arch = "aarch64") +))] +#[global_allocator] +static ALLOCATOR: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; + +fn main() -> anyhow::Result<()> { + codex_build_info::initialize!(); + codex_exec_server_cli::run_standalone() +} diff --git a/codex-rs/exec-server-cli/src/snapshots/codex_exec_server_cli__command__tests__standalone_help_documents_executor_options.snap b/codex-rs/exec-server-cli/src/snapshots/codex_exec_server_cli__command__tests__standalone_help_documents_executor_options.snap new file mode 100644 index 0000000000..ca16be8532 --- /dev/null +++ b/codex-rs/exec-server-cli/src/snapshots/codex_exec_server_cli__command__tests__standalone_help_documents_executor_options.snap @@ -0,0 +1,124 @@ +--- +source: exec-server-cli/src/exec_server_args_tests.rs +assertion_line: 318 +expression: help +--- +[EXPERIMENTAL] Run the standalone exec-server service + +Usage: codex-exec-server [OPTIONS] [COMMAND] + +Commands: + forward Register an existing WebSocket exec-server as a remote environment + help Print this message or the help of the given subcommand(s) + +Options: + -c, --config + Override a configuration value that would otherwise be loaded from + `~/.codex/config.toml`. Use a dotted path (`foo.bar.baz`) to override + nested values. The `value` portion is parsed as TOML. If it fails to + parse as TOML, the raw string is used as a literal. + + Examples: - `-c model="o3"` - `-c + 'sandbox_permissions=["disk-full-read-access"]'` - `-c + shell_environment_policy.inherit=all` + + --strict-config + Error out when config.toml contains fields that are not recognized by + this version of Codex + + --linux-sandbox-pid-namespace + Linux PID namespace: isolate (default) or inherit. Inherit allows + signals to other same-UID processes; enable only when provisioning a + dedicated environment + + [default: isolate] + + --proxy-private-ips-via-upstream + Allow permitted private IP destinations to use the configured upstream + proxy. If no valid upstream proxy applies to the request protocol, + connect directly. Loopback stays local. This flag does not require + upstream routing + + [env: CODEX_EXEC_SERVER_PROXY_PRIVATE_IPS_VIA_UPSTREAM] + + --concurrent-requests + Maximum number of requests to process concurrently on each connection + + [default: 1] + + --listen + Transport endpoint URL. Supported values: `ws://IP:PORT` (default), + `stdio`, `stdio://` + + --ws-auth + Authentication mode for incoming WebSocket connections + + [possible values: capability-token, signed-bearer-token] + + --ws-token-file + Absolute path to the capability-token file + + --ws-token-sha256 + Hex-encoded SHA-256 digest of the capability token + + --ws-shared-secret-file + Absolute path to the shared secret file for signed JWT bearer tokens + + --ws-issuer + Expected issuer for signed JWT bearer tokens + + --ws-audience + Expected audience for signed JWT bearer tokens + + --ws-max-clock-skew-seconds + Maximum clock skew when validating signed JWT bearer tokens + + --remote + Register this exec-server as a remote environment using the given base + URL + + --remote-transport + Transport used for the remote executor connection + + [default: noise] + [possible values: noise, direct] + + --environment-id + Environment id to attach to when registering remotely + + --name + Human-readable environment name + + --use-agent-identity-auth + Use Agent Identity auth from CODEX_ACCESS_TOKEN for remote + registration + + --executor-credential-stdin + Read rotating executor credentials from stdin; exit when the pipe + closes + + --aws-sigv4 + Sign Direct registration and WebSocket handshake requests with AWS + SigV4 + + --aws-profile + AWS profile used for SigV4 authentication + + --aws-region + AWS signing region. Uses the SDK region chain when omitted + + --aws-service + AWS signing service + + [default: execute-api] + + --exit-on-stdin-close + Exit when the parent-owned standard-input pipe closes + + [env: CODEX_EXEC_SERVER_EXIT_ON_STDIN_CLOSE] + + -h, --help + Print help (see a summary with '-h') + + -V, --version + Print version diff --git a/codex-rs/exec-server-cli/tests/standalone.rs b/codex-rs/exec-server-cli/tests/standalone.rs new file mode 100644 index 0000000000..22ed228264 --- /dev/null +++ b/codex-rs/exec-server-cli/tests/standalone.rs @@ -0,0 +1,150 @@ +//! Exercise the shipped executor through its public stdio interface, including child re-exec. + +use anyhow::Context; +use codex_exec_server::FileSystemSandboxContext; +#[cfg(unix)] +use codex_protocol::models::PermissionProfile; +#[cfg(unix)] +use codex_protocol::permissions::FileSystemAccessMode; +#[cfg(unix)] +use codex_protocol::permissions::FileSystemSandboxEntry; +#[cfg(unix)] +use codex_protocol::permissions::NetworkSandboxPolicy; +#[cfg(unix)] +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_cargo_bin::cargo_bin; +use codex_utils_process::background_command; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::process::Stdio; +use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncWriteExt; +use tokio::io::BufReader; + +#[tokio::test] +async fn standalone_initializes_reports_version_and_runs_helpers() -> anyhow::Result<()> { + let binary = cargo_bin("codex-exec-server")?; + let directory = tempfile::tempdir()?; + let cwd = url::Url::from_directory_path(directory.path()) + .map_err(|()| anyhow::anyhow!("file URI"))?; + let version = tokio::process::Command::from(background_command(&binary)) + .arg("--version") + .env("CODEX_HOME", directory.path()) + .output() + .await?; + assert!(version.status.success(), "{version:?}"); + assert_eq!( + String::from_utf8(version.stdout)?, + format!("codex-exec-server {}\n", env!("CARGO_PKG_VERSION")) + ); + + // The unsandboxed case also runs on Windows. Unix additionally verifies that + // sandboxed children and filesystem helpers can re-execute the standalone binary. + #[cfg(not(unix))] + let sandboxes: Vec> = vec![None]; + #[cfg(unix)] + let sandboxes = { + let mut sandboxes = vec![None]; + if codex_sandboxing::system_bwrap_warning(&PermissionProfile::read_only(), directory.path()) + .is_none() + { + // Restrict reads as well, so fs/readFile must dispatch the filesystem helper. + let hidden = directory.path().join("hidden.txt"); + std::fs::write(&hidden, "private")?; + let mut policy = PermissionProfile::read_only().file_system_sandbox_policy(); + policy.entries.push(FileSystemSandboxEntry::new( + AbsolutePathBuf::try_from(hidden)?.into(), + FileSystemAccessMode::Deny, + )); + let permissions = PermissionProfile::from_runtime_permissions( + &policy, + NetworkSandboxPolicy::Restricted, + ); + sandboxes.push(Some(FileSystemSandboxContext::from_permission_profile( + permissions, + cwd.as_str().parse()?, + ))); + } else { + eprintln!("sandbox case unavailable: system bubblewrap cannot run"); + } + sandboxes + }; + for sandbox in sandboxes { + let mut child = tokio::process::Command::from(background_command(&binary)) + .args(["--listen", "stdio"]) + .current_dir(directory.path()) + .env("CODEX_HOME", directory.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn()?; + let mut stdin = child.stdin.take().context("executor stdin")?; + let mut stdout = BufReader::new(child.stdout.take().context("executor stdout")?).lines(); + let request = + json!({"id": 1, "method": "initialize", "params": {"clientName": "standalone-test"}}); + stdin.write_all(format!("{request}\n").as_bytes()).await?; + let response: Value = + serde_json::from_str(&stdout.next_line().await?.context("initialize response")?)?; + assert_eq!(response["id"], 1); + assert!(response["result"]["sessionId"].is_string(), "{response}"); + let environment = response["result"]["environmentInfo"].clone(); + assert_eq!(environment["executorVersion"], env!("CARGO_PKG_VERSION")); + let initialized = json!({"method": "initialized", "params": {}}); + let info = json!({"id": 2, "method": "environment/info", "params": {}}); + stdin + .write_all(format!("{initialized}\n{info}\n").as_bytes()) + .await?; + let response: Value = + serde_json::from_str(&stdout.next_line().await?.context("environment response")?)?; + assert_eq!(response, json!({"id": 2, "result": environment})); + + let file = directory.path().join("readable.txt"); + std::fs::write(&file, "ok")?; + let read = json!({"id": 3, "method": "fs/readFile", "params": { + "path": url::Url::from_file_path(&file).unwrap(), "sandbox": sandbox, + }}); + stdin.write_all(format!("{read}\n").as_bytes()).await?; + let response: Value = + serde_json::from_str(&stdout.next_line().await?.context("read response")?)?; + assert_eq!(response, json!({"id": 3, "result": {"dataBase64": "b2s="}})); + + #[cfg(windows)] + let argv = vec!["cmd.exe", "/C", "exit 0"]; + #[cfg(not(windows))] + let argv = vec![ + "/bin/sh", + "-c", + "if touch forbidden 2>/dev/null; then exit 7; fi", + ]; + #[cfg(not(windows))] + let expected_exit = if sandbox.is_some() { 0 } else { 7 }; + #[cfg(windows)] + let expected_exit = 0; + let start = json!({"id": 4, "method": "process/start", "params": { + "processId": "standalone-process", "cwd": cwd, "sandbox": sandbox, + "argv": argv, "env": {"PATH": std::env::var("PATH")?}, "tty": false, "pipeStdin": false, "arg0": null, + }}); + stdin.write_all(format!("{start}\n").as_bytes()).await?; + let mut started = false; + let mut exit_code = None; + let mut notifications = Vec::new(); + while !started || exit_code.is_none() { + let response: Value = + serde_json::from_str(&stdout.next_line().await?.context("process response")?)?; + if response["id"] == 4 { + assert!(response.get("error").is_none(), "{response}"); + started = true; + } + if response["method"] == "process/exited" { + exit_code = response["params"]["exitCode"].as_i64(); + } + notifications.push(response); + } + assert_eq!(exit_code, Some(expected_exit), "{notifications:#?}"); + drop(stdin); + assert!(child.wait().await?.success()); + } + Ok(()) +} diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md index ee497de689..13d6c80986 100644 --- a/codex-rs/exec-server/README.md +++ b/codex-rs/exec-server/README.md @@ -1,18 +1,22 @@ # codex-exec-server -`codex-exec-server` is the library backing `codex exec-server`, a small +`codex-exec-server` is the library backing `codex exec-server` and the standalone +`codex-exec-server` executable, a small JSON-RPC server for spawning and controlling subprocesses through `codex-utils-pty`. It provides: -- a CLI entrypoint: `codex exec-server` +- CLI entrypoints: `codex exec-server` and `codex-exec-server` - a Rust client: `ExecServerClient` - a small protocol module with shared request/response types This crate owns the transport, protocol, and filesystem/process handlers. The -top-level `codex` binary owns hidden helper dispatch for sandboxed -filesystem operations and `codex-linux-sandbox`. +`codex-exec-server-cli` crate shares startup between the two executables. Both +use `codex-arg0` for hidden filesystem, process, and platform sandbox dispatch. +The standalone executable accepts the same executor arguments and `-c` config +overrides, including `--listen stdio`. Build it with +`cargo build -p codex-exec-server-cli --bin codex-exec-server`. ## Command metadata diff --git a/codex-rs/utils/cli/src/config_override.rs b/codex-rs/utils/cli/src/config_override.rs index 61177dd765..9751e0ee2b 100644 --- a/codex-rs/utils/cli/src/config_override.rs +++ b/codex-rs/utils/cli/src/config_override.rs @@ -101,6 +101,39 @@ fn parse_toml_value(raw: &str) -> Result { .ok_or_else(|| SerdeError::custom("missing sentinel key")) } +/// Collects config overrides across command boundaries in argument order. +/// +/// Clap keeps the deepest global argument's values instead of appending across +/// scopes. Reparse with local config arguments after validating the typed CLI. +pub fn collect_config_overrides( + command: clap::Command, + args: impl IntoIterator + Clone>, +) -> Result { + let matches = localize_config_arg(command).try_get_matches_from(args)?; + let raw_overrides = std::iter::successors(Some(&matches), |scope| { + scope.subcommand().map(|(_, child)| child) + }) + .filter_map(|scope| scope.get_many::("raw_overrides")) + .flatten() + .cloned() + .collect(); + Ok(CliConfigOverrides { raw_overrides }) +} + +fn localize_config_arg(mut command: clap::Command) -> clap::Command { + use clap::CommandFactory; + + if !command + .get_arguments() + .any(|arg| arg.get_id() == "raw_overrides") + { + command = command.args(CliConfigOverrides::command().get_arguments().cloned()); + } + command + .mut_arg("raw_overrides", |arg| arg.global(false)) + .mut_subcommands(localize_config_arg) +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/utils/cli/src/lib.rs b/codex-rs/utils/cli/src/lib.rs index 633638e72d..6e284d7bc1 100644 --- a/codex-rs/utils/cli/src/lib.rs +++ b/codex-rs/utils/cli/src/lib.rs @@ -8,6 +8,7 @@ mod shared_options; pub use approval_mode_cli_arg::ApprovalModeCliArg; pub use codex_protocol::config_types::ProfileV2Name; pub use config_override::CliConfigOverrides; +pub use config_override::collect_config_overrides; pub use format_env_display::format_env_display; pub use resume_command::resume_command; pub use resume_command::resume_hint;