mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Summary Stacked on #26706. Adds the shared auth/system-proxy contract that later platform resolver PRs plug into. This PR moves Codex-owned auth and startup HTTP clients through a common route-aware boundary, but does not yet add Windows or macOS system proxy resolution. The default path remains unchanged when `respect_system_proxy` is absent or disabled. ## Implementation - Adds `codex-client/src/outbound_proxy.rs` with the shared route-selection model: - `OutboundProxyConfig`; - `ClientRouteClass`; - `RouteFailureClass`; - `build_reqwest_client_for_route`. - Preserves the existing reqwest/default-client behavior when no route config is supplied. - Uses the fixed MVP routing policy when route config is supplied: platform system/PAC/WPAD discovery, then explicit env proxy variables, then direct connection. - Keeps platform-specific system discovery behind the shared client boundary. This PR provides the contract and fallback behavior; later resolver PRs plug in Windows and macOS discovery. - Adds `login::AuthRouteConfig` so auth call sites depend on a small policy type instead of platform resolver details. - Maps the resolved `Config.respect_system_proxy` boolean into `AuthRouteConfig` for auth-owned clients. - Wires the route config through browser login, device-code login, access-token login, login status, logout/revoke, token refresh, API-key exchange, app-server account login, TUI/app startup, cloud-config bootstrap, cloud tasks, plugin auth, and exec startup config loading. ## End-user behavior - No behavior changes by default. - When `respect_system_proxy = true`, auth-owned clients opt into the shared route-aware client path. - On platforms without a resolver implementation in this PR, system discovery is unavailable and the route-aware path falls back to explicit env proxy handling, then direct connection. - Custom CA handling remains separate from proxy route selection and still runs through the shared client builder. - No proxy URLs, PAC contents, or resolved platform details are exposed through the public config surface introduced here. ## Tests Adds or updates coverage for: - preserving default auth-client fallback behavior when no route config is provided; - injected environment-proxy fallback without mutating process environment; - existing login-server E2E flows using explicit `auth_route_config: None` to guard unchanged default behavior; - updated auth manager, login, logout, cloud-config, startup, and plugin-auth call sites passing route config explicitly.
408 lines
14 KiB
Rust
408 lines
14 KiB
Rust
//! Shared implementation for `codex archive`, `codex delete`, and `codex unarchive`.
|
|
//!
|
|
//! The CLI commands are thin app-server clients: resolve a user-provided UUID or exact session
|
|
//! name, then call the corresponding app-server RPC.
|
|
|
|
use std::io::IsTerminal;
|
|
use std::io::Write;
|
|
use std::sync::Arc;
|
|
|
|
use crate::Cli;
|
|
use crate::app_server_session::AppServerSession;
|
|
use crate::legacy_core::config::ConfigBuilder;
|
|
use crate::legacy_core::config::ConfigOverrides;
|
|
use crate::legacy_core::config::load_config_toml_with_layer_stack;
|
|
use crate::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind;
|
|
use crate::legacy_core::config::resolve_bootstrap_auth_route_config;
|
|
use crate::legacy_core::config::resolve_oss_provider;
|
|
use crate::legacy_core::config::resolve_profile_v2_config_path;
|
|
use codex_app_server_protocol::Thread as AppServerThread;
|
|
use codex_app_server_protocol::ThreadListParams;
|
|
use codex_app_server_protocol::ThreadSortKey;
|
|
use codex_arg0::Arg0DispatchPaths;
|
|
use codex_cloud_config::cloud_config_bundle_loader_for_storage;
|
|
use codex_config::CloudConfigBundleLoader;
|
|
use codex_config::ConfigLoadOptions;
|
|
use codex_config::LoaderOverrides;
|
|
use codex_exec_server::EnvironmentManager;
|
|
use codex_exec_server::ExecServerRuntimePaths;
|
|
use codex_protocol::ThreadId;
|
|
use codex_utils_cli::CliConfigOverrides;
|
|
use codex_utils_home_dir::find_codex_home;
|
|
use codex_utils_oss::get_default_model_for_oss_provider;
|
|
use color_eyre::eyre::Result;
|
|
use color_eyre::eyre::WrapErr;
|
|
use color_eyre::eyre::eyre;
|
|
|
|
use super::RemoteAppServerEndpoint;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DeleteConfirmation {
|
|
Prompt,
|
|
Skip,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SessionArchiveAction {
|
|
Archive,
|
|
Delete(DeleteConfirmation),
|
|
Unarchive,
|
|
}
|
|
|
|
pub struct SessionArchiveCommandOptions {
|
|
pub cli: Cli,
|
|
pub arg0_paths: Arg0DispatchPaths,
|
|
pub explicit_remote_endpoint: Option<RemoteAppServerEndpoint>,
|
|
}
|
|
|
|
fn success_message(
|
|
action: SessionArchiveAction,
|
|
session_id: ThreadId,
|
|
session_name: Option<&str>,
|
|
) -> String {
|
|
let action = match action {
|
|
SessionArchiveAction::Archive => "Archived",
|
|
SessionArchiveAction::Delete(_) => "Deleted",
|
|
SessionArchiveAction::Unarchive => "Unarchived",
|
|
};
|
|
match session_name {
|
|
Some(name) => format!("{action} session {name} ({session_id})."),
|
|
None => format!("{action} session {session_id}."),
|
|
}
|
|
}
|
|
|
|
struct ResolvedSessionTarget {
|
|
session_id: ThreadId,
|
|
session_name: Option<String>,
|
|
}
|
|
|
|
pub async fn run_session_archive_command(
|
|
action: SessionArchiveAction,
|
|
target: String,
|
|
options: SessionArchiveCommandOptions,
|
|
) -> Result<String> {
|
|
let mut app_server = start_app_server_for_archive_command(options).await?;
|
|
run_session_archive_action_with_app_server(&mut app_server, action, &target).await
|
|
}
|
|
|
|
async fn run_session_archive_action_with_app_server(
|
|
app_server: &mut AppServerSession,
|
|
action: SessionArchiveAction,
|
|
target: &str,
|
|
) -> Result<String> {
|
|
let resolved = resolve_session_target(app_server, action, target).await?;
|
|
let session_name = match action {
|
|
SessionArchiveAction::Archive => {
|
|
app_server.thread_archive(resolved.session_id).await?;
|
|
resolved.session_name
|
|
}
|
|
SessionArchiveAction::Delete(confirmation) => {
|
|
if matches!(confirmation, DeleteConfirmation::Prompt)
|
|
&& !confirm_session_delete(&resolved)?
|
|
{
|
|
return Ok("Delete cancelled.".to_string());
|
|
}
|
|
app_server.thread_delete(resolved.session_id).await?;
|
|
resolved.session_name
|
|
}
|
|
SessionArchiveAction::Unarchive => {
|
|
let thread = app_server.thread_unarchive(resolved.session_id).await?;
|
|
thread.name.or(resolved.session_name)
|
|
}
|
|
};
|
|
Ok(success_message(
|
|
action,
|
|
resolved.session_id,
|
|
session_name.as_deref(),
|
|
))
|
|
}
|
|
|
|
async fn resolve_session_target(
|
|
app_server: &mut AppServerSession,
|
|
action: SessionArchiveAction,
|
|
target: &str,
|
|
) -> Result<ResolvedSessionTarget> {
|
|
if let Ok(session_id) = ThreadId::from_string(target) {
|
|
if matches!(
|
|
action,
|
|
SessionArchiveAction::Delete(DeleteConfirmation::Prompt)
|
|
) {
|
|
let thread = app_server
|
|
.thread_read(session_id, /*include_turns*/ false)
|
|
.await
|
|
.with_context(|| {
|
|
format!("No active or archived session found matching '{target}'.")
|
|
})?;
|
|
return Ok(ResolvedSessionTarget {
|
|
session_id,
|
|
session_name: thread.name,
|
|
});
|
|
}
|
|
return Ok(ResolvedSessionTarget {
|
|
session_id,
|
|
session_name: None,
|
|
});
|
|
}
|
|
|
|
let (search_scope, archived_values): (&str, &[bool]) = match action {
|
|
SessionArchiveAction::Archive => ("active", &[false]),
|
|
SessionArchiveAction::Delete(_) => ("active or archived", &[false, true]),
|
|
SessionArchiveAction::Unarchive => ("archived", &[true]),
|
|
};
|
|
for &archived in archived_values {
|
|
if let Some(thread) = lookup_session_by_exact_name(app_server, target, archived).await? {
|
|
return session_target_from_app_server_thread(thread);
|
|
}
|
|
}
|
|
Err(eyre!(
|
|
"No {search_scope} session found matching '{target}'."
|
|
))
|
|
}
|
|
|
|
async fn lookup_session_by_exact_name(
|
|
app_server: &mut AppServerSession,
|
|
name: &str,
|
|
archived: bool,
|
|
) -> Result<Option<AppServerThread>> {
|
|
// Search is the fast path, but some stores attach renamed titles after applying the filter.
|
|
for search_term in [Some(name), None] {
|
|
let mut cursor = None;
|
|
loop {
|
|
let response = app_server
|
|
.thread_list(ThreadListParams {
|
|
cursor: cursor.clone(),
|
|
limit: Some(100),
|
|
sort_key: Some(ThreadSortKey::UpdatedAt),
|
|
sort_direction: None,
|
|
model_providers: None,
|
|
source_kinds: Some(super::resume_source_kinds(
|
|
/*include_non_interactive*/ false,
|
|
)),
|
|
archived: Some(archived),
|
|
parent_thread_id: None,
|
|
cwd: None,
|
|
use_state_db_only: false,
|
|
search_term: search_term.map(str::to_string),
|
|
})
|
|
.await
|
|
.wrap_err("failed to list sessions while resolving session name")?;
|
|
|
|
if let Some(thread) = response
|
|
.data
|
|
.into_iter()
|
|
.find(|thread| thread.name.as_deref() == Some(name))
|
|
{
|
|
return Ok(Some(thread));
|
|
}
|
|
let Some(next_cursor) = response.next_cursor else {
|
|
break;
|
|
};
|
|
cursor = Some(next_cursor);
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
fn session_target_from_app_server_thread(thread: AppServerThread) -> Result<ResolvedSessionTarget> {
|
|
let session_id = ThreadId::from_string(&thread.id)
|
|
.wrap_err_with(|| format!("app server returned invalid session id `{}`", thread.id))?;
|
|
Ok(ResolvedSessionTarget {
|
|
session_id,
|
|
session_name: thread.name,
|
|
})
|
|
}
|
|
|
|
fn confirm_session_delete(target: &ResolvedSessionTarget) -> Result<bool> {
|
|
if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) {
|
|
return Err(eyre!(
|
|
"cannot confirm session deletion without an interactive terminal; rerun with --force and a session UUID"
|
|
));
|
|
}
|
|
|
|
let mut stderr = std::io::stderr().lock();
|
|
match target.session_name.as_deref() {
|
|
Some(name) => writeln!(
|
|
stderr,
|
|
"Permanently delete session '{name}' ({})?",
|
|
target.session_id
|
|
),
|
|
None => writeln!(stderr, "Permanently delete session {}?", target.session_id),
|
|
}?;
|
|
writeln!(
|
|
stderr,
|
|
"This cannot be undone. Subagent threads will also be deleted."
|
|
)?;
|
|
write!(stderr, "Continue? [y/N]: ")?;
|
|
stderr.flush()?;
|
|
|
|
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"))
|
|
}
|
|
|
|
async fn start_app_server_for_archive_command(
|
|
options: SessionArchiveCommandOptions,
|
|
) -> Result<AppServerSession> {
|
|
let SessionArchiveCommandOptions {
|
|
cli,
|
|
arg0_paths,
|
|
explicit_remote_endpoint,
|
|
} = options;
|
|
let loader_overrides = LoaderOverrides::default();
|
|
let strict_config = cli.strict_config;
|
|
let raw_overrides = cli.config_overrides.raw_overrides.clone();
|
|
let overrides_cli = CliConfigOverrides { raw_overrides };
|
|
let cli_kv_overrides = overrides_cli
|
|
.parse_overrides()
|
|
.map_err(|err| eyre!("failed to parse -c overrides: {err}"))?;
|
|
let codex_home = find_codex_home().wrap_err("failed to find Codex home")?;
|
|
|
|
let mut launch_loader_overrides = loader_overrides.clone();
|
|
if let Some(profile_v2) = cli.config_profile_v2.as_ref() {
|
|
launch_loader_overrides.user_config_path = Some(resolve_profile_v2_config_path(
|
|
codex_home.as_path(),
|
|
profile_v2,
|
|
));
|
|
launch_loader_overrides.user_config_profile = Some(profile_v2.clone());
|
|
}
|
|
|
|
let reuse_implicit_local_daemon = super::can_reuse_implicit_local_daemon(
|
|
&cli_kv_overrides,
|
|
&launch_loader_overrides,
|
|
strict_config,
|
|
cli.bypass_hook_trust,
|
|
);
|
|
let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon {
|
|
super::maybe_probe_default_daemon_socket(codex_home.as_path()).await
|
|
} else {
|
|
None
|
|
};
|
|
let app_server_target = super::app_server_target_for_launch(
|
|
explicit_remote_endpoint,
|
|
default_daemon,
|
|
reuse_implicit_local_daemon,
|
|
);
|
|
let remote_cwd_override = cli
|
|
.cwd
|
|
.clone()
|
|
.filter(|_| app_server_target.uses_remote_workspace());
|
|
|
|
let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths(
|
|
arg0_paths.codex_self_exe.clone(),
|
|
arg0_paths.codex_linux_sandbox_exe.clone(),
|
|
)
|
|
.wrap_err("failed to resolve local runtime paths")?;
|
|
let environment_manager = EnvironmentManager::from_env(Some(local_runtime_paths))
|
|
.await
|
|
.map(Arc::new)
|
|
.wrap_err("failed to initialize environment manager")?;
|
|
let config_cwd = super::config_cwd_for_app_server_target(
|
|
cli.cwd.as_deref(),
|
|
&app_server_target,
|
|
&environment_manager,
|
|
)
|
|
.wrap_err("failed to resolve config cwd")?;
|
|
|
|
let mut loader_overrides = loader_overrides;
|
|
if let Some(profile_v2) = cli.config_profile_v2.as_ref() {
|
|
loader_overrides.user_config_path = Some(resolve_profile_v2_config_path(
|
|
codex_home.as_path(),
|
|
profile_v2,
|
|
));
|
|
loader_overrides.user_config_profile = Some(profile_v2.clone());
|
|
}
|
|
|
|
let bootstrap_config = load_config_toml_with_layer_stack(
|
|
codex_home.as_path(),
|
|
config_cwd.as_ref(),
|
|
cli_kv_overrides.clone(),
|
|
ConfigLoadOptions {
|
|
loader_overrides: loader_overrides.clone(),
|
|
strict_config,
|
|
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
|
},
|
|
)
|
|
.await
|
|
.wrap_err("failed to load config.toml")?;
|
|
let config_toml = &bootstrap_config.config_toml;
|
|
let chatgpt_base_url = config_toml
|
|
.chatgpt_base_url
|
|
.clone()
|
|
.unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string());
|
|
let auth_route_config = resolve_bootstrap_auth_route_config(
|
|
config_toml,
|
|
bootstrap_config
|
|
.config_layer_stack
|
|
.requirements()
|
|
.feature_requirements
|
|
.as_ref(),
|
|
)?;
|
|
let cloud_config_bundle = cloud_config_bundle_loader_for_storage(
|
|
codex_home.to_path_buf(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
config_toml.cli_auth_credentials_store.unwrap_or_default(),
|
|
resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?,
|
|
chatgpt_base_url,
|
|
auth_route_config,
|
|
)
|
|
.await;
|
|
|
|
let model_provider = if cli.oss {
|
|
resolve_oss_provider(cli.oss_provider.as_deref(), config_toml)
|
|
} else {
|
|
None
|
|
};
|
|
let model = cli.model.clone().or_else(|| {
|
|
model_provider
|
|
.as_deref()
|
|
.and_then(get_default_model_for_oss_provider)
|
|
.map(ToOwned::to_owned)
|
|
});
|
|
let cwd = cli.cwd.clone();
|
|
let config = ConfigBuilder::default()
|
|
.cli_overrides(cli_kv_overrides.clone())
|
|
.harness_overrides(ConfigOverrides {
|
|
model,
|
|
cwd: if app_server_target.uses_remote_workspace() {
|
|
None
|
|
} else {
|
|
cwd
|
|
},
|
|
model_provider,
|
|
codex_self_exe: arg0_paths.codex_self_exe.clone(),
|
|
codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(),
|
|
main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(),
|
|
show_raw_agent_reasoning: cli.oss.then_some(true),
|
|
bypass_hook_trust: cli.bypass_hook_trust.then_some(true),
|
|
..Default::default()
|
|
})
|
|
.loader_overrides(loader_overrides.clone())
|
|
.strict_config(strict_config)
|
|
.cloud_config_bundle(cloud_config_bundle.clone())
|
|
.build()
|
|
.await
|
|
.wrap_err("failed to load configuration")?;
|
|
let state_db = super::init_state_db_for_app_server_target(&config, &app_server_target)
|
|
.await
|
|
.wrap_err("failed to initialize state database")?;
|
|
let app_server = super::start_app_server(
|
|
&app_server_target,
|
|
arg0_paths,
|
|
config,
|
|
cli_kv_overrides,
|
|
loader_overrides,
|
|
strict_config,
|
|
cloud_config_bundle,
|
|
codex_feedback::CodexFeedback::new(),
|
|
/*log_db*/ None,
|
|
state_db,
|
|
environment_manager,
|
|
)
|
|
.await?;
|
|
Ok(
|
|
AppServerSession::new(app_server, app_server_target.thread_params_mode())
|
|
.with_remote_cwd_override(remote_cwd_override),
|
|
)
|
|
}
|