app-server-daemon: route updater download through HTTP client factory

This commit is contained in:
Michael Bolin
2026-07-09 11:53:22 -07:00
parent cfcc11db69
commit bf21583df2
8 changed files with 192 additions and 19 deletions

3
codex-rs/Cargo.lock generated
View File

@@ -2092,12 +2092,12 @@ dependencies = [
"anyhow",
"codex-app-server-protocol",
"codex-app-server-transport",
"codex-http-client",
"codex-uds",
"codex-utils-home-dir",
"futures",
"libc",
"pretty_assertions",
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -2347,6 +2347,7 @@ dependencies = [
"codex-features",
"codex-git-utils",
"codex-home",
"codex-http-client",
"codex-install-context",
"codex-login",
"codex-mcp",

View File

@@ -16,11 +16,11 @@ workspace = true
anyhow = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-app-server-transport = { workspace = true }
codex-http-client = { workspace = true }
codex-utils-home-dir = { workspace = true }
codex-uds = { workspace = true }
futures = { workspace = true }
libc = { workspace = true }
reqwest = { workspace = true, features = ["rustls-tls"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }

View File

@@ -238,9 +238,11 @@ pub async fn set_remote_control(mode: RemoteControlMode) -> Result<RemoteControl
Daemon::from_environment()?.set_remote_control(mode).await
}
pub async fn run_pid_update_loop() -> Result<()> {
pub async fn run_pid_update_loop(
http_client_factory: codex_http_client::HttpClientFactory,
) -> Result<()> {
ensure_supported_platform()?;
update_loop::run().await
update_loop::run(http_client_factory).await
}
#[cfg(unix)]

View File

@@ -11,6 +11,11 @@ use anyhow::Result;
#[cfg(not(unix))]
use anyhow::bail;
#[cfg(unix)]
use codex_http_client::ClientRouteClass;
use codex_http_client::HttpClientFactory;
#[cfg(unix)]
use codex_http_client::RouteAwareClientPool;
#[cfg(unix)]
use futures::FutureExt;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
@@ -48,17 +53,20 @@ const INITIAL_UPDATE_DELAY: Duration = Duration::from_secs(5 * 60);
const RESTART_RETRY_INTERVAL: Duration = Duration::from_millis(50);
#[cfg(unix)]
const UPDATE_INTERVAL: Duration = Duration::from_secs(60 * 60);
#[cfg(unix)]
const INSTALL_URL: &str = "https://chatgpt.com/codex/install.sh";
#[cfg(unix)]
pub(crate) async fn run() -> Result<()> {
pub(crate) async fn run(http_client_factory: HttpClientFactory) -> Result<()> {
let mut terminate =
signal(SignalKind::terminate()).context("failed to install updater shutdown handler")?;
let running_updater_identity = current_updater_identity().await?;
let http = RouteAwareClientPool::new(http_client_factory, ClientRouteClass::Other);
if sleep_or_terminate(INITIAL_UPDATE_DELAY, &mut terminate).await {
return Ok(());
}
loop {
match update_once(&running_updater_identity, &mut terminate).await {
match update_once(&http, &running_updater_identity, &mut terminate).await {
Ok(UpdateLoopControl::Continue) | Err(_) => {}
Ok(UpdateLoopControl::Stop) => return Ok(()),
}
@@ -69,7 +77,7 @@ pub(crate) async fn run() -> Result<()> {
}
#[cfg(not(unix))]
pub(crate) async fn run() -> Result<()> {
pub(crate) async fn run(_http_client_factory: HttpClientFactory) -> Result<()> {
bail!("pid-managed updater loop is unsupported on this platform")
}
@@ -89,10 +97,11 @@ enum UpdateLoopControl {
#[cfg(unix)]
async fn update_once(
http: &RouteAwareClientPool,
running_updater_identity: &ExecutableIdentity,
terminate: &mut Signal,
) -> Result<UpdateLoopControl> {
install_latest_standalone().await?;
install_latest_standalone(http).await?;
let daemon = Daemon::from_environment()?;
let managed_codex_bin = resolved_managed_codex_bin(&daemon.managed_codex_bin).await?;
@@ -154,15 +163,8 @@ pub(crate) fn reexec_managed_updater(managed_codex_bin: &std::path::Path) -> Res
}
#[cfg(unix)]
async fn install_latest_standalone() -> Result<()> {
let script = reqwest::get("https://chatgpt.com/codex/install.sh")
.await
.context("failed to fetch standalone Codex updater")?
.error_for_status()
.context("standalone Codex updater request failed")?
.bytes()
.await
.context("failed to read standalone Codex updater")?;
async fn install_latest_standalone(http: &RouteAwareClientPool) -> Result<()> {
let script = fetch_installer_script(http).await?;
let mut child = Command::new("/bin/sh")
.arg("-s")
@@ -192,6 +194,54 @@ async fn install_latest_standalone() -> Result<()> {
}
}
#[cfg(unix)]
async fn fetch_installer_script(http: &impl InstallerHttp) -> Result<Vec<u8>> {
let response = http.get(INSTALL_URL).await?;
if !(200..300).contains(&response.status) {
anyhow::bail!(
"standalone Codex updater request failed with status {}",
response.status
);
}
Ok(response.body)
}
#[cfg(unix)]
#[derive(Clone, Debug, PartialEq, Eq)]
struct InstallerResponse {
status: u16,
body: Vec<u8>,
}
#[cfg(unix)]
trait InstallerHttp: Send + Sync {
fn get<'a>(
&'a self,
url: &'a str,
) -> impl std::future::Future<Output = Result<InstallerResponse>> + Send + 'a;
}
#[cfg(unix)]
impl InstallerHttp for RouteAwareClientPool {
async fn get(&self, url: &str) -> Result<InstallerResponse> {
let response = self
.client_for_url(url)
.await
.context("failed to configure standalone Codex updater client")?
.get(url)
.send()
.await
.context("failed to fetch standalone Codex updater")?;
let status = response.status().as_u16();
let body = response
.bytes()
.await
.context("failed to read standalone Codex updater")?
.to_vec();
Ok(InstallerResponse { status, body })
}
}
#[cfg(all(test, unix))]
#[path = "update_loop_tests.rs"]
mod tests;

View File

@@ -1,5 +1,11 @@
use std::sync::Mutex;
use pretty_assertions::assert_eq;
use super::INSTALL_URL;
use super::InstallerHttp;
use super::InstallerResponse;
use super::fetch_installer_script;
use super::update_modes_for_identities;
use crate::RestartMode;
use crate::UpdaterRefreshMode;
@@ -29,3 +35,66 @@ fn changed_updater_forces_refresh_even_when_version_may_match() {
)
);
}
#[tokio::test]
async fn installer_fetch_uses_exact_url_and_preserves_bytes() {
let script = b"#!/bin/sh\nprintf 'update bytes'\n".to_vec();
let http = FakeInstallerHttp::new(InstallerResponse {
status: 200,
body: script.clone(),
});
assert_eq!(
fetch_installer_script(&http)
.await
.expect("installer fetch should succeed"),
script
);
assert_eq!(http.requested_urls(), vec![INSTALL_URL.to_string()]);
}
#[tokio::test]
async fn installer_fetch_rejects_non_success_status() {
let http = FakeInstallerHttp::new(InstallerResponse {
status: 503,
body: b"unavailable".to_vec(),
});
let error = fetch_installer_script(&http)
.await
.expect_err("non-success response should fail");
assert!(error.to_string().contains("503"));
assert_eq!(http.requested_urls(), vec![INSTALL_URL.to_string()]);
}
struct FakeInstallerHttp {
response: InstallerResponse,
requested_urls: Mutex<Vec<String>>,
}
impl FakeInstallerHttp {
fn new(response: InstallerResponse) -> Self {
Self {
response,
requested_urls: Mutex::new(Vec::new()),
}
}
fn requested_urls(&self) -> Vec<String> {
self.requested_urls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
}
impl InstallerHttp for FakeInstallerHttp {
async fn get(&self, url: &str) -> anyhow::Result<InstallerResponse> {
self.requested_urls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(url.to_string());
Ok(self.response.clone())
}
}

View File

@@ -34,6 +34,7 @@ codex-config = { workspace = true }
codex-core = { workspace = true }
codex-core-plugins = { workspace = true }
codex-home = { workspace = true }
codex-http-client = { workspace = true }
codex-exec = { workspace = true }
codex-exec-server = { workspace = true }
codex-execpolicy = { workspace = true }

View File

@@ -1182,7 +1182,16 @@ async fn cli_main(
print_app_server_daemon_output(AppServerLifecycleCommand::Version).await?;
}
AppServerDaemonSubcommand::PidUpdateLoop => {
codex_app_server_daemon::run_pid_update_loop().await?;
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);
codex_app_server_daemon::run_pid_update_loop(http_client_factory).await?;
}
},
Some(AppServerSubcommand::Proxy(proxy_cli)) => {
@@ -2205,6 +2214,20 @@ async fn print_app_server_daemon_output(command: AppServerLifecycleCommand) -> a
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<()> {
@@ -2501,6 +2524,34 @@ mod tests {
use codex_tui::TokenUsage;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn updater_http_client_factory_honors_respect_system_proxy() {
let codex_home = tempfile::tempdir().expect("temporary Codex home");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.cli_overrides(vec![(
"features.respect_system_proxy".to_string(),
toml::Value::Boolean(true),
)])
.build()
.await
.expect("config should load");
assert_eq!(
updater_http_client_factory(Ok(config)).outbound_proxy_policy(),
codex_http_client::OutboundProxyPolicy::RespectSystemProxy
);
}
#[test]
fn updater_http_client_factory_falls_back_when_config_load_fails() {
assert_eq!(
updater_http_client_factory(Err(anyhow::anyhow!("invalid config")))
.outbound_proxy_policy(),
codex_http_client::OutboundProxyPolicy::ReqwestDefault
);
}
#[test]
fn exec_server_remote_auth_accepts_api_key_auth() {
let auth = CodexAuth::from_api_key("sk-test");

View File

@@ -242,7 +242,6 @@ deny = [
"codex-agent-identity",
"codex-api",
"codex-app-server",
"codex-app-server-daemon",
"codex-core",
"codex-core-plugins",
"codex-exec-server",