From 4fb10f33f1fb38732e40a65eef6f1f34e67ff915 Mon Sep 17 00:00:00 2001 From: Zyy <39424122+zyycn@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:05:14 +0800 Subject: [PATCH] fix(update): reuse the served asset directory (#111) Co-authored-by: Zyy Co-authored-by: Codex --- backend/apps/gateway/src/bootstrap.rs | 9 ++- backend/apps/gateway/tests/bootstrap.rs | 44 +++++++++++ backend/crates/gateway-api/src/lib.rs | 16 ++-- backend/crates/gateway-api/tests/main.rs | 52 +++++++++++++ .../crates/gateway-api/tests/openai/mod.rs | 12 ++- backend/crates/gateway-host/src/config.rs | 14 +++- .../gateway-host/src/system_update/mod.rs | 29 +++++-- .../gateway-host/src/system_update/swap.rs | 5 +- backend/crates/gateway-host/tests/config.rs | 77 +++++++++++++++++-- .../crates/gateway-host/tests/logging/mod.rs | 5 +- .../gateway-host/tests/system_update/mod.rs | 42 +++++++--- deploy/README.md | 4 +- 12 files changed, 269 insertions(+), 40 deletions(-) diff --git a/backend/apps/gateway/src/bootstrap.rs b/backend/apps/gateway/src/bootstrap.rs index f48d39ed3..8acf94525 100644 --- a/backend/apps/gateway/src/bootstrap.rs +++ b/backend/apps/gateway/src/bootstrap.rs @@ -28,7 +28,11 @@ impl LoadableConfig for GatewayConfig { if self.schema_version != CONFIG_SCHEMA_VERSION { return Err(ConfigError::InvalidField("schema_version")); } - self.host.resolve_and_validate(source_dir)?; + self.api + .resolve_and_validate(source_dir) + .map_err(|_| ConfigError::InvalidField("api"))?; + self.host + .resolve_and_validate(source_dir, &self.api.asset_directory)?; let runtime_data_dir = self.host.runtime_data_dir().to_path_buf(); self.store .resolve_and_validate(&runtime_data_dir) @@ -39,9 +43,6 @@ impl LoadableConfig for GatewayConfig { self.client .resolve_and_validate(source_dir) .map_err(|_| ConfigError::InvalidField("client"))?; - self.api - .resolve_and_validate(source_dir) - .map_err(|_| ConfigError::InvalidField("api"))?; self.openai .resolve_and_validate(&runtime_data_dir) .map_err(|_| ConfigError::InvalidField("openai"))?; diff --git a/backend/apps/gateway/tests/bootstrap.rs b/backend/apps/gateway/tests/bootstrap.rs index 12538dce1..4731a0472 100644 --- a/backend/apps/gateway/tests/bootstrap.rs +++ b/backend/apps/gateway/tests/bootstrap.rs @@ -168,6 +168,50 @@ fn config_loader_should_resolve_paths_relative_to_config_file() { assert!(debug.contains("frontend/dist")); } +#[test] +fn config_loader_should_share_resolved_assets_with_system_update() { + const CHILD_ENV: &str = "CPR_TEST_UPDATE_ASSETS_CHILD"; + let Ok(case) = std::env::var(CHILD_ENV) else { + // 使用子进程覆盖无环境变量、Docker 路径和相对路径,避免污染并行测试。 + for (case, web_dist) in [ + ("binary", None), + ("docker", Some("/app/web/dist")), + ("relative", Some("../custom/dist")), + ] { + let mut child = Command::new(std::env::current_exe().expect("test executable")); + child + .args([ + "--exact", + "bootstrap::config_loader_should_share_resolved_assets_with_system_update", + ]) + .env(CHILD_ENV, case) + .env_remove("CPR_WEB_DIST_DIR"); + if let Some(web_dist) = web_dist { + child.env("CPR_WEB_DIST_DIR", web_dist); + } + let output = child.output().expect("isolated configuration test"); + assert!( + output.status.success(), + "{case}: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + return; + }; + let mut document = valid_config_document(); + document["api"]["asset_directory"] = serde_json::json!("../web/dist"); + let (config, directory) = parse_config(&document.to_string()).expect("binary configuration"); + let assets = match case.as_str() { + "docker" => std::path::PathBuf::from("/app/web/dist"), + "relative" => directory.path().join("deploy/../custom/dist"), + _ => directory.path().join("deploy/../web/dist"), + }; + let debug = format!("{config:?}"); + assert!(debug.contains(&format!("asset_directory: {assets:?}"))); + assert!(debug.contains(&format!("web_dist_dir: Some({assets:?})"))); +} + #[test] fn config_loader_should_reject_missing_runtime_data_dir() { let config = valid_config().replace(" runtime_data_dir: '../.runtime/data'\n", ""); diff --git a/backend/crates/gateway-api/src/lib.rs b/backend/crates/gateway-api/src/lib.rs index af03046e4..19e30810f 100644 --- a/backend/crates/gateway-api/src/lib.rs +++ b/backend/crates/gateway-api/src/lib.rs @@ -62,12 +62,17 @@ impl ApiConfig { return Err(ApiConfigError::InvalidAssetDirectory); } } - if self.asset_directory.as_os_str().is_empty() { - return Err(ApiConfigError::InvalidAssetDirectory); - } + self.validate()?; if self.asset_directory.is_relative() { self.asset_directory = source_dir.join(&self.asset_directory); } + Ok(()) + } + + fn validate(&mut self) -> Result<(), ApiConfigError> { + if self.asset_directory.as_os_str().is_empty() { + return Err(ApiConfigError::InvalidAssetDirectory); + } if self.request_timeout_seconds == Some(0) { return Err(ApiConfigError::InvalidRequestTimeout); } @@ -124,9 +129,8 @@ pub fn initialize( worker_health: Arc, lifecycle: Arc, ) -> Result { - config - .resolve_and_validate(Path::new(".")) - .map_err(ApiError::Config)?; + // 配置加载已解析环境变量与相对路径,初始化只校验,避免覆盖最终目录。 + config.validate().map_err(ApiError::Config)?; let request_id_header = HeaderName::from_str(&config.request_id_header) .map_err(|_| ApiError::Config(ApiConfigError::InvalidRequestIdHeader))?; let state = ApiState { diff --git a/backend/crates/gateway-api/tests/main.rs b/backend/crates/gateway-api/tests/main.rs index f8f307a37..4d0e55378 100644 --- a/backend/crates/gateway-api/tests/main.rs +++ b/backend/crates/gateway-api/tests/main.rs @@ -5,3 +5,55 @@ mod health; mod key_usage; mod openai; mod support; + +#[tokio::test] +async fn api_should_serve_resolved_assets_with_relative_environment_override() { + use axum::{body::Body, http::Request}; + use std::{fs, process::Command}; + use tower::ServiceExt as _; + + const CHILD_ENV: &str = "CPR_TEST_ASSET_DIRECTORY"; + let Ok(directory) = std::env::var(CHILD_ENV) else { + let directory = std::env::temp_dir().join(format!("cpr-assets-{}", uuid::Uuid::now_v7())); + fs::create_dir_all(directory.join("deploy")).expect("config directory"); + fs::create_dir_all(directory.join("web/dist")).expect("asset directory"); + fs::write(directory.join("web/dist/index.html"), "resolved-web-assets").expect("index"); + let output = Command::new(std::env::current_exe().expect("test executable")) + .args([ + "--exact", + "api_should_serve_resolved_assets_with_relative_environment_override", + ]) + .current_dir(&directory) + .env(CHILD_ENV, &directory) + .env("CPR_WEB_DIST_DIR", "../web/dist") + .output() + .expect("isolated asset directory test"); + fs::remove_dir_all(directory).expect("remove fixture"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + }; + let mut config = gateway_api::ApiConfig { + asset_directory: "unused".into(), + cors_allowed_origins: Vec::new(), + request_timeout_seconds: None, + request_id_header: "x-request-id".to_owned(), + }; + config + .resolve_and_validate(&std::path::Path::new(&directory).join("deploy")) + .expect("resolved API config"); + let admin = admin::AdminTestFixture::new().await; + let response = openai::api_router_with_config(admin.services, config) + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .expect("static response"); + assert_eq!(response.status(), axum::http::StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .unwrap(); + assert_eq!(body.as_ref(), b"resolved-web-assets"); +} diff --git a/backend/crates/gateway-api/tests/openai/mod.rs b/backend/crates/gateway-api/tests/openai/mod.rs index 7d5031ce6..d514d6b65 100644 --- a/backend/crates/gateway-api/tests/openai/mod.rs +++ b/backend/crates/gateway-api/tests/openai/mod.rs @@ -50,13 +50,23 @@ pub(super) async fn api_router(execution: Arc) -> axum::Ro } pub(super) fn api_router_with_admin(admin: gateway_admin::AdminServices) -> axum::Router { - gateway_api::initialize( + api_router_with_config( + admin, gateway_api::ApiConfig { asset_directory: std::env::temp_dir(), cors_allowed_origins: Vec::new(), request_timeout_seconds: None, request_id_header: "x-request-id".to_owned(), }, + ) +} + +pub(super) fn api_router_with_config( + admin: gateway_admin::AdminServices, + config: gateway_api::ApiConfig, +) -> axum::Router { + gateway_api::initialize( + config, Arc::new(DefaultExecutionService::new( RuntimeSnapshotHandle::new(snapshot("unused-client-route-key", "openai")), Arc::new(UnusedExecutionStore), diff --git a/backend/crates/gateway-host/src/config.rs b/backend/crates/gateway-host/src/config.rs index 97a6770c8..0fcdf091b 100644 --- a/backend/crates/gateway-host/src/config.rs +++ b/backend/crates/gateway-host/src/config.rs @@ -52,7 +52,12 @@ pub struct HostConfig { } impl HostConfig { - pub fn resolve_and_validate(&mut self, source_dir: &Path) -> Result<(), ConfigError> { + /// 解析 Host 配置;在线更新默认沿用已解析的 API 静态资源目录。 + pub fn resolve_and_validate( + &mut self, + source_dir: &Path, + asset_directory: &Path, + ) -> Result<(), ConfigError> { if let Some(host) = optional_environment_value(SERVER_HOST_ENV)? { self.listen.host = host; } @@ -82,8 +87,11 @@ impl HostConfig { } resolve_relative_path(source_dir, &mut self.runtime_data_dir); self.logging.resolve_and_validate(source_dir)?; - self.system_update - .resolve_and_validate(source_dir, &self.runtime_data_dir)?; + self.system_update.resolve_and_validate( + source_dir, + &self.runtime_data_dir, + asset_directory, + )?; Ok(()) } diff --git a/backend/crates/gateway-host/src/system_update/mod.rs b/backend/crates/gateway-host/src/system_update/mod.rs index 6a537b29e..b12b381fb 100644 --- a/backend/crates/gateway-host/src/system_update/mod.rs +++ b/backend/crates/gateway-host/src/system_update/mod.rs @@ -43,7 +43,6 @@ use self::swap::{replace_release_files, rollback_release}; pub use self::release::validate_download_url; const APP_BINARY_NAME: &str = "codex-proxy-rs"; -const DEFAULT_WEB_DIST_DIR: &str = "/app/web/dist"; const DEFAULT_GITHUB_API_BASE: &str = "https://api.github.com/repos"; const DEFAULT_UPDATE_REPOSITORY: &str = "zyycn/codex-proxy-rs"; @@ -62,7 +61,8 @@ pub struct SystemUpdateConfig { pub update_repository: Option, pub github_api_base: String, pub executable_path: Option, - pub web_dist_dir: PathBuf, + /// 未显式指定时,由组合根传入 API 实际使用的静态资源目录。 + pub web_dist_dir: Option, pub update_state_file: PathBuf, pub update_lock_file: PathBuf, pub update_temp_dir: PathBuf, @@ -107,9 +107,7 @@ impl Default for SystemUpdateConfig { github_api_base: environment_value("CPR_GITHUB_API_BASE") .unwrap_or_else(|| DEFAULT_GITHUB_API_BASE.to_owned()), executable_path, - web_dist_dir: environment_value("CPR_WEB_DIST_DIR") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(DEFAULT_WEB_DIST_DIR)), + web_dist_dir: None, update_state_file, update_lock_file, update_temp_dir, @@ -124,8 +122,15 @@ impl SystemUpdateConfig { &mut self, source_dir: &Path, runtime_data_dir: &Path, + asset_directory: &Path, ) -> Result<(), ConfigError> { - for path in [self.executable_path.as_mut(), Some(&mut self.web_dist_dir)] + let web_dist_dir = self + .web_dist_dir + .get_or_insert_with(|| asset_directory.to_path_buf()); + if web_dist_dir.as_os_str().is_empty() { + return Err(ConfigError::InvalidField("host.system_update.web_dist_dir")); + } + for path in [self.executable_path.as_mut(), self.web_dist_dir.as_mut()] .into_iter() .flatten() { @@ -174,6 +179,9 @@ impl SystemUpdateConfig { if let Err(error) = release::validate_api_base(&self.github_api_base) { return Some(error); } + if let Err(error) = self.web_dist_dir() { + return Some(error.to_string()); + } None } @@ -197,6 +205,13 @@ impl SystemUpdateConfig { .and_then(fs::canonicalize) .map_err(|error| internal(format!("failed to resolve executable: {error}"))) } + + pub(crate) fn web_dist_dir(&self) -> Result<&Path, OperationError> { + self.web_dist_dir + .as_deref() + .filter(|path| !path.as_os_str().is_empty()) + .ok_or_else(|| conflict("web assets directory is not configured")) + } } /// gateway-admin 消费的真实进程/文件系统操作实现。 @@ -395,7 +410,7 @@ impl ProcessSystemOperations { .info(Some(operation_id), Some("replace"), "正在替换应用文件"); replace_release_files( &self.config.executable_path()?, - &self.config.web_dist_dir, + self.config.web_dist_dir()?, extracted, )?; self.events diff --git a/backend/crates/gateway-host/src/system_update/swap.rs b/backend/crates/gateway-host/src/system_update/swap.rs index c41f49df6..007e26a5a 100644 --- a/backend/crates/gateway-host/src/system_update/swap.rs +++ b/backend/crates/gateway-host/src/system_update/swap.rs @@ -81,6 +81,7 @@ pub(crate) fn replace_release_files( } pub(crate) fn rollback_release(config: &SystemUpdateConfig) -> Result<(), OperationError> { + let web_dist = config.web_dist_dir()?; let executable = config.executable_path()?; let binary_backup = backup_path_for(&executable); if !binary_backup.exists() { @@ -89,9 +90,9 @@ pub(crate) fn rollback_release(config: &SystemUpdateConfig) -> Result<(), Operat swap_file(&executable, &binary_backup) .map_err(|error| internal(format!("binary rollback failed: {error}")))?; - let web_backup = backup_path_for(&config.web_dist_dir); + let web_backup = backup_path_for(web_dist); if web_backup.exists() - && let Err(error) = swap_dir(&config.web_dist_dir, &web_backup) + && let Err(error) = swap_dir(web_dist, &web_backup) { let mut rollback = Vec::new(); collect_rollback_error( diff --git a/backend/crates/gateway-host/tests/config.rs b/backend/crates/gateway-host/tests/config.rs index 4df0c4db0..54a3cf3da 100644 --- a/backend/crates/gateway-host/tests/config.rs +++ b/backend/crates/gateway-host/tests/config.rs @@ -21,11 +21,61 @@ fn system_update_defaults_should_use_host_build_metadata_and_official_repository ); } +#[test] +fn host_config_should_default_update_assets_to_the_resolved_api_directory() { + let mut config = valid_config(); + config.system_update.web_dist_dir = None; + let assets = std::path::Path::new("/srv/gateway/web/dist"); + + config + .resolve_and_validate(std::path::Path::new("/srv/gateway/deploy"), assets) + .expect("host config"); + + assert_eq!(config.system_update.web_dist_dir.as_deref(), Some(assets)); +} + +#[test] +fn host_config_should_preserve_explicit_update_asset_directory() { + let mut config = valid_config(); + config.system_update.web_dist_dir = Some(PathBuf::from("../custom/dist")); + + config + .resolve_and_validate( + std::path::Path::new("/srv/gateway/deploy"), + std::path::Path::new("/srv/gateway/web/dist"), + ) + .expect("host config"); + + assert_eq!( + config.system_update.web_dist_dir, + Some(PathBuf::from("/srv/gateway/deploy/../custom/dist")) + ); +} + +#[test] +fn host_config_should_reject_empty_update_asset_directory() { + let mut config = valid_config(); + config.system_update.web_dist_dir = Some(PathBuf::new()); + + assert!(matches!( + config.resolve_and_validate( + std::path::Path::new("/srv/gateway/deploy"), + std::path::Path::new("/srv/gateway/web/dist"), + ), + Err(gateway_host::ConfigError::InvalidField( + "host.system_update.web_dist_dir" + )) + )); +} + #[test] fn host_config_resolves_only_host_owned_relative_paths() { let mut config = valid_config(); config - .resolve_and_validate(std::path::Path::new("/srv/gateway")) + .resolve_and_validate( + std::path::Path::new("/srv/gateway"), + std::path::Path::new("/srv/gateway/web/dist"), + ) .expect("valid host config"); assert_eq!( @@ -38,7 +88,10 @@ fn host_config_resolves_only_host_owned_relative_paths() { fn host_config_resolves_runtime_data_dir_relative_to_configuration() { let mut config = valid_config(); config - .resolve_and_validate(std::path::Path::new("/srv/gateway")) + .resolve_and_validate( + std::path::Path::new("/srv/gateway"), + std::path::Path::new("/srv/gateway/web/dist"), + ) .expect("valid host config"); assert_eq!( @@ -51,7 +104,10 @@ fn host_config_resolves_runtime_data_dir_relative_to_configuration() { fn host_config_derives_update_paths_from_runtime_data_dir() { let mut config = valid_config(); config - .resolve_and_validate(std::path::Path::new("/srv/gateway")) + .resolve_and_validate( + std::path::Path::new("/srv/gateway"), + std::path::Path::new("/srv/gateway/web/dist"), + ) .expect("valid host config"); assert_eq!( @@ -75,7 +131,10 @@ fn host_config_rejects_zero_drain_window() { assert!( config - .resolve_and_validate(std::path::Path::new("/srv/gateway")) + .resolve_and_validate( + std::path::Path::new("/srv/gateway"), + std::path::Path::new("/srv/gateway/web/dist"), + ) .is_err() ); } @@ -91,7 +150,10 @@ fn logging_rejects_zero_retention_windows() { } assert!( config - .resolve_and_validate(std::path::Path::new("/srv/gateway")) + .resolve_and_validate( + std::path::Path::new("/srv/gateway"), + std::path::Path::new("/srv/gateway/web/dist"), + ) .is_err() ); } @@ -120,7 +182,10 @@ fn dedicated_file_channels_can_be_enabled_without_application_sinks() { config.logging.oauth_recovery = oauth_recovery; config.logging.request_dump = !oauth_recovery; config - .resolve_and_validate(std::path::Path::new("/srv/gateway")) + .resolve_and_validate( + std::path::Path::new("/srv/gateway"), + std::path::Path::new("/srv/gateway/web/dist"), + ) .unwrap(); } } diff --git a/backend/crates/gateway-host/tests/logging/mod.rs b/backend/crates/gateway-host/tests/logging/mod.rs index 52b7ffcb5..db72be4f9 100644 --- a/backend/crates/gateway-host/tests/logging/mod.rs +++ b/backend/crates/gateway-host/tests/logging/mod.rs @@ -53,7 +53,10 @@ fn logging_requires_at_least_one_sink() { assert!( config - .resolve_and_validate(std::path::Path::new("/tmp")) + .resolve_and_validate( + std::path::Path::new("/tmp"), + std::path::Path::new("/tmp/web/dist"), + ) .is_err() ); } diff --git a/backend/crates/gateway-host/tests/system_update/mod.rs b/backend/crates/gateway-host/tests/system_update/mod.rs index 2ccc219e3..8f92048d5 100644 --- a/backend/crates/gateway-host/tests/system_update/mod.rs +++ b/backend/crates/gateway-host/tests/system_update/mod.rs @@ -550,7 +550,7 @@ async fn update_should_remove_stale_file_lock_and_continue() { } #[tokio::test] -async fn update_should_replace_local_release_files_with_latest_asset() { +async fn update_and_rollback_should_use_the_default_api_asset_directory() { let server = MockServer::start().await; let fixture = Fixture::new(); fixture @@ -561,8 +561,21 @@ async fn update_should_replace_local_release_files_with_latest_asset() { ChecksumKind::Valid, ) .await; - fixture - .service(&server) + let mut host: gateway_host::HostConfig = serde_json::from_value(serde_json::json!({ + "listen": { "host": "127.0.0.1", "port": 8080 }, + "runtime_data_dir": "../.runtime/data", + "logging": { + "level": "info", "stdout": true, + "file": { "enabled": false, "directory": "../.runtime/logs", "max_file_size_mb": 20 } + } + })) + .expect("binary host config"); + host.system_update = fixture.config(&format!("{}/repos", server.uri())); + host.system_update.web_dist_dir = None; + host.resolve_and_validate(&fixture.root.path().join("deploy"), &fixture.web()) + .expect("inherit API asset directory"); + let service = ProcessSystemOperations::new(CancellationToken::new(), host.system_update); + service .perform_update(Some(TARGET_VERSION.to_owned())) .await .expect("update"); @@ -575,6 +588,16 @@ async fn update_should_replace_local_release_files_with_latest_asset() { fs::read(fixture.web().join("index.html")).expect("web"), b"new-web" ); + + service.rollback().await.expect("rollback"); + assert_eq!( + fs::read(fixture.executable()).expect("binary"), + b"old-binary" + ); + assert_eq!( + fs::read(fixture.web().join("index.html")).expect("web"), + b"old-web" + ); } #[tokio::test] @@ -593,17 +616,18 @@ async fn update_should_replace_web_assets_across_filesystems() { ) .await; let mut config = fixture.config(&format!("{}/repos", server.uri())); - config.web_dist_dir = external.path().join("dist"); - fs::create_dir_all(&config.web_dist_dir).expect("web dir"); - fs::write(config.web_dist_dir.join("index.html"), "old-web").expect("web"); - let service = ProcessSystemOperations::new(CancellationToken::new(), config.clone()); + let web_dist = external.path().join("dist"); + config.web_dist_dir = Some(web_dist.clone()); + fs::create_dir_all(&web_dist).expect("web dir"); + fs::write(web_dist.join("index.html"), "old-web").expect("web"); + let service = ProcessSystemOperations::new(CancellationToken::new(), config); service .perform_update(Some(TARGET_VERSION.to_owned())) .await .expect("update"); assert_eq!( - fs::read(config.web_dist_dir.join("index.html")).expect("web"), + fs::read(web_dist.join("index.html")).expect("web"), b"new-web" ); } @@ -726,7 +750,7 @@ impl Fixture { update_repository: Some("owner/repository".to_owned()), github_api_base: api_base.to_owned(), executable_path: Some(self.executable()), - web_dist_dir: self.web(), + web_dist_dir: Some(self.web()), update_state_file: self.state(), update_lock_file: self.lock(), update_temp_dir: self.root.path().join("tmp"), diff --git a/deploy/README.md b/deploy/README.md index 55937ba3a..0b633724b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -337,6 +337,7 @@ OpenAI 主动额度重置卡及其消费结果由上游持有,不写入 Postgr 每个 Release 独立提供 `config.example.yaml`、默认镜像固定到该版本的 `compose.yaml` 和校验和; 各平台归档也包含 `deploy/config.example.yaml`。配置模板来自构建该版本的同一提交。 使用二进制归档手动部署时,将模板中的 `api.asset_directory` 改为 `../web/dist`,指向归档内的静态资源。 +在线更新默认使用同一目录;如显式设置 `host.system_update.web_dist_dir`,应确保它指向实际提供页面的目录。 升级时先阅读目标版本说明,下载同一 Release 的部署附件,对比模板并合并必要配置,保留已有凭据 和 Compose 自定义项。不要用模板覆盖 `config.yaml`,也不要从 `main` 下载模板搭配旧镜像。 @@ -375,7 +376,8 @@ Compose 已显式装配正式发布构建所需的运行参数: - `CPR_UPDATE_REPOSITORY`:只接受 `owner/repository`;默认 `zyycn/codex-proxy-rs`。 - `CPR_GITHUB_API_BASE`:正式环境必须为 `https://api.github.com/repos`。 - `CPR_UPDATE_CHANNEL`:`stable` 会拒绝 prerelease。 -- `CPR_UPDATE_EXE_PATH`、`CPR_WEB_DIST_DIR`:分别指向容器内二进制和前端静态目录。 +- `CPR_UPDATE_EXE_PATH`、`CPR_WEB_DIST_DIR`:分别指向容器内二进制和前端静态目录; + `CPR_WEB_DIST_DIR` 同时供页面服务与更新器使用,相对路径以 `deploy/config.yaml` 所在目录为基准。 - 更新临时目录、状态文件和锁文件默认由 `host.runtime_data_dir` 派生; `CPR_UPDATE_TEMP_DIR`、`CPR_UPDATE_STATE_FILE`、`CPR_UPDATE_LOCK_FILE` 仅用于显式覆盖。 - `CPR_ENABLE_SELF_RESTART=true`:更新或回滚完成后允许管理端请求重启;Docker 进程退出后由