| use clap::Args; |
| use clap::CommandFactory; |
| use clap::Parser; |
| use clap_complete::Shell; |
| use clap_complete::generate; |
| use codex_app_server_daemon::BootstrapOptions as AppServerBootstrapOptions; |
| use codex_app_server_daemon::LifecycleCommand as AppServerLifecycleCommand; |
| use codex_app_server_daemon::RemoteControlMode as AppServerRemoteControlMode; |
| use codex_arg0::Arg0DispatchPaths; |
| use codex_arg0::arg0_dispatch_or_else; |
| use codex_chatgpt::apply_command::ApplyCommand; |
| use codex_chatgpt::apply_command::run_apply_command; |
| use codex_cli::read_access_token_from_stdin; |
| use codex_cli::read_api_key_from_stdin; |
| use codex_cli::run_login_status; |
| use codex_cli::run_login_with_access_token; |
| use codex_cli::run_login_with_api_key; |
| use codex_cli::run_login_with_chatgpt; |
| use codex_cli::run_login_with_device_code; |
| use codex_cli::run_logout; |
| use codex_cloud_config::cloud_config_bundle_loader_for_storage; |
| use codex_cloud_tasks::Cli as CloudTasksCli; |
| use codex_exec::Cli as ExecCli; |
| use codex_exec::Command as ExecCommand; |
| use codex_exec::ReviewArgs; |
| use codex_exec_server::ExecServerRuntimePaths; |
| use codex_execpolicy::ExecPolicyCheckCommand; |
| use codex_http_client::HttpClientFactory; |
| use codex_http_client::OutboundProxyPolicy; |
| use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; |
| use codex_rollout_trace::REDUCED_STATE_FILE_NAME; |
| use codex_rollout_trace::replay_bundle; |
| use codex_state::StateRuntime; |
| use codex_tui::AppExitInfo; |
| use codex_tui::Cli as TuiCli; |
| use codex_tui::ExitReason; |
| use codex_tui::UpdateAction; |
| use codex_utils_absolute_path::AbsolutePathBuf; |
| use codex_utils_cli::CliConfigOverrides; |
| use codex_utils_cli::ProfileV2Name; |
| use codex_utils_cli::SharedCliOptions; |
| use std::collections::HashSet; |
| use std::io::IsTerminal; |
| use std::io::Write; |
| use std::path::PathBuf; |
| use std::sync::Arc; |
| use supports_color::Stream; |
|
|
| #[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; |
|
|
| #[cfg(any(target_os = "macos", target_os = "windows"))] |
| mod app_cmd; |
| mod cloud_config; |
| mod daemon_install; |
| #[cfg(any(target_os = "macos", target_os = "windows"))] |
| mod desktop_app; |
| mod doctor; |
| #[cfg(test)] |
| #[path = "exec_server_args_tests.rs"] |
| mod exec_server_args_tests; |
| mod exec_server_auth; |
| mod exec_server_telemetry; |
| mod marketplace_cmd; |
| mod mcp_cmd; |
| mod mcp_login; |
| mod migrate_rollouts; |
| mod plugin_cmd; |
| mod queue_cmd; |
| mod remote_control_cmd; |
| #[cfg(target_os = "windows")] |
| mod sandbox_setup; |
| mod state_db_recovery; |
| #[cfg(not(windows))] |
| mod wsl_paths; |
|
|
| 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 doctor::DoctorCommand; |
| use state_db_recovery as local_state_db; |
|
|
| use codex_config::LoaderOverrides; |
| use codex_core::build_models_manager; |
| use codex_core::config::Config; |
| use codex_core::config::ConfigBuilder; |
| use codex_core::config::ConfigLoadOptions; |
| use codex_core::config::ConfigOverrides; |
| use codex_core::config::bootstrap_auth_config; |
| use codex_core::config::edit::ConfigEditsBuilder; |
| use codex_core::config::find_codex_home; |
| use codex_core::config::load_config_toml_with_layer_stack; |
| use codex_core::config::resolve_profile_v2_config_path; |
| use codex_features::FEATURES; |
| use codex_features::Stage; |
| use codex_features::is_known_feature_key; |
| use codex_home::CodexHomeUserInstructionsProvider; |
| use codex_login::AuthManager; |
| use codex_login::CodexAuth; |
| use codex_login::is_workload_identity_selected; |
| use codex_login::read_codex_access_token_from_env; |
| use codex_memories_write::clear_memory_roots_contents; |
| use codex_models_manager::bundled_models_response; |
| use codex_models_manager::manager::RefreshStrategy; |
| use codex_protocol::protocol::AskForApproval; |
| use codex_protocol::user_input::UserInput; |
| use codex_terminal_detection::TerminalName; |
|
|
| |
| |
| |
| #[derive(Debug, Parser)] |
| #[clap( |
| author, |
| version, |
| // If a sub‑command is given, ignore requirements of the default args. |
| subcommand_negates_reqs = true, |
| // The executable is sometimes invoked via a platform‑specific name like |
| // `codex-x86_64-unknown-linux-musl`, but the help output should always use |
| // the generic `codex` command name that users run. |
| bin_name = "codex", |
| override_usage = "codex [OPTIONS] [PROMPT]\n codex [OPTIONS] <COMMAND> [ARGS]" |
| )] |
| struct MultitoolCli { |
| #[clap(flatten)] |
| pub config_overrides: CliConfigOverrides, |
|
|
| #[clap(flatten)] |
| pub feature_toggles: FeatureToggles, |
|
|
| #[clap(flatten)] |
| remote: InteractiveRemoteOptions, |
|
|
| #[clap(flatten)] |
| interactive: TuiCli, |
|
|
| #[clap(subcommand)] |
| subcommand: Option<Subcommand>, |
| } |
|
|
| #[derive(Debug, clap::Subcommand)] |
| enum Subcommand { |
| |
| Agents(AgentsCommand), |
|
|
| |
| #[clap(hide = true)] |
| TcpTunnel(codex_tcp_tunnel::Args), |
| |
| #[clap(visible_alias = "e")] |
| Exec(ExecCli), |
|
|
| |
| Review(ReviewCommand), |
|
|
| |
| Login(LoginCommand), |
|
|
| |
| Logout(LogoutCommand), |
|
|
| |
| Mcp(McpCli), |
|
|
| |
| Plugin(PluginCli), |
|
|
| |
| AppServer(AppServerCommand), |
|
|
| |
| RemoteControl(RemoteControlCommand), |
|
|
| |
| #[cfg(any(target_os = "macos", target_os = "windows"))] |
| App(app_cmd::AppCommand), |
|
|
| |
| Completion(CompletionCommand), |
|
|
| |
| Update, |
|
|
| |
| Doctor(DoctorCommand), |
|
|
| |
| Sandbox(HostSandboxArgs), |
|
|
| |
| Debug(DebugCommand), |
|
|
| |
| #[clap(hide = true)] |
| Execpolicy(ExecpolicyCommand), |
|
|
| |
| #[clap(visible_alias = "a")] |
| Apply(ApplyCommand), |
|
|
| |
| Resume(ResumeCommand), |
|
|
| |
| Queue(QueueCommand), |
|
|
| |
| Archive(SessionArchiveCommand), |
|
|
| |
| Delete(DeleteCommand), |
|
|
| |
| MigrateRollouts(migrate_rollouts::MigrateRolloutsCommand), |
|
|
| |
| Unarchive(SessionArchiveCommand), |
|
|
| |
| Fork(ForkCommand), |
|
|
| |
| #[clap(name = "cloud", alias = "cloud-tasks")] |
| Cloud(CloudTasksCli), |
|
|
| |
| #[clap(hide = true)] |
| ResponsesApiProxy(ResponsesApiProxyArgs), |
|
|
| |
| #[clap(hide = true, name = "stdio-to-uds")] |
| StdioToUds(StdioToUdsCommand), |
|
|
| |
| ExecServer(ExecServerCommand), |
|
|
| |
| Features(FeaturesCli), |
| } |
|
|
| #[derive(Debug, Parser)] |
| struct CompletionCommand { |
| |
| #[clap(value_enum, default_value_t = Shell::Bash)] |
| shell: Shell, |
| } |
|
|
| #[derive(Debug, Parser)] |
| struct DebugCommand { |
| #[command(subcommand)] |
| subcommand: DebugSubcommand, |
| } |
|
|
| #[derive(Debug, clap::Subcommand)] |
| enum DebugSubcommand { |
| |
| Models(DebugModelsCommand), |
|
|
| |
| AppServer(DebugAppServerCommand), |
|
|
| |
| PromptInput(DebugPromptInputCommand), |
|
|
| |
| #[clap(hide = true)] |
| TraceReduce(DebugTraceReduceCommand), |
|
|
| |
| #[clap(hide = true)] |
| ClearMemories, |
| } |
|
|
| #[derive(Debug, Parser)] |
| struct DebugAppServerCommand { |
| #[command(subcommand)] |
| subcommand: DebugAppServerSubcommand, |
| } |
|
|
| #[derive(Debug, clap::Subcommand)] |
| enum DebugAppServerSubcommand { |
| |
| SendMessageV2(DebugAppServerSendMessageV2Command), |
| } |
|
|
| #[derive(Debug, Parser)] |
| struct DebugAppServerSendMessageV2Command { |
| #[arg(value_name = "USER_MESSAGE", required = true)] |
| user_message: String, |
| } |
|
|
| #[derive(Debug, Parser)] |
| struct DebugPromptInputCommand { |
| |
| #[arg(value_name = "PROMPT")] |
| prompt: Option<String>, |
|
|
| |
| #[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)] |
| images: Vec<PathBuf>, |
| } |
|
|
| #[derive(Debug, Parser)] |
| struct DebugModelsCommand { |
| |
| #[arg(long = "bundled", default_value_t = false)] |
| bundled: bool, |
| } |
|
|
| #[derive(Debug, Parser)] |
| struct ReviewCommand { |
| |
| #[arg(long = "strict-config", default_value_t = false)] |
| strict_config: bool, |
|
|
| #[clap(flatten)] |
| args: ReviewArgs, |
| } |
|
|
| #[derive(Debug, Parser)] |
| struct DebugTraceReduceCommand { |
| |
| #[arg(value_name = "TRACE_BUNDLE")] |
| trace_bundle: PathBuf, |
|
|
| |
| #[arg(long = "output", short = 'o', value_name = "FILE")] |
| output: Option<PathBuf>, |
| } |
|
|
| #[derive(Debug, Parser)] |
| struct AgentsCommand { |
| #[clap(flatten)] |
| remote: InteractiveRemoteOptions, |
|
|
| |
| #[arg(long = "cd", short = 'C', value_name = "DIR")] |
| cwd: Option<PathBuf>, |
|
|
| |
| #[arg(long = "no-alt-screen", default_value_t = false)] |
| no_alt_screen: bool, |
| } |
|
|
| #[derive(Debug, Parser)] |
| struct ResumeCommand { |
| |
| |
| #[arg(value_name = "SESSION_ID")] |
| session_id: Option<String>, |
|
|
| |
| #[arg(long = "last", default_value_t = false)] |
| last: bool, |
|
|
| |
| #[arg(long = "all", default_value_t = false)] |
| all: bool, |
|
|
| |
| #[arg(long = "include-non-interactive", default_value_t = false)] |
| include_non_interactive: bool, |
|
|
| #[clap(flatten)] |
| remote: InteractiveRemoteOptions, |
|
|
| #[clap(flatten)] |
| config_overrides: SessionTuiCli, |
| } |
|
|
| #[derive(Debug, Parser)] |
| struct SessionArchiveCommand { |
| |
| #[arg(value_name = "SESSION")] |
| target: String, |
|
|
| #[clap(flatten)] |
| remote: InteractiveRemoteOptions, |
|
|
| #[clap(flatten)] |
| config_overrides: SessionArchiveConfigOverrides, |
| } |
|
|
| #[derive(Debug, Args, Clone, Default)] |
| struct SessionArchiveConfigOverrides { |
| #[clap(flatten)] |
| shared: SharedCliOptions, |
|
|
| |
| #[arg(long = "strict-config", default_value_t = false)] |
| strict_config: bool, |
|
|
| #[clap(flatten)] |
| config_overrides: CliConfigOverrides, |
| } |
|
|
| #[derive(Debug, Args)] |
| struct DeleteCommand { |
| #[clap(flatten)] |
| session: SessionArchiveCommand, |
|
|
| |
| #[arg(long, default_value_t = false)] |
| force: bool, |
| } |
|
|
| #[derive(Debug, Parser)] |
| struct ForkCommand { |
| |
| |
| #[arg(value_name = "SESSION_ID")] |
| session_id: Option<String>, |
|
|
| |
| #[arg(long = "last", default_value_t = false)] |
| last: bool, |
|
|
| |
| #[arg(long = "all", default_value_t = false)] |
| all: bool, |
|
|
| #[clap(flatten)] |
| remote: InteractiveRemoteOptions, |
|
|
| #[clap(flatten)] |
| config_overrides: SessionTuiCli, |
| } |
|
|
| |
| |
| |
| #[derive(Debug)] |
| struct SessionTuiCli(TuiCli); |
|
|
| impl Args for SessionTuiCli { |
| fn augment_args(cmd: clap::Command) -> clap::Command { |
| TuiCli::augment_args(cmd).mut_arg("prompt", |arg| arg.conflicts_with("last")) |
| } |
|
|
| fn augment_args_for_update(cmd: clap::Command) -> clap::Command { |
| TuiCli::augment_args_for_update(cmd).mut_arg("prompt", |arg| arg.conflicts_with("last")) |
| } |
| } |
|
|
| impl clap::FromArgMatches for SessionTuiCli { |
| fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error> { |
| TuiCli::from_arg_matches(matches).map(Self) |
| } |
|
|
| fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> { |
| self.0.update_from_arg_matches(matches) |
| } |
| } |
|
|
| #[cfg(target_os = "macos")] |
| type HostSandboxArgs = codex_cli::SeatbeltCommand; |
| #[cfg(target_os = "linux")] |
| type HostSandboxArgs = codex_cli::LandlockCommand; |
| #[cfg(target_os = "windows")] |
| type HostSandboxArgs = codex_cli::WindowsCommand; |
|
|
| #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] |
| type HostSandboxArgs = UnsupportedSandboxArgs; |
|
|
| #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] |
| #[derive(Debug, Parser)] |
| struct UnsupportedSandboxArgs { |
| |
| #[arg(long = "profile", short = 'p')] |
| pub config_profile: Option<ProfileV2Name>, |
|
|
| #[clap(skip)] |
| pub config_overrides: CliConfigOverrides, |
|
|
| |
| #[arg(trailing_var_arg = true)] |
| pub command: Vec<String>, |
| } |
|
|
| #[derive(Debug, Parser)] |
| struct ExecpolicyCommand { |
| #[command(subcommand)] |
| sub: ExecpolicySubcommand, |
| } |
|
|
| #[derive(Debug, clap::Subcommand)] |
| enum ExecpolicySubcommand { |
| |
| #[clap(name = "check")] |
| Check(ExecPolicyCheckCommand), |
| } |
|
|
| #[derive(Debug, Parser)] |
| struct LoginCommand { |
| #[clap(skip)] |
| config_overrides: CliConfigOverrides, |
|
|
| #[arg( |
| long = "with-api-key", |
| help = "Read the API key from stdin (e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`)" |
| )] |
| with_api_key: bool, |
|
|
| #[arg( |
| long = "with-access-token", |
| help = "Read the access token from stdin (e.g. `printenv CODEX_ACCESS_TOKEN | codex login --with-access-token`)" |
| )] |
| with_access_token: bool, |
|
|
| #[arg( |
| long = "api-key", |
| num_args = 0..=1, |
| default_missing_value = "", |
| value_name = "API_KEY", |
| help = "(deprecated) Previously accepted the API key directly; now exits with guidance to use --with-api-key", |
| hide = true |
| )] |
| api_key: Option<String>, |
|
|
| #[arg(long = "device-auth")] |
| use_device_code: bool, |
|
|
| |
| |
| #[arg(long = "experimental_issuer", value_name = "URL", hide = true)] |
| issuer_base_url: Option<String>, |
|
|
| |
| #[arg(long = "experimental_client-id", value_name = "CLIENT_ID", hide = true)] |
| client_id: Option<String>, |
|
|
| #[command(subcommand)] |
| action: Option<LoginSubcommand>, |
| } |
|
|
| #[derive(Debug, clap::Subcommand)] |
| enum LoginSubcommand { |
| |
| Status, |
| } |
|
|
| #[derive(Debug, Parser)] |
| struct LogoutCommand { |
| #[clap(skip)] |
| config_overrides: CliConfigOverrides, |
| } |
|
|
| #[derive(Debug, Parser)] |
| struct AppServerCommand { |
| |
| #[command(subcommand)] |
| subcommand: Option<AppServerSubcommand>, |
|
|
| #[command(flatten)] |
| code_mode_host: codex_app_server::AppServerCodeModeHostArgs, |
|
|
| |
| #[arg(long = "strict-config", default_value_t = false)] |
| strict_config: bool, |
|
|
| |
| |
| #[arg( |
| long = "listen", |
| value_name = "URL", |
| default_value = codex_app_server::AppServerTransport::DEFAULT_LISTEN_URL |
| )] |
| listen: codex_app_server::AppServerTransport, |
|
|
| |
| #[arg(long = "stdio", conflicts_with = "listen")] |
| stdio: bool, |
|
|
| |
| #[arg(long = "remote-control", hide = true)] |
| remote_control: bool, |
|
|
| |
| #[arg(long, hide = true)] |
| managed_daemon: bool, |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| #[arg(long = "analytics-default-enabled")] |
| analytics_default_enabled: bool, |
|
|
| #[command(flatten)] |
| auth: codex_app_server::AppServerWebsocketAuthArgs, |
| } |
|
|
| #[derive(Debug, Parser)] |
| struct ExecServerCommand { |
| #[command(subcommand)] |
| command: Option<ExecServerSubcommand>, |
|
|
| |
| #[arg( |
| id = "exec_server_strict_config", |
| long = "strict-config", |
| default_value_t = false, |
| global = true |
| )] |
| strict_config: bool, |
|
|
| |
| #[arg( |
| long = "concurrent-requests", |
| value_name = "COUNT", |
| default_value = "1" |
| )] |
| request_dispatch_mode: codex_exec_server::RequestDispatchMode, |
|
|
| |
| #[arg( |
| long = "listen", |
| value_name = "URL", |
| conflicts_with = "exec_server_remote" |
| )] |
| listen: Option<String>, |
|
|
| |
| #[arg( |
| long = "remote", |
| id = "exec_server_remote", |
| value_name = "URL", |
| requires = "environment_id", |
| global = true |
| )] |
| remote: Option<String>, |
|
|
| |
| #[arg( |
| long = "remote-transport", |
| value_enum, |
| default_value_t = ExecServerRemoteTransport::Noise, |
| requires = "exec_server_remote", |
| requires_if("direct", "aws_sigv4"), |
| global = true |
| )] |
| remote_transport: ExecServerRemoteTransport, |
|
|
| |
| #[arg(long = "environment-id", value_name = "ID", global = true)] |
| environment_id: Option<String>, |
|
|
| |
| #[arg(long = "name", value_name = "NAME", global = true)] |
| name: Option<String>, |
|
|
| |
| #[arg( |
| long = "use-agent-identity-auth", |
| requires = "exec_server_remote", |
| conflicts_with = "aws_sigv4", |
| global = true |
| )] |
| use_agent_identity_auth: bool, |
|
|
| |
| #[arg(long = "aws-sigv4", requires = "exec_server_remote", global = true)] |
| aws_sigv4: bool, |
|
|
| |
| #[arg( |
| long = "aws-profile", |
| value_name = "PROFILE", |
| requires = "aws_sigv4", |
| global = true |
| )] |
| aws_profile: Option<String>, |
|
|
| |
| #[arg( |
| long = "aws-region", |
| value_name = "REGION", |
| requires = "aws_sigv4", |
| global = true |
| )] |
| aws_region: Option<String>, |
|
|
| |
| #[arg( |
| long = "aws-service", |
| value_name = "SERVICE", |
| default_value = "execute-api", |
| requires = "aws_sigv4", |
| global = true |
| )] |
| aws_service: String, |
|
|
| |
| #[arg( |
| long = "exit-on-stdin-close", |
| env = codex_exec_server::CODEX_EXEC_SERVER_EXIT_ON_STDIN_CLOSE_ENV_VAR, |
| requires_if("true", "exec_server_remote"), |
| global = true |
| )] |
| exit_on_stdin_close: bool, |
| } |
|
|
| impl ExecServerCommand { |
| 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"); |
| } |
| (ExecServerRemoteTransport::Direct, false) => { |
| anyhow::bail!("--remote-transport direct requires --aws-sigv4"); |
| } |
| (ExecServerRemoteTransport::Noise, false) |
| | (ExecServerRemoteTransport::Direct, true) => {} |
| } |
| if self.remote_transport == ExecServerRemoteTransport::Direct |
| && matches!( |
| self.command.as_ref(), |
| Some(ExecServerSubcommand::Forward { .. }) |
| ) |
| { |
| anyhow::bail!("direct exec-server transport does not support forwarding"); |
| } |
| Ok(()) |
| } |
| } |
|
|
| #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)] |
| enum ExecServerRemoteTransport { |
| #[default] |
| Noise, |
| Direct, |
| } |
|
|
| #[derive(Debug, clap::Subcommand)] |
| enum ExecServerSubcommand { |
| |
| Forward { |
| |
| #[arg(long, value_name = "URL", requires = "exec_server_remote")] |
| connect: String, |
| }, |
| } |
|
|
| #[derive(Debug, clap::Subcommand)] |
| #[allow(clippy::enum_variant_names)] |
| enum AppServerSubcommand { |
| |
| Daemon(AppServerDaemonCommand), |
|
|
| |
| Proxy(AppServerProxyCommand), |
|
|
| |
| GenerateTs(GenerateTsCommand), |
|
|
| |
| GenerateJsonSchema(GenerateJsonSchemaCommand), |
|
|
| |
| #[clap(hide = true)] |
| GenerateInternalJsonSchema(GenerateInternalJsonSchemaCommand), |
| } |
|
|
| #[derive(Debug, Args)] |
| struct AppServerDaemonCommand { |
| #[command(subcommand)] |
| subcommand: AppServerDaemonSubcommand, |
| } |
|
|
| #[derive(Debug, clap::Subcommand)] |
| enum AppServerDaemonSubcommand { |
| |
| Bootstrap(AppServerBootstrapCommand), |
|
|
| |
| Start, |
|
|
| |
| Restart, |
|
|
| |
| Update { |
| |
| #[arg(long)] |
| from_cli: bool, |
| |
| #[arg(short = 'y', long, requires = "from_cli")] |
| yes: bool, |
| }, |
|
|
| |
| EnableRemoteControl, |
|
|
| |
| DisableRemoteControl, |
|
|
| |
| Stop, |
|
|
| |
| Version, |
|
|
| |
| #[clap(hide = true)] |
| PidUpdateLoop { |
| |
| #[arg(long, hide = true)] |
| check_package_ownership: bool, |
| |
| #[arg(long, hide = true)] |
| restore_release: Option<String>, |
| }, |
| } |
|
|
| #[derive(Debug, Args)] |
| struct AppServerProxyCommand { |
| |
| #[arg(long = "sock", value_name = "SOCKET_PATH", value_parser = parse_socket_path)] |
| socket_path: Option<AbsolutePathBuf>, |
| } |
|
|
| #[derive(Debug, Args)] |
| struct AppServerBootstrapCommand { |
| |
| #[arg(long = "remote-control")] |
| remote_control: bool, |
| } |
|
|
| #[derive(Debug, Args)] |
| struct GenerateTsCommand { |
| |
| #[arg(short = 'o', long = "out", value_name = "DIR")] |
| out_dir: PathBuf, |
|
|
| |
| #[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")] |
| prettier: Option<PathBuf>, |
|
|
| |
| #[arg(long = "experimental", default_value_t = false)] |
| experimental: bool, |
| } |
|
|
| #[derive(Debug, Args)] |
| struct GenerateJsonSchemaCommand { |
| |
| #[arg(short = 'o', long = "out", value_name = "DIR")] |
| out_dir: PathBuf, |
|
|
| |
| #[arg(long = "experimental", default_value_t = false)] |
| experimental: bool, |
| } |
|
|
| #[derive(Debug, Args)] |
| struct GenerateInternalJsonSchemaCommand { |
| |
| #[arg(short = 'o', long = "out", value_name = "DIR")] |
| out_dir: PathBuf, |
| } |
|
|
| #[derive(Debug, Parser)] |
| struct StdioToUdsCommand { |
| |
| #[arg(value_name = "SOCKET_PATH", value_parser = parse_socket_path)] |
| socket_path: AbsolutePathBuf, |
| } |
|
|
| fn parse_socket_path(raw: &str) -> Result<AbsolutePathBuf, String> { |
| AbsolutePathBuf::relative_to_current_dir(raw) |
| .map_err(|err| format!("failed to resolve socket path `{raw}`: {err}")) |
| } |
|
|
| |
| fn handle_app_exit( |
| exit_info: AppExitInfo, |
| cli_executable: Option<&std::path::Path>, |
| ) -> anyhow::Result<()> { |
| let is_fatal = match &exit_info.exit_reason { |
| ExitReason::Fatal(message) => { |
| eprintln!("ERROR: {message}"); |
| true |
| } |
| ExitReason::UserRequested |
| | ExitReason::Archived(_) |
| | ExitReason::TurnInterrupted |
| | ExitReason::ThreadRemoved => false, |
| }; |
|
|
| let update_action = exit_info.update_action; |
| if !matches!(update_action, Some(UpdateAction::Daemon(_))) { |
| let color_enabled = supports_color::on(Stream::Stdout).is_some(); |
| for line in exit_info.format_exit_messages(color_enabled) { |
| println!("{line}"); |
| } |
| } |
| if is_fatal { |
| std::io::stdout().flush()?; |
| std::process::exit(1); |
| } |
| if let Some(action) = update_action { |
| run_update_action(action, cli_executable)?; |
| } |
| Ok(()) |
| } |
|
|
| |
| fn run_update_action( |
| action: UpdateAction, |
| cli_executable: Option<&std::path::Path>, |
| ) -> anyhow::Result<()> { |
| if let UpdateAction::Daemon(source) = action { |
| let executable = cli_executable |
| .ok_or_else(|| anyhow::anyhow!("Cannot locate the launching Codex CLI"))?; |
| println!("Updating the local background server..."); |
| let status = std::process::Command::new(executable) |
| .args(source.command_args()) |
| .status()?; |
| anyhow::ensure!( |
| status.success(), |
| "Daemon update failed with status {status}" |
| ); |
| println!("Relaunch Codex to reconnect."); |
| return Ok(()); |
| } |
| println!(); |
| let cmd_str = action.command_str(); |
| println!("Updating Codex via `{cmd_str}`..."); |
| let status = { |
| #[cfg(windows)] |
| { |
| let (cmd, args) = action.command_args(); |
| let cmd = if action == UpdateAction::StandaloneWindows { |
| |
| |
| "powershell.exe" |
| } else { |
| cmd |
| }; |
| let path_env = |
| std::env::var_os("PATH").ok_or_else(|| anyhow::anyhow!("PATH is not set"))?; |
| let command_path = resolve_windows_update_command_from_path(cmd, &path_env)?; |
| |
| |
| let update_cwd = tempfile::tempdir()?; |
| |
| |
| |
| std::process::Command::new(command_path) |
| .args(args) |
| .current_dir(update_cwd.path()) |
| .status()? |
| } |
| #[cfg(not(windows))] |
| { |
| let (cmd, args) = action.command_args(); |
| let command_path = crate::wsl_paths::normalize_for_wsl(cmd); |
| let normalized_args: Vec<String> = args |
| .iter() |
| .map(crate::wsl_paths::normalize_for_wsl) |
| .collect(); |
| std::process::Command::new(&command_path) |
| .args(&normalized_args) |
| .status()? |
| } |
| }; |
| if !status.success() { |
| anyhow::bail!("`{cmd_str}` failed with status {status}"); |
| } |
| println!("\n🎉 Update ran successfully! Please restart Codex."); |
| Ok(()) |
| } |
|
|
| #[cfg(windows)] |
| fn resolve_windows_update_command_from_path( |
| command: &str, |
| path_env: &std::ffi::OsStr, |
| ) -> anyhow::Result<PathBuf> { |
| let path_env = |
| std::env::join_paths(std::env::split_paths(path_env).filter(|path| path.is_absolute()))?; |
| if path_env.is_empty() { |
| anyhow::bail!( |
| "Could not find an absolute update command `{command}` on PATH. Please update manually: https://developers.openai.com/codex/cli/" |
| ); |
| } |
| which::which_in_global(command, Some(&path_env))? |
| .next() |
| .ok_or_else(|| anyhow::anyhow!("could not find update command `{command}` on PATH")) |
| } |
|
|
| fn run_update_command() -> anyhow::Result<()> { |
| #[cfg(debug_assertions)] |
| { |
| anyhow::bail!( |
| "`codex update` is not available in debug builds. Install a release build of Codex to use this command." |
| ); |
| } |
|
|
| #[cfg(not(debug_assertions))] |
| { |
| let Some(action) = codex_tui::get_update_action() else { |
| anyhow::bail!( |
| "Could not detect the Codex installation method. Please update manually: https://developers.openai.com/codex/cli/" |
| ); |
| }; |
| run_update_action(action, None) |
| } |
| } |
|
|
| fn run_execpolicycheck(cmd: ExecPolicyCheckCommand) -> anyhow::Result<()> { |
| cmd.run() |
| } |
|
|
| async fn run_session_archive_cli_command( |
| action: codex_tui::SessionArchiveAction, |
| cmd: SessionArchiveCommand, |
| mut interactive: TuiCli, |
| root_config_overrides: CliConfigOverrides, |
| root_remote: Option<String>, |
| root_remote_auth_token_env: Option<String>, |
| arg0_paths: Arg0DispatchPaths, |
| ) -> anyhow::Result<String> { |
| let SessionArchiveCommand { |
| target, |
| remote, |
| config_overrides, |
| } = cmd; |
| interactive = |
| finalize_session_archive_interactive(interactive, root_config_overrides, config_overrides); |
| let explicit_remote_endpoint = resolve_remote_endpoint( |
| remote.remote.or(root_remote), |
| remote.remote_auth_token_env.or(root_remote_auth_token_env), |
| )?; |
| codex_tui::run_session_archive_command( |
| action, |
| target, |
| codex_tui::SessionArchiveCommandOptions { |
| cli: interactive, |
| arg0_paths, |
| explicit_remote_endpoint, |
| }, |
| ) |
| .await |
| .map_err(|err| anyhow::anyhow!("{err}")) |
| } |
|
|
| fn delete_action(target: &str, force: bool) -> anyhow::Result<codex_tui::SessionArchiveAction> { |
| if force && codex_protocol::ThreadId::from_string(target).is_err() { |
| anyhow::bail!("--force requires a session UUID; names must be confirmed interactively"); |
| } |
| let confirmation = match force { |
| true => codex_tui::DeleteConfirmation::Skip, |
| false => codex_tui::DeleteConfirmation::Prompt, |
| }; |
| Ok(codex_tui::SessionArchiveAction::Delete(confirmation)) |
| } |
|
|
| async fn run_debug_app_server_command(cmd: DebugAppServerCommand) -> anyhow::Result<()> { |
| match cmd.subcommand { |
| DebugAppServerSubcommand::SendMessageV2(cmd) => { |
| let codex_bin = std::env::current_exe()?; |
| codex_app_server_test_client::send_message_v2(&codex_bin, &[], cmd.user_message, &None) |
| .await |
| } |
| } |
| } |
|
|
| #[derive(Debug, Default, Parser, Clone)] |
| struct FeatureToggles { |
| |
| #[arg(long = "enable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)] |
| enable: Vec<String>, |
|
|
| |
| #[arg(long = "disable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)] |
| disable: Vec<String>, |
| } |
|
|
| #[derive(Debug, Default, Parser, Clone)] |
| struct InteractiveRemoteOptions { |
| |
| |
| |
| #[arg(long = "remote", value_name = "ADDR")] |
| remote: Option<String>, |
|
|
| |
| |
| #[arg(long = "remote-auth-token-env", value_name = "ENV_VAR")] |
| remote_auth_token_env: Option<String>, |
| } |
|
|
| impl FeatureToggles { |
| fn to_overrides(&self) -> anyhow::Result<Vec<String>> { |
| let mut v = Vec::new(); |
| for feature in &self.enable { |
| Self::validate_feature(feature)?; |
| v.push(format!("features.{feature}=true")); |
| } |
| for feature in &self.disable { |
| Self::validate_feature(feature)?; |
| v.push(format!("features.{feature}=false")); |
| } |
| Ok(v) |
| } |
|
|
| fn validate_feature(feature: &str) -> anyhow::Result<()> { |
| if is_known_feature_key(feature) { |
| Ok(()) |
| } else { |
| anyhow::bail!("Unknown feature flag: {feature}") |
| } |
| } |
| } |
|
|
| #[derive(Debug, Parser)] |
| struct FeaturesCli { |
| #[command(subcommand)] |
| sub: FeaturesSubcommand, |
| } |
|
|
| #[derive(Debug, Parser)] |
| enum FeaturesSubcommand { |
| |
| List, |
| |
| Enable(FeatureSetArgs), |
| |
| Disable(FeatureSetArgs), |
| } |
|
|
| #[derive(Debug, Parser)] |
| struct FeatureSetArgs { |
| |
| feature: String, |
| } |
|
|
| fn stage_str(stage: Stage) -> &'static str { |
| match stage { |
| Stage::UnderDevelopment => "under development", |
| Stage::Experimental { .. } => "experimental", |
| Stage::Stable => "stable", |
| Stage::Deprecated => "deprecated", |
| Stage::Removed => "removed", |
| } |
| } |
|
|
| fn main() -> anyhow::Result<()> { |
| codex_build_info::initialize!(); |
| let remote_control_disabled = codex_app_server::take_remote_control_disabled_env(); |
| arg0_dispatch_or_else(move |arg0_paths: Arg0DispatchPaths| async move { |
| cli_main(arg0_paths, remote_control_disabled).await?; |
| Ok(()) |
| }) |
| } |
|
|
| async fn cli_main( |
| arg0_paths: Arg0DispatchPaths, |
| remote_control_disabled: bool, |
| ) -> anyhow::Result<()> { |
| let MultitoolCli { |
| config_overrides: mut root_config_overrides, |
| feature_toggles, |
| remote, |
| mut interactive, |
| subcommand, |
| } = MultitoolCli::parse(); |
| |
| let daemon_cli_executable = arg0_paths |
| .codex_self_exe |
| .as_ref() |
| .and_then(|path| path.canonicalize().ok()); |
| interactive.daemon_cli_executable = daemon_cli_executable |
| .clone() |
| .and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()); |
| reject_unsupported_worktree_for_subcommand(interactive.shared.worktree, &subcommand)?; |
| |
| let toggle_overrides = feature_toggles.to_overrides()?; |
| root_config_overrides.raw_overrides.extend(toggle_overrides); |
| let agents_options = match &subcommand { |
| Some(Subcommand::Agents(options)) => Some(options), |
| _ => None, |
| }; |
| if let Some(options) = agents_options |
| && let Some(root_endpoint) = &remote.remote |
| && let Some(agents_endpoint) = &options.remote.remote |
| && root_endpoint != agents_endpoint |
| { |
| anyhow::bail!("`codex agents` received conflicting remote server endpoints"); |
| } |
| let root_remote = agents_options |
| .and_then(|options| options.remote.remote.clone()) |
| .or(remote.remote); |
| let root_remote_auth_token_env = agents_options |
| .and_then(|options| options.remote.remote_auth_token_env.clone()) |
| .or(remote.remote_auth_token_env); |
| if let Some(options) = agents_options { |
| interactive.cwd = options.cwd.clone().or(interactive.cwd.take()); |
| interactive.no_alt_screen |= options.no_alt_screen; |
| } |
| let root_strict_config = interactive.strict_config; |
| interactive |
| .shared |
| .take_auto_review_config_overrides(&mut root_config_overrides); |
| reject_root_strict_config_for_subcommand(root_strict_config, &subcommand)?; |
| if let Some(subcommand) = subcommand.as_ref() { |
| profile_v2_for_subcommand(&interactive, subcommand)?; |
| } |
|
|
| let open_agents_overview = matches!(&subcommand, Some(Subcommand::Agents(_))); |
| match subcommand { |
| None | Some(Subcommand::Agents(_)) => { |
| prepend_config_flags( |
| &mut interactive.config_overrides, |
| root_config_overrides.clone(), |
| ); |
| if open_agents_overview { |
| if interactive.prompt.is_some() || !interactive.images.is_empty() { |
| anyhow::bail!("`codex agents` does not accept an initial prompt or images"); |
| } |
| if root_remote.is_some() |
| && (interactive.oss |
| || interactive.oss_provider.is_some() |
| || !interactive.add_dir.is_empty() |
| || interactive |
| .config_overrides |
| .parse_overrides() |
| .map_err(anyhow::Error::msg)? |
| .iter() |
| .any(|(key, value)| { |
| key == "sandbox_workspace_write.writable_roots" |
| || (key == "sandbox_workspace_write" |
| && value.get("writable_roots").is_some()) |
| })) |
| { |
| anyhow::bail!( |
| "`codex agents` cannot apply local provider or additional-directory overrides to a remote server" |
| ); |
| } |
| if is_workload_identity_selected() { |
| anyhow::bail!( |
| "`codex agents` is unavailable while workload identity is active" |
| ); |
| } |
| if root_remote.is_none() { |
| resolve_remote_endpoint( |
| None, |
| root_remote_auth_token_env.clone(), |
| )?; |
| #[cfg(not(any(unix, windows)))] |
| anyhow::bail!("`codex agents` requires `--remote` on this platform"); |
| } |
| interactive.agents_overview = true; |
| } |
| let exit_info = run_interactive_tui( |
| interactive, |
| root_remote.clone(), |
| root_remote_auth_token_env.clone(), |
| arg0_paths.clone(), |
| ) |
| .await?; |
| handle_app_exit(exit_info, daemon_cli_executable.as_deref())?; |
| } |
| Some(Subcommand::TcpTunnel(args)) => { |
| return codex_tcp_tunnel::run(args).await; |
| } |
| Some(Subcommand::Exec(mut exec_cli)) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "exec", |
| )?; |
| exec_cli |
| .shared |
| .inherit_exec_root_options(&interactive.shared); |
| exec_cli.strict_config |= root_strict_config; |
| prepend_config_flags( |
| &mut exec_cli.config_overrides, |
| root_config_overrides.clone(), |
| ); |
| codex_exec::run_main(exec_cli, arg0_paths.clone()).await?; |
| } |
| Some(Subcommand::Review(ReviewCommand { |
| strict_config, |
| args: review_args, |
| })) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "review", |
| )?; |
| let mut exec_cli = ExecCli::try_parse_from(["codex", "exec"])?; |
| exec_cli |
| .shared |
| .inherit_exec_root_options(&interactive.shared); |
| exec_cli.command = Some(ExecCommand::Review(review_args)); |
| exec_cli.strict_config = strict_config || root_strict_config; |
| prepend_config_flags( |
| &mut exec_cli.config_overrides, |
| root_config_overrides.clone(), |
| ); |
| codex_exec::run_main(exec_cli, arg0_paths.clone()).await?; |
| } |
| Some(Subcommand::Mcp(mut mcp_cli)) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "mcp", |
| )?; |
| |
| prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone()); |
| let loader_overrides = |
| loader_overrides_for_profile(interactive.config_profile_v2.as_ref())?; |
| mcp_cli.run(loader_overrides).await?; |
| } |
| Some(Subcommand::Plugin(plugin_cli)) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "plugin", |
| )?; |
| let PluginCli { |
| mut config_overrides, |
| subcommand, |
| } = plugin_cli; |
| prepend_config_flags(&mut config_overrides, root_config_overrides.clone()); |
| match subcommand { |
| PluginSubcommand::Add(args) => { |
| let overrides = config_overrides |
| .parse_overrides() |
| .map_err(anyhow::Error::msg)?; |
| plugin_cmd::run_plugin_add(overrides, args).await?; |
| } |
| PluginSubcommand::List(args) => { |
| let overrides = config_overrides |
| .parse_overrides() |
| .map_err(anyhow::Error::msg)?; |
| plugin_cmd::run_plugin_list(overrides, args).await?; |
| } |
| PluginSubcommand::Marketplace(mut marketplace_cli) => { |
| prepend_config_flags(&mut marketplace_cli.config_overrides, config_overrides); |
| marketplace_cli.run().await?; |
| } |
| PluginSubcommand::Remove(args) => { |
| let overrides = config_overrides |
| .parse_overrides() |
| .map_err(anyhow::Error::msg)?; |
| plugin_cmd::run_plugin_remove(overrides, args).await?; |
| } |
| } |
| } |
| Some(Subcommand::AppServer(app_server_cli)) => { |
| let AppServerCommand { |
| subcommand, |
| code_mode_host, |
| strict_config: app_server_strict_config, |
| listen, |
| stdio, |
| remote_control, |
| managed_daemon, |
| analytics_default_enabled, |
| auth, |
| } = app_server_cli; |
| let strict_config = app_server_strict_config || root_strict_config; |
| reject_strict_config_for_app_server_subcommand(strict_config, subcommand.as_ref())?; |
| reject_remote_mode_for_app_server_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| subcommand.as_ref(), |
| )?; |
| match subcommand { |
| None => { |
| let transport = if stdio { |
| codex_app_server::AppServerTransport::Stdio |
| } else { |
| listen |
| }; |
| let auth = auth.try_into_settings()?; |
| let runtime_options = codex_app_server::AppServerRuntimeOptions { |
| code_mode_host_transport: code_mode_host.into(), |
| managed_daemon, |
| remote_control_startup_mode: match (remote_control, remote_control_disabled) |
| { |
| (true, _) => { |
| codex_app_server::RemoteControlStartupMode::EnabledEphemeral |
| } |
| (false, true) => { |
| codex_app_server::RemoteControlStartupMode::DisabledEphemeral |
| } |
| (false, false) => { |
| codex_app_server::RemoteControlStartupMode::ResolvePersisted |
| } |
| }, |
| ..Default::default() |
| }; |
| let exit = codex_app_server::run_main_with_transport_options( |
| arg0_paths.clone(), |
| root_config_overrides, |
| LoaderOverrides::default(), |
| strict_config, |
| analytics_default_enabled, |
| transport, |
| codex_protocol::protocol::SessionSource::VSCode, |
| auth, |
| runtime_options, |
| ) |
| .await?; |
| if exit == codex_app_server::AppServerExit::Forced { |
| |
| std::process::exit(0); |
| } |
| } |
| Some(AppServerSubcommand::Daemon(daemon_cli)) => match daemon_cli.subcommand { |
| AppServerDaemonSubcommand::Start => { |
| print_app_server_daemon_output(AppServerLifecycleCommand::Start).await?; |
| } |
| AppServerDaemonSubcommand::Bootstrap(bootstrap_cli) => { |
| let output = |
| codex_app_server_daemon::bootstrap(AppServerBootstrapOptions { |
| remote_control_enabled: bootstrap_cli.remote_control, |
| }) |
| .await?; |
| println!("{}", serde_json::to_string(&output)?); |
| } |
| AppServerDaemonSubcommand::Restart => { |
| print_app_server_daemon_output(AppServerLifecycleCommand::Restart).await?; |
| } |
| AppServerDaemonSubcommand::Update { |
| from_cli: true, |
| yes, |
| } => { |
| if let Some(output) = codex_app_server_daemon::update_from_cli(|request| { |
| daemon_install::confirm_install(request, yes) |
| }) |
| .await? |
| { |
| println!("{}", serde_json::to_string(&output)?); |
| } |
| } |
| AppServerDaemonSubcommand::EnableRemoteControl => { |
| print_app_server_remote_control_output(AppServerRemoteControlMode::Enabled) |
| .await?; |
| } |
| AppServerDaemonSubcommand::DisableRemoteControl => { |
| print_app_server_remote_control_output( |
| AppServerRemoteControlMode::Disabled, |
| ) |
| .await?; |
| } |
| AppServerDaemonSubcommand::Stop => { |
| print_app_server_daemon_output(AppServerLifecycleCommand::Stop).await?; |
| } |
| AppServerDaemonSubcommand::Version => { |
| print_app_server_daemon_output(AppServerLifecycleCommand::Version).await?; |
| } |
| AppServerDaemonSubcommand::PidUpdateLoop { |
| check_package_ownership: true, |
| .. |
| } => return Ok(()), |
| AppServerDaemonSubcommand::Update { |
| from_cli: false, .. |
| } |
| | AppServerDaemonSubcommand::PidUpdateLoop { |
| check_package_ownership: false, |
| .. |
| } => { |
| let cli_overrides = root_config_overrides |
| .parse_overrides() |
| .map_err(anyhow::Error::msg)?; |
| let config = ConfigBuilder::default() |
| .cli_overrides(cli_overrides) |
| .build() |
| .await |
| .map_err(anyhow::Error::from); |
| let http_client_factory = updater_http_client_factory(config); |
| if matches!( |
| daemon_cli.subcommand, |
| AppServerDaemonSubcommand::Update { .. } |
| ) { |
| let output = |
| codex_app_server_daemon::update(http_client_factory).await?; |
| println!("{}", serde_json::to_string(&output)?); |
| } else { |
| let AppServerDaemonSubcommand::PidUpdateLoop { |
| restore_release, .. |
| } = daemon_cli.subcommand |
| else { |
| unreachable!() |
| }; |
| codex_app_server_daemon::run_pid_update_loop( |
| http_client_factory, |
| restore_release, |
| ) |
| .await?; |
| } |
| } |
| }, |
| Some(AppServerSubcommand::Proxy(proxy_cli)) => { |
| let socket_path = match proxy_cli.socket_path { |
| Some(socket_path) => socket_path, |
| None => { |
| let codex_home = find_codex_home()?; |
| codex_app_server::app_server_control_socket_path(&codex_home)? |
| } |
| }; |
| codex_stdio_to_uds::run(socket_path.as_path()).await?; |
| } |
| Some(AppServerSubcommand::GenerateTs(gen_cli)) => { |
| let options = codex_app_server_protocol::GenerateTsOptions { |
| experimental_api: gen_cli.experimental, |
| ..Default::default() |
| }; |
| codex_app_server_protocol::generate_ts_with_options( |
| &gen_cli.out_dir, |
| gen_cli.prettier.as_deref(), |
| options, |
| )?; |
| } |
| Some(AppServerSubcommand::GenerateJsonSchema(gen_cli)) => { |
| codex_app_server_protocol::generate_json_with_experimental( |
| &gen_cli.out_dir, |
| gen_cli.experimental, |
| )?; |
| } |
| Some(AppServerSubcommand::GenerateInternalJsonSchema(gen_cli)) => { |
| codex_app_server_protocol::generate_internal_json_schema(&gen_cli.out_dir)?; |
| } |
| } |
| } |
| Some(Subcommand::RemoteControl(remote_control_cli)) => { |
| let subcommand_name = remote_control_cli.subcommand_name(); |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| subcommand_name, |
| )?; |
| remote_control_cmd::run( |
| remote_control_cli, |
| arg0_paths.clone(), |
| root_config_overrides, |
| ) |
| .await?; |
| } |
| #[cfg(any(target_os = "macos", target_os = "windows"))] |
| Some(Subcommand::App(app_cli)) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "app", |
| )?; |
| app_cmd::run_app(app_cli).await?; |
| } |
| Some(Subcommand::Resume(ResumeCommand { |
| session_id, |
| last, |
| all, |
| include_non_interactive, |
| remote, |
| config_overrides, |
| })) => { |
| let SessionTuiCli(config_overrides) = config_overrides; |
| interactive = finalize_resume_interactive( |
| interactive, |
| root_config_overrides.clone(), |
| session_id, |
| last, |
| all, |
| include_non_interactive, |
| config_overrides, |
| ); |
| let exit_info = run_interactive_tui( |
| interactive, |
| remote.remote.or(root_remote.clone()), |
| remote |
| .remote_auth_token_env |
| .or(root_remote_auth_token_env.clone()), |
| arg0_paths.clone(), |
| ) |
| .await?; |
| handle_app_exit(exit_info, daemon_cli_executable.as_deref())?; |
| } |
| Some(Subcommand::Archive(cmd)) => { |
| let output = run_session_archive_cli_command( |
| codex_tui::SessionArchiveAction::Archive, |
| cmd, |
| interactive, |
| root_config_overrides.clone(), |
| root_remote.clone(), |
| root_remote_auth_token_env.clone(), |
| arg0_paths.clone(), |
| ) |
| .await?; |
| println!("{output}"); |
| } |
| Some(Subcommand::Queue(cmd)) => { |
| let output = queue_cmd::run_queue_command( |
| cmd, |
| interactive, |
| root_config_overrides.clone(), |
| root_remote.clone(), |
| root_remote_auth_token_env.clone(), |
| arg0_paths.clone(), |
| ) |
| .await?; |
| println!("{output}"); |
| } |
| Some(Subcommand::Delete(DeleteCommand { session, force })) => { |
| let action = delete_action(&session.target, force)?; |
| let output = run_session_archive_cli_command( |
| action, |
| session, |
| interactive, |
| root_config_overrides.clone(), |
| root_remote.clone(), |
| root_remote_auth_token_env.clone(), |
| arg0_paths.clone(), |
| ) |
| .await?; |
| println!("{output}"); |
| } |
| Some(Subcommand::MigrateRollouts(command)) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "migrate-rollouts", |
| )?; |
| migrate_rollouts::run(command, root_config_overrides).await?; |
| } |
| Some(Subcommand::Unarchive(cmd)) => { |
| let output = run_session_archive_cli_command( |
| codex_tui::SessionArchiveAction::Unarchive, |
| cmd, |
| interactive, |
| root_config_overrides.clone(), |
| root_remote.clone(), |
| root_remote_auth_token_env.clone(), |
| arg0_paths.clone(), |
| ) |
| .await?; |
| println!("{output}"); |
| } |
| Some(Subcommand::Fork(ForkCommand { |
| session_id, |
| last, |
| all, |
| remote, |
| config_overrides, |
| })) => { |
| let SessionTuiCli(config_overrides) = config_overrides; |
| interactive = finalize_fork_interactive( |
| interactive, |
| root_config_overrides.clone(), |
| session_id, |
| last, |
| all, |
| config_overrides, |
| ); |
| let exit_info = run_interactive_tui( |
| interactive, |
| remote.remote.or(root_remote.clone()), |
| remote |
| .remote_auth_token_env |
| .or(root_remote_auth_token_env.clone()), |
| arg0_paths.clone(), |
| ) |
| .await?; |
| handle_app_exit(exit_info, daemon_cli_executable.as_deref())?; |
| } |
| Some(Subcommand::Login(mut login_cli)) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "login", |
| )?; |
| prepend_config_flags( |
| &mut login_cli.config_overrides, |
| root_config_overrides.clone(), |
| ); |
| match login_cli.action { |
| Some(LoginSubcommand::Status) => { |
| run_login_status(login_cli.config_overrides).await; |
| } |
| None => { |
| if login_cli.with_api_key && login_cli.with_access_token { |
| eprintln!( |
| "Choose one login credential source: --with-api-key or --with-access-token." |
| ); |
| std::process::exit(1); |
| } else if login_cli.use_device_code { |
| run_login_with_device_code( |
| login_cli.config_overrides, |
| login_cli.issuer_base_url, |
| login_cli.client_id, |
| ) |
| .await; |
| } else if login_cli.api_key.is_some() { |
| eprintln!( |
| "The --api-key flag is no longer supported. Pipe the key instead, e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`." |
| ); |
| std::process::exit(1); |
| } else if login_cli.with_api_key { |
| let api_key = read_api_key_from_stdin(); |
| run_login_with_api_key(login_cli.config_overrides, api_key).await; |
| } else if login_cli.with_access_token { |
| let access_token = read_access_token_from_stdin(); |
| run_login_with_access_token(login_cli.config_overrides, access_token).await; |
| } else { |
| run_login_with_chatgpt(login_cli.config_overrides).await; |
| } |
| } |
| } |
| } |
| Some(Subcommand::Logout(mut logout_cli)) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "logout", |
| )?; |
| prepend_config_flags( |
| &mut logout_cli.config_overrides, |
| root_config_overrides.clone(), |
| ); |
| run_logout(logout_cli.config_overrides).await; |
| } |
| Some(Subcommand::Completion(completion_cli)) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "completion", |
| )?; |
| print_completion(completion_cli); |
| } |
| Some(Subcommand::Update) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "update", |
| )?; |
| run_update_command()?; |
| } |
| Some(Subcommand::Doctor(doctor_cli)) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "doctor", |
| )?; |
| doctor::run_doctor( |
| doctor_cli, |
| root_config_overrides.clone(), |
| &interactive, |
| &arg0_paths, |
| ) |
| .await?; |
| } |
| Some(Subcommand::Cloud(mut cloud_cli)) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "cloud", |
| )?; |
| prepend_config_flags( |
| &mut cloud_cli.config_overrides, |
| root_config_overrides.clone(), |
| ); |
| codex_cloud_tasks::run_main(cloud_cli, arg0_paths.codex_linux_sandbox_exe.clone()) |
| .await?; |
| } |
| Some(Subcommand::Sandbox(mut sandbox_cli)) => { |
| let config_profile = sandbox_cli |
| .config_profile |
| .as_ref() |
| .or(interactive.config_profile_v2.as_ref()); |
| prepend_config_flags( |
| &mut sandbox_cli.config_overrides, |
| root_config_overrides.clone(), |
| ); |
| #[cfg(target_os = "windows")] |
| if let Some(setup_cli) = sandbox_setup::parse_setup_command(&sandbox_cli.command)? { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "sandbox setup", |
| )?; |
| let cli_overrides = sandbox_cli |
| .config_overrides |
| .parse_overrides() |
| .map_err(anyhow::Error::msg)?; |
| sandbox_setup::run(setup_cli, config_profile.cloned(), cli_overrides).await?; |
| return Ok(()); |
| } |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "sandbox", |
| )?; |
| let loader_overrides = loader_overrides_for_profile(config_profile)?; |
| #[cfg(target_os = "macos")] |
| codex_cli::run_command_under_seatbelt( |
| sandbox_cli, |
| arg0_paths.codex_linux_sandbox_exe.clone(), |
| loader_overrides, |
| ) |
| .await?; |
| #[cfg(target_os = "linux")] |
| codex_cli::run_command_under_landlock( |
| sandbox_cli, |
| arg0_paths.codex_linux_sandbox_exe.clone(), |
| loader_overrides, |
| ) |
| .await?; |
| #[cfg(target_os = "windows")] |
| codex_cli::run_command_under_windows_sandbox( |
| sandbox_cli, |
| arg0_paths.codex_linux_sandbox_exe.clone(), |
| loader_overrides, |
| ) |
| .await?; |
| #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] |
| { |
| let _ = loader_overrides; |
| anyhow::bail!("`codex sandbox` is not supported on this operating system"); |
| } |
| } |
| Some(Subcommand::Debug(DebugCommand { subcommand })) => match subcommand { |
| DebugSubcommand::Models(cmd) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "debug models", |
| )?; |
| run_debug_models_command(cmd, root_config_overrides).await?; |
| } |
| DebugSubcommand::AppServer(cmd) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "debug app-server", |
| )?; |
| run_debug_app_server_command(cmd).await?; |
| } |
| DebugSubcommand::PromptInput(cmd) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "debug prompt-input", |
| )?; |
| run_debug_prompt_input_command( |
| cmd, |
| root_config_overrides, |
| interactive, |
| arg0_paths.clone(), |
| ) |
| .await?; |
| } |
| DebugSubcommand::TraceReduce(cmd) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "debug trace-reduce", |
| )?; |
| run_debug_trace_reduce_command(cmd).await?; |
| } |
| DebugSubcommand::ClearMemories => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "debug clear-memories", |
| )?; |
| run_debug_clear_memories_command(&root_config_overrides).await?; |
| } |
| }, |
| Some(Subcommand::Execpolicy(ExecpolicyCommand { sub })) => match sub { |
| ExecpolicySubcommand::Check(cmd) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "execpolicy check", |
| )?; |
| run_execpolicycheck(cmd)? |
| } |
| }, |
| Some(Subcommand::Apply(mut apply_cli)) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "apply", |
| )?; |
| prepend_config_flags( |
| &mut apply_cli.config_overrides, |
| root_config_overrides.clone(), |
| ); |
| run_apply_command(apply_cli, None).await?; |
| } |
| Some(Subcommand::ResponsesApiProxy(args)) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "responses-api-proxy", |
| )?; |
| tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) |
| .await??; |
| } |
| Some(Subcommand::StdioToUds(cmd)) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "stdio-to-uds", |
| )?; |
| let socket_path = cmd.socket_path; |
| codex_stdio_to_uds::run(socket_path.as_path()).await?; |
| } |
| Some(Subcommand::ExecServer(cmd)) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "exec-server", |
| )?; |
| let strict_config = cmd.strict_config || root_strict_config; |
| run_exec_server_command(cmd, &arg0_paths, &root_config_overrides, strict_config) |
| .await?; |
| } |
| Some(Subcommand::Features(FeaturesCli { sub })) => match sub { |
| FeaturesSubcommand::List => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "features list", |
| )?; |
| |
| if interactive.web_search { |
| root_config_overrides |
| .raw_overrides |
| .push("web_search=\"live\"".to_string()); |
| } |
|
|
| let config = |
| cloud_config::load_config(&root_config_overrides, LoaderOverrides::default()) |
| .await?; |
| let mut rows = Vec::with_capacity(FEATURES.len()); |
| let mut name_width = 0; |
| let mut stage_width = 0; |
| for def in FEATURES { |
| let name = def.key; |
| let stage = stage_str(def.stage); |
| let enabled = config.features.enabled(def.id); |
| name_width = name_width.max(name.len()); |
| stage_width = stage_width.max(stage.len()); |
| rows.push((name, stage, enabled)); |
| } |
| rows.sort_unstable_by_key(|(name, _, _)| *name); |
|
|
| for (name, stage, enabled) in rows { |
| println!("{name:<name_width$} {stage:<stage_width$} {enabled}"); |
| } |
| } |
| FeaturesSubcommand::Enable(FeatureSetArgs { feature }) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "features enable", |
| )?; |
| enable_feature_in_config(&feature).await?; |
| } |
| FeaturesSubcommand::Disable(FeatureSetArgs { feature }) => { |
| reject_remote_mode_for_subcommand( |
| root_remote.as_deref(), |
| root_remote_auth_token_env.as_deref(), |
| "features disable", |
| )?; |
| disable_feature_in_config(&feature).await?; |
| } |
| }, |
| } |
|
|
| Ok(()) |
| } |
|
|
| fn profile_v2_for_subcommand<'a>( |
| interactive: &'a TuiCli, |
| subcommand: &Subcommand, |
| ) -> anyhow::Result<Option<&'a ProfileV2Name>> { |
| let Some(profile_v2) = interactive.config_profile_v2.as_ref() else { |
| return Ok(None); |
| }; |
|
|
| match subcommand { |
| Subcommand::Agents(_) |
| | Subcommand::Exec(_) |
| | Subcommand::Review(_) |
| | Subcommand::Resume(_) |
| | Subcommand::Queue(_) |
| | Subcommand::Archive(_) |
| | Subcommand::Delete(_) |
| | Subcommand::Unarchive(_) |
| | Subcommand::Fork(_) |
| | Subcommand::Mcp(_) |
| | Subcommand::Sandbox(_) |
| | Subcommand::Debug(DebugCommand { |
| subcommand: DebugSubcommand::PromptInput(_), |
| }) => Ok(Some(profile_v2)), |
| _ => anyhow::bail!( |
| "--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex queue`, `codex archive`, `codex delete`, `codex unarchive`, `codex fork`, `codex mcp`, `codex sandbox`, and `codex debug prompt-input`." |
| ), |
| } |
| } |
|
|
| async fn run_exec_server_command( |
| mut cmd: ExecServerCommand, |
| arg0_paths: &Arg0DispatchPaths, |
| root_config_overrides: &CliConfigOverrides, |
| strict_config: bool, |
| ) -> anyhow::Result<()> { |
| cmd.validate_remote_transport()?; |
| let codex_self_exe = arg0_paths |
| .codex_self_exe |
| .clone() |
| .ok_or_else(|| anyhow::anyhow!("Codex executable path is not configured"))?; |
| let runtime_paths = |
| ExecServerRuntimePaths::new(codex_self_exe, arg0_paths.codex_linux_sandbox_exe.clone())?; |
| if let Some(base_url) = cmd.remote.take() { |
| let environment_id = cmd |
| .environment_id |
| .take() |
| .ok_or_else(|| anyhow::anyhow!("--environment-id is required when --remote is set"))?; |
| let config = load_exec_server_config( |
| root_config_overrides, |
| strict_config, |
| true, |
| ) |
| .await?; |
| let direct_transport = cmd.remote_transport == ExecServerRemoteTransport::Direct; |
| let (_otel, telemetry) = exec_server_telemetry::init(Some(&config)); |
| let auth_provider = if cmd.aws_sigv4 { |
| exec_server_auth::aws_sigv4_auth_provider(codex_aws_auth::AwsAuthConfig { |
| profile: cmd.aws_profile, |
| region: cmd.aws_region, |
| service: cmd.aws_service, |
| }) |
| .await? |
| } else { |
| load_exec_server_remote_auth_provider(&config, &base_url, cmd.use_agent_identity_auth) |
| .await? |
| }; |
| let mut remote_config = codex_exec_server::RemoteEnvironmentConfig::new_with_transport( |
| base_url, |
| environment_id, |
| if direct_transport { |
| codex_exec_server::RemoteEnvironmentTransport::Direct |
| } else { |
| codex_exec_server::RemoteEnvironmentTransport::Noise |
| }, |
| auth_provider, |
| config.http_client_factory(), |
| )?; |
| if let Some(name) = cmd.name { |
| remote_config.name = name; |
| } |
| remote_config.request_dispatch_mode = cmd.request_dispatch_mode; |
| let remote_config = remote_config.with_telemetry(telemetry); |
| let parent_lifetime = if cmd.exit_on_stdin_close { |
| exec_server_telemetry::ParentLifetime::StdinPipe |
| } else { |
| exec_server_telemetry::ParentLifetime::Independent |
| }; |
| let (shutdown_sender, shutdown_receiver) = tokio::sync::oneshot::channel(); |
| #[cfg(target_os = "macos")] |
| let runtime_paths = runtime_paths.with_allowed_symlinked_codex_home( |
| codex_config::allowed_symlinked_codex_home( |
| &config.config_layer_stack, |
| &config.codex_home, |
| ), |
| ); |
| exec_server_telemetry::run_until_shutdown( |
| async move { |
| let shutdown = async move { |
| let _ = shutdown_receiver.await; |
| }; |
| match cmd.command { |
| Some(ExecServerSubcommand::Forward { connect }) => { |
| codex_exec_server::run_remote_environment_forward_until_shutdown( |
| remote_config, |
| connect, |
| shutdown, |
| ) |
| .await |
| } |
| None => { |
| codex_exec_server::run_remote_environment_until_shutdown( |
| remote_config, |
| runtime_paths, |
| shutdown, |
| ) |
| .await |
| } |
| } |
| .map_err(anyhow::Error::new) |
| }, |
| parent_lifetime, |
| exec_server_telemetry::ShutdownBehavior::Graceful(shutdown_sender), |
| ) |
| .await |
| } else { |
| let config_result = load_exec_server_config( |
| root_config_overrides, |
| strict_config, |
| false, |
| ) |
| .await; |
| let config = if strict_config { |
| Some(config_result?) |
| } else { |
| config_result.ok() |
| }; |
| let (_otel, telemetry) = exec_server_telemetry::init(config.as_ref()); |
| #[cfg(target_os = "macos")] |
| let runtime_paths = |
| runtime_paths.with_allowed_symlinked_codex_home(config.as_ref().and_then(|config| { |
| codex_config::allowed_symlinked_codex_home( |
| &config.config_layer_stack, |
| &config.codex_home, |
| ) |
| })); |
| let http_client_factory = config |
| .as_ref() |
| .map(Config::http_client_factory) |
| .unwrap_or_else(|| HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault)); |
| let listen_url = cmd |
| .listen |
| .unwrap_or_else(|| codex_exec_server::DEFAULT_LISTEN_URL.to_string()); |
| let run = exec_server_telemetry::run_until_shutdown( |
| codex_exec_server::run_main_with_telemetry( |
| &listen_url, |
| runtime_paths, |
| telemetry, |
| http_client_factory, |
| cmd.request_dispatch_mode, |
| ), |
| exec_server_telemetry::ParentLifetime::Independent, |
| exec_server_telemetry::ShutdownBehavior::Immediate, |
| ); |
| run.await.map_err(anyhow::Error::from_boxed) |
| } |
| } |
|
|
| async fn load_exec_server_remote_auth_provider( |
| config: &codex_core::config::Config, |
| base_url: &str, |
| use_agent_identity_auth: bool, |
| ) -> anyhow::Result<codex_api::SharedAuthProvider> { |
| 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, 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_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)) |
| } |
| } |
|
|
| fn is_supported_exec_server_remote_auth(auth: &CodexAuth) -> bool { |
| auth.is_chatgpt_auth() || auth.is_api_key_auth() |
| } |
|
|
| 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_workload_identity: bool, |
| ) -> anyhow::Result<codex_core::config::Config> { |
| 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_workload_identity && is_workload_identity_selected() { |
| let codex_home = find_codex_home()?; |
| 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: LoaderOverrides::default(), |
| strict_config, |
| cloud_config_bundle: Default::default(), |
| }, |
| ) |
| .await?; |
| let bootstrap_auth_config = bootstrap_auth_config(&codex_home, &bootstrap_config)?; |
| let cloud_config_bundle = cloud_config_bundle_loader_for_storage( |
| bootstrap_auth_config, |
| false, |
| ) |
| .await?; |
| builder = builder.cloud_config_bundle(cloud_config_bundle); |
| } |
| Ok(builder.build().await?) |
| } |
|
|
| async fn load_exec_server_remote_auth( |
| config: &codex_core::config::Config, |
| missing_auth_error: &'static str, |
| ) -> anyhow::Result<(Arc<AuthManager>, codex_login::CodexAuth)> { |
| let auth_manager = |
| AuthManager::shared_from_config(config, 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)) |
| } |
|
|
| async fn enable_feature_in_config(feature: &str) -> anyhow::Result<()> { |
| FeatureToggles::validate_feature(feature)?; |
| let codex_home = find_codex_home()?; |
| ConfigEditsBuilder::new(&codex_home) |
| .set_feature_enabled(feature, true) |
| .apply() |
| .await?; |
| println!("Enabled feature `{feature}` in config.toml."); |
| maybe_print_under_development_feature_warning(&codex_home, feature); |
| Ok(()) |
| } |
|
|
| async fn disable_feature_in_config(feature: &str) -> anyhow::Result<()> { |
| FeatureToggles::validate_feature(feature)?; |
| let codex_home = find_codex_home()?; |
| ConfigEditsBuilder::new(&codex_home) |
| .set_feature_enabled(feature, false) |
| .apply() |
| .await?; |
| println!("Disabled feature `{feature}` in config.toml."); |
| Ok(()) |
| } |
|
|
| fn loader_overrides_for_profile( |
| profile_v2: Option<&ProfileV2Name>, |
| ) -> anyhow::Result<LoaderOverrides> { |
| match profile_v2 { |
| Some(profile_v2) => { |
| let codex_home = find_codex_home()?; |
| Ok(loader_overrides_for_profile_at_codex_home( |
| Some(profile_v2), |
| &codex_home, |
| )) |
| } |
| None => Ok(LoaderOverrides::default()), |
| } |
| } |
|
|
| fn loader_overrides_for_profile_at_codex_home( |
| profile_v2: Option<&ProfileV2Name>, |
| codex_home: &std::path::Path, |
| ) -> LoaderOverrides { |
| match profile_v2 { |
| Some(profile_v2) => LoaderOverrides { |
| user_config_path: Some(resolve_profile_v2_config_path(codex_home, profile_v2)), |
| user_config_profile: Some(profile_v2.clone()), |
| ..Default::default() |
| }, |
| None => LoaderOverrides::default(), |
| } |
| } |
|
|
| fn maybe_print_under_development_feature_warning(codex_home: &std::path::Path, feature: &str) { |
| let Some(spec) = FEATURES.iter().find(|spec| spec.key == feature) else { |
| return; |
| }; |
| if !matches!(spec.stage, Stage::UnderDevelopment) { |
| return; |
| } |
|
|
| let config_path = codex_home.join(codex_config::CONFIG_TOML_FILE); |
| eprintln!( |
| "Under-development features enabled: {feature}. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` in {}.", |
| config_path.display() |
| ); |
| } |
|
|
| async fn run_debug_trace_reduce_command(cmd: DebugTraceReduceCommand) -> anyhow::Result<()> { |
| let output = cmd |
| .output |
| .unwrap_or_else(|| cmd.trace_bundle.join(REDUCED_STATE_FILE_NAME)); |
|
|
| let trace = replay_bundle(&cmd.trace_bundle)?; |
| let reduced_json = serde_json::to_vec_pretty(&trace)?; |
| tokio::fs::write(&output, reduced_json).await?; |
| println!("{}", output.display()); |
|
|
| Ok(()) |
| } |
|
|
| async fn run_debug_prompt_input_command( |
| cmd: DebugPromptInputCommand, |
| root_config_overrides: CliConfigOverrides, |
| interactive: TuiCli, |
| arg0_paths: Arg0DispatchPaths, |
| ) -> anyhow::Result<()> { |
| let loader_overrides = loader_overrides_for_profile(interactive.config_profile_v2.as_ref())?; |
| let shared = interactive.shared.into_inner(); |
| let mut cli_kv_overrides = root_config_overrides |
| .parse_overrides() |
| .map_err(anyhow::Error::msg)?; |
| if interactive.web_search { |
| cli_kv_overrides.push(( |
| "web_search".to_string(), |
| toml::Value::String("live".to_string()), |
| )); |
| } |
|
|
| let approval_policy = if shared.dangerously_bypass_approvals_and_sandbox { |
| Some(AskForApproval::Never) |
| } else { |
| interactive.approval_policy.map(Into::into) |
| }; |
| let sandbox_mode = if shared.dangerously_bypass_approvals_and_sandbox { |
| Some(codex_protocol::config_types::SandboxMode::DangerFullAccess) |
| } else { |
| shared.sandbox_mode.map(Into::into) |
| }; |
| let overrides = ConfigOverrides { |
| model: shared.model, |
| approval_policy, |
| sandbox_mode, |
| cwd: shared.cwd, |
| codex_self_exe: arg0_paths.codex_self_exe, |
| codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe, |
| main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe, |
| show_raw_agent_reasoning: shared.oss.then_some(true), |
| ephemeral: Some(true), |
| bypass_hook_trust: shared.bypass_hook_trust.then_some(true), |
| additional_writable_roots: shared.add_dir, |
| ..Default::default() |
| }; |
| let config = ConfigBuilder::default() |
| .cli_overrides(cli_kv_overrides) |
| .harness_overrides(overrides) |
| .loader_overrides(loader_overrides) |
| .build() |
| .await?; |
|
|
| let mut input = shared |
| .images |
| .into_iter() |
| .chain(cmd.images) |
| .map(|path| UserInput::LocalImage { path, detail: None }) |
| .collect::<Vec<_>>(); |
| if let Some(prompt) = cmd.prompt.or(interactive.prompt) { |
| input.push(UserInput::Text { |
| text: prompt.replace("\r\n", "\n").replace('\r', "\n"), |
| text_elements: Vec::new(), |
| }); |
| } |
|
|
| let user_instructions_provider = Arc::new(CodexHomeUserInstructionsProvider::new( |
| config.codex_home.clone(), |
| )); |
| let auth_manager = |
| AuthManager::shared_from_config(&config, false).await?; |
| let mut extensions = codex_extension_api::ExtensionRegistryBuilder::new(); |
| codex_git_attribution::install( |
| &mut extensions, |
| auth_manager, |
| config.chatgpt_base_url.clone(), |
| config.http_client_factory(), |
| ); |
| codex_skills_extension::install(&mut extensions, |config: &Config| { |
| codex_skills_extension::SkillsExtensionConfig { |
| include_instructions: config.include_skill_instructions, |
| max_context_tokens: config.skill_max_context_tokens, |
| bundled_skills_enabled: config.bundled_skills_enabled(), |
| orchestrator_skills_enabled: config.orchestrator_skills_enabled, |
| shadow_selection_enabled: config |
| .features |
| .enabled(codex_features::Feature::SkillSearch), |
| } |
| }); |
| let prompt_input = codex_core::build_prompt_input( |
| config, |
| input, |
| None, |
| Arc::new(extensions.build()), |
| user_instructions_provider, |
| ) |
| .await?; |
| println!("{}", serde_json::to_string_pretty(&prompt_input)?); |
|
|
| Ok(()) |
| } |
|
|
| async fn run_debug_models_command( |
| cmd: DebugModelsCommand, |
| root_config_overrides: CliConfigOverrides, |
| ) -> anyhow::Result<()> { |
| let catalog = if cmd.bundled { |
| bundled_models_response()? |
| } else { |
| let cli_overrides = root_config_overrides |
| .parse_overrides() |
| .map_err(anyhow::Error::msg)?; |
| let config = ConfigBuilder::default() |
| .cli_overrides(cli_overrides) |
| .build() |
| .await?; |
| let auth_manager = |
| AuthManager::shared_from_config(&config, true).await?; |
| let models_manager = build_models_manager(&config, auth_manager); |
| models_manager |
| .raw_model_catalog( |
| RefreshStrategy::OnlineIfUncached, |
| config.http_client_factory(), |
| ) |
| .await |
| }; |
|
|
| serde_json::to_writer(std::io::stdout(), &catalog)?; |
| println!(); |
| Ok(()) |
| } |
|
|
| async fn run_debug_clear_memories_command( |
| root_config_overrides: &CliConfigOverrides, |
| ) -> anyhow::Result<()> { |
| let cli_kv_overrides = root_config_overrides |
| .parse_overrides() |
| .map_err(anyhow::Error::msg)?; |
| let config = ConfigBuilder::default() |
| .cli_overrides(cli_kv_overrides) |
| .build() |
| .await?; |
|
|
| let memories_path = config.sqlite_config().memories_db_path(); |
| let cleared_memories_db = |
| StateRuntime::clear_memory_data_in_sqlite_home(config.sqlite_config()).await?; |
|
|
| clear_memory_roots_contents(&config.codex_home).await?; |
|
|
| let mut message = if cleared_memories_db { |
| format!("Cleared memory state from {}.", memories_path.display()) |
| } else { |
| format!("No memories db found at {}.", memories_path.display()) |
| }; |
| message.push_str(&format!( |
| " Cleared memory directories under {}.", |
| config.codex_home.display() |
| )); |
|
|
| println!("{message}"); |
|
|
| Ok(()) |
| } |
|
|
| |
| |
| fn prepend_config_flags( |
| subcommand_config_overrides: &mut CliConfigOverrides, |
| cli_config_overrides: CliConfigOverrides, |
| ) { |
| subcommand_config_overrides.prepend_root_overrides(cli_config_overrides); |
| } |
|
|
| fn reject_remote_mode_for_subcommand( |
| remote: Option<&str>, |
| remote_auth_token_env: Option<&str>, |
| subcommand: &str, |
| ) -> anyhow::Result<()> { |
| if let Some(remote) = remote { |
| anyhow::bail!( |
| "`--remote {remote}` is only supported for interactive TUI commands, not `codex {subcommand}`" |
| ); |
| } |
| if remote_auth_token_env.is_some() { |
| anyhow::bail!( |
| "`--remote-auth-token-env` is only supported for interactive TUI commands, not `codex {subcommand}`" |
| ); |
| } |
| Ok(()) |
| } |
|
|
| fn reject_unsupported_worktree_for_subcommand( |
| root_worktree: bool, |
| subcommand: &Option<Subcommand>, |
| ) -> anyhow::Result<()> { |
| let subcommand_worktree = match subcommand { |
| Some(Subcommand::Exec(command)) => command.shared.worktree, |
| Some(Subcommand::Resume(command)) => command.config_overrides.0.shared.worktree, |
| Some(Subcommand::Fork(command)) => command.config_overrides.0.shared.worktree, |
| Some(Subcommand::Archive(command)) | Some(Subcommand::Unarchive(command)) => { |
| command.config_overrides.shared.worktree |
| } |
| Some(Subcommand::Delete(command)) => command.session.config_overrides.shared.worktree, |
| Some(Subcommand::Queue(command)) => command.config_overrides.shared.worktree, |
| _ => false, |
| }; |
|
|
| if !root_worktree && !subcommand_worktree { |
| return Ok(()); |
| } |
|
|
| match subcommand { |
| None => Ok(()), |
| Some(Subcommand::Fork(command)) if command.session_id.is_some() && !command.last => Ok(()), |
| Some(Subcommand::Fork(_)) => { |
| anyhow::bail!("`codex fork --worktree` requires an explicit session ID") |
| } |
| Some(Subcommand::Exec(command)) => match &command.command { |
| None | Some(ExecCommand::Fork(_)) => Ok(()), |
| Some(ExecCommand::Resume(_)) => anyhow::bail!( |
| "`--worktree` cannot resume an existing session; use `codex exec fork --worktree`" |
| ), |
| Some(ExecCommand::Review(_)) => { |
| anyhow::bail!("`--worktree` is not supported for code review") |
| } |
| }, |
| _ => { |
| anyhow::bail!( |
| "`--worktree` supports new interactive sessions, `codex fork`, `codex exec`, and `codex exec fork`" |
| ) |
| } |
| } |
| } |
|
|
| fn reject_root_strict_config_for_subcommand( |
| strict_config: bool, |
| subcommand: &Option<Subcommand>, |
| ) -> anyhow::Result<()> { |
| if !strict_config { |
| return Ok(()); |
| } |
|
|
| match unsupported_subcommand_name_for_strict_config(subcommand) { |
| Some(subcommand_name) => { |
| reject_strict_config_for_unsupported_subcommand(strict_config, subcommand_name) |
| } |
| None => Ok(()), |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| fn unsupported_subcommand_name_for_strict_config( |
| subcommand: &Option<Subcommand>, |
| ) -> Option<&'static str> { |
| match subcommand { |
| None |
| | Some(Subcommand::Agents(_)) |
| | Some(Subcommand::Exec(_)) |
| | Some(Subcommand::Review(_)) |
| | Some(Subcommand::ExecServer(_)) |
| | Some(Subcommand::Resume(_)) |
| | Some(Subcommand::Queue(_)) |
| | Some(Subcommand::Archive(_)) |
| | Some(Subcommand::Delete(_)) |
| | Some(Subcommand::Unarchive(_)) |
| | Some(Subcommand::Fork(_)) |
| | Some(Subcommand::Doctor(_)) => None, |
| Some(Subcommand::AppServer(app_server)) if app_server.subcommand.is_none() => None, |
| Some(Subcommand::AppServer(app_server)) => { |
| Some(app_server_subcommand_name(app_server.subcommand.as_ref())) |
| } |
| Some(Subcommand::RemoteControl(remote_control)) => Some(remote_control.subcommand_name()), |
| Some(Subcommand::Mcp(_)) => Some("mcp"), |
| Some(Subcommand::Plugin(_)) => Some("plugin"), |
| Some(Subcommand::MigrateRollouts(_)) => Some("migrate-rollouts"), |
| #[cfg(any(target_os = "macos", target_os = "windows"))] |
| Some(Subcommand::App(_)) => Some("app"), |
| Some(Subcommand::Login(_)) => Some("login"), |
| Some(Subcommand::Logout(_)) => Some("logout"), |
| Some(Subcommand::Completion(_)) => Some("completion"), |
| Some(Subcommand::Update) => Some("update"), |
| Some(Subcommand::Cloud(_)) => Some("cloud"), |
| Some(Subcommand::Sandbox(_)) => Some("sandbox"), |
| Some(Subcommand::Debug(_)) => Some("debug"), |
| Some(Subcommand::Execpolicy(_)) => Some("execpolicy"), |
| Some(Subcommand::Apply(_)) => Some("apply"), |
| Some(Subcommand::ResponsesApiProxy(_)) => Some("responses-api-proxy"), |
| Some(Subcommand::StdioToUds(_)) => Some("stdio-to-uds"), |
| Some(Subcommand::Features(_)) => Some("features"), |
| Some(Subcommand::TcpTunnel(_)) => Some("tcp-tunnel"), |
| } |
| } |
|
|
| fn reject_strict_config_for_app_server_subcommand( |
| strict_config: bool, |
| subcommand: Option<&AppServerSubcommand>, |
| ) -> anyhow::Result<()> { |
| if subcommand.is_none() { |
| return Ok(()); |
| } |
| reject_strict_config_for_unsupported_subcommand( |
| strict_config, |
| app_server_subcommand_name(subcommand), |
| ) |
| } |
|
|
| fn reject_strict_config_for_unsupported_subcommand( |
| strict_config: bool, |
| subcommand: &str, |
| ) -> anyhow::Result<()> { |
| if strict_config { |
| anyhow::bail!("`--strict-config` is not supported for `codex {subcommand}`"); |
| } |
| Ok(()) |
| } |
|
|
| fn reject_remote_mode_for_app_server_subcommand( |
| remote: Option<&str>, |
| remote_auth_token_env: Option<&str>, |
| subcommand: Option<&AppServerSubcommand>, |
| ) -> anyhow::Result<()> { |
| let subcommand_name = app_server_subcommand_name(subcommand); |
| reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name) |
| } |
|
|
| fn app_server_subcommand_name(subcommand: Option<&AppServerSubcommand>) -> &'static str { |
| match subcommand { |
| None => "app-server", |
| Some(AppServerSubcommand::Daemon(daemon)) => match daemon.subcommand { |
| AppServerDaemonSubcommand::Bootstrap(_) => "app-server daemon bootstrap", |
| AppServerDaemonSubcommand::Start => "app-server daemon start", |
| AppServerDaemonSubcommand::Restart => "app-server daemon restart", |
| AppServerDaemonSubcommand::Update { .. } => "app-server daemon update", |
| AppServerDaemonSubcommand::EnableRemoteControl => { |
| "app-server daemon enable-remote-control" |
| } |
| AppServerDaemonSubcommand::DisableRemoteControl => { |
| "app-server daemon disable-remote-control" |
| } |
| AppServerDaemonSubcommand::Stop => "app-server daemon stop", |
| AppServerDaemonSubcommand::Version => "app-server daemon version", |
| AppServerDaemonSubcommand::PidUpdateLoop { .. } => "app-server daemon pid-update-loop", |
| }, |
| Some(AppServerSubcommand::Proxy(_)) => "app-server proxy", |
| Some(AppServerSubcommand::GenerateTs(_)) => "app-server generate-ts", |
| Some(AppServerSubcommand::GenerateJsonSchema(_)) => "app-server generate-json-schema", |
| Some(AppServerSubcommand::GenerateInternalJsonSchema(_)) => { |
| "app-server generate-internal-json-schema" |
| } |
| } |
| } |
|
|
| async fn print_app_server_daemon_output(command: AppServerLifecycleCommand) -> anyhow::Result<()> { |
| let output = codex_app_server_daemon::run(command).await?; |
| println!("{}", serde_json::to_string(&output)?); |
| Ok(()) |
| } |
|
|
| fn updater_http_client_factory( |
| config: anyhow::Result<codex_core::config::Config>, |
| ) -> codex_http_client::HttpClientFactory { |
| match config { |
| Ok(config) => config.http_client_factory(), |
| Err(error) => { |
| eprintln!("warning: failed to load updater network configuration: {error}"); |
| codex_http_client::HttpClientFactory::new( |
| codex_http_client::OutboundProxyPolicy::ReqwestDefault, |
| ) |
| } |
| } |
| } |
|
|
| async fn print_app_server_remote_control_output( |
| mode: AppServerRemoteControlMode, |
| ) -> anyhow::Result<()> { |
| let output = codex_app_server_daemon::set_remote_control(mode).await?; |
| println!("{}", serde_json::to_string(&output)?); |
| Ok(()) |
| } |
|
|
| fn read_remote_auth_token_from_env_var_with<F>( |
| env_var_name: &str, |
| get_var: F, |
| ) -> anyhow::Result<String> |
| where |
| F: FnOnce(&str) -> Result<String, std::env::VarError>, |
| { |
| let auth_token = get_var(env_var_name) |
| .map_err(|_| anyhow::anyhow!("environment variable `{env_var_name}` is not set"))?; |
| let auth_token = auth_token.trim().to_string(); |
| if auth_token.is_empty() { |
| anyhow::bail!("environment variable `{env_var_name}` is empty"); |
| } |
| Ok(auth_token) |
| } |
|
|
| fn read_remote_auth_token_from_env_var(env_var_name: &str) -> anyhow::Result<String> { |
| read_remote_auth_token_from_env_var_with(env_var_name, |name| std::env::var(name)) |
| } |
|
|
| async fn run_interactive_tui( |
| mut interactive: TuiCli, |
| remote: Option<String>, |
| remote_auth_token_env: Option<String>, |
| arg0_paths: Arg0DispatchPaths, |
| ) -> std::io::Result<AppExitInfo> { |
| if let Some(prompt) = interactive.prompt.take() { |
| |
| interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n")); |
| } |
|
|
| let terminal_info = codex_terminal_detection::terminal_info(); |
| if terminal_info.name == TerminalName::Dumb { |
| if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) { |
| return Ok(AppExitInfo::fatal( |
| "TERM is set to \"dumb\". Refusing to start the interactive TUI because no terminal is available for a confirmation prompt (stdin/stderr is not a TTY). Run in a supported terminal or unset TERM.", |
| )); |
| } |
|
|
| eprintln!( |
| "WARNING: TERM is set to \"dumb\". Codex's interactive TUI may not work in this terminal." |
| ); |
| if !confirm("Continue anyway? [y/N]: ")? { |
| return Ok(AppExitInfo::fatal( |
| "Refusing to start the interactive TUI because TERM is set to \"dumb\". Run in a supported terminal or unset TERM.", |
| )); |
| } |
| } |
|
|
| #[cfg(any(unix, windows))] |
| if interactive.agents_overview && remote.is_none() { |
| if !std::io::stdin().is_terminal() { |
| return Ok(AppExitInfo::fatal("stdin is not a terminal")); |
| } |
| if !std::io::stdout().is_terminal() { |
| return Ok(AppExitInfo::fatal("stdout is not a terminal")); |
| } |
| cloud_config::load_config(&interactive.config_overrides, LoaderOverrides::default()) |
| .await |
| .map_err(std::io::Error::other)?; |
| codex_app_server_daemon::run(AppServerLifecycleCommand::Start) |
| .await |
| .map_err(std::io::Error::other)?; |
| } |
|
|
| let remote_endpoint = match resolve_remote_endpoint(remote, remote_auth_token_env.clone()) { |
| Ok(remote_endpoint) => remote_endpoint, |
| Err(err) if is_remote_auth_usage_error(&err) => { |
| return Ok(AppExitInfo::fatal(err.to_string())); |
| } |
| Err(err) => return Err(err), |
| }; |
| let start_tui = || { |
| codex_tui::run_main( |
| interactive.clone(), |
| arg0_paths.clone(), |
| codex_config::LoaderOverrides::default(), |
| remote_endpoint.clone(), |
| ) |
| }; |
| run_tui_with_recovery(start_tui, remote_auth_token_env.as_deref()).await |
| } |
|
|
| async fn run_tui_with_recovery<F, Fut>( |
| mut start_tui: F, |
| remote_auth_token_env: Option<&str>, |
| ) -> std::io::Result<AppExitInfo> |
| where |
| F: FnMut() -> Fut, |
| Fut: std::future::Future<Output = std::io::Result<AppExitInfo>>, |
| { |
| let mut attempted_backups = HashSet::new(); |
| loop { |
| |
| let err = match Box::pin(start_tui()).await { |
| Ok(mut exit_info) => { |
| if let Some(disconnect) = &mut exit_info.disconnect_info |
| && let Some(env_var) = remote_auth_token_env |
| { |
| disconnect |
| .command |
| .extend(["--remote-auth-token-env".to_string(), env_var.to_string()]); |
| } |
| return Ok(exit_info); |
| } |
| Err(err) => err, |
| }; |
| let Some(startup_error) = local_state_db::startup_error(&err) else { |
| return Err(err); |
| }; |
| if local_state_db::is_locked(startup_error.detail()) { |
| local_state_db::print_locked_guidance(startup_error); |
| return Ok(AppExitInfo::fatal(startup_error.to_string())); |
| } |
| if !local_state_db::is_auto_backup_recoverable(startup_error) { |
| local_state_db::print_diagnostic_guidance(startup_error); |
| return Ok(AppExitInfo::fatal(startup_error.to_string())); |
| } |
| if !attempted_backups.insert(startup_error.database_path().to_path_buf()) { |
| local_state_db::print_diagnostic_guidance(startup_error); |
| return Ok(AppExitInfo::fatal(startup_error.to_string())); |
| } |
|
|
| local_state_db::print_auto_backup_start(startup_error); |
| match local_state_db::backup_files_for_fresh_start(startup_error).await { |
| Ok(backups) => local_state_db::confirm_fresh_start_rebuild(startup_error, &backups)?, |
| Err(backup_err) => { |
| local_state_db::print_diagnostic_guidance(startup_error); |
| return Ok(AppExitInfo::fatal(format!( |
| "failed to move damaged Codex local database files into a backup folder automatically: {backup_err}" |
| ))); |
| } |
| } |
| } |
| } |
|
|
| fn resolve_remote_endpoint( |
| remote: Option<String>, |
| remote_auth_token_env: Option<String>, |
| ) -> std::io::Result<Option<codex_tui::RemoteAppServerEndpoint>> { |
| let mut remote_endpoint = remote |
| .as_deref() |
| .map(codex_tui::resolve_remote_addr) |
| .transpose() |
| .map_err(std::io::Error::other)?; |
| if let Some(remote_auth_token_env) = remote_auth_token_env { |
| let Some(endpoint) = remote_endpoint.as_mut() else { |
| return Err(std::io::Error::other( |
| "`--remote-auth-token-env` requires `--remote`.", |
| )); |
| }; |
| if !codex_tui::remote_addr_supports_auth_token(endpoint) { |
| return Err(std::io::Error::other( |
| "`--remote-auth-token-env` requires a `wss://` or loopback `ws://` remote.", |
| )); |
| } |
| let auth_token = read_remote_auth_token_from_env_var(&remote_auth_token_env) |
| .map_err(std::io::Error::other)?; |
| let codex_tui::RemoteAppServerEndpoint::WebSocket { |
| auth_token: slot, .. |
| } = endpoint |
| else { |
| return Err(std::io::Error::other( |
| "`--remote-auth-token-env` requires a `wss://` or loopback `ws://` remote.", |
| )); |
| }; |
| *slot = Some(auth_token); |
| } |
| Ok(remote_endpoint) |
| } |
|
|
| fn is_remote_auth_usage_error(err: &std::io::Error) -> bool { |
| err.to_string() |
| .starts_with("`--remote-auth-token-env` requires") |
| } |
|
|
| fn confirm(prompt: &str) -> std::io::Result<bool> { |
| eprintln!("{prompt}"); |
|
|
| let mut input = String::new(); |
| std::io::stdin().read_line(&mut input)?; |
| let answer = input.trim(); |
| Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes")) |
| } |
|
|
| |
| fn finalize_resume_interactive( |
| mut interactive: TuiCli, |
| root_config_overrides: CliConfigOverrides, |
| session_id: Option<String>, |
| last: bool, |
| show_all: bool, |
| include_non_interactive: bool, |
| mut resume_cli: TuiCli, |
| ) -> TuiCli { |
| |
| |
| |
| |
| let resume_session_id = if last && resume_cli.prompt.is_none() { |
| resume_cli.prompt = session_id; |
| None |
| } else { |
| session_id |
| }; |
| interactive.resume_picker = resume_session_id.is_none() && !last; |
| interactive.resume_last = last; |
| interactive.resume_session_id = resume_session_id; |
| interactive.resume_show_all = show_all; |
| interactive.resume_include_non_interactive = include_non_interactive; |
|
|
| |
| merge_interactive_cli_flags(&mut interactive, resume_cli); |
|
|
| |
| prepend_config_flags(&mut interactive.config_overrides, root_config_overrides); |
|
|
| interactive |
| } |
|
|
| |
| fn finalize_fork_interactive( |
| mut interactive: TuiCli, |
| root_config_overrides: CliConfigOverrides, |
| session_id: Option<String>, |
| last: bool, |
| show_all: bool, |
| mut fork_cli: TuiCli, |
| ) -> TuiCli { |
| |
| |
| |
| |
| let fork_session_id = if last && fork_cli.prompt.is_none() { |
| fork_cli.prompt = session_id; |
| None |
| } else { |
| session_id |
| }; |
| interactive.fork_picker = fork_session_id.is_none() && !last; |
| interactive.fork_last = last; |
| interactive.fork_session_id = fork_session_id; |
| interactive.fork_show_all = show_all; |
|
|
| |
| merge_interactive_cli_flags(&mut interactive, fork_cli); |
|
|
| |
| prepend_config_flags(&mut interactive.config_overrides, root_config_overrides); |
|
|
| interactive |
| } |
|
|
| fn finalize_session_archive_interactive( |
| mut interactive: TuiCli, |
| root_config_overrides: CliConfigOverrides, |
| archive_cli: SessionArchiveConfigOverrides, |
| ) -> TuiCli { |
| let SessionArchiveConfigOverrides { |
| shared, |
| strict_config, |
| config_overrides, |
| } = archive_cli; |
| interactive.shared.apply_subcommand_overrides(shared); |
| if strict_config { |
| interactive.strict_config = true; |
| } |
| interactive |
| .config_overrides |
| .raw_overrides |
| .extend(config_overrides.raw_overrides); |
| prepend_config_flags(&mut interactive.config_overrides, root_config_overrides); |
| interactive |
| } |
|
|
| |
| |
| |
| fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) { |
| let TuiCli { |
| shared, |
| strict_config, |
| approval_policy, |
| web_search, |
| no_alt_screen, |
| prompt, |
| mut config_overrides, |
| .. |
| } = subcommand_cli; |
| let subcommand_auto_review = shared.auto_review; |
| interactive |
| .shared |
| .apply_subcommand_overrides(shared.into_inner()); |
| interactive |
| .shared |
| .take_auto_review_config_overrides(&mut config_overrides); |
| if subcommand_auto_review { |
| interactive.approval_policy = None; |
| } else if let Some(approval) = approval_policy { |
| interactive.approval_policy = Some(approval); |
| } |
| if web_search { |
| interactive.web_search = true; |
| } |
| interactive.no_alt_screen |= no_alt_screen; |
| if strict_config { |
| interactive.strict_config = true; |
| } |
| if let Some(prompt) = prompt { |
| |
| interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n")); |
| } |
|
|
| interactive |
| .config_overrides |
| .raw_overrides |
| .extend(config_overrides.raw_overrides); |
| } |
|
|
| fn print_completion(cmd: CompletionCommand) { |
| let mut app = MultitoolCli::command(); |
| let name = "codex"; |
| generate(cmd.shell, &mut app, name, &mut std::io::stdout()); |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| use assert_matches::assert_matches; |
| use codex_protocol::ThreadId; |
| use codex_tui::TokenUsage; |
| use pretty_assertions::assert_eq; |
|
|
| #[test] |
| fn interactive_tui_future_stays_bounded() { |
| let future = run_interactive_tui( |
| TuiCli::parse_from(["codex"]), |
| None, |
| None, |
| Arg0DispatchPaths::default(), |
| ); |
| let size = std::mem::size_of_val(&future); |
|
|
| assert!(size < 64 * 1024, "interactive TUI future is {size} bytes"); |
| } |
|
|
| #[cfg(windows)] |
| #[test] |
| fn windows_update_command_resolution_ignores_relative_path_entries() { |
| let cwd = std::env::current_dir().expect("current directory"); |
| let decoy_dir = tempfile::tempdir_in(&cwd).expect("relative decoy directory"); |
| let trusted_dir = tempfile::tempdir().expect("trusted PATH directory"); |
| let relative_decoy_dir = decoy_dir |
| .path() |
| .strip_prefix(&cwd) |
| .expect("decoy directory should be relative to cwd"); |
|
|
| for command in ["npm.cmd", "pnpm.cmd", "bun.exe"] { |
| std::fs::write(decoy_dir.path().join(command), "decoy") |
| .expect("write cwd-relative decoy"); |
| std::fs::write(trusted_dir.path().join(command), "trusted") |
| .expect("write trusted PATH command"); |
| let path_env = std::env::join_paths([relative_decoy_dir, trusted_dir.path()]) |
| .expect("join synthetic PATH"); |
|
|
| let resolved = resolve_windows_update_command_from_path(command, &path_env) |
| .expect("resolve update command"); |
|
|
| assert_eq!(resolved, trusted_dir.path().join(command)); |
| } |
|
|
| let cwd_decoy = tempfile::Builder::new() |
| .suffix(".cmd") |
| .tempfile_in(&cwd) |
| .expect("cwd-local decoy"); |
| let command = cwd_decoy |
| .path() |
| .file_name() |
| .and_then(|name| name.to_str()) |
| .expect("decoy filename"); |
| let relative_only_path_env = std::env::join_paths(["."]).expect("join relative-only PATH"); |
| let err = resolve_windows_update_command_from_path(command, &relative_only_path_env) |
| .expect_err("relative-only PATH should not resolve a cwd command"); |
|
|
| assert_eq!( |
| err.to_string(), |
| format!( |
| "Could not find an absolute update command `{command}` on PATH. Please update manually: https://developers.openai.com/codex/cli/" |
| ) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn updater_http_client_factory_honors_respect_system_proxy() { |
| let codex_home = tempfile::tempdir().expect("temporary Codex home"); |
| let config = ConfigBuilder::default() |
| .codex_home(codex_home.path().to_path_buf()) |
| .cli_overrides(vec![( |
| "features.respect_system_proxy".to_string(), |
| toml::Value::Boolean(true), |
| )]) |
| .build() |
| .await |
| .expect("config should load"); |
|
|
| assert_eq!( |
| updater_http_client_factory(Ok(config)).outbound_proxy_policy(), |
| codex_http_client::OutboundProxyPolicy::RespectSystemProxy |
| ); |
| } |
|
|
| #[test] |
| fn updater_http_client_factory_falls_back_when_config_load_fails() { |
| assert_eq!( |
| updater_http_client_factory(Err(anyhow::anyhow!("invalid config"))) |
| .outbound_proxy_policy(), |
| codex_http_client::OutboundProxyPolicy::ReqwestDefault |
| ); |
| } |
|
|
| #[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 { |
| mut interactive, |
| config_overrides: mut root_overrides, |
| subcommand, |
| feature_toggles: _, |
| remote: _, |
| } = cli; |
| interactive |
| .shared |
| .take_auto_review_config_overrides(&mut root_overrides); |
|
|
| let Subcommand::Resume(ResumeCommand { |
| session_id, |
| last, |
| all, |
| include_non_interactive, |
| remote: _, |
| config_overrides: resume_cli, |
| }) = subcommand.expect("resume present") |
| else { |
| unreachable!() |
| }; |
| let SessionTuiCli(resume_cli) = resume_cli; |
|
|
| finalize_resume_interactive( |
| interactive, |
| root_overrides, |
| session_id, |
| last, |
| all, |
| include_non_interactive, |
| resume_cli, |
| ) |
| } |
|
|
| fn finalize_fork_from_args(args: &[&str]) -> TuiCli { |
| let cli = MultitoolCli::try_parse_from(args).expect("parse"); |
| let MultitoolCli { |
| mut interactive, |
| config_overrides: mut root_overrides, |
| subcommand, |
| feature_toggles: _, |
| remote: _, |
| } = cli; |
| interactive |
| .shared |
| .take_auto_review_config_overrides(&mut root_overrides); |
|
|
| let Subcommand::Fork(ForkCommand { |
| session_id, |
| last, |
| all, |
| remote: _, |
| config_overrides: fork_cli, |
| }) = subcommand.expect("fork present") |
| else { |
| unreachable!() |
| }; |
| let SessionTuiCli(fork_cli) = fork_cli; |
|
|
| finalize_fork_interactive(interactive, root_overrides, session_id, last, all, fork_cli) |
| } |
|
|
| fn finalize_exec_from_args(args: &[&str]) -> ExecCli { |
| let mut cli = MultitoolCli::try_parse_from(args).expect("parse"); |
| cli.interactive |
| .shared |
| .take_auto_review_config_overrides(&mut cli.config_overrides); |
| let Some(Subcommand::Exec(mut exec)) = cli.subcommand else { |
| panic!("expected exec subcommand"); |
| }; |
| exec.shared |
| .inherit_exec_root_options(&cli.interactive.shared); |
| prepend_config_flags(&mut exec.config_overrides, cli.config_overrides); |
| exec.shared |
| .take_auto_review_config_overrides(&mut exec.config_overrides); |
| exec |
| } |
|
|
| fn finalize_archive_from_args(args: &[&str]) -> (String, TuiCli, InteractiveRemoteOptions) { |
| let cli = MultitoolCli::try_parse_from(args).expect("parse"); |
| let MultitoolCli { |
| interactive, |
| config_overrides: root_overrides, |
| subcommand, |
| feature_toggles: _, |
| remote: _, |
| } = cli; |
|
|
| let Subcommand::Archive(SessionArchiveCommand { |
| target, |
| remote, |
| config_overrides: archive_cli, |
| }) = subcommand.expect("archive present") |
| else { |
| unreachable!() |
| }; |
|
|
| ( |
| target, |
| finalize_session_archive_interactive(interactive, root_overrides, archive_cli), |
| remote, |
| ) |
| } |
|
|
| fn profile_v2_for_args(args: &[&str]) -> anyhow::Result<Option<String>> { |
| let cli = MultitoolCli::try_parse_from(args).expect("parse"); |
| let Some(subcommand) = cli.subcommand.as_ref() else { |
| return Ok(cli |
| .interactive |
| .config_profile_v2 |
| .as_ref() |
| .map(std::string::ToString::to_string)); |
| }; |
| Ok(profile_v2_for_subcommand(&cli.interactive, subcommand)?.map(ToString::to_string)) |
| } |
|
|
| #[test] |
| fn profile_loader_overrides_use_explicit_codex_home() -> anyhow::Result<()> { |
| let codex_home = tempfile::tempdir()?; |
| let profile: ProfileV2Name = "work".parse()?; |
|
|
| let overrides = |
| loader_overrides_for_profile_at_codex_home(Some(&profile), codex_home.path()); |
|
|
| assert_eq!( |
| overrides.user_config_path, |
| Some(resolve_profile_v2_config_path(codex_home.path(), &profile)) |
| ); |
| assert_eq!(overrides.user_config_profile, Some(profile)); |
| Ok(()) |
| } |
|
|
| #[test] |
| fn profile_v2_is_rejected_for_config_management_subcommands() { |
| assert!(profile_v2_for_args(&["codex", "--profile", "work", "features", "list"]).is_err()); |
| } |
|
|
| #[test] |
| fn profile_v2_is_allowed_for_runtime_subcommands() { |
| assert_eq!( |
| profile_v2_for_args(&["codex", "--profile", "work", "resume"]) |
| .expect("resume supports profile-v2") |
| .as_deref(), |
| Some("work") |
| ); |
| assert_eq!( |
| profile_v2_for_args(&["codex", "--profile", "work", "debug", "prompt-input"]) |
| .expect("debug prompt-input supports profile-v2") |
| .as_deref(), |
| Some("work") |
| ); |
| assert_eq!( |
| profile_v2_for_args(&["codex", "--profile", "work", "mcp", "list"]) |
| .expect("mcp supports profile-v2") |
| .as_deref(), |
| Some("work") |
| ); |
| assert_eq!( |
| profile_v2_for_args(&["codex", "--profile", "work", "sandbox"]) |
| .expect("sandbox supports config profile") |
| .as_deref(), |
| Some("work") |
| ); |
| } |
|
|
| #[test] |
| fn import_remains_an_interactive_prompt() { |
| let cli = MultitoolCli::try_parse_from(["codex", "import"]).expect("parse"); |
|
|
| assert!(cli.subcommand.is_none()); |
| assert_eq!(cli.interactive.prompt.as_deref(), Some("import")); |
| } |
|
|
| #[test] |
| fn profile_v2_rejects_non_plain_names_at_parse_time() { |
| assert!( |
| MultitoolCli::try_parse_from(["codex", "--profile", "nested/work", "resume"]).is_err() |
| ); |
| } |
|
|
| #[test] |
| fn worktree_flag_supports_interactive_exec_and_explicit_fork_positions() { |
| let arguments = [ |
| vec!["codex", "--worktree"], |
| vec!["codex", "--worktree", "hello"], |
| vec!["codex", "--worktree", "exec", "hello"], |
| vec!["codex", "exec", "--worktree", "hello"], |
| vec![ |
| "codex", |
| "fork", |
| "--worktree", |
| "019f1234-5678-7000-8000-000000000001", |
| ], |
| vec![ |
| "codex", |
| "exec", |
| "fork", |
| "--worktree", |
| "019f1234-5678-7000-8000-000000000001", |
| ], |
| ]; |
|
|
| for arguments in arguments { |
| let cli = MultitoolCli::try_parse_from(&arguments).expect("parse worktree command"); |
| assert!( |
| reject_unsupported_worktree_for_subcommand( |
| cli.interactive.shared.worktree, |
| &cli.subcommand, |
| ) |
| .is_ok(), |
| "supported worktree command should be accepted: {arguments:?}", |
| ); |
| } |
| } |
|
|
| #[test] |
| fn worktree_flag_rejects_unsupported_session_and_management_commands() { |
| let arguments = [ |
| vec!["codex", "--worktree", "login"], |
| vec!["codex", "fork", "--worktree"], |
| vec!["codex", "fork", "--worktree", "--last"], |
| vec!["codex", "exec", "resume", "--worktree", "session"], |
| vec!["codex", "exec", "review", "--worktree"], |
| vec!["codex", "resume", "--worktree", "session"], |
| vec!["codex", "archive", "session", "--worktree"], |
| vec![ |
| "codex", |
| "queue", |
| "--thread", |
| "session", |
| "--message", |
| "hi", |
| "--worktree", |
| ], |
| ]; |
|
|
| let mut errors = Vec::new(); |
| for arguments in arguments { |
| let cli = MultitoolCli::try_parse_from(&arguments).expect("parse shared worktree flag"); |
| let error = reject_unsupported_worktree_for_subcommand( |
| cli.interactive.shared.worktree, |
| &cli.subcommand, |
| ) |
| .expect_err("unsupported worktree command"); |
| errors.push(format!("{}: {error}", arguments.join(" "))); |
| } |
| insta::assert_snapshot!("unsupported_worktree_commands", errors.join("\n")); |
| } |
|
|
| #[test] |
| fn exec_resume_last_accepts_prompt_positional() { |
| let cli = |
| MultitoolCli::try_parse_from(["codex", "exec", "--json", "resume", "--last", "2+2"]) |
| .expect("parse should succeed"); |
|
|
| let Some(Subcommand::Exec(exec)) = cli.subcommand else { |
| panic!("expected exec subcommand"); |
| }; |
| let Some(codex_exec::Command::Resume(args)) = exec.command else { |
| panic!("expected exec resume"); |
| }; |
|
|
| assert!(args.last); |
| assert_eq!(args.session_id, None); |
| assert_eq!(args.prompt.as_deref(), Some("2+2")); |
| } |
|
|
| #[test] |
| fn exec_resume_accepts_output_flags_after_subcommand() { |
| let cli = MultitoolCli::try_parse_from([ |
| "codex", |
| "exec", |
| "resume", |
| "session-123", |
| "-o", |
| "/tmp/resume-output.md", |
| "--output-schema", |
| "/tmp/schema.json", |
| "re-review", |
| ]) |
| .expect("parse should succeed"); |
|
|
| let Some(Subcommand::Exec(exec)) = cli.subcommand else { |
| panic!("expected exec subcommand"); |
| }; |
| let Some(codex_exec::Command::Resume(args)) = exec.command else { |
| panic!("expected exec resume"); |
| }; |
|
|
| assert_eq!( |
| exec.last_message_file, |
| Some(std::path::PathBuf::from("/tmp/resume-output.md")) |
| ); |
| assert_eq!( |
| exec.output_schema, |
| Some(std::path::PathBuf::from("/tmp/schema.json")) |
| ); |
| assert_eq!(args.session_id.as_deref(), Some("session-123")); |
| assert_eq!(args.prompt.as_deref(), Some("re-review")); |
| } |
|
|
| #[test] |
| fn dangerous_bypass_conflicts_with_approval_policy() { |
| let err = MultitoolCli::try_parse_from([ |
| "codex", |
| "--dangerously-bypass-approvals-and-sandbox", |
| "--ask-for-approval", |
| "on-request", |
| ]) |
| .expect_err("conflicting permission flags should be rejected"); |
|
|
| assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); |
| } |
|
|
| #[test] |
| fn approve_for_me_configures_interactive_mode() { |
| for flag in ["--approve-for-me", "--not-so-yolo"] { |
| let mut cli = MultitoolCli::try_parse_from(["codex", flag]).expect("parse flag"); |
|
|
| assert!(cli.interactive.auto_review); |
| cli.interactive |
| .shared |
| .take_auto_review_config_overrides(&mut cli.interactive.config_overrides); |
| assert_eq!( |
| cli.interactive.config_overrides.raw_overrides, |
| vec![ |
| r#"approvals_reviewer="auto_review""#.to_string(), |
| r#"approval_policy="on-request""#.to_string(), |
| r#"sandbox_mode="workspace-write""#.to_string(), |
| ] |
| ); |
| assert!(!cli.interactive.auto_review); |
| } |
| } |
|
|
| #[test] |
| fn not_so_yolo_alias_is_hidden_from_help() { |
| for args in [&["codex", "--help"][..], &["codex", "exec", "--help"][..]] { |
| let help = help_from_args(args); |
|
|
| assert!(!help.contains("--not-so-yolo"), "{help}"); |
| } |
| } |
|
|
| #[test] |
| fn approve_for_me_defaults_propagate_from_root_to_exec() { |
| let exec = finalize_exec_from_args(&["codex", "--approve-for-me", "exec", "summarize"]); |
|
|
| assert_eq!( |
| exec.config_overrides.raw_overrides, |
| vec![ |
| r#"approvals_reviewer="auto_review""#.to_string(), |
| r#"approval_policy="on-request""#.to_string(), |
| r#"sandbox_mode="workspace-write""#.to_string(), |
| ] |
| ); |
| assert!(exec.sandbox_mode.is_none()); |
| } |
|
|
| #[test] |
| fn later_exec_sandbox_partially_overrides_approve_for_me() { |
| let exec = finalize_exec_from_args(&[ |
| "codex", |
| "--approve-for-me", |
| "exec", |
| "--sandbox", |
| "read-only", |
| ]); |
|
|
| assert_matches!( |
| exec.sandbox_mode, |
| Some(codex_utils_cli::SandboxModeCliArg::ReadOnly) |
| ); |
| assert_eq!( |
| exec.config_overrides.raw_overrides, |
| vec![ |
| r#"approvals_reviewer="auto_review""#.to_string(), |
| r#"approval_policy="on-request""#.to_string(), |
| r#"sandbox_mode="workspace-write""#.to_string(), |
| ] |
| ); |
| } |
|
|
| #[test] |
| fn later_approve_for_me_overrides_root_exec_sandbox() { |
| let exec = finalize_exec_from_args(&[ |
| "codex", |
| "--sandbox", |
| "read-only", |
| "exec", |
| "--approve-for-me", |
| ]); |
|
|
| assert!(exec.sandbox_mode.is_none()); |
| assert_eq!( |
| exec.config_overrides.raw_overrides, |
| vec![ |
| r#"approvals_reviewer="auto_review""#.to_string(), |
| r#"approval_policy="on-request""#.to_string(), |
| r#"sandbox_mode="workspace-write""#.to_string(), |
| ] |
| ); |
| } |
|
|
| #[test] |
| fn later_resume_approval_policy_partially_overrides_approve_for_me() { |
| let interactive = finalize_resume_from_args(&[ |
| "codex", |
| "--approve-for-me", |
| "resume", |
| "--ask-for-approval", |
| "never", |
| ]); |
|
|
| assert_matches!( |
| interactive.approval_policy, |
| Some(codex_utils_cli::ApprovalModeCliArg::Never) |
| ); |
| assert_eq!( |
| interactive.config_overrides.raw_overrides, |
| vec![ |
| r#"approvals_reviewer="auto_review""#.to_string(), |
| r#"approval_policy="on-request""#.to_string(), |
| r#"sandbox_mode="workspace-write""#.to_string(), |
| ] |
| ); |
| } |
|
|
| #[test] |
| fn later_approve_for_me_overrides_root_tui_approval_policy() { |
| let interactive = finalize_resume_from_args(&[ |
| "codex", |
| "--ask-for-approval", |
| "never", |
| "resume", |
| "--approve-for-me", |
| ]); |
|
|
| assert!(interactive.approval_policy.is_none()); |
| assert_eq!( |
| interactive.config_overrides.raw_overrides, |
| vec![ |
| r#"approvals_reviewer="auto_review""#.to_string(), |
| r#"approval_policy="on-request""#.to_string(), |
| r#"sandbox_mode="workspace-write""#.to_string(), |
| ] |
| ); |
| } |
|
|
| #[test] |
| fn approve_for_me_conflicts_with_explicit_interactive_permissions() { |
| for conflicting_args in [ |
| vec!["--sandbox", "read-only"], |
| vec!["--ask-for-approval", "on-request"], |
| vec!["--dangerously-bypass-approvals-and-sandbox"], |
| ] { |
| let mut args = vec!["codex", "--approve-for-me"]; |
| args.extend(conflicting_args); |
|
|
| let error = |
| MultitoolCli::try_parse_from(args).expect_err("permission flags should conflict"); |
| assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); |
| } |
| } |
|
|
| fn app_server_from_args(args: &[&str]) -> AppServerCommand { |
| let cli = MultitoolCli::try_parse_from(args).expect("parse"); |
| let Subcommand::AppServer(app_server) = cli.subcommand.expect("app-server present") else { |
| unreachable!() |
| }; |
| app_server |
| } |
|
|
| fn default_app_server_socket_path() -> AbsolutePathBuf { |
| let codex_home = find_codex_home().expect("codex home"); |
| codex_app_server::app_server_control_socket_path(&codex_home) |
| .expect("default app-server socket path") |
| } |
|
|
| #[test] |
| fn debug_prompt_input_parses_prompt_and_images() { |
| let cli = MultitoolCli::try_parse_from([ |
| "codex", |
| "debug", |
| "prompt-input", |
| "hello", |
| "--image", |
| "/tmp/a.png,/tmp/b.png", |
| ]) |
| .expect("parse"); |
|
|
| let Some(Subcommand::Debug(DebugCommand { |
| subcommand: DebugSubcommand::PromptInput(cmd), |
| })) = cli.subcommand |
| else { |
| panic!("expected debug prompt-input subcommand"); |
| }; |
|
|
| assert_eq!(cmd.prompt.as_deref(), Some("hello")); |
| assert_eq!( |
| cmd.images, |
| vec![PathBuf::from("/tmp/a.png"), PathBuf::from("/tmp/b.png")] |
| ); |
| } |
|
|
| #[test] |
| fn debug_models_parses_bundled_flag() { |
| let cli = |
| MultitoolCli::try_parse_from(["codex", "debug", "models", "--bundled"]).expect("parse"); |
|
|
| let Some(Subcommand::Debug(DebugCommand { |
| subcommand: DebugSubcommand::Models(cmd), |
| })) = cli.subcommand |
| else { |
| panic!("expected debug models subcommand"); |
| }; |
|
|
| assert!(cmd.bundled); |
| } |
|
|
| #[test] |
| fn responses_subcommand_is_not_registered() { |
| let command = MultitoolCli::command(); |
| assert!( |
| command |
| .get_subcommands() |
| .all(|subcommand| subcommand.get_name() != "responses") |
| ); |
| } |
|
|
| fn help_from_args(args: &[&str]) -> String { |
| let err = MultitoolCli::try_parse_from(args).expect_err("help should short-circuit"); |
| assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp); |
| err.to_string() |
| } |
|
|
| #[test] |
| fn tcp_tunnel_is_hidden_and_accepts_its_control_input_contract() { |
| let root_help = help_from_args(&["codex", "--help"]); |
| assert!(!root_help.contains("tcp-tunnel"), "{root_help}"); |
| let help = help_from_args(&["codex", "tcp-tunnel", "--help"]); |
| for option in [ |
| "--target", |
| "--auth-token-stdin", |
| "--auth-token-updates-stdin", |
| "--connect-headers-stdin", |
| ] { |
| assert!(help.contains(option), "{help}"); |
| } |
| let cli = MultitoolCli::try_parse_from([ |
| "codex", |
| "tcp-tunnel", |
| "--proxy-url", |
| "https://proxy.example.org", |
| "--proxy-origins-file", |
| "/tmp/approved-origins", |
| "--target", |
| "[::1]:22", |
| "--auth-token-stdin", |
| "--auth-token-updates-stdin", |
| "--connect-headers-stdin", |
| ]) |
| .expect("generic tunnel should parse"); |
| assert!(matches!(cli.subcommand, Some(Subcommand::TcpTunnel(_)))); |
| } |
|
|
| #[test] |
| fn plugin_marketplace_help_uses_plugin_namespace() { |
| let help = help_from_args(&["codex", "plugin", "marketplace", "--help"]); |
| assert!( |
| help.contains("Usage: codex plugin marketplace [OPTIONS] <COMMAND>"), |
| "{help}" |
| ); |
|
|
| for (subcommand, usage) in [ |
| ("add", "Usage: codex plugin marketplace add"), |
| ("list", "Usage: codex plugin marketplace list"), |
| ("upgrade", "Usage: codex plugin marketplace upgrade"), |
| ("remove", "Usage: codex plugin marketplace remove"), |
| ] { |
| let help = help_from_args(&["codex", "plugin", "marketplace", subcommand, "--help"]); |
| assert!(help.contains(usage), "{help}"); |
| } |
| } |
|
|
| #[test] |
| fn plugin_marketplace_add_parses_under_plugin() { |
| let cli = |
| MultitoolCli::try_parse_from(["codex", "plugin", "marketplace", "add", "owner/repo"]) |
| .expect("parse"); |
|
|
| assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_)))); |
| } |
|
|
| #[test] |
| fn plugin_marketplace_upgrade_parses_under_plugin() { |
| let cli = |
| MultitoolCli::try_parse_from(["codex", "plugin", "marketplace", "upgrade", "debug"]) |
| .expect("parse"); |
|
|
| assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_)))); |
| } |
|
|
| #[test] |
| fn plugin_add_parses_under_plugin() { |
| let cli = MultitoolCli::try_parse_from([ |
| "codex", |
| "plugin", |
| "add", |
| "sample", |
| "--marketplace", |
| "debug", |
| ]) |
| .expect("parse"); |
|
|
| assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_)))); |
| } |
|
|
| #[test] |
| fn plugin_list_parses_under_plugin() { |
| let cli = |
| MultitoolCli::try_parse_from(["codex", "plugin", "list", "--marketplace", "debug"]) |
| .expect("parse"); |
|
|
| assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_)))); |
| } |
|
|
| #[test] |
| fn plugin_remove_parses_under_plugin() { |
| let cli = MultitoolCli::try_parse_from([ |
| "codex", |
| "plugin", |
| "remove", |
| "sample", |
| "--marketplace", |
| "debug", |
| ]) |
| .expect("parse"); |
|
|
| assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_)))); |
| } |
|
|
| #[test] |
| fn update_parses_as_update_subcommand() { |
| let cli = MultitoolCli::try_parse_from(["codex", "update"]).expect("parse"); |
| assert!(matches!(cli.subcommand, Some(Subcommand::Update))); |
| } |
|
|
| #[test] |
| fn archive_merges_scoped_tui_flags() { |
| let (target, interactive, remote) = finalize_archive_from_args( |
| [ |
| "codex", |
| "-C", |
| "/root", |
| "archive", |
| "--remote", |
| "unix://archive.sock", |
| "--strict-config", |
| "--dangerously-bypass-hook-trust", |
| "-m", |
| "gpt-5.1-test", |
| "-p", |
| "work", |
| "-C", |
| "/archive", |
| "my-thread", |
| ] |
| .as_ref(), |
| ); |
|
|
| assert_eq!(target, "my-thread"); |
| assert_eq!(remote.remote.as_deref(), Some("unix://archive.sock")); |
| assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test")); |
| assert_eq!(interactive.config_profile_v2.as_deref(), Some("work")); |
| assert_eq!( |
| interactive.cwd.as_deref(), |
| Some(std::path::Path::new("/archive")) |
| ); |
| assert!(interactive.strict_config); |
| assert!(interactive.bypass_hook_trust); |
| } |
|
|
| #[test] |
| fn delete_force_requires_uuid() { |
| assert!(delete_action("123e4567-e89b-12d3-a456-426614174000", true).is_ok()); |
|
|
| let err = |
| delete_action("my-thread", true).expect_err("name should require prompt"); |
| assert_eq!( |
| err.to_string(), |
| "--force requires a session UUID; names must be confirmed interactively" |
| ); |
| } |
|
|
| #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] |
| #[test] |
| fn sandbox_parses_permission_profile() { |
| let cli = MultitoolCli::try_parse_from([ |
| "codex", |
| "sandbox", |
| "--permission-profile", |
| ":workspace", |
| "--", |
| "echo", |
| ]) |
| .expect("parse"); |
|
|
| let Some(Subcommand::Sandbox(command)) = cli.subcommand else { |
| panic!("expected sandbox command"); |
| }; |
|
|
| assert_eq!(command.permissions_profile.as_deref(), Some(":workspace")); |
| assert_eq!(command.command, vec!["echo"]); |
| } |
|
|
| #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] |
| #[test] |
| fn sandbox_parses_legacy_permissions_profile_alias() { |
| let cli = MultitoolCli::try_parse_from([ |
| "codex", |
| "sandbox", |
| "--permissions-profile", |
| ":workspace", |
| "--", |
| "echo", |
| ]) |
| .expect("parse"); |
|
|
| let Some(Subcommand::Sandbox(command)) = cli.subcommand else { |
| panic!("expected sandbox command"); |
| }; |
|
|
| assert_eq!(command.permissions_profile.as_deref(), Some(":workspace")); |
| assert_eq!(command.command, vec!["echo"]); |
| } |
|
|
| #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] |
| #[test] |
| fn sandbox_help_only_shows_singular_permission_profile() { |
| let help = help_from_args(&["codex", "sandbox", "--help"]); |
| assert!(help.contains("--permission-profile"), "{help}"); |
| assert!(!help.contains("--permissions-profile"), "{help}"); |
| } |
|
|
| #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] |
| #[test] |
| fn sandbox_parses_permissions_profile_short_alias() { |
| let cli = |
| MultitoolCli::try_parse_from(["codex", "sandbox", "-P", ":workspace", "--", "echo"]) |
| .expect("parse"); |
|
|
| let Some(Subcommand::Sandbox(command)) = cli.subcommand else { |
| panic!("expected sandbox command"); |
| }; |
|
|
| assert_eq!(command.permissions_profile.as_deref(), Some(":workspace")); |
| assert_eq!(command.command, vec!["echo"]); |
| } |
|
|
| #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] |
| #[test] |
| fn sandbox_parses_config_profile() { |
| let cli = |
| MultitoolCli::try_parse_from(["codex", "sandbox", "--profile", "work", "--", "echo"]) |
| .expect("parse"); |
|
|
| let Some(Subcommand::Sandbox(command)) = cli.subcommand else { |
| panic!("expected sandbox command"); |
| }; |
|
|
| assert_eq!(command.config_profile.as_deref(), Some("work")); |
| assert_eq!(command.command, vec!["echo"]); |
| } |
|
|
| #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] |
| #[test] |
| fn sandbox_rejects_explicit_profile_controls_without_profile() { |
| let err = MultitoolCli::try_parse_from(["codex", "sandbox", "-C", "/tmp"]) |
| .expect_err("parse should fail"); |
|
|
| assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); |
| } |
|
|
| #[test] |
| fn plugin_marketplace_remove_parses_under_plugin() { |
| let cli = |
| MultitoolCli::try_parse_from(["codex", "plugin", "marketplace", "remove", "debug"]) |
| .expect("parse"); |
|
|
| assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_)))); |
| } |
|
|
| #[test] |
| fn marketplace_no_longer_parses_at_top_level() { |
| let add_result = |
| MultitoolCli::try_parse_from(["codex", "marketplace", "add", "owner/repo"]); |
| assert!(add_result.is_err()); |
|
|
| let upgrade_result = |
| MultitoolCli::try_parse_from(["codex", "marketplace", "upgrade", "debug"]); |
| assert!(upgrade_result.is_err()); |
|
|
| let remove_result = |
| MultitoolCli::try_parse_from(["codex", "marketplace", "remove", "debug"]); |
| assert!(remove_result.is_err()); |
| } |
|
|
| fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo { |
| let token_usage = TokenUsage { |
| output_tokens: 2, |
| total_tokens: 2, |
| ..Default::default() |
| }; |
| let thread_id = conversation_id |
| .map(ThreadId::from_string) |
| .map(Result::unwrap); |
| AppExitInfo { |
| token_usage, |
| thread_id, |
| resume_hint: thread_id.map(|thread_id| codex_tui::ResumableThread { |
| thread_id, |
| thread_name: thread_name.map(str::to_string), |
| }), |
| disconnect_info: None, |
| update_action: None, |
| exit_reason: ExitReason::UserRequested, |
| } |
| } |
|
|
| #[test] |
| fn format_exit_messages_skips_zero_usage() { |
| let exit_info = AppExitInfo { |
| token_usage: TokenUsage::default(), |
| thread_id: None, |
| resume_hint: None, |
| disconnect_info: None, |
| update_action: None, |
| exit_reason: ExitReason::UserRequested, |
| }; |
| let lines = exit_info.format_exit_messages( false); |
| assert!(lines.is_empty()); |
| } |
|
|
| #[tokio::test] |
| async fn format_exit_messages_preserves_auth_env_through_tui_runner() { |
| let exit_info = run_tui_with_recovery( |
| || async { |
| let mut exit_info = sample_exit_info( |
| Some("123e4567-e89b-12d3-a456-426614174000"), |
| None, |
| ); |
| exit_info.disconnect_info = Some(codex_tui::DisconnectInfo { |
| command: vec![ |
| "codex".to_string(), |
| "--remote".to_string(), |
| "wss://example.com:443/".to_string(), |
| ], |
| stop_hint: "press ctrl + x".to_string(), |
| }); |
| Ok(exit_info) |
| }, |
| Some("CODEX_REMOTE_TOKEN"), |
| ) |
| .await |
| .unwrap(); |
| assert_eq!( |
| exit_info.format_exit_messages( false), |
| vec![ |
| "Disconnected from this task. Any running work continues.", |
| "Reconnect: codex --remote wss://example.com:443/ --remote-auth-token-env CODEX_REMOTE_TOKEN resume 123e4567-e89b-12d3-a456-426614174000", |
| "Stop the current turn: run codex --remote wss://example.com:443/ --remote-auth-token-env CODEX_REMOTE_TOKEN agents, select this task, and press ctrl + x.", |
| "Token usage so far: total=2 input=0 output=2", |
| ] |
| ); |
| } |
|
|
| #[test] |
| fn format_exit_messages_includes_session_id_without_resume_hint() { |
| let mut exit_info = sample_exit_info( |
| Some("123e4567-e89b-12d3-a456-426614174000"), |
| None, |
| ); |
| exit_info.token_usage = TokenUsage::default(); |
| exit_info.resume_hint = None; |
| let lines = exit_info.format_exit_messages( false); |
| insta::assert_snapshot!(lines.join("\n"), @"Session ID: 123e4567-e89b-12d3-a456-426614174000"); |
| } |
|
|
| #[test] |
| fn format_exit_messages_confirms_archive() { |
| let mut exit_info = sample_exit_info( |
| Some("123e4567-e89b-12d3-a456-426614174000"), |
| None, |
| ); |
| exit_info.exit_reason = ExitReason::Archived(exit_info.thread_id.unwrap()); |
| let lines = exit_info.format_exit_messages( false); |
| insta::assert_snapshot!(lines.join("\n"), @" |
| Token usage: total=2 input=0 output=2 |
| Session archived: 123e4567-e89b-12d3-a456-426614174000 |
| "); |
| } |
|
|
| #[test] |
| fn format_exit_messages_includes_session_id_for_fatal_exit_without_resume_hint() { |
| let exit_info = AppExitInfo { |
| token_usage: TokenUsage::default(), |
| thread_id: Some(ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap()), |
| resume_hint: None, |
| disconnect_info: None, |
| update_action: None, |
| exit_reason: ExitReason::Fatal("boom".to_string()), |
| }; |
| let lines = exit_info.format_exit_messages( false); |
| assert_eq!( |
| lines, |
| vec!["Session ID: 123e4567-e89b-12d3-a456-426614174000".to_string()] |
| ); |
| } |
|
|
| #[test] |
| fn format_exit_messages_includes_resume_hint_for_fatal_exit() { |
| let mut exit_info = sample_exit_info( |
| Some("123e4567-e89b-12d3-a456-426614174000"), |
| None, |
| ); |
| exit_info.exit_reason = ExitReason::Fatal("boom".to_string()); |
| let lines = exit_info.format_exit_messages( false); |
| assert_eq!( |
| lines, |
| vec![ |
| "Token usage: total=2 input=0 output=2".to_string(), |
| "To continue this session, run:".to_string(), |
| " codex resume 123e4567-e89b-12d3-a456-426614174000".to_string(), |
| ] |
| ); |
| } |
|
|
| #[test] |
| fn format_exit_messages_includes_resume_hint_without_color() { |
| insta::allow_duplicates! { |
| for thread_name in [None, Some("")] { |
| let exit_info = |
| sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"), thread_name); |
| let lines = exit_info.format_exit_messages( false); |
| insta::assert_snapshot!(lines.join("\n"), @" |
| Token usage: total=2 input=0 output=2 |
| To continue this session, run: |
| codex resume 123e4567-e89b-12d3-a456-426614174000 |
| "); |
| } |
| } |
| } |
|
|
| #[test] |
| fn format_exit_messages_applies_color_when_enabled() { |
| let exit_info = sample_exit_info( |
| Some("123e4567-e89b-12d3-a456-426614174000"), |
| None, |
| ); |
| let lines = exit_info.format_exit_messages( true); |
| assert_eq!( |
| lines, |
| vec![ |
| "Token usage: total=2 input=0 output=2", |
| "To continue this session, run:", |
| " \u{1b}[36mcodex resume 123e4567-e89b-12d3-a456-426614174000\u{1b}[39m", |
| ] |
| ); |
| } |
|
|
| #[test] |
| fn format_exit_messages_names_picker_item_when_thread_has_name() { |
| let exit_info = sample_exit_info( |
| Some("123e4567-e89b-12d3-a456-426614174000"), |
| Some("my-thread"), |
| ); |
| let lines = exit_info.format_exit_messages( false); |
| insta::assert_snapshot!(lines.join("\n"), @" |
| Token usage: total=2 input=0 output=2 |
| To continue this session, run: |
| codex resume 123e4567-e89b-12d3-a456-426614174000 |
| Or run codex resume and select my-thread. |
| "); |
| } |
|
|
| #[test] |
| fn format_exit_messages_colors_commands_and_thread_name() { |
| let exit_info = sample_exit_info( |
| Some("123e4567-e89b-12d3-a456-426614174000"), |
| Some("my-thread"), |
| ); |
| let lines = exit_info.format_exit_messages( true); |
| assert_eq!( |
| lines, |
| vec![ |
| "Token usage: total=2 input=0 output=2", |
| "To continue this session, run:", |
| " \u{1b}[36mcodex resume 123e4567-e89b-12d3-a456-426614174000\u{1b}[39m", |
| "Or run \u{1b}[36mcodex resume\u{1b}[39m and select \u{1b}[36mmy-thread\u{1b}[39m.", |
| ] |
| ); |
| } |
|
|
| #[test] |
| fn resume_model_flag_applies_when_no_root_flags() { |
| let interactive = |
| finalize_resume_from_args(["codex", "resume", "-m", "gpt-5.1-test"].as_ref()); |
|
|
| assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test")); |
| assert!(interactive.resume_picker); |
| assert!(!interactive.resume_last); |
| assert_eq!(interactive.resume_session_id, None); |
| } |
|
|
| #[test] |
| fn resume_and_fork_preserve_no_alt_screen() { |
| for (command, finalize) in [ |
| ("resume", finalize_resume_from_args as fn(&[&str]) -> TuiCli), |
| ("fork", finalize_fork_from_args as fn(&[&str]) -> TuiCli), |
| ] { |
| assert!(finalize(&["codex", command, "--no-alt-screen"]).no_alt_screen); |
| assert!(finalize(&["codex", "--no-alt-screen", command]).no_alt_screen); |
| assert!(!finalize(&["codex", command]).no_alt_screen); |
| } |
| } |
|
|
| #[test] |
| fn resume_picker_logic_none_and_not_last() { |
| let interactive = finalize_resume_from_args(["codex", "resume"].as_ref()); |
| assert!(interactive.resume_picker); |
| assert!(!interactive.resume_last); |
| assert_eq!(interactive.resume_session_id, None); |
| assert!(!interactive.resume_show_all); |
| } |
|
|
| #[test] |
| fn resume_picker_logic_last() { |
| let interactive = finalize_resume_from_args(["codex", "resume", "--last"].as_ref()); |
| assert!(!interactive.resume_picker); |
| assert!(interactive.resume_last); |
| assert_eq!(interactive.resume_session_id, None); |
| assert!(!interactive.resume_show_all); |
| } |
|
|
| #[test] |
| fn resume_last_accepts_prompt_positional() { |
| let interactive = finalize_resume_from_args( |
| ["codex", "resume", "--last", "/compact focus on auth"].as_ref(), |
| ); |
|
|
| assert!(!interactive.resume_picker); |
| assert!(interactive.resume_last); |
| assert_eq!(interactive.resume_session_id, None); |
| assert_eq!( |
| interactive.prompt.as_deref(), |
| Some("/compact focus on auth") |
| ); |
| } |
|
|
| #[test] |
| fn resume_last_rejects_explicit_session_and_prompt() { |
| let err = |
| MultitoolCli::try_parse_from(["codex", "resume", "--last", "1234", "continue here"]) |
| .expect_err("--last with an explicit session and prompt should be rejected"); |
|
|
| assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); |
| } |
|
|
| #[test] |
| fn resume_picker_logic_with_session_id() { |
| let interactive = finalize_resume_from_args(["codex", "resume", "1234"].as_ref()); |
| assert!(!interactive.resume_picker); |
| assert!(!interactive.resume_last); |
| assert_eq!(interactive.resume_session_id.as_deref(), Some("1234")); |
| assert!(!interactive.resume_show_all); |
| } |
|
|
| #[test] |
| fn resume_with_session_id_accepts_prompt_positional() { |
| let interactive = |
| finalize_resume_from_args(["codex", "resume", "1234", "continue here"].as_ref()); |
|
|
| assert!(!interactive.resume_picker); |
| assert!(!interactive.resume_last); |
| assert_eq!(interactive.resume_session_id.as_deref(), Some("1234")); |
| assert_eq!(interactive.prompt.as_deref(), Some("continue here")); |
| } |
|
|
| #[test] |
| fn resume_all_flag_sets_show_all() { |
| let interactive = finalize_resume_from_args(["codex", "resume", "--all"].as_ref()); |
| assert!(interactive.resume_picker); |
| assert!(interactive.resume_show_all); |
| } |
|
|
| #[test] |
| fn resume_include_non_interactive_flag_sets_source_filter_override() { |
| let interactive = |
| finalize_resume_from_args(["codex", "resume", "--include-non-interactive"].as_ref()); |
|
|
| assert!(interactive.resume_picker); |
| assert!(interactive.resume_include_non_interactive); |
| } |
|
|
| #[test] |
| fn resume_merges_option_flags() { |
| let interactive = finalize_resume_from_args( |
| [ |
| "codex", |
| "resume", |
| "sid", |
| "--oss", |
| "--search", |
| "--sandbox", |
| "workspace-write", |
| "--ask-for-approval", |
| "on-request", |
| "-m", |
| "gpt-5.1-test", |
| "-p", |
| "my-config", |
| "-C", |
| "/tmp", |
| "--strict-config", |
| "-i", |
| "/tmp/a.png,/tmp/b.png", |
| ] |
| .as_ref(), |
| ); |
|
|
| assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test")); |
| assert!(interactive.oss); |
| assert_eq!(interactive.config_profile_v2.as_deref(), Some("my-config")); |
| assert_matches!( |
| interactive.sandbox_mode, |
| Some(codex_utils_cli::SandboxModeCliArg::WorkspaceWrite) |
| ); |
| assert_matches!( |
| interactive.approval_policy, |
| Some(codex_utils_cli::ApprovalModeCliArg::OnRequest) |
| ); |
| assert_eq!( |
| interactive.cwd.as_deref(), |
| Some(std::path::Path::new("/tmp")) |
| ); |
| assert!(interactive.web_search); |
| assert!(interactive.strict_config); |
| let has_a = interactive |
| .images |
| .iter() |
| .any(|p| p == std::path::Path::new("/tmp/a.png")); |
| let has_b = interactive |
| .images |
| .iter() |
| .any(|p| p == std::path::Path::new("/tmp/b.png")); |
| assert!(has_a && has_b); |
| assert!(!interactive.resume_picker); |
| assert!(!interactive.resume_last); |
| assert_eq!(interactive.resume_session_id.as_deref(), Some("sid")); |
| } |
|
|
| #[test] |
| fn resume_merges_dangerously_bypass_flag() { |
| let interactive = finalize_resume_from_args( |
| [ |
| "codex", |
| "resume", |
| "--dangerously-bypass-approvals-and-sandbox", |
| ] |
| .as_ref(), |
| ); |
| assert!(interactive.dangerously_bypass_approvals_and_sandbox); |
| assert!(interactive.resume_picker); |
| assert!(!interactive.resume_last); |
| assert_eq!(interactive.resume_session_id, None); |
| } |
|
|
| #[test] |
| fn resume_merges_bypass_hook_trust_flag() { |
| let interactive = finalize_resume_from_args( |
| ["codex", "resume", "--dangerously-bypass-hook-trust"].as_ref(), |
| ); |
|
|
| assert!(interactive.bypass_hook_trust); |
| assert!(interactive.resume_picker); |
| assert!(!interactive.resume_last); |
| assert_eq!(interactive.resume_session_id, None); |
| } |
|
|
| #[test] |
| fn fork_picker_logic_none_and_not_last() { |
| let interactive = finalize_fork_from_args(["codex", "fork"].as_ref()); |
| assert!(interactive.fork_picker); |
| assert!(!interactive.fork_last); |
| assert_eq!(interactive.fork_session_id, None); |
| assert!(!interactive.fork_show_all); |
| } |
|
|
| #[test] |
| fn fork_picker_logic_last() { |
| let interactive = finalize_fork_from_args(["codex", "fork", "--last"].as_ref()); |
| assert!(!interactive.fork_picker); |
| assert!(interactive.fork_last); |
| assert_eq!(interactive.fork_session_id, None); |
| assert!(!interactive.fork_show_all); |
| } |
|
|
| #[test] |
| fn fork_last_accepts_prompt_positional() { |
| let interactive = |
| finalize_fork_from_args(["codex", "fork", "--last", "/compact focus on auth"].as_ref()); |
|
|
| assert!(!interactive.fork_picker); |
| assert!(interactive.fork_last); |
| assert_eq!(interactive.fork_session_id, None); |
| assert_eq!( |
| interactive.prompt.as_deref(), |
| Some("/compact focus on auth") |
| ); |
| } |
|
|
| #[test] |
| fn fork_last_rejects_explicit_session_and_prompt() { |
| let err = |
| MultitoolCli::try_parse_from(["codex", "fork", "--last", "1234", "continue here"]) |
| .expect_err("--last with an explicit session and prompt should be rejected"); |
|
|
| assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); |
| } |
|
|
| #[test] |
| fn fork_picker_logic_with_session_id() { |
| let interactive = finalize_fork_from_args(["codex", "fork", "1234"].as_ref()); |
| assert!(!interactive.fork_picker); |
| assert!(!interactive.fork_last); |
| assert_eq!(interactive.fork_session_id.as_deref(), Some("1234")); |
| assert!(!interactive.fork_show_all); |
| } |
|
|
| #[test] |
| fn fork_with_session_id_accepts_prompt_positional() { |
| let interactive = |
| finalize_fork_from_args(["codex", "fork", "1234", "continue here"].as_ref()); |
|
|
| assert!(!interactive.fork_picker); |
| assert!(!interactive.fork_last); |
| assert_eq!(interactive.fork_session_id.as_deref(), Some("1234")); |
| assert_eq!(interactive.prompt.as_deref(), Some("continue here")); |
| } |
|
|
| #[test] |
| fn fork_all_flag_sets_show_all() { |
| let interactive = finalize_fork_from_args(["codex", "fork", "--all"].as_ref()); |
| assert!(interactive.fork_picker); |
| assert!(interactive.fork_show_all); |
| } |
|
|
| #[test] |
| fn app_server_analytics_default_disabled_without_flag() { |
| let app_server = app_server_from_args(["codex", "app-server"].as_ref()); |
| assert!(!app_server.analytics_default_enabled); |
| assert!(!app_server.remote_control); |
| assert_eq!( |
| app_server.listen, |
| codex_app_server::AppServerTransport::Stdio |
| ); |
| } |
|
|
| #[test] |
| fn app_server_remote_control_startup_flag_enables_remote_control() { |
| let enabled = app_server_from_args(["codex", "app-server", "--remote-control"].as_ref()); |
| assert!(enabled.remote_control); |
| } |
|
|
| #[test] |
| fn app_server_analytics_default_enabled_with_flag() { |
| let app_server = |
| app_server_from_args(["codex", "app-server", "--analytics-default-enabled"].as_ref()); |
| assert!(app_server.analytics_default_enabled); |
| } |
|
|
| #[test] |
| fn strict_config_parses_for_supported_commands() { |
| let cli = MultitoolCli::try_parse_from(["codex", "--strict-config"]).expect("parse"); |
| assert!(cli.interactive.strict_config); |
|
|
| let cli = |
| MultitoolCli::try_parse_from(["codex", "review", "--strict-config", "--uncommitted"]) |
| .expect("parse"); |
| assert_matches!( |
| cli.subcommand, |
| Some(Subcommand::Review(ReviewCommand { |
| strict_config: true, |
| .. |
| })) |
| ); |
|
|
| let cli = MultitoolCli::try_parse_from(["codex", "exec-server", "--strict-config"]) |
| .expect("parse"); |
| assert_matches!( |
| cli.subcommand, |
| Some(Subcommand::ExecServer(ExecServerCommand { |
| strict_config: true, |
| .. |
| })) |
| ); |
| } |
|
|
| #[test] |
| fn exec_server_forward_parses_shared_remote_options() { |
| let cli = MultitoolCli::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!(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"); |
| } |
|
|
| #[test] |
| fn exec_server_forward_requires_registration_and_destination() { |
| for args in [ |
| vec!["forward", "--connect", "ws://127.0.0.1:8765"], |
| vec![ |
| "forward", |
| "--remote", |
| "https://example.openai.com", |
| "--environment-id", |
| "env-1", |
| ], |
| vec![ |
| "forward", |
| "--connect", |
| "ws://127.0.0.1:8765", |
| "--remote", |
| "https://example.openai.com", |
| ], |
| ] { |
| assert!( |
| MultitoolCli::try_parse_from(["codex", "exec-server"].into_iter().chain(args)) |
| .is_err() |
| ); |
| } |
| } |
|
|
| #[test] |
| fn root_strict_config_is_supported_for_exec_server() { |
| let cli = MultitoolCli::try_parse_from(["codex", "--strict-config", "exec-server"]) |
| .expect("parse"); |
|
|
| reject_root_strict_config_for_subcommand(cli.interactive.strict_config, &cli.subcommand) |
| .expect("exec-server should support root --strict-config"); |
| } |
|
|
| #[test] |
| fn root_strict_config_is_rejected_for_unsupported_subcommands() { |
| let cli = MultitoolCli::try_parse_from(["codex", "--strict-config", "mcp", "list"]) |
| .expect("parse"); |
| let err = reject_root_strict_config_for_subcommand( |
| cli.interactive.strict_config, |
| &cli.subcommand, |
| ) |
| .expect_err("mcp should not support root --strict-config"); |
|
|
| assert_eq!( |
| err.to_string(), |
| "`--strict-config` is not supported for `codex mcp`" |
| ); |
|
|
| let cli = MultitoolCli::try_parse_from(["codex", "--strict-config", "remote-control"]) |
| .expect("parse"); |
| let err = reject_root_strict_config_for_subcommand( |
| cli.interactive.strict_config, |
| &cli.subcommand, |
| ) |
| .expect_err("remote-control should not support root --strict-config"); |
|
|
| assert_eq!( |
| err.to_string(), |
| "`--strict-config` is not supported for `codex remote-control`" |
| ); |
| } |
|
|
| #[test] |
| fn app_server_subcommands_reject_strict_config() { |
| let app_server = |
| app_server_from_args(["codex", "app-server", "--strict-config", "proxy"].as_ref()); |
| let err = reject_strict_config_for_app_server_subcommand( |
| app_server.strict_config, |
| app_server.subcommand.as_ref(), |
| ) |
| .expect_err("app-server proxy should not support --strict-config"); |
|
|
| assert_eq!( |
| err.to_string(), |
| "`--strict-config` is not supported for `codex app-server proxy`" |
| ); |
| } |
|
|
| #[test] |
| fn reject_remote_flag_for_remote_control() { |
| let cli = MultitoolCli::try_parse_from(["codex", "--remote", "unix://", "remote-control"]) |
| .expect("parse"); |
| let Some(Subcommand::RemoteControl(remote_control)) = &cli.subcommand else { |
| panic!("expected remote-control subcommand"); |
| }; |
| assert_eq!(remote_control.subcommand_name(), "remote-control"); |
|
|
| let err = reject_remote_mode_for_subcommand( |
| cli.remote.remote.as_deref(), |
| cli.remote.remote_auth_token_env.as_deref(), |
| "remote-control", |
| ) |
| .expect_err("remote-control should reject root --remote"); |
|
|
| assert!(err.to_string().contains("remote-control")); |
| } |
|
|
| #[test] |
| fn remote_control_pair_parses() { |
| let cli = MultitoolCli::try_parse_from(["codex", "remote-control", "pair"]).expect("parse"); |
| let Some(Subcommand::RemoteControl(remote_control)) = &cli.subcommand else { |
| panic!("expected remote-control subcommand"); |
| }; |
| assert_eq!(remote_control.subcommand_name(), "remote-control pair"); |
| } |
|
|
| #[test] |
| fn remote_flag_parses_for_interactive_root() { |
| let cli = MultitoolCli::try_parse_from(["codex", "--remote", "unix://codex.sock"]) |
| .expect("parse"); |
| assert_eq!(cli.remote.remote.as_deref(), Some("unix://codex.sock")); |
| } |
|
|
| #[test] |
| fn remote_auth_token_env_flag_parses_for_interactive_root() { |
| let cli = MultitoolCli::try_parse_from([ |
| "codex", |
| "--remote-auth-token-env", |
| "CODEX_REMOTE_AUTH_TOKEN", |
| "--remote", |
| "ws://127.0.0.1:4500", |
| ]) |
| .expect("parse"); |
| assert_eq!( |
| cli.remote.remote_auth_token_env.as_deref(), |
| Some("CODEX_REMOTE_AUTH_TOKEN") |
| ); |
| } |
|
|
| #[test] |
| fn remote_flag_parses_for_resume_subcommand() { |
| let cli = |
| MultitoolCli::try_parse_from(["codex", "resume", "--remote", "unix://codex.sock"]) |
| .expect("parse"); |
| let Subcommand::Resume(ResumeCommand { remote, .. }) = |
| cli.subcommand.expect("resume present") |
| else { |
| panic!("expected resume subcommand"); |
| }; |
| assert_eq!(remote.remote.as_deref(), Some("unix://codex.sock")); |
| } |
|
|
| #[test] |
| fn agents_subcommand_accepts_remote_session_options() { |
| let cli = MultitoolCli::try_parse_from([ |
| "codex", |
| "agents", |
| "--remote", |
| "ws://127.0.0.1:4500", |
| "--remote-auth-token-env", |
| "CODEX_REMOTE_AUTH_TOKEN", |
| "--cd", |
| "/workspace", |
| "--no-alt-screen", |
| ]) |
| .expect("parse"); |
| let Some(Subcommand::Agents(options)) = cli.subcommand else { |
| panic!("expected agents subcommand"); |
| }; |
|
|
| assert_eq!( |
| options.remote.remote.as_deref(), |
| Some("ws://127.0.0.1:4500") |
| ); |
| assert_eq!( |
| options.remote.remote_auth_token_env.as_deref(), |
| Some("CODEX_REMOTE_AUTH_TOKEN") |
| ); |
| assert_eq!( |
| options.cwd.as_deref(), |
| Some(std::path::Path::new("/workspace")) |
| ); |
| assert!(options.no_alt_screen); |
| } |
|
|
| #[test] |
| fn reject_remote_mode_for_non_interactive_subcommands() { |
| let err = reject_remote_mode_for_subcommand( |
| Some("127.0.0.1:4500"), |
| None, |
| "exec", |
| ) |
| .expect_err("non-interactive subcommands should reject --remote"); |
| assert!( |
| err.to_string() |
| .contains("only supported for interactive TUI commands") |
| ); |
| } |
|
|
| #[test] |
| fn reject_remote_auth_token_env_for_non_interactive_subcommands() { |
| let err = reject_remote_mode_for_subcommand( |
| None, |
| Some("CODEX_REMOTE_AUTH_TOKEN"), |
| "exec", |
| ) |
| .expect_err("non-interactive subcommands should reject --remote-auth-token-env"); |
| assert!( |
| err.to_string() |
| .contains("only supported for interactive TUI commands") |
| ); |
| } |
|
|
| #[test] |
| fn reject_remote_auth_token_env_for_app_server_generate_internal_json_schema() { |
| let subcommand = |
| AppServerSubcommand::GenerateInternalJsonSchema(GenerateInternalJsonSchemaCommand { |
| out_dir: PathBuf::from("/tmp/out"), |
| }); |
| let err = reject_remote_mode_for_app_server_subcommand( |
| None, |
| Some("CODEX_REMOTE_AUTH_TOKEN"), |
| Some(&subcommand), |
| ) |
| .expect_err("non-interactive app-server subcommands should reject --remote-auth-token-env"); |
| assert!(err.to_string().contains("generate-internal-json-schema")); |
| } |
|
|
| #[test] |
| fn read_remote_auth_token_from_env_var_reports_missing_values() { |
| let err = read_remote_auth_token_from_env_var_with("CODEX_REMOTE_AUTH_TOKEN", |_| { |
| Err(std::env::VarError::NotPresent) |
| }) |
| .expect_err("missing env vars should be rejected"); |
| assert!(err.to_string().contains("is not set")); |
| } |
|
|
| #[test] |
| fn read_remote_auth_token_from_env_var_trims_values() { |
| let auth_token = |
| read_remote_auth_token_from_env_var_with("CODEX_REMOTE_AUTH_TOKEN", |_| { |
| Ok(" bearer-token ".to_string()) |
| }) |
| .expect("env var should parse"); |
| assert_eq!(auth_token, "bearer-token"); |
| } |
|
|
| #[test] |
| fn read_remote_auth_token_from_env_var_rejects_empty_values() { |
| let err = read_remote_auth_token_from_env_var_with("CODEX_REMOTE_AUTH_TOKEN", |_| { |
| Ok(" \n\t ".to_string()) |
| }) |
| .expect_err("empty env vars should be rejected"); |
| assert!(err.to_string().contains("is empty")); |
| } |
|
|
| #[test] |
| fn app_server_grpc_code_mode_host_url_parses_independently_of_listen_transport() { |
| let app_server = app_server_from_args( |
| [ |
| "codex", |
| "app-server", |
| "--code-mode-host", |
| "https://example.test", |
| "--listen", |
| "ws://127.0.0.1:4500", |
| ] |
| .as_ref(), |
| ); |
|
|
| assert_eq!( |
| app_server.code_mode_host.code_mode_host, |
| Some(url::Url::parse("https://example.test").expect("test endpoint should parse")) |
| ); |
| } |
|
|
| #[test] |
| fn app_server_rejects_invalid_code_mode_host_urls() { |
| for endpoint in [ |
| "ftp://127.0.0.1:8765", |
| "ws://", |
| "ws://127.0.0.1:8765", |
| "wss://example.test/code-mode", |
| "ws://alice:secret@example.test/code-mode", |
| "wss://alice:secret@example.test/code-mode", |
| "wss://example.test/code-mode#fragment", |
| "http://", |
| "https://example.test/#fragment", |
| "https://example.test/code-mode", |
| "http://alice:secret@example.test", |
| "https://alice:secret@example.test", |
| "http://example.test/?token=secret", |
| ] { |
| let error = |
| MultitoolCli::try_parse_from(["codex", "app-server", "--code-mode-host", endpoint]) |
| .expect_err("invalid code-mode host endpoint should fail argument parsing"); |
|
|
| assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); |
| let rendered_error = error.to_string(); |
| assert!(!rendered_error.contains("alice")); |
| assert!(!rendered_error.contains("secret")); |
| } |
| } |
|
|
| #[test] |
| fn app_server_listen_websocket_url_parses() { |
| let app_server = app_server_from_args( |
| ["codex", "app-server", "--listen", "ws://127.0.0.1:4500"].as_ref(), |
| ); |
| assert_eq!( |
| app_server.listen, |
| codex_app_server::AppServerTransport::WebSocket { |
| bind_address: "127.0.0.1:4500".parse().expect("valid socket address"), |
| } |
| ); |
| } |
|
|
| #[test] |
| fn app_server_listen_stdio_url_parses() { |
| let app_server = |
| app_server_from_args(["codex", "app-server", "--listen", "stdio://"].as_ref()); |
| assert_eq!( |
| app_server.listen, |
| codex_app_server::AppServerTransport::Stdio |
| ); |
| } |
|
|
| #[test] |
| fn app_server_stdio_flag_parses() { |
| let app_server = app_server_from_args(["codex", "app-server", "--stdio"].as_ref()); |
| assert!(app_server.stdio); |
| } |
|
|
| #[test] |
| fn app_server_stdio_flag_conflicts_with_listen() { |
| let err = MultitoolCli::try_parse_from([ |
| "codex", |
| "app-server", |
| "--stdio", |
| "--listen", |
| "stdio://", |
| ]) |
| .expect_err("--stdio and --listen should be rejected together"); |
| assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); |
| } |
|
|
| #[test] |
| fn app_server_listen_unix_socket_url_parses() { |
| let app_server = |
| app_server_from_args(["codex", "app-server", "--listen", "unix://"].as_ref()); |
| assert_eq!( |
| app_server.listen, |
| codex_app_server::AppServerTransport::UnixSocket { |
| socket_path: default_app_server_socket_path() |
| } |
| ); |
| } |
|
|
| #[test] |
| fn app_server_listen_unix_socket_path_parses() { |
| let app_server = app_server_from_args( |
| ["codex", "app-server", "--listen", "unix:///tmp/codex.sock"].as_ref(), |
| ); |
| assert_eq!( |
| app_server.listen, |
| codex_app_server::AppServerTransport::UnixSocket { |
| socket_path: AbsolutePathBuf::from_absolute_path("/tmp/codex.sock") |
| .expect("absolute path should parse") |
| } |
| ); |
| } |
|
|
| #[test] |
| fn app_server_listen_off_parses() { |
| let app_server = app_server_from_args(["codex", "app-server", "--listen", "off"].as_ref()); |
| assert_eq!(app_server.listen, codex_app_server::AppServerTransport::Off); |
| } |
|
|
| #[test] |
| fn app_server_listen_invalid_url_fails_to_parse() { |
| let parse_result = |
| MultitoolCli::try_parse_from(["codex", "app-server", "--listen", "http://foo"]); |
| assert!(parse_result.is_err()); |
| } |
|
|
| #[test] |
| fn app_server_proxy_subcommand_parses() { |
| let app_server = app_server_from_args(["codex", "app-server", "proxy"].as_ref()); |
| assert!(matches!( |
| app_server.subcommand, |
| Some(AppServerSubcommand::Proxy(AppServerProxyCommand { |
| socket_path: None |
| })) |
| )); |
| } |
|
|
| #[test] |
| fn app_server_daemon_subcommands_parse() { |
| assert!(matches!( |
| app_server_from_args( |
| [ |
| "codex", |
| "app-server", |
| "daemon", |
| "bootstrap", |
| "--remote-control" |
| ] |
| .as_ref() |
| ) |
| .subcommand, |
| Some(AppServerSubcommand::Daemon(AppServerDaemonCommand { |
| subcommand: AppServerDaemonSubcommand::Bootstrap(AppServerBootstrapCommand { |
| remote_control: true |
| }) |
| })) |
| )); |
| assert!(matches!( |
| app_server_from_args(["codex", "app-server", "daemon", "start"].as_ref()).subcommand, |
| Some(AppServerSubcommand::Daemon(AppServerDaemonCommand { |
| subcommand: AppServerDaemonSubcommand::Start |
| })) |
| )); |
| assert!(matches!( |
| app_server_from_args(["codex", "app-server", "daemon", "restart"].as_ref()).subcommand, |
| Some(AppServerSubcommand::Daemon(AppServerDaemonCommand { |
| subcommand: AppServerDaemonSubcommand::Restart |
| })) |
| )); |
| assert!(matches!( |
| app_server_from_args( |
| ["codex", "app-server", "daemon", "enable-remote-control"].as_ref() |
| ) |
| .subcommand, |
| Some(AppServerSubcommand::Daemon(AppServerDaemonCommand { |
| subcommand: AppServerDaemonSubcommand::EnableRemoteControl |
| })) |
| )); |
| assert!(matches!( |
| app_server_from_args( |
| ["codex", "app-server", "daemon", "disable-remote-control"].as_ref() |
| ) |
| .subcommand, |
| Some(AppServerSubcommand::Daemon(AppServerDaemonCommand { |
| subcommand: AppServerDaemonSubcommand::DisableRemoteControl |
| })) |
| )); |
| assert!(matches!( |
| app_server_from_args(["codex", "app-server", "daemon", "stop"].as_ref()).subcommand, |
| Some(AppServerSubcommand::Daemon(AppServerDaemonCommand { |
| subcommand: AppServerDaemonSubcommand::Stop |
| })) |
| )); |
| assert!(matches!( |
| app_server_from_args(["codex", "app-server", "daemon", "version"].as_ref()).subcommand, |
| Some(AppServerSubcommand::Daemon(AppServerDaemonCommand { |
| subcommand: AppServerDaemonSubcommand::Version |
| })) |
| )); |
| } |
|
|
| #[test] |
| fn app_server_proxy_sock_path_parses() { |
| let app_server = |
| app_server_from_args(["codex", "app-server", "proxy", "--sock", "codex.sock"].as_ref()); |
| let Some(AppServerSubcommand::Proxy(proxy)) = app_server.subcommand else { |
| panic!("expected proxy subcommand"); |
| }; |
| assert_eq!( |
| proxy.socket_path, |
| Some( |
| AbsolutePathBuf::relative_to_current_dir("codex.sock") |
| .expect("relative path should resolve") |
| ) |
| ); |
| } |
|
|
| #[test] |
| fn reject_remote_auth_token_env_for_app_server_proxy() { |
| let subcommand = AppServerSubcommand::Proxy(AppServerProxyCommand { socket_path: None }); |
| let err = reject_remote_mode_for_app_server_subcommand( |
| None, |
| Some("CODEX_REMOTE_AUTH_TOKEN"), |
| Some(&subcommand), |
| ) |
| .expect_err("app-server proxy should reject --remote-auth-token-env"); |
| assert!(err.to_string().contains("app-server proxy")); |
| } |
|
|
| #[test] |
| fn reject_remote_auth_token_env_for_app_server_version() { |
| let subcommand = AppServerSubcommand::Daemon(AppServerDaemonCommand { |
| subcommand: AppServerDaemonSubcommand::Version, |
| }); |
| let err = reject_remote_mode_for_app_server_subcommand( |
| None, |
| Some("CODEX_REMOTE_AUTH_TOKEN"), |
| Some(&subcommand), |
| ) |
| .expect_err("app-server daemon version should reject --remote-auth-token-env"); |
| assert!(err.to_string().contains("app-server daemon version")); |
| } |
|
|
| #[test] |
| fn app_server_capability_token_flags_parse() { |
| let app_server = app_server_from_args( |
| [ |
| "codex", |
| "app-server", |
| "--ws-auth", |
| "capability-token", |
| "--ws-token-file", |
| "/tmp/codex-token", |
| ] |
| .as_ref(), |
| ); |
| assert_eq!( |
| app_server.auth.ws_auth, |
| Some(codex_app_server::WebsocketAuthCliMode::CapabilityToken) |
| ); |
| assert_eq!( |
| app_server.auth.ws_token_file, |
| Some(PathBuf::from("/tmp/codex-token")) |
| ); |
| } |
|
|
| #[test] |
| fn app_server_signed_bearer_flags_parse() { |
| let app_server = app_server_from_args( |
| [ |
| "codex", |
| "app-server", |
| "--ws-auth", |
| "signed-bearer-token", |
| "--ws-shared-secret-file", |
| "/tmp/codex-secret", |
| "--ws-issuer", |
| "issuer", |
| "--ws-audience", |
| "audience", |
| "--ws-max-clock-skew-seconds", |
| "9", |
| ] |
| .as_ref(), |
| ); |
| assert_eq!( |
| app_server.auth.ws_auth, |
| Some(codex_app_server::WebsocketAuthCliMode::SignedBearerToken) |
| ); |
| assert_eq!( |
| app_server.auth.ws_shared_secret_file, |
| Some(PathBuf::from("/tmp/codex-secret")) |
| ); |
| assert_eq!(app_server.auth.ws_issuer.as_deref(), Some("issuer")); |
| assert_eq!(app_server.auth.ws_audience.as_deref(), Some("audience")); |
| assert_eq!(app_server.auth.ws_max_clock_skew_seconds, Some(9)); |
| } |
|
|
| #[test] |
| fn app_server_rejects_removed_insecure_non_loopback_flag() { |
| let parse_result = MultitoolCli::try_parse_from([ |
| "codex", |
| "app-server", |
| "--allow-unauthenticated-non-loopback-ws", |
| ]); |
| assert!(parse_result.is_err()); |
| } |
|
|
| #[test] |
| fn features_enable_parses_feature_name() { |
| let cli = MultitoolCli::try_parse_from(["codex", "features", "enable", "unified_exec"]) |
| .expect("parse should succeed"); |
| let Some(Subcommand::Features(FeaturesCli { sub })) = cli.subcommand else { |
| panic!("expected features subcommand"); |
| }; |
| let FeaturesSubcommand::Enable(FeatureSetArgs { feature }) = sub else { |
| panic!("expected features enable"); |
| }; |
| assert_eq!(feature, "unified_exec"); |
| } |
|
|
| #[test] |
| fn features_disable_parses_feature_name() { |
| let cli = MultitoolCli::try_parse_from(["codex", "features", "disable", "shell_tool"]) |
| .expect("parse should succeed"); |
| let Some(Subcommand::Features(FeaturesCli { sub })) = cli.subcommand else { |
| panic!("expected features subcommand"); |
| }; |
| let FeaturesSubcommand::Disable(FeatureSetArgs { feature }) = sub else { |
| panic!("expected features disable"); |
| }; |
| assert_eq!(feature, "shell_tool"); |
| } |
|
|
| #[test] |
| fn feature_toggles_known_features_generate_overrides() { |
| let toggles = FeatureToggles { |
| enable: vec!["web_search_request".to_string()], |
| disable: vec!["unified_exec".to_string()], |
| }; |
| let overrides = toggles.to_overrides().expect("valid features"); |
| assert_eq!( |
| overrides, |
| vec![ |
| "features.web_search_request=true".to_string(), |
| "features.unified_exec=false".to_string(), |
| ] |
| ); |
| } |
|
|
| #[test] |
| fn feature_toggles_accept_legacy_linux_sandbox_flag() { |
| let toggles = FeatureToggles { |
| enable: vec!["use_linux_sandbox_bwrap".to_string()], |
| disable: Vec::new(), |
| }; |
| let overrides = toggles.to_overrides().expect("valid features"); |
| assert_eq!( |
| overrides, |
| vec!["features.use_linux_sandbox_bwrap=true".to_string(),] |
| ); |
| } |
|
|
| #[test] |
| fn feature_toggles_accept_removed_image_detail_original_flag() { |
| let toggles = FeatureToggles { |
| enable: vec!["image_detail_original".to_string()], |
| disable: Vec::new(), |
| }; |
| let overrides = toggles.to_overrides().expect("valid features"); |
| assert_eq!( |
| overrides, |
| vec!["features.image_detail_original=true".to_string(),] |
| ); |
| } |
|
|
| #[test] |
| fn feature_toggles_accept_removed_enable_fanout_flag() { |
| let toggles = FeatureToggles { |
| enable: vec!["enable_fanout".to_string()], |
| disable: Vec::new(), |
| }; |
| let overrides = toggles.to_overrides().expect("valid features"); |
| assert_eq!(overrides, vec!["features.enable_fanout=true".to_string(),]); |
| } |
|
|
| #[test] |
| fn feature_toggles_accept_removed_item_ids_flag() { |
| let toggles = FeatureToggles { |
| enable: vec!["item_ids".to_string()], |
| disable: Vec::new(), |
| }; |
| let overrides = toggles.to_overrides().expect("valid features"); |
| assert_eq!(overrides, vec!["features.item_ids=true".to_string()]); |
| } |
|
|
| #[test] |
| fn feature_toggles_unknown_feature_errors() { |
| let toggles = FeatureToggles { |
| enable: vec!["does_not_exist".to_string()], |
| disable: Vec::new(), |
| }; |
| let err = toggles |
| .to_overrides() |
| .expect_err("feature should be rejected"); |
| assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist"); |
| } |
|
|
| #[test] |
| fn strict_config_with_unknown_enable_errors() { |
| let err = strict_config_feature_toggle_error(["--enable", "does_not_exist"].as_ref()); |
| assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist"); |
| } |
|
|
| #[test] |
| fn strict_config_with_unknown_disable_errors() { |
| let err = strict_config_feature_toggle_error(["--disable", "does_not_exist"].as_ref()); |
| assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist"); |
| } |
|
|
| #[test] |
| fn strict_config_with_compound_enable_errors() { |
| let err = strict_config_feature_toggle_error( |
| ["--enable", "multi_agent_v2.subagent_usage_hint_text"].as_ref(), |
| ); |
| assert_eq!( |
| err.to_string(), |
| "Unknown feature flag: multi_agent_v2.subagent_usage_hint_text" |
| ); |
| } |
|
|
| fn strict_config_feature_toggle_error(args: &[&str]) -> anyhow::Error { |
| let cli_args = std::iter::once("codex") |
| .chain(std::iter::once("--strict-config")) |
| .chain(args.iter().copied()); |
| let cli = MultitoolCli::try_parse_from(cli_args).expect("parse should succeed"); |
| assert!(cli.interactive.strict_config); |
| cli.feature_toggles |
| .to_overrides() |
| .expect_err("feature should be rejected") |
| } |
| } |
|
|
| #[cfg(all(test, unix))] |
| #[path = "daemon_update_tests.rs"] |
| mod daemon_update_tests; |
|
|