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:
Zyy
2026-09-17 04:08:17 +08:00
co-authored by Codex
parent 1e7ea8d46f
commit bcfea792e8
14 changed files with 161 additions and 48 deletions
+11
View File
@@ -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"
+3 -4
View File
@@ -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?;
+100 -17
View File
@@ -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");
}
}
-2
View File
@@ -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,
}
-1
View File
@@ -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>,
+1
View File
@@ -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"
+34 -6
View File
@@ -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(&current)?;
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,
+5 -2
View File
@@ -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]
+1 -3
View File
@@ -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,
+4 -3
View File
@@ -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,
+1 -1
View File
@@ -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());
}