mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(config): tolerate unused startup settings
Ignore unused YAML fields across startup configuration and report their paths without values. Report missing required fields while retaining existing type and value validation. Validation: configuration and bootstrap tests, strict Clippy, and architecture checks passed. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Generated
+11
@@ -1541,6 +1541,7 @@ dependencies = [
|
||||
"scraper",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_ignored",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tar",
|
||||
@@ -3346,6 +3347,16 @@ dependencies = [
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_ignored"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
|
||||
@@ -7,8 +7,8 @@ use serde::Deserialize;
|
||||
const CONFIG_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// 顶层配置只组合各包拥有的配置段,不解释任何业务字段。
|
||||
/// 各启动配置忽略未知字段,允许升级时保留旧配置;已知字段仍按类型和业务约束校验。
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GatewayConfig {
|
||||
schema_version: u32,
|
||||
host: HostConfig,
|
||||
@@ -19,11 +19,11 @@ pub struct GatewayConfig {
|
||||
api: gateway_api::ApiConfig,
|
||||
openai: provider_openai::OpenAiConfig,
|
||||
xai: provider_xai::XaiConfig,
|
||||
#[serde(default, rename = "services")]
|
||||
_compose_services: Option<serde::de::IgnoredAny>,
|
||||
}
|
||||
|
||||
impl LoadableConfig for GatewayConfig {
|
||||
const EXTERNAL_SECTIONS: &'static [&'static str] = &["services"];
|
||||
|
||||
fn resolve_and_validate(&mut self, source_dir: &std::path::Path) -> Result<(), ConfigError> {
|
||||
if self.schema_version != CONFIG_SCHEMA_VERSION {
|
||||
return Err(ConfigError::InvalidField("schema_version"));
|
||||
@@ -65,7 +65,6 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
api,
|
||||
openai,
|
||||
xai,
|
||||
_compose_services: _,
|
||||
} = config;
|
||||
|
||||
let host = gateway_host::initialize(host).await?;
|
||||
|
||||
@@ -272,16 +272,107 @@ fn bootstrap_config_debug_should_redact_all_passwords() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_unknown_fields() {
|
||||
assert_rejected(format!(
|
||||
"{}\nunknown_terminal_field: true\n",
|
||||
valid_config()
|
||||
));
|
||||
fn config_loader_should_ignore_unknown_fields_in_configuration_sections() {
|
||||
let mut document = valid_config_document();
|
||||
document["host"]["system_update"] = serde_json::json!({});
|
||||
document["store"]["pool"] = serde_json::json!({});
|
||||
for path in [
|
||||
"",
|
||||
"/host",
|
||||
"/host/listen",
|
||||
"/host/logging",
|
||||
"/host/logging/file",
|
||||
"/host/system_update",
|
||||
"/store",
|
||||
"/store/database",
|
||||
"/store/redis",
|
||||
"/store/pool",
|
||||
"/admin",
|
||||
"/client",
|
||||
"/api",
|
||||
"/openai",
|
||||
"/openai/wire_profile",
|
||||
"/xai",
|
||||
"/xai/wire_profile",
|
||||
] {
|
||||
let mut extended = document.clone();
|
||||
extended.pointer_mut(path).expect("configuration section")["unused_setting"] =
|
||||
serde_json::json!({"nested": [true, null, "ignored"]});
|
||||
parse_config(&extended.to_string())
|
||||
.unwrap_or_else(|error| panic!("unknown field in {path}: {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_removed_tls_section() {
|
||||
assert_rejected(valid_config().replace("openai:\n", "openai:\n tls: {}\n"));
|
||||
fn config_loader_should_ignore_removed_tls_and_fingerprint_sections() {
|
||||
let mut document = valid_config_document();
|
||||
document["openai"]["tls"] = serde_json::json!({});
|
||||
document["openai"]["fingerprint"] = serde_json::json!({"browser": "removed"});
|
||||
parse_config(&document.to_string()).expect("removed provider settings are ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_known_fields_alongside_unknown_fields() {
|
||||
for port in [serde_json::json!(0), serde_json::json!("not-a-port")] {
|
||||
let mut document = valid_config_document();
|
||||
document["host"]["listen"]["unused_setting"] = serde_json::json!(true);
|
||||
document["host"]["listen"]["port"] = port;
|
||||
assert_rejected(document.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_report_startup_configuration_diagnostics() {
|
||||
const CHILD_ENV: &str = "CPR_TEST_CONFIG_DIAGNOSTICS_CHILD";
|
||||
const UNUSED_SECRET: &str = "unused-value-must-not-appear-in-diagnostics";
|
||||
if std::env::var_os(CHILD_ENV).is_some() {
|
||||
gateway_host::load_config::<GatewayConfig>().expect("startup configuration");
|
||||
return;
|
||||
}
|
||||
for case in ["normal", "unused", "missing"] {
|
||||
let mut document = valid_config_document();
|
||||
if case == "unused" {
|
||||
document["openai"]["wire_profile"]["location"] = serde_json::json!(UNUSED_SECRET);
|
||||
} else if case == "missing" {
|
||||
document["host"]["listen"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("port");
|
||||
}
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(directory.path().join("deploy")).unwrap();
|
||||
fs::write(
|
||||
directory.path().join("deploy/config.yaml"),
|
||||
document.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let output = Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"bootstrap::config_loader_should_report_startup_configuration_diagnostics",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_ENV, "1")
|
||||
.current_dir(directory.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(
|
||||
output.status.success(),
|
||||
case != "missing",
|
||||
"{case}: {stderr}"
|
||||
);
|
||||
match case {
|
||||
"unused" => assert!(
|
||||
stderr.contains("openai.wire_profile.location") && stderr.contains("已忽略"),
|
||||
"{stderr}"
|
||||
),
|
||||
"missing" => assert!(stderr.contains("host.listen.port"), "{stderr}"),
|
||||
_ => assert!(!stderr.contains("警告"), "{stderr}"),
|
||||
}
|
||||
assert!(!stderr.contains(UNUSED_SECRET), "{stderr}");
|
||||
assert!(!stderr.contains("services"), "{stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -353,14 +444,6 @@ fn config_loader_should_reject_missing_client_session_ttl() {
|
||||
assert_rejected(config.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_removed_fingerprint_section() {
|
||||
assert_rejected(valid_config().replace(
|
||||
"openai:\n",
|
||||
"openai:\n fingerprint:\n browser: removed\n",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_codex_cli_version() {
|
||||
assert_rejected(valid_config().replace("codex_version: '0.153.4'", "codex_version: 'latest'"));
|
||||
@@ -386,7 +469,7 @@ fn config_loader_should_reject_invalid_desktop_profile_fields() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_removed_request_location_setting() {
|
||||
fn config_loader_should_ignore_removed_request_location_setting() {
|
||||
let original = valid_config();
|
||||
parse_config(&original).expect("location belongs to runtime settings");
|
||||
for value in [
|
||||
@@ -400,7 +483,7 @@ fn config_loader_should_reject_removed_request_location_setting() {
|
||||
1,
|
||||
);
|
||||
assert_ne!(legacy, original);
|
||||
assert_rejected(legacy);
|
||||
parse_config(&legacy).expect("removed request location does not prevent startup");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,6 @@ impl fmt::Debug for InitialAdminPassword {
|
||||
|
||||
/// 管理控制面的启动配置。
|
||||
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AdminConfig {
|
||||
pub session_ttl_minutes: u64,
|
||||
pub default_username: String,
|
||||
@@ -106,7 +105,6 @@ pub struct AdminConfig {
|
||||
|
||||
/// Client 登录域的通用启动配置。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ClientConfig {
|
||||
pub session_ttl_minutes: u64,
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ mod session_cookie;
|
||||
|
||||
/// API-owned HTTP 与静态资源配置。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ApiConfig {
|
||||
pub asset_directory: PathBuf,
|
||||
pub cors_allowed_origins: Vec<String>,
|
||||
|
||||
@@ -21,6 +21,7 @@ reqwest = { version = "=0.12.28", default-features = false, features = ["json",
|
||||
scraper = "0.27.0"
|
||||
semver = "1.0.27"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_ignored = "0.1.14"
|
||||
serde_json = "1.0.150"
|
||||
sha2 = "0.11.0"
|
||||
tar = "0.4.44"
|
||||
|
||||
@@ -19,6 +19,9 @@ const SERVER_PORT_ENV: &str = "CPR_SERVER_PORT";
|
||||
/// Host 只负责找到和解析文件;每个包的字段解释与相对路径解析
|
||||
/// 由顶层配置委托给对应的包完成。
|
||||
pub trait LoadableConfig: DeserializeOwned {
|
||||
/// 同一配置文件中由其他工具消费的顶层配置段。
|
||||
const EXTERNAL_SECTIONS: &'static [&'static str] = &[];
|
||||
|
||||
fn resolve_and_validate(&mut self, source_dir: &Path) -> Result<(), ConfigError>;
|
||||
}
|
||||
|
||||
@@ -27,18 +30,44 @@ pub fn load_config<T: LoadableConfig>() -> Result<T, ConfigError> {
|
||||
let current = env::current_dir().map_err(|_| ConfigError::CurrentDirectory)?;
|
||||
let path = discover_config_path(¤t)?;
|
||||
let source_dir = path.parent().ok_or(ConfigError::InvalidConfigPath)?;
|
||||
let mut value: T = config::Config::builder()
|
||||
let document = config::Config::builder()
|
||||
.add_source(config::File::from(path.as_path()).required(true))
|
||||
.build()
|
||||
.and_then(config::Config::try_deserialize)
|
||||
.map_err(|_| ConfigError::InvalidDocument { path: path.clone() })?;
|
||||
let mut unused = std::collections::BTreeSet::new();
|
||||
let mut value: T = serde_ignored::deserialize(document, |field| {
|
||||
unused.insert(field.to_string());
|
||||
})
|
||||
.map_err(|error| match missing_config_field(&error) {
|
||||
Some(field) => ConfigError::MissingField {
|
||||
path: path.clone(),
|
||||
field,
|
||||
},
|
||||
None => ConfigError::InvalidDocument { path: path.clone() },
|
||||
})?;
|
||||
// 此时日志尚未初始化;只报告字段路径,不回显可能包含凭据的配置值。
|
||||
for field in unused {
|
||||
if !T::EXTERNAL_SECTIONS.contains(&field.as_str()) {
|
||||
eprintln!("[警告] 配置字段 {field:?} 未使用,已忽略");
|
||||
}
|
||||
}
|
||||
value.resolve_and_validate(source_dir)?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn missing_config_field(error: &config::ConfigError) -> Option<String> {
|
||||
match error {
|
||||
config::ConfigError::NotFound(field) => Some(field.clone()),
|
||||
config::ConfigError::At { error, key, .. } => missing_config_field(error).map(|field| {
|
||||
key.as_ref()
|
||||
.map_or_else(|| field.clone(), |key| format!("{key}.{field}"))
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Host 唯一拥有的进程配置。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HostConfig {
|
||||
pub listen: ListenConfig,
|
||||
pub runtime_data_dir: PathBuf,
|
||||
@@ -113,7 +142,6 @@ impl HostConfig {
|
||||
|
||||
/// HTTP 监听地址。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ListenConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
@@ -121,7 +149,6 @@ pub struct ListenConfig {
|
||||
|
||||
/// Host 结构化日志配置。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LoggingConfig {
|
||||
pub level: String,
|
||||
pub stdout: bool,
|
||||
@@ -178,7 +205,6 @@ impl LoggingConfig {
|
||||
|
||||
/// Host 文件日志配置。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FileLoggingConfig {
|
||||
pub enabled: bool,
|
||||
pub directory: PathBuf,
|
||||
@@ -198,6 +224,8 @@ pub enum ConfigError {
|
||||
InvalidConfigPath,
|
||||
#[error("configuration document is invalid: {path}")]
|
||||
InvalidDocument { path: PathBuf },
|
||||
#[error("配置文件 {path} 缺少必填字段 {field:?}")]
|
||||
MissingField { path: PathBuf, field: String },
|
||||
#[error("configuration field is invalid: {0}")]
|
||||
InvalidField(&'static str),
|
||||
#[error("environment variable is invalid: {0}")]
|
||||
|
||||
@@ -50,7 +50,7 @@ type OperationError = SystemOperationError;
|
||||
|
||||
/// 系统更新与重启配置;所有字段只由 Host 解释。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
#[serde(default)]
|
||||
pub struct SystemUpdateConfig {
|
||||
pub version: String,
|
||||
pub git_sha: String,
|
||||
|
||||
@@ -160,7 +160,7 @@ fn logging_rejects_zero_retention_windows() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logging_defaults_to_seven_days_and_one_day_for_payloads_and_rejects_count_retention() {
|
||||
fn logging_defaults_to_time_retention_and_ignores_removed_count_retention() {
|
||||
let mut value = serde_json::json!({
|
||||
"level": "info", "stdout": true,
|
||||
"file": { "enabled": true, "directory": "logs", "max_file_size_mb": 20 }
|
||||
@@ -170,7 +170,10 @@ fn logging_defaults_to_seven_days_and_one_day_for_payloads_and_rejects_count_ret
|
||||
assert_eq!(config.file.retention_days, 7);
|
||||
assert!(!config.oauth_recovery);
|
||||
value["file"]["max_files"] = serde_json::json!(20);
|
||||
assert!(serde_json::from_value::<LoggingConfig>(value).is_err());
|
||||
assert_eq!(
|
||||
serde_json::from_value::<LoggingConfig>(value).unwrap(),
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,7 +19,6 @@ pub(crate) const POSTGRES_HEALTH_RETRY_DELAY: Duration = Duration::from_millis(5
|
||||
|
||||
/// Store 自己拥有并校验的启动配置。
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StoreConfig {
|
||||
pub(crate) database: StoreConnectionConfig,
|
||||
pub(crate) redis: StoreConnectionConfig,
|
||||
@@ -31,7 +30,7 @@ pub struct StoreConfig {
|
||||
|
||||
/// PostgreSQL 连接池预算;acquire 超时决定池耗尽时快速失败而非排队积压。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
#[serde(default)]
|
||||
pub struct StorePoolConfig {
|
||||
pub max_connections: u32,
|
||||
pub acquire_timeout_seconds: u64,
|
||||
@@ -149,7 +148,6 @@ impl fmt::Debug for StoreConfig {
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct StoreConnectionConfig {
|
||||
pub(crate) url: String,
|
||||
pub(crate) password: String,
|
||||
|
||||
@@ -18,7 +18,7 @@ fn store_config_should_derive_backup_staging_from_runtime_data_dir() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_pool_config_keeps_the_public_surface_to_connection_pool_limits() {
|
||||
fn store_pool_config_ignores_unknown_options_and_keeps_connection_pool_limits() {
|
||||
let pool: StorePoolConfig =
|
||||
serde_json::from_value(serde_json::json!({})).expect("default pool configuration");
|
||||
|
||||
@@ -34,11 +34,12 @@ fn store_pool_config_keeps_the_public_surface_to_connection_pool_limits() {
|
||||
.observability_max_connections(),
|
||||
40
|
||||
);
|
||||
assert!(
|
||||
assert_eq!(
|
||||
serde_json::from_value::<StorePoolConfig>(serde_json::json!({
|
||||
"statement_timeout_seconds": 60,
|
||||
}))
|
||||
.is_err()
|
||||
.unwrap(),
|
||||
pool,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ const fn default_stream_max_retries() -> u64 {
|
||||
|
||||
/// OpenAI Provider 唯一启动配置。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OpenAiConfig {
|
||||
#[serde(default)]
|
||||
pub api: CodexApiConfig,
|
||||
@@ -129,7 +128,6 @@ impl Default for OpenAiConfig {
|
||||
|
||||
/// Codex 上游 API 的 Provider-owned 地址配置。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CodexApiConfig {
|
||||
pub base_url: String,
|
||||
}
|
||||
@@ -153,7 +151,6 @@ impl CodexApiConfig {
|
||||
|
||||
/// Codex Responses WebSocket pool 的 Provider-owned 启动设置。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CodexWebSocketPoolSettings {
|
||||
pub enabled: bool,
|
||||
pub max_age_ms: u64,
|
||||
@@ -194,7 +191,6 @@ impl CodexWebSocketPoolSettings {
|
||||
|
||||
/// OpenAI Provider 的额度刷新策略。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CodexQuotaSettings {
|
||||
/// 保留模型目录的刷新周期;额度独立每 30 秒检查周期复核和 reset 到期条件。
|
||||
pub refresh_interval_minutes: u64,
|
||||
@@ -227,7 +223,6 @@ impl CodexQuotaSettings {
|
||||
|
||||
/// OpenAI OAuth 的 Provider-owned 运行开关和端点。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CodexAuthSettings {
|
||||
pub refresh_enabled: bool,
|
||||
pub oauth_client_id: String,
|
||||
@@ -260,7 +255,6 @@ impl CodexAuthSettings {
|
||||
|
||||
/// 经审计固定的 Codex Desktop 上游请求画像。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CodexWireProfileConfig {
|
||||
pub originator: String,
|
||||
/// 官方 Desktop ZIP 内嵌 Core 的启动基线;运行时按完整制品元组更新。
|
||||
|
||||
@@ -9,7 +9,6 @@ use crate::{GrokOAuthConfig, XaiWireProfileState};
|
||||
|
||||
/// xAI Provider 唯一启动配置。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct XaiConfig {
|
||||
pub wire_profile: XaiWireProfileConfig,
|
||||
}
|
||||
@@ -33,7 +32,6 @@ impl XaiConfig {
|
||||
|
||||
/// 经参考实现核验的 Grok CLI 请求画像。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct XaiWireProfileConfig {
|
||||
pub client_identifier: String,
|
||||
pub client_version: String,
|
||||
|
||||
@@ -18,5 +18,5 @@ fn xai_config_requires_and_freezes_the_explicit_wire_profile() {
|
||||
assert!(serde_json::from_str::<XaiConfig>(
|
||||
r#"{"wire_profile":{"client_identifier":"grok-shell","client_version":"0.2.106","client_mode":"headless","target_os":"linux","target_arch":"x86_64","verified_at":"2026-07-21T00:00:00+08:00","unknown":true}}"#,
|
||||
)
|
||||
.is_err());
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user