mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
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:
@@ -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]
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
|
||||
@@ -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())],
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 =
|
||||
|
||||
@@ -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}"))
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user