Merge remote-tracking branch 'refs/remotes/origin/main' into dev/winston/credentialed-routes-discovery

# Conflicts:
#	codex-rs/backend-client/src/client.rs
#	codex-rs/backend-client/src/lib.rs
#	codex-rs/network-proxy/Cargo.toml
#	codex-rs/network-proxy/README.md
#	codex-rs/network-proxy/src/certs.rs
#	codex-rs/network-proxy/src/proxy.rs
This commit is contained in:
Winston Howes
2026-06-01 17:41:36 -07:00
1014 changed files with 45761 additions and 12215 deletions
+1
View File
@@ -83,6 +83,7 @@ tracing = { workspace = true }
tracing-appender = { workspace = true }
tracing-subscriber = { workspace = true }
unicode-segmentation = { workspace = true }
url = { workspace = true }
which = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
+29 -26
View File
@@ -23,6 +23,7 @@ use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_permission
use codex_sandboxing::seatbelt::CreateSeatbeltCommandArgsParams;
#[cfg(target_os = "macos")]
use codex_sandboxing::seatbelt::create_seatbelt_command_args;
use codex_sandboxing::with_managed_mitm_ca_readable_root;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::CliConfigOverrides;
use tokio::process::Child;
@@ -208,10 +209,12 @@ async fn run_command_under_sandbox(
// In practice, this should be `std::env::current_dir()` because this CLI
// does not support `--cwd`, but let's use the config value for consistency.
let cwd = config.cwd.clone();
// For now, we always use the same cwd for both the command and the
// sandbox policy. In the future, we could add a CLI option to set them
// separately.
// Non-Windows sandbox launchers still use `sandbox_policy_cwd` for any
// remaining cwd-dependent policy resolution. `:workspace_roots` entries in
// the effective profile have already been materialized from config roots.
let sandbox_policy_cwd = cwd.clone();
#[cfg(target_os = "windows")]
let workspace_roots = config.effective_workspace_roots();
let env = create_env(
&config.permissions.shell_environment_policy,
@@ -222,7 +225,7 @@ async fn run_command_under_sandbox(
if let SandboxType::Windows = sandbox_type {
#[cfg(target_os = "windows")]
{
run_command_under_windows_session(&config, command, cwd, sandbox_policy_cwd, env).await;
run_command_under_windows_session(&config, command, cwd, workspace_roots, env).await;
}
#[cfg(not(target_os = "windows"))]
{
@@ -255,12 +258,21 @@ async fn run_command_under_sandbox(
let network = network_proxy
.as_ref()
.map(codex_core::config::StartedNetworkProxy::proxy);
let managed_mitm_ca_trust_bundle_path = match network.as_ref() {
Some(network) => network.managed_mitm_ca_trust_bundle_path(),
None => None,
};
let runtime_permission_profile = with_managed_mitm_ca_readable_root(
config.permissions.effective_permission_profile(),
managed_mitm_ca_trust_bundle_path.as_ref(),
sandbox_policy_cwd.as_path(),
);
let mut child = match sandbox_type {
#[cfg(target_os = "macos")]
SandboxType::Seatbelt => {
let file_system_sandbox_policy = config.permissions.file_system_sandbox_policy();
let network_sandbox_policy = config.permissions.network_sandbox_policy();
let (file_system_sandbox_policy, network_sandbox_policy) =
runtime_permission_profile.to_runtime_permissions();
let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams {
command,
file_system_sandbox_policy: &file_system_sandbox_policy,
@@ -292,11 +304,11 @@ async fn run_command_under_sandbox(
.codex_linux_sandbox_exe
.expect("codex-linux-sandbox executable not found");
let use_legacy_landlock = config.features.use_legacy_landlock();
let network_sandbox_policy = config.permissions.network_sandbox_policy();
let network_sandbox_policy = runtime_permission_profile.network_sandbox_policy();
let args = create_linux_sandbox_command_args_for_permission_profile(
command,
cwd.as_path(),
&config.permissions.effective_permission_profile(),
&runtime_permission_profile,
sandbox_policy_cwd.as_path(),
use_legacy_landlock,
allow_network_for_proxy(managed_network_requirements_enabled),
@@ -350,24 +362,15 @@ async fn run_command_under_windows_session(
config: &Config,
command: Vec<String>,
cwd: AbsolutePathBuf,
sandbox_policy_cwd: AbsolutePathBuf,
workspace_roots: Vec<AbsolutePathBuf>,
env: std::collections::HashMap<String, String>,
) -> ! {
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_windows_sandbox::spawn_windows_sandbox_session_elevated;
use codex_windows_sandbox::spawn_windows_sandbox_session_elevated_for_permission_profile;
use codex_windows_sandbox::spawn_windows_sandbox_session_legacy;
let sandbox_policy = config
.permissions
.legacy_sandbox_policy(sandbox_policy_cwd.as_path());
let policy_str = match serde_json::to_string(&sandbox_policy) {
Ok(policy_str) => policy_str,
Err(err) => {
eprintln!("windows sandbox failed to serialize policy: {err}");
std::process::exit(1);
}
};
let permission_profile = config.permissions.effective_permission_profile();
let use_elevated = matches!(
WindowsSandboxLevel::from_config(config),
@@ -375,9 +378,9 @@ async fn run_command_under_windows_session(
);
let spawned = if use_elevated {
spawn_windows_sandbox_session_elevated(
policy_str.as_str(),
sandbox_policy_cwd.as_path(),
spawn_windows_sandbox_session_elevated_for_permission_profile(
&permission_profile,
workspace_roots.as_slice(),
config.codex_home.as_path(),
command,
cwd.as_path(),
@@ -395,8 +398,8 @@ async fn run_command_under_windows_session(
.await
} else {
spawn_windows_sandbox_session_legacy(
policy_str.as_str(),
sandbox_policy_cwd.as_path(),
&permission_profile,
workspace_roots.as_slice(),
config.codex_home.as_path(),
command,
cwd.as_path(),
@@ -1134,7 +1137,7 @@ mod tests {
}
#[tokio::test]
async fn debug_sandbox_uses_explicit_profile_cwd() -> anyhow::Result<()> {
async fn debug_sandbox_uses_explicit_cwd() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
+35 -3
View File
@@ -82,10 +82,11 @@ async fn open_codex_app(app_path: &Path, workspace: &Path) -> anyhow::Result<()>
"Opening workspace {workspace}...",
workspace = workspace.display()
);
let url = codex_new_thread_url(workspace);
let status = Command::new("open")
.arg("-a")
.arg(app_path)
.arg(workspace)
.arg(&url)
.status()
.await
.context("failed to invoke `open`")?;
@@ -95,12 +96,20 @@ async fn open_codex_app(app_path: &Path, workspace: &Path) -> anyhow::Result<()>
}
anyhow::bail!(
"`open -a {app_path} {workspace}` exited with {status}",
"`open -a {app_path} {url}` exited with {status}",
app_path = app_path.display(),
workspace = workspace.display()
url = url
);
}
fn codex_new_thread_url(workspace: &Path) -> String {
let workspace = workspace.as_os_str().to_string_lossy();
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
serializer.append_pair("path", workspace.as_ref());
let query = serializer.finish();
format!("codex://threads/new?{query}")
}
async fn download_and_install_codex_to_user_applications(dmg_url: &str) -> anyhow::Result<PathBuf> {
let temp_dir = Builder::new()
.prefix("codex-app-installer-")
@@ -293,8 +302,10 @@ fn parse_hdiutil_attach_mount_point(output: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::codex_new_thread_url;
use super::parse_hdiutil_attach_mount_point;
use pretty_assertions::assert_eq;
use std::path::Path;
#[test]
fn parses_mount_point_from_tab_separated_hdiutil_output() {
@@ -313,4 +324,25 @@ mod tests {
Some("/Volumes/Codex Installer")
);
}
#[test]
fn codex_new_thread_url_encodes_workspace_path() {
let url = url::Url::parse(&codex_new_thread_url(Path::new("/tmp/codex workspace/#1")))
.expect("deep link should parse");
assert_eq!(
(
url.scheme().to_string(),
url.host_str().map(str::to_string),
url.path().to_string(),
url.query_pairs().into_owned().collect::<Vec<_>>(),
),
(
"codex".to_string(),
Some("threads".to_string()),
"/new".to_string(),
vec![("path".to_string(), "/tmp/codex workspace/#1".to_string())],
)
);
}
}
+1 -5
View File
@@ -2176,11 +2176,7 @@ struct RolloutStats {
impl RolloutStats {
fn average_bytes(&self) -> u64 {
if self.files == 0 {
0
} else {
self.total_bytes / self.files
}
self.total_bytes.checked_div(self.files).unwrap_or(0)
}
}
+1
View File
@@ -143,6 +143,7 @@ fn terminal_title_item_id(item: &str) -> Option<&'static str> {
"fast-mode" => Some("fast-mode"),
"model" | "model-name" => Some("model"),
"model-with-reasoning" => Some("model-with-reasoning"),
"reasoning" => Some("reasoning"),
"task-progress" => Some("task-progress"),
_ => None,
}
+376 -37
View File
@@ -35,6 +35,7 @@ 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 codex_utils_cli::resume_hint;
use owo_colors::OwoColorize;
use std::io::IsTerminal;
@@ -50,6 +51,8 @@ mod marketplace_cmd;
mod mcp_cmd;
mod plugin_cmd;
mod remote_control_cmd;
#[cfg(target_os = "windows")]
mod sandbox_setup;
mod state_db_recovery;
#[cfg(not(windows))]
mod wsl_paths;
@@ -173,6 +176,12 @@ enum Subcommand {
/// Resume a previous interactive session (picker by default; use --last to continue the most recent).
Resume(ResumeCommand),
/// Archive a saved session by id or session name.
Archive(SessionArchiveCommand),
/// 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),
@@ -294,7 +303,7 @@ struct DebugTraceReduceCommand {
#[derive(Debug, Parser)]
struct ResumeCommand {
/// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses.
/// Session id (UUID) or session name. UUIDs take precedence if it parses.
/// If omitted, use --last to pick the most recent recorded session.
#[arg(value_name = "SESSION_ID")]
session_id: Option<String>,
@@ -318,6 +327,32 @@ struct ResumeCommand {
config_overrides: TuiCli,
}
#[derive(Debug, Parser)]
struct SessionArchiveCommand {
/// Session id (UUID) or session name. UUIDs take precedence if it parses.
#[arg(value_name = "SESSION")]
target: String,
#[clap(flatten)]
remote: InteractiveRemoteOptions,
#[clap(flatten)]
config_overrides: SessionArchiveConfigOverrides,
}
#[derive(Debug, Args, Clone, Default)]
struct SessionArchiveConfigOverrides {
#[clap(flatten)]
shared: SharedCliOptions,
/// Error out when config.toml contains fields that are not recognized by this version of Codex.
#[arg(long = "strict-config", default_value_t = false)]
strict_config: bool,
#[clap(flatten)]
config_overrides: CliConfigOverrides,
}
#[derive(Debug, Parser)]
struct ForkCommand {
/// Conversation/session id (UUID). When provided, forks this session.
@@ -452,6 +487,10 @@ struct AppServerCommand {
)]
listen: codex_app_server::AppServerTransport,
/// Use stdio as the transport (equivalent to `--listen stdio://`).
#[arg(long = "stdio", conflicts_with = "listen")]
stdio: bool,
/// Enable remote control for this app-server process.
#[arg(long = "remote-control", hide = true)]
remote_control: bool,
@@ -730,6 +769,39 @@ 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}"))
}
async fn run_debug_app_server_command(cmd: DebugAppServerCommand) -> anyhow::Result<()> {
match cmd.subcommand {
DebugAppServerSubcommand::SendMessageV2(cmd) => {
@@ -965,6 +1037,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
subcommand,
strict_config: app_server_strict_config,
listen,
stdio,
remote_control,
analytics_default_enabled,
auth,
@@ -978,7 +1051,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
)?;
match subcommand {
None => {
let transport = listen;
let transport = if stdio {
codex_app_server::AppServerTransport::Stdio
} else {
listen
};
let auth = auth.try_into_settings()?;
let runtime_options = codex_app_server::AppServerRuntimeOptions {
remote_control_enabled: remote_control,
@@ -1115,6 +1192,32 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
.await?;
handle_app_exit(exit_info)?;
}
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::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,
@@ -1241,6 +1344,16 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
.await?;
}
Some(Subcommand::Sandbox(mut sandbox_cli)) => {
#[cfg(target_os = "windows")]
if let Some(setup_cli) = sandbox_setup::parse_setup_command(&sandbox_cli.command)? {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"sandbox setup",
)?;
sandbox_setup::run(setup_cli).await?;
return Ok(());
}
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
@@ -1454,6 +1567,8 @@ fn profile_v2_for_subcommand<'a>(
Subcommand::Exec(_)
| Subcommand::Review(_)
| Subcommand::Resume(_)
| Subcommand::Archive(_)
| Subcommand::Unarchive(_)
| Subcommand::Fork(_)
| Subcommand::Mcp(_)
| Subcommand::Sandbox(_)
@@ -1461,7 +1576,7 @@ fn profile_v2_for_subcommand<'a>(
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 fork`, `codex mcp`, `codex sandbox`, and `codex debug prompt-input`."
"--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex archive`, `codex unarchive`, `codex fork`, `codex mcp`, `codex sandbox`, and `codex debug prompt-input`."
),
}
}
@@ -1486,7 +1601,8 @@ async fn run_exec_server_command(
.ok_or_else(|| anyhow::anyhow!("--environment-id is required when --remote is set"))?;
let config = load_exec_server_config(root_config_overrides, strict_config).await?;
let auth_provider =
load_exec_server_remote_auth_provider(&config, cmd.use_agent_identity_auth).await?;
load_exec_server_remote_auth_provider(&config, &base_url, cmd.use_agent_identity_auth)
.await?;
let mut remote_config = codex_exec_server::RemoteEnvironmentConfig::new(
base_url,
environment_id,
@@ -1516,6 +1632,7 @@ async fn run_exec_server_command(
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 {
@@ -1530,19 +1647,61 @@ async fn load_exec_server_remote_auth_provider(
let auth = load_exec_server_remote_auth(
config,
"remote exec-server registration requires ChatGPT authentication; run `codex login` first",
"remote exec-server registration requires ChatGPT authentication or API key authentication; run `codex login` or set CODEX_API_KEY",
)
.await?;
if !auth.is_chatgpt_auth() {
if !is_supported_exec_server_remote_auth(&auth) {
anyhow::bail!(
"remote exec-server registration requires ChatGPT authentication; API key and Agent Identity auth are not supported"
"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)?;
}
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,
@@ -1837,6 +1996,8 @@ fn unsupported_subcommand_name_for_strict_config(
| Some(Subcommand::McpServer(_))
| Some(Subcommand::ExecServer(_))
| Some(Subcommand::Resume(_))
| Some(Subcommand::Archive(_))
| Some(Subcommand::Unarchive(_))
| Some(Subcommand::Fork(_))
| Some(Subcommand::Doctor(_)) => None,
Some(Subcommand::AppServer(app_server)) if app_server.subcommand.is_none() => None,
@@ -1984,34 +2145,13 @@ async fn run_interactive_tui(
}
}
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 Ok(AppExitInfo::fatal(
"`--remote-auth-token-env` requires `--remote`.",
));
};
if !codex_tui::remote_addr_supports_auth_token(endpoint) {
return Ok(AppExitInfo::fatal(
"`--remote-auth-token-env` requires a `wss://` or loopback `ws://` remote.",
));
let remote_endpoint = match resolve_remote_endpoint(remote, remote_auth_token_env) {
Ok(remote_endpoint) => remote_endpoint,
Err(err) if is_remote_auth_usage_error(&err) => {
return Ok(AppExitInfo::fatal(err.to_string()));
}
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 Ok(AppExitInfo::fatal(
"`--remote-auth-token-env` requires a `wss://` or loopback `ws://` remote.",
));
};
*slot = Some(auth_token);
}
Err(err) => return Err(err),
};
let start_tui = || {
codex_tui::run_main(
interactive.clone(),
@@ -2055,6 +2195,46 @@ async fn run_interactive_tui(
}
}
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}");
@@ -2118,9 +2298,31 @@ fn finalize_fork_interactive(
interactive
}
/// Merge flags provided to `codex resume`/`codex fork` 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 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,
@@ -2168,6 +2370,63 @@ mod tests {
use codex_tui::TokenUsage;
use pretty_assertions::assert_eq;
#[test]
fn exec_server_remote_auth_accepts_api_key_auth() {
let auth = CodexAuth::from_api_key("sk-test");
assert!(is_supported_exec_server_remote_auth(&auth));
}
#[test]
fn exec_server_remote_api_key_auth_accepts_https_openai_domains() {
for base_url in [
"https://openai.com/api",
"https://service.openai.com/api",
"https://openai.org/api",
"https://service.openai.org/api",
] {
assert!(validate_api_key_remote_host(base_url).is_ok());
}
}
#[test]
fn exec_server_remote_api_key_auth_accepts_http_loopback() {
for base_url in [
"http://localhost:8098/api",
"http://127.0.0.1:8098/api",
"http://[::1]:8098/api",
] {
assert!(validate_api_key_remote_host(base_url).is_ok());
}
}
#[test]
fn exec_server_remote_api_key_auth_rejects_http_openai_domain() {
for base_url in [
"http://service.openai.com/api",
"http://service.openai.org/api",
] {
let error = validate_api_key_remote_host(base_url)
.expect_err("reject plaintext OpenAI destination");
assert_eq!(
error.to_string(),
"remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts"
);
}
}
#[test]
fn exec_server_remote_api_key_auth_rejects_suffix_spoof() {
let error = validate_api_key_remote_host("https://service.openai.org.evil.example/api")
.expect_err("reject suffix spoof");
assert_eq!(
error.to_string(),
"remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts"
);
}
fn finalize_resume_from_args(args: &[&str]) -> TuiCli {
let cli = MultitoolCli::try_parse_from(args).expect("parse");
let MultitoolCli {
@@ -2225,6 +2484,32 @@ mod tests {
finalize_fork_interactive(interactive, root_overrides, session_id, last, all, fork_cli)
}
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 {
@@ -2495,6 +2780,41 @@ mod tests {
assert!(matches!(cli.subcommand, Some(Subcommand::Update)));
}
#[test]
fn archive_merges_scoped_tui_flags() {
let (target, interactive, remote) = finalize_archive_from_args(
[
"codex",
"-C",
"/root",
"archive",
"--remote",
"unix://archive.sock",
"--strict-config",
"--dangerously-bypass-hook-trust",
"-m",
"gpt-5.1-test",
"-p",
"work",
"-C",
"/archive",
"my-thread",
]
.as_ref(),
);
assert_eq!(target, "my-thread");
assert_eq!(remote.remote.as_deref(), Some("unix://archive.sock"));
assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test"));
assert_eq!(interactive.config_profile_v2.as_deref(), Some("work"));
assert_eq!(
interactive.cwd.as_deref(),
Some(std::path::Path::new("/archive"))
);
assert!(interactive.strict_config);
assert!(interactive.bypass_hook_trust);
}
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
#[test]
fn sandbox_parses_permissions_profile() {
@@ -3096,6 +3416,25 @@ mod tests {
);
}
#[test]
fn app_server_stdio_flag_parses() {
let app_server = app_server_from_args(["codex", "app-server", "--stdio"].as_ref());
assert!(app_server.stdio);
}
#[test]
fn app_server_stdio_flag_conflicts_with_listen() {
let err = MultitoolCli::try_parse_from([
"codex",
"app-server",
"--stdio",
"--listen",
"stdio://",
])
.expect_err("--stdio and --listen should be rejected together");
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn app_server_listen_unix_socket_url_parses() {
let app_server =
+3 -3
View File
@@ -537,7 +537,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
let effective_mcp_servers = mcp_manager.effective_servers(&config, /*auth*/ None).await;
let mut entries: Vec<_> = mcp_servers.iter().collect();
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
entries.sort_by_key(|(name, _)| *name);
let auth_statuses = compute_auth_statuses(
effective_mcp_servers.iter(),
config.mcp_oauth_credentials_store_mode,
@@ -906,7 +906,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re
let headers_display = match http_headers {
Some(map) if !map.is_empty() => {
let mut pairs: Vec<_> = map.iter().collect();
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
pairs.sort_by_key(|(name, _)| *name);
pairs
.into_iter()
.map(|(k, _)| format!("{k}=*****"))
@@ -919,7 +919,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re
let env_headers_display = match env_http_headers {
Some(map) if !map.is_empty() => {
let mut pairs: Vec<_> = map.iter().collect();
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
pairs.sort_by_key(|(name, _)| *name);
pairs
.into_iter()
.map(|(k, var)| format!("{k}={var}"))
+206
View File
@@ -0,0 +1,206 @@
use std::path::PathBuf;
use clap::ArgAction;
use clap::ArgGroup;
use clap::Parser;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::find_codex_home;
#[derive(Debug, Parser)]
#[command(group(
ArgGroup::new("sandbox_user")
.required(true)
.args(["user", "current_user"])
))]
pub(crate) struct SandboxSetupCommand {
/// Set up the elevated Windows sandbox.
#[arg(long = "elevated", action = ArgAction::SetTrue)]
elevated_sandbox_level: bool,
/// Windows user that will run Codex after managed deployment.
#[arg(
long = "user",
value_name = "USER",
conflicts_with = "current_user",
requires = "codex_home"
)]
user: Option<String>,
/// Use the current Windows user as the Codex user.
#[arg(
long = "current-user",
default_value_t = false,
conflicts_with = "user"
)]
current_user: bool,
/// CODEX_HOME for the Codex user. Required with --user.
#[arg(long = "codex-home", value_name = "DIR")]
codex_home: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SandboxSetupLevel {
Elevated,
}
impl SandboxSetupCommand {
fn setup_level(&self) -> anyhow::Result<SandboxSetupLevel> {
if self.elevated_sandbox_level {
Ok(SandboxSetupLevel::Elevated)
} else {
anyhow::bail!("`codex sandbox setup` currently requires --elevated");
}
}
}
pub(crate) async fn run(cmd: SandboxSetupCommand) -> anyhow::Result<()> {
match cmd.setup_level()? {
SandboxSetupLevel::Elevated => run_elevated(cmd).await,
}
}
pub(crate) fn parse_setup_command(
sandbox_command: &[String],
) -> anyhow::Result<Option<SandboxSetupCommand>> {
if sandbox_command
.first()
.is_none_or(|command| command != "setup")
{
return Ok(None);
}
SandboxSetupCommand::try_parse_from(sandbox_command.iter().map(String::as_str))
.map(Some)
.map_err(anyhow::Error::from)
}
async fn run_elevated(cmd: SandboxSetupCommand) -> anyhow::Result<()> {
let identity = resolve_sandbox_setup_identity(&cmd)?;
codex_core::windows_sandbox::run_elevated_provisioning_setup(
identity.codex_home.as_path(),
identity.real_user.as_str(),
)?;
ConfigEditsBuilder::new(identity.codex_home.as_path())
.set_windows_sandbox_mode("elevated")
.apply()
.await
.map_err(|err| {
anyhow::anyhow!(
"sandbox provisioning succeeded, but failed to persist elevated sandbox config: {err}"
)
})?;
println!(
"Windows elevated sandbox setup completed for {} at {}.",
identity.real_user,
identity.codex_home.display()
);
Ok(())
}
struct SandboxSetupIdentity {
real_user: String,
codex_home: PathBuf,
}
fn resolve_sandbox_setup_identity(
cmd: &SandboxSetupCommand,
) -> anyhow::Result<SandboxSetupIdentity> {
if cmd.current_user {
let real_user = std::env::var("USERNAME")
.or_else(|_| std::env::var("USER"))
.map_err(|err| {
anyhow::anyhow!("failed to determine current user from environment: {err}")
})?;
let codex_home = match cmd.codex_home.clone() {
Some(codex_home) => codex_home,
None => find_codex_home()?.to_path_buf(),
};
return Ok(SandboxSetupIdentity {
real_user,
codex_home,
});
}
let real_user = cmd
.user
.clone()
.ok_or_else(|| anyhow::anyhow!("--user or --current-user is required"))?;
let codex_home = cmd
.codex_home
.clone()
.ok_or_else(|| anyhow::anyhow!("--codex-home is required with --user"))?;
Ok(SandboxSetupIdentity {
real_user,
codex_home,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_managed_user_identity() {
let command = SandboxSetupCommand::try_parse_from([
"setup",
"--elevated",
"--user",
"DOMAIN\\alice",
"--codex-home",
r"C:\Users\alice\.codex",
])
.expect("parse");
assert!(command.elevated_sandbox_level);
assert_eq!(command.user.as_deref(), Some(r"DOMAIN\alice"));
assert!(!command.current_user);
assert_eq!(
command.codex_home.as_deref(),
Some(std::path::Path::new(r"C:\Users\alice\.codex"))
);
}
#[test]
fn requires_explicit_user_identity() {
let err = SandboxSetupCommand::try_parse_from(["setup", "--elevated"])
.expect_err("parse should fail");
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn requires_codex_home_for_managed_user() {
let err =
SandboxSetupCommand::try_parse_from(["setup", "--elevated", "--user", "DOMAIN\\alice"])
.expect_err("parse should fail");
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn parses_setup_from_sandbox_command_args() {
let command = parse_setup_command(&[
"setup".to_string(),
"--elevated".to_string(),
"--user".to_string(),
r"DOMAIN\alice".to_string(),
"--codex-home".to_string(),
r"C:\Users\alice\.codex".to_string(),
])
.expect("parse")
.expect("setup command");
assert_eq!(command.user.as_deref(), Some(r"DOMAIN\alice"));
}
#[test]
fn ignores_non_setup_sandbox_command_args() {
let command =
parse_setup_command(&["echo".to_string(), "hello".to_string()]).expect("parse");
assert!(command.is_none());
}
}