Download codex-rs/cli/src/main.rs from SaylorTwift/codex: direct link, hf CLI and curl.
- Browser
- Download file 190 kB
-
https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/cli/src/main.rs
- Command line
-
hf download hf://SaylorTwift/codex/codex-rs/cli/src/main.rs
-
curl -L -o main.rs https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/cli/src/main.rs
190 kB
| 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; | |
| static ALLOCATOR: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; | |
| mod app_cmd; | |
| mod cloud_config; | |
| mod daemon_install; | |
| mod desktop_app; | |
| mod doctor; | |
| 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; | |
| mod sandbox_setup; | |
| mod state_db_recovery; | |
| 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; | |
| /// Codex CLI | |
| /// | |
| /// If no subcommand is specified, options will be forwarded to the interactive CLI. | |
| struct MultitoolCli { | |
| pub config_overrides: CliConfigOverrides, | |
| pub feature_toggles: FeatureToggles, | |
| remote: InteractiveRemoteOptions, | |
| interactive: TuiCli, | |
| subcommand: Option<Subcommand>, | |
| } | |
| enum Subcommand { | |
| /// Browse all agent sessions on the shared local app-server daemon. | |
| Agents(AgentsCommand), | |
| /// Internal: forward a local TCP socket through an HTTP/3 CONNECT proxy. | |
| TcpTunnel(codex_tcp_tunnel::Args), | |
| /// Run Codex non-interactively. | |
| Exec(ExecCli), | |
| /// Run a code review non-interactively. | |
| Review(ReviewCommand), | |
| /// Manage login. | |
| Login(LoginCommand), | |
| /// Remove stored authentication credentials. | |
| Logout(LogoutCommand), | |
| /// Manage external MCP servers for Codex. | |
| Mcp(McpCli), | |
| /// Manage Codex plugins. | |
| Plugin(PluginCli), | |
| /// [experimental] Run the app server or related tooling. | |
| AppServer(AppServerCommand), | |
| /// [experimental] Manage the app-server daemon with remote control enabled. | |
| RemoteControl(RemoteControlCommand), | |
| /// Launch the Desktop app (opens the app installer if missing). | |
| App(app_cmd::AppCommand), | |
| /// Generate shell completion scripts. | |
| Completion(CompletionCommand), | |
| /// Update Codex to the latest version. | |
| Update, | |
| /// Diagnose local Codex installation, config, auth, and runtime health. | |
| Doctor(DoctorCommand), | |
| /// Run commands within a Codex-provided sandbox. | |
| Sandbox(HostSandboxArgs), | |
| /// Debugging tools. | |
| Debug(DebugCommand), | |
| /// Execpolicy tooling. | |
| Execpolicy(ExecpolicyCommand), | |
| /// Apply the latest diff produced by Codex agent as a `git apply` to your local working tree. | |
| Apply(ApplyCommand), | |
| /// Resume a previous interactive session (picker by default; use --last to continue the most recent). | |
| Resume(ResumeCommand), | |
| /// Queue a message for an existing session. | |
| Queue(QueueCommand), | |
| /// Archive a saved session by id or session name. | |
| Archive(SessionArchiveCommand), | |
| /// Permanently delete a saved session by id or session name. | |
| Delete(DeleteCommand), | |
| /// Inspect or migrate legacy local sessions to paginated thread history. | |
| MigrateRollouts(migrate_rollouts::MigrateRolloutsCommand), | |
| /// Unarchive a saved session by id or session name. | |
| Unarchive(SessionArchiveCommand), | |
| /// Fork a previous interactive session (picker by default; use --last to fork the most recent). | |
| Fork(ForkCommand), | |
| /// [EXPERIMENTAL] Browse tasks from Codex Cloud and apply changes locally. | |
| Cloud(CloudTasksCli), | |
| /// Internal: run the responses API proxy. | |
| ResponsesApiProxy(ResponsesApiProxyArgs), | |
| /// Internal: relay stdio to a Unix domain socket. | |
| StdioToUds(StdioToUdsCommand), | |
| /// [EXPERIMENTAL] Run the standalone exec-server service. | |
| ExecServer(ExecServerCommand), | |
| /// Inspect feature flags. | |
| Features(FeaturesCli), | |
| } | |
| struct CompletionCommand { | |
| /// Shell to generate completions for | |
| shell: Shell, | |
| } | |
| struct DebugCommand { | |
| subcommand: DebugSubcommand, | |
| } | |
| enum DebugSubcommand { | |
| /// Render the raw model catalog as JSON. | |
| Models(DebugModelsCommand), | |
| /// Tooling: helps debug the app server. | |
| AppServer(DebugAppServerCommand), | |
| /// Render the model-visible prompt input list as JSON. | |
| PromptInput(DebugPromptInputCommand), | |
| /// Replay a rollout trace bundle and write reduced state JSON. | |
| TraceReduce(DebugTraceReduceCommand), | |
| /// Internal: reset local memory state for a fresh start. | |
| ClearMemories, | |
| } | |
| struct DebugAppServerCommand { | |
| subcommand: DebugAppServerSubcommand, | |
| } | |
| enum DebugAppServerSubcommand { | |
| // Send message to app server V2. | |
| SendMessageV2(DebugAppServerSendMessageV2Command), | |
| } | |
| struct DebugAppServerSendMessageV2Command { | |
| user_message: String, | |
| } | |
| struct DebugPromptInputCommand { | |
| /// Optional user prompt to append after session context. | |
| prompt: Option<String>, | |
| /// Optional image(s) to attach to the user prompt. | |
| images: Vec<PathBuf>, | |
| } | |
| struct DebugModelsCommand { | |
| /// Skip refresh and dump only the bundled catalog shipped with this binary. | |
| bundled: bool, | |
| } | |
| struct ReviewCommand { | |
| /// Error out when config.toml contains fields that are not recognized by this version of Codex. | |
| strict_config: bool, | |
| args: ReviewArgs, | |
| } | |
| struct DebugTraceReduceCommand { | |
| /// Trace bundle directory containing manifest.json and trace.jsonl. | |
| trace_bundle: PathBuf, | |
| /// Output path for reduced RolloutTrace JSON. Defaults to TRACE_BUNDLE/state.json. | |
| output: Option<PathBuf>, | |
| } | |
| struct AgentsCommand { | |
| remote: InteractiveRemoteOptions, | |
| /// Use this directory for new tasks on a remote server. | |
| cwd: Option<PathBuf>, | |
| /// Disable alternate screen mode. | |
| no_alt_screen: bool, | |
| } | |
| struct ResumeCommand { | |
| /// Session id (UUID) or session name. UUIDs take precedence if it parses. | |
| /// If omitted, use --last to pick the most recent recorded session. | |
| session_id: Option<String>, | |
| /// Continue the most recent session without showing the picker. | |
| last: bool, | |
| /// Show all sessions (disables cwd filtering and shows CWD column). | |
| all: bool, | |
| /// Include non-interactive sessions in the resume picker and --last selection. | |
| include_non_interactive: bool, | |
| remote: InteractiveRemoteOptions, | |
| config_overrides: SessionTuiCli, | |
| } | |
| struct SessionArchiveCommand { | |
| /// Session id (UUID) or session name. UUIDs take precedence if it parses. | |
| target: String, | |
| remote: InteractiveRemoteOptions, | |
| config_overrides: SessionArchiveConfigOverrides, | |
| } | |
| struct SessionArchiveConfigOverrides { | |
| shared: SharedCliOptions, | |
| /// Error out when config.toml contains fields that are not recognized by this version of Codex. | |
| strict_config: bool, | |
| config_overrides: CliConfigOverrides, | |
| } | |
| struct DeleteCommand { | |
| session: SessionArchiveCommand, | |
| /// Delete without prompting. SESSION must be a UUID. | |
| force: bool, | |
| } | |
| struct ForkCommand { | |
| /// Conversation/session id (UUID). When provided, forks this session. | |
| /// If omitted, use --last to pick the most recent recorded session. | |
| session_id: Option<String>, | |
| /// Fork the most recent session without showing the picker. | |
| last: bool, | |
| /// Show all sessions (disables cwd filtering and shows CWD column). | |
| all: bool, | |
| remote: InteractiveRemoteOptions, | |
| config_overrides: SessionTuiCli, | |
| } | |
| /// TUI arguments for session commands where a parsed prompt implies an explicit session id. | |
| /// | |
| /// This keeps `--last PROMPT` valid while rejecting `--last SESSION_ID PROMPT`. | |
| 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) | |
| } | |
| } | |
| type HostSandboxArgs = codex_cli::SeatbeltCommand; | |
| type HostSandboxArgs = codex_cli::LandlockCommand; | |
| type HostSandboxArgs = codex_cli::WindowsCommand; | |
| type HostSandboxArgs = UnsupportedSandboxArgs; | |
| struct UnsupportedSandboxArgs { | |
| /// Layer $CODEX_HOME/<name>.config.toml on top of the base user config. | |
| pub config_profile: Option<ProfileV2Name>, | |
| pub config_overrides: CliConfigOverrides, | |
| /// Full command args to run under the host sandbox. | |
| pub command: Vec<String>, | |
| } | |
| struct ExecpolicyCommand { | |
| sub: ExecpolicySubcommand, | |
| } | |
| enum ExecpolicySubcommand { | |
| /// Check execpolicy files against a command. | |
| Check(ExecPolicyCheckCommand), | |
| } | |
| struct LoginCommand { | |
| config_overrides: CliConfigOverrides, | |
| with_api_key: bool, | |
| with_access_token: bool, | |
| api_key: Option<String>, | |
| use_device_code: bool, | |
| /// EXPERIMENTAL: Use custom OAuth issuer base URL (advanced) | |
| /// Override the OAuth issuer base URL (advanced) | |
| issuer_base_url: Option<String>, | |
| /// EXPERIMENTAL: Use custom OAuth client ID (advanced) | |
| client_id: Option<String>, | |
| action: Option<LoginSubcommand>, | |
| } | |
| enum LoginSubcommand { | |
| /// Show login status. | |
| Status, | |
| } | |
| struct LogoutCommand { | |
| config_overrides: CliConfigOverrides, | |
| } | |
| struct AppServerCommand { | |
| /// Omit to run the app server; specify a subcommand for tooling. | |
| subcommand: Option<AppServerSubcommand>, | |
| code_mode_host: codex_app_server::AppServerCodeModeHostArgs, | |
| /// Error out when config.toml contains fields that are not recognized by this version of Codex. | |
| strict_config: bool, | |
| /// Transport endpoint URL. Supported values: `stdio://` (default), | |
| /// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`. | |
| listen: codex_app_server::AppServerTransport, | |
| /// Use stdio as the transport (equivalent to `--listen stdio://`). | |
| stdio: bool, | |
| /// Enable remote control for this app-server process without changing persistence. | |
| remote_control: bool, | |
| /// Save loaded threads during managed daemon shutdown. | |
| managed_daemon: bool, | |
| /// Controls whether analytics are enabled by default. | |
| /// | |
| /// Analytics are disabled by default for app-server. Users have to explicitly opt in | |
| /// via the `analytics` section in the config.toml file. | |
| /// | |
| /// However, for first-party use cases like the VSCode IDE extension, we default analytics | |
| /// to be enabled by default by setting this flag. Users can still opt out by setting this | |
| /// in their config.toml: | |
| /// | |
| /// ```toml | |
| /// [analytics] | |
| /// enabled = false | |
| /// ``` | |
| /// | |
| /// See https://developers.openai.com/codex/config-advanced/#metrics for more details. | |
| analytics_default_enabled: bool, | |
| auth: codex_app_server::AppServerWebsocketAuthArgs, | |
| } | |
| struct ExecServerCommand { | |
| command: Option<ExecServerSubcommand>, | |
| /// Error out when config.toml contains fields that are not recognized by this version of Codex. | |
| strict_config: bool, | |
| /// Maximum number of requests to process concurrently on each connection. | |
| request_dispatch_mode: codex_exec_server::RequestDispatchMode, | |
| /// Transport endpoint URL. Supported values: `ws://IP:PORT` (default), `stdio`, `stdio://`. | |
| listen: Option<String>, | |
| /// Register this exec-server as a remote environment using the given base URL. | |
| remote: Option<String>, | |
| /// Transport used for the remote executor connection. | |
| remote_transport: ExecServerRemoteTransport, | |
| /// Environment id to attach to when registering remotely. | |
| environment_id: Option<String>, | |
| /// Human-readable environment name. | |
| name: Option<String>, | |
| /// Use Agent Identity auth from CODEX_ACCESS_TOKEN for remote registration. | |
| use_agent_identity_auth: bool, | |
| /// Sign Direct registration and WebSocket handshake requests with AWS SigV4. | |
| aws_sigv4: bool, | |
| /// AWS profile used for SigV4 authentication. | |
| aws_profile: Option<String>, | |
| /// AWS signing region. Uses the SDK region chain when omitted. | |
| aws_region: Option<String>, | |
| /// AWS signing service. | |
| aws_service: String, | |
| /// Exit when the parent-owned standard-input pipe closes. | |
| 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(()) | |
| } | |
| } | |
| enum ExecServerRemoteTransport { | |
| Noise, | |
| Direct, | |
| } | |
| enum ExecServerSubcommand { | |
| /// Register an existing WebSocket exec-server as a remote environment. | |
| Forward { | |
| /// Destination exec-server WebSocket URL. | |
| connect: String, | |
| }, | |
| } | |
| enum AppServerSubcommand { | |
| /// Manage the local app-server daemon. | |
| Daemon(AppServerDaemonCommand), | |
| /// Proxy stdio bytes to the running app-server control socket. | |
| Proxy(AppServerProxyCommand), | |
| /// [experimental] Generate TypeScript bindings for the app server protocol. | |
| GenerateTs(GenerateTsCommand), | |
| /// [experimental] Generate JSON Schema for the app server protocol. | |
| GenerateJsonSchema(GenerateJsonSchemaCommand), | |
| /// [internal] Generate internal JSON Schema artifacts for Codex tooling. | |
| GenerateInternalJsonSchema(GenerateInternalJsonSchemaCommand), | |
| } | |
| struct AppServerDaemonCommand { | |
| subcommand: AppServerDaemonSubcommand, | |
| } | |
| enum AppServerDaemonSubcommand { | |
| /// Install durable local app-server management for SSH-driven use. | |
| Bootstrap(AppServerBootstrapCommand), | |
| /// Start the local app server daemon if it is not already running. | |
| Start, | |
| /// Restart the local app server daemon. | |
| Restart, | |
| /// Update the daemon package (may interrupt running work). | |
| Update { | |
| /// Copy and pin this CLI package. | |
| from_cli: bool, | |
| /// Confirm replacing the daemon package without an interactive prompt. | |
| yes: bool, | |
| }, | |
| /// Enable remote control for future starts and a currently running managed daemon. | |
| EnableRemoteControl, | |
| /// Disable remote control for future starts and a currently running managed daemon. | |
| DisableRemoteControl, | |
| /// Stop the local app server daemon. | |
| Stop, | |
| /// Print local CLI and running app-server versions as JSON. | |
| Version, | |
| /// [internal] Run the detached pid-backed standalone updater loop. | |
| PidUpdateLoop { | |
| /// Check support for daemon-owned packages without starting the updater. | |
| check_package_ownership: bool, | |
| /// Authorize one production restoration of this selected release. | |
| restore_release: Option<String>, | |
| }, | |
| } | |
| struct AppServerProxyCommand { | |
| /// Path to the app-server Unix domain socket to connect to. | |
| socket_path: Option<AbsolutePathBuf>, | |
| } | |
| struct AppServerBootstrapCommand { | |
| /// Launch the managed app-server with remote control enabled. | |
| remote_control: bool, | |
| } | |
| struct GenerateTsCommand { | |
| /// Output directory where .ts files will be written | |
| out_dir: PathBuf, | |
| /// Optional path to the Prettier executable to format generated files | |
| prettier: Option<PathBuf>, | |
| /// Include experimental methods and fields in the generated output | |
| experimental: bool, | |
| } | |
| struct GenerateJsonSchemaCommand { | |
| /// Output directory where the schema bundle will be written | |
| out_dir: PathBuf, | |
| /// Include experimental methods and fields in the generated output | |
| experimental: bool, | |
| } | |
| struct GenerateInternalJsonSchemaCommand { | |
| /// Output directory where internal JSON Schema artifacts will be written | |
| out_dir: PathBuf, | |
| } | |
| struct StdioToUdsCommand { | |
| /// Path to the Unix domain socket to connect to. | |
| 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}")) | |
| } | |
| /// Handle the app exit and print the results. Optionally run the update action. | |
| 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(()) | |
| } | |
| /// Run the update action and print the result. | |
| 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 = { | |
| { | |
| let (cmd, args) = action.command_args(); | |
| let cmd = if action == UpdateAction::StandaloneWindows { | |
| // These args contain PowerShell metacharacters, so do not let | |
| // PATHEXT select a batch shim for this action. | |
| "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)?; | |
| // Do not let a project-local command or package-manager config | |
| // influence the updater after the user accepts the update prompt. | |
| let update_cwd = tempfile::tempdir()?; | |
| // Resolve through PATH without consulting the project cwd. When | |
| // this returns a .cmd/.bat shim, std::process::Command routes the | |
| // absolute path through the system command processor. | |
| std::process::Command::new(command_path) | |
| .args(args) | |
| .current_dir(update_cwd.path()) | |
| .status()? | |
| } | |
| { | |
| 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(()) | |
| } | |
| 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<()> { | |
| { | |
| anyhow::bail!( | |
| "`codex update` is not available in debug builds. Install a release build of Codex to use this command." | |
| ); | |
| } | |
| { | |
| 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, /*cli_executable*/ 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 | |
| } | |
| } | |
| } | |
| struct FeatureToggles { | |
| /// Enable a feature (repeatable). Equivalent to `-c features.<name>=true`. | |
| enable: Vec<String>, | |
| /// Disable a feature (repeatable). Equivalent to `-c features.<name>=false`. | |
| disable: Vec<String>, | |
| } | |
| struct InteractiveRemoteOptions { | |
| /// Connect the TUI to a remote app server endpoint. | |
| /// | |
| /// Accepted forms: `ws://host:port`, `wss://host:port`, `unix://`, or `unix://PATH`. | |
| remote: Option<String>, | |
| /// Name of the environment variable containing the bearer token to send to | |
| /// a remote app server websocket. | |
| 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}") | |
| } | |
| } | |
| } | |
| struct FeaturesCli { | |
| sub: FeaturesSubcommand, | |
| } | |
| enum FeaturesSubcommand { | |
| /// List known features with their stage and effective state. | |
| List, | |
| /// Enable a feature in config.toml. | |
| Enable(FeatureSetArgs), | |
| /// Disable a feature in config.toml. | |
| Disable(FeatureSetArgs), | |
| } | |
| struct FeatureSetArgs { | |
| /// Feature key to update (for example: unified_exec). | |
| 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(); | |
| // Retain the launch target through TUI exit, even if a launcher changes selection. | |
| 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)?; | |
| // Fold --enable/--disable into config overrides so they flow to all subcommands. | |
| 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( | |
| /*remote*/ None, | |
| root_remote_auth_token_env.clone(), | |
| )?; | |
| 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", | |
| )?; | |
| // Propagate any root-level config overrides (e.g. `-c key=value`). | |
| 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 { | |
| // Runtime teardown can wait forever for blocked rollout I/O. | |
| 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?; | |
| } | |
| 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(), | |
| ); | |
| 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)?; | |
| codex_cli::run_command_under_seatbelt( | |
| sandbox_cli, | |
| arg0_paths.codex_linux_sandbox_exe.clone(), | |
| loader_overrides, | |
| ) | |
| .await?; | |
| codex_cli::run_command_under_landlock( | |
| sandbox_cli, | |
| arg0_paths.codex_linux_sandbox_exe.clone(), | |
| loader_overrides, | |
| ) | |
| .await?; | |
| codex_cli::run_command_under_windows_sandbox( | |
| sandbox_cli, | |
| arg0_paths.codex_linux_sandbox_exe.clone(), | |
| loader_overrides, | |
| ) | |
| .await?; | |
| { | |
| 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, /*cwd*/ 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", | |
| )?; | |
| // Honor `--search` via the canonical web_search mode. | |
| 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, | |
| /*enable_workload_identity*/ 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(); | |
| 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, | |
| /*enable_workload_identity*/ false, | |
| ) | |
| .await; | |
| let config = if strict_config { | |
| Some(config_result?) | |
| } else { | |
| config_result.ok() | |
| }; | |
| let (_otel, telemetry) = exec_server_telemetry::init(config.as_ref()); | |
| 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, /*enable_codex_api_key_env*/ false) | |
| .await? | |
| .auth() | |
| .await | |
| .ok_or_else(|| anyhow::anyhow!("Agent Identity authentication is unavailable"))?; | |
| if !matches!(auth, CodexAuth::AgentIdentity(_)) { | |
| anyhow::bail!( | |
| "CODEX_ACCESS_TOKEN did not provide permitted Agent Identity authentication" | |
| ); | |
| } | |
| return Ok(codex_model_provider::auth_provider_from_auth(&auth)); | |
| } | |
| let (auth_manager, auth) = load_exec_server_remote_auth( | |
| config, | |
| "remote exec-server registration requires ChatGPT authentication or API key authentication; run `codex login` or set CODEX_API_KEY", | |
| ) | |
| .await?; | |
| if !is_supported_exec_server_remote_auth(&auth) { | |
| anyhow::bail!( | |
| "remote exec-server registration requires ChatGPT authentication or API key authentication; Agent Identity auth requires --use-agent-identity-auth" | |
| ); | |
| } | |
| if auth.is_api_key_auth() { | |
| validate_api_key_remote_host(base_url)?; | |
| } | |
| if auth_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, | |
| /*enable_codex_api_key_env*/ 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, /*enable_codex_api_key_env*/ true).await?; | |
| let auth = match auth_manager.auth().await { | |
| Some(auth) => auth, | |
| None => { | |
| auth_manager.reload().await; | |
| auth_manager | |
| .auth() | |
| .await | |
| .ok_or_else(|| anyhow::anyhow!(missing_auth_error))? | |
| } | |
| }; | |
| Ok((auth_manager, auth)) | |
| } | |
| 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, /*enabled*/ 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, /*enabled*/ 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, /*enable_codex_api_key_env*/ 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, | |
| /*state_db*/ 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, /*enable_codex_api_key_env*/ 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(()) | |
| } | |
| /// Prepend root-level overrides so they have lower precedence than | |
| /// CLI-specific ones specified after the subcommand (if any). | |
| 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(()), | |
| } | |
| } | |
| /// Return the selected subcommand name when a root-level `--strict-config` | |
| /// flag should be rejected after parsing. | |
| /// | |
| /// `--strict-config` is parsed on the root interactive CLI so commands like | |
| /// `codex --strict-config` continue to work for the TUI and for wrappers that | |
| /// forward root options into another command shape. Clap will still accept that | |
| /// root flag before the dispatcher knows which subcommand the user selected, so | |
| /// unsupported subcommands need an explicit post-parse reject path. | |
| /// | |
| /// `Some(...)` returns the user-facing command name fragment to embed in the | |
| /// rejection error, such as `cloud` or `app-server proxy`. `None` means the | |
| /// selected command is allowed to inherit root `--strict-config`. | |
| 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"), | |
| 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() { | |
| // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state. | |
| 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.", | |
| )); | |
| } | |
| } | |
| 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 { | |
| // Keep the large TUI future out of the CLI dispatcher's stack frame. | |
| 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")) | |
| } | |
| /// Build the final `TuiCli` for a `codex resume` invocation. | |
| 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 { | |
| // Start with the parsed interactive CLI so resume shares the same | |
| // configuration surface area as `codex` without additional flags. | |
| // Clap assigns the first positional to `session_id`. With `--last`, reinterpret it as the | |
| // prompt when no second positional prompt was provided. | |
| 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 resume-scoped flags and overrides with highest precedence. | |
| merge_interactive_cli_flags(&mut interactive, resume_cli); | |
| // Propagate any root-level config overrides (e.g. `-c key=value`). | |
| prepend_config_flags(&mut interactive.config_overrides, root_config_overrides); | |
| interactive | |
| } | |
| /// Build the final `TuiCli` for a `codex fork` invocation. | |
| 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 { | |
| // Start with the parsed interactive CLI so fork shares the same | |
| // configuration surface area as `codex` without additional flags. | |
| // Clap assigns the first positional to `session_id`. With `--last`, reinterpret it as the | |
| // prompt when no second positional prompt was provided. | |
| 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 fork-scoped flags and overrides with highest precedence. | |
| merge_interactive_cli_flags(&mut interactive, fork_cli); | |
| // Propagate any root-level config overrides (e.g. `-c key=value`). | |
| 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 | |
| } | |
| /// Merge flags provided to runtime wrapper commands so they take precedence over any root-level | |
| /// flags. Only overrides fields explicitly set on the subcommand-scoped CLI. Also appends | |
| /// `-c key=value` overrides with highest precedence. | |
| 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 { | |
| // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state. | |
| 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()); | |
| } | |
| mod tests { | |
| use super::*; | |
| use assert_matches::assert_matches; | |
| use codex_protocol::ThreadId; | |
| use codex_tui::TokenUsage; | |
| use pretty_assertions::assert_eq; | |
| fn interactive_tui_future_stays_bounded() { | |
| let future = run_interactive_tui( | |
| TuiCli::parse_from(["codex"]), | |
| /*remote*/ None, | |
| /*remote_auth_token_env*/ None, | |
| Arg0DispatchPaths::default(), | |
| ); | |
| let size = std::mem::size_of_val(&future); | |
| assert!(size < 64 * 1024, "interactive TUI future is {size} bytes"); | |
| } | |
| 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/" | |
| ) | |
| ); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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)); | |
| } | |
| 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()); | |
| } | |
| } | |
| 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()); | |
| } | |
| } | |
| 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" | |
| ); | |
| } | |
| } | |
| 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)) | |
| } | |
| 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(()) | |
| } | |
| fn profile_v2_is_rejected_for_config_management_subcommands() { | |
| assert!(profile_v2_for_args(&["codex", "--profile", "work", "features", "list"]).is_err()); | |
| } | |
| 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") | |
| ); | |
| } | |
| 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")); | |
| } | |
| fn profile_v2_rejects_non_plain_names_at_parse_time() { | |
| assert!( | |
| MultitoolCli::try_parse_from(["codex", "--profile", "nested/work", "resume"]).is_err() | |
| ); | |
| } | |
| 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:?}", | |
| ); | |
| } | |
| } | |
| 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")); | |
| } | |
| 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")); | |
| } | |
| 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")); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| } | |
| 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}"); | |
| } | |
| } | |
| 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()); | |
| } | |
| 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(), | |
| ] | |
| ); | |
| } | |
| 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(), | |
| ] | |
| ); | |
| } | |
| 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(), | |
| ] | |
| ); | |
| } | |
| 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(), | |
| ] | |
| ); | |
| } | |
| 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") | |
| } | |
| 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")] | |
| ); | |
| } | |
| 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); | |
| } | |
| 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() | |
| } | |
| 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(_)))); | |
| } | |
| 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}"); | |
| } | |
| } | |
| 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(_)))); | |
| } | |
| 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(_)))); | |
| } | |
| 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(_)))); | |
| } | |
| 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(_)))); | |
| } | |
| 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(_)))); | |
| } | |
| fn update_parses_as_update_subcommand() { | |
| let cli = MultitoolCli::try_parse_from(["codex", "update"]).expect("parse"); | |
| assert!(matches!(cli.subcommand, Some(Subcommand::Update))); | |
| } | |
| 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); | |
| } | |
| fn delete_force_requires_uuid() { | |
| assert!(delete_action("123e4567-e89b-12d3-a456-426614174000", /*force*/ true).is_ok()); | |
| let err = | |
| delete_action("my-thread", /*force*/ true).expect_err("name should require prompt"); | |
| assert_eq!( | |
| err.to_string(), | |
| "--force requires a session UUID; names must be confirmed interactively" | |
| ); | |
| } | |
| 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"]); | |
| } | |
| 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"]); | |
| } | |
| 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}"); | |
| } | |
| 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"]); | |
| } | |
| 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"]); | |
| } | |
| 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); | |
| } | |
| 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(_)))); | |
| } | |
| 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, | |
| } | |
| } | |
| 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(/*color_enabled*/ false); | |
| assert!(lines.is_empty()); | |
| } | |
| 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"), | |
| /*thread_name*/ 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(/*color_enabled*/ 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", | |
| ] | |
| ); | |
| } | |
| fn format_exit_messages_includes_session_id_without_resume_hint() { | |
| let mut exit_info = sample_exit_info( | |
| Some("123e4567-e89b-12d3-a456-426614174000"), | |
| /*thread_name*/ None, | |
| ); | |
| exit_info.token_usage = TokenUsage::default(); | |
| exit_info.resume_hint = None; | |
| let lines = exit_info.format_exit_messages(/*color_enabled*/ false); | |
| insta::assert_snapshot!(lines.join("\n"), @"Session ID: 123e4567-e89b-12d3-a456-426614174000"); | |
| } | |
| fn format_exit_messages_confirms_archive() { | |
| let mut exit_info = sample_exit_info( | |
| Some("123e4567-e89b-12d3-a456-426614174000"), | |
| /*thread_name*/ None, | |
| ); | |
| exit_info.exit_reason = ExitReason::Archived(exit_info.thread_id.unwrap()); | |
| let lines = exit_info.format_exit_messages(/*color_enabled*/ false); | |
| insta::assert_snapshot!(lines.join("\n"), @" | |
| Token usage: total=2 input=0 output=2 | |
| Session archived: 123e4567-e89b-12d3-a456-426614174000 | |
| "); | |
| } | |
| 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(/*color_enabled*/ false); | |
| assert_eq!( | |
| lines, | |
| vec!["Session ID: 123e4567-e89b-12d3-a456-426614174000".to_string()] | |
| ); | |
| } | |
| fn format_exit_messages_includes_resume_hint_for_fatal_exit() { | |
| let mut exit_info = sample_exit_info( | |
| Some("123e4567-e89b-12d3-a456-426614174000"), | |
| /*thread_name*/ None, | |
| ); | |
| exit_info.exit_reason = ExitReason::Fatal("boom".to_string()); | |
| let lines = exit_info.format_exit_messages(/*color_enabled*/ 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(), | |
| ] | |
| ); | |
| } | |
| 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(/*color_enabled*/ 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 | |
| "); | |
| } | |
| } | |
| } | |
| fn format_exit_messages_applies_color_when_enabled() { | |
| let exit_info = sample_exit_info( | |
| Some("123e4567-e89b-12d3-a456-426614174000"), | |
| /*thread_name*/ None, | |
| ); | |
| let lines = exit_info.format_exit_messages(/*color_enabled*/ 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", | |
| ] | |
| ); | |
| } | |
| 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(/*color_enabled*/ 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. | |
| "); | |
| } | |
| 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(/*color_enabled*/ 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.", | |
| ] | |
| ); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| 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") | |
| ); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| 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")); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| 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")); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| 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") | |
| ); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| 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")); | |
| } | |
| 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); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| 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, | |
| .. | |
| })) | |
| ); | |
| } | |
| 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"); | |
| } | |
| 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() | |
| ); | |
| } | |
| } | |
| 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"); | |
| } | |
| 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`" | |
| ); | |
| } | |
| 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`" | |
| ); | |
| } | |
| 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")); | |
| } | |
| 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"); | |
| } | |
| 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")); | |
| } | |
| 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") | |
| ); | |
| } | |
| 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")); | |
| } | |
| 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); | |
| } | |
| fn reject_remote_mode_for_non_interactive_subcommands() { | |
| let err = reject_remote_mode_for_subcommand( | |
| Some("127.0.0.1:4500"), | |
| /*remote_auth_token_env*/ None, | |
| "exec", | |
| ) | |
| .expect_err("non-interactive subcommands should reject --remote"); | |
| assert!( | |
| err.to_string() | |
| .contains("only supported for interactive TUI commands") | |
| ); | |
| } | |
| fn reject_remote_auth_token_env_for_non_interactive_subcommands() { | |
| let err = reject_remote_mode_for_subcommand( | |
| /*remote*/ 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") | |
| ); | |
| } | |
| 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( | |
| /*remote*/ 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")); | |
| } | |
| 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")); | |
| } | |
| 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"); | |
| } | |
| 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")); | |
| } | |
| 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")) | |
| ); | |
| } | |
| 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")); | |
| } | |
| } | |
| 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"), | |
| } | |
| ); | |
| } | |
| 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 | |
| ); | |
| } | |
| fn app_server_stdio_flag_parses() { | |
| let app_server = app_server_from_args(["codex", "app-server", "--stdio"].as_ref()); | |
| assert!(app_server.stdio); | |
| } | |
| 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); | |
| } | |
| 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() | |
| } | |
| ); | |
| } | |
| 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") | |
| } | |
| ); | |
| } | |
| 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); | |
| } | |
| 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()); | |
| } | |
| 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 | |
| })) | |
| )); | |
| } | |
| 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 | |
| })) | |
| )); | |
| } | |
| 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") | |
| ) | |
| ); | |
| } | |
| 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( | |
| /*remote*/ 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")); | |
| } | |
| 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( | |
| /*remote*/ 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")); | |
| } | |
| 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")) | |
| ); | |
| } | |
| 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)); | |
| } | |
| 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()); | |
| } | |
| 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"); | |
| } | |
| 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"); | |
| } | |
| 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(), | |
| ] | |
| ); | |
| } | |
| 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(),] | |
| ); | |
| } | |
| 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(),] | |
| ); | |
| } | |
| 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(),]); | |
| } | |
| 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()]); | |
| } | |
| 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"); | |
| } | |
| 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"); | |
| } | |
| 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"); | |
| } | |
| 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") | |
| } | |
| } | |
| mod daemon_update_tests; | |