feat(config): redesign settings navigation and preserve v7 config migration

- reorganize advanced settings with horizontal navigation, search, and contextual help
- remove LAN discovery and trusted proxy settings
- add robust v7-to-v8 migration for custom, plugin, routing, TLS, and credential fields
- preserve unsaved drafts during external configuration refresh
- add migration and settings UI coverage
This commit is contained in:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent c89e2fb020
commit fdf56ee7eb
22 files changed
+1497 -632

No files matched your search

+19 -34
View File
@@ -65,10 +65,8 @@ const PATHS: &[(&str, &str)] = &[
("oauth/providers/xai", "xai"),
("server/host", "host"),
("server/port", "port"),
("server/trusted-proxies", "trusted-proxies"),
("server/tls", "tls"),
("server/commercial-mode", "commercial-mode"),
("server/discovery", "discovery"),
("management", "remote-management"),
("credentials/concurrency", "credential-concurrency"),
("credentials/in-flight", "credential-in-flight"),
@@ -139,6 +137,18 @@ const PATHS: &[(&str, &str)] = &[
("observability/pprof", "pprof"),
];
pub(crate) fn legacy_extended_config_paths() -> Vec<(Vec<&'static str>, Vec<&'static str>)> {
PATHS
.iter()
.map(|(native, legacy)| {
(
legacy.split('/').collect::<Vec<_>>(),
native.split('/').collect::<Vec<_>>(),
)
})
.collect()
}
fn parts(path: &str) -> Vec<String> {
path.split('/').map(str::to_owned).collect()
}
@@ -285,8 +295,6 @@ fn legacy_path(path: &[String]) -> Vec<String> {
// Only template-backed settings are writable. Existing GUI-owned settings and
// system metadata cannot be changed by this command, even via a parent object.
const OBJECT_PATHS: &[&str] = &[
"server/discovery",
"server/discovery/interfaces",
"credentials/concurrency",
"credentials/in-flight",
"client/codex",
@@ -306,11 +314,6 @@ const OBJECT_PATHS: &[&str] = &[
"oauth/providers/xai",
];
const FIELD_GROUPS: &[(&str, &str, &str)] = &[
("server", "strings", "trusted-proxies"),
("server/discovery", "bool", "enabled auth-required advertise-management"),
("server/discovery", "string", "service-name service-type"),
("server/discovery", "strings", "subtypes"),
("server/discovery/interfaces", "strings", "include exclude"),
("management", "bool", "allow-remote disable-control-panel disable-auto-update-panel"),
("management", "string", "base-url panel-github-repository"),
("credentials/concurrency", "string", "cpa-heartbeat-timeout cpa-cancel-bound reclaim-grace cleanup-interval release-flush-interval release-max-backoff busy-retry-min busy-retry-max"),
@@ -421,17 +424,6 @@ fn duration_nanos(value: &str) -> Option<i64> {
(end == text.len() && nanos < i64::MAX as f64)
.then(|| (nanos as i64) * if value.starts_with('-') { -1 } else { 1 })
}
fn valid_ip_or_cidr(value: &str) -> bool {
if let Some((address, prefix)) = value.split_once('/') {
address.parse::<IpAddr>().is_ok_and(|address| {
prefix
.parse::<u8>()
.is_ok_and(|prefix| prefix <= if address.is_ipv4() { 32 } else { 128 })
})
} else {
value.parse::<IpAddr>().is_ok()
}
}
fn valid_ice_url(value: &str) -> bool {
let Some((scheme, address)) = value.split_once(':') else {
return false;
@@ -722,10 +714,9 @@ fn validate_value(path: &[String], value: &Json, before: &Json) -> Result<(), St
Some("strings")
if value.as_array().is_some_and(|items| {
items.iter().all(|value| {
value.as_str().is_some_and(|text| {
!text.chars().any(char::is_control)
&& (key != "server/trusted-proxies" || valid_ip_or_cidr(text))
})
value
.as_str()
.is_some_and(|text| !text.chars().any(char::is_control))
})
}) => {}
Some("image-mode")
@@ -1075,7 +1066,6 @@ fn prepare_patch(
"server/host",
"server/port",
"server/tls",
"server/trusted-proxies",
"management",
"oauth/auth-dir",
]
@@ -1242,7 +1232,7 @@ client: {codex: {optimize-multi-agent-v2: false}}
json!(true),
false,
),
change(content, "server/trusted-proxies", json!([]), false),
change(content, "management/allow-remote", json!(true), false),
];
let (rendered, result) = prepare_patch(content, &changes).unwrap();
assert!(rendered.contains("# keep heading"));
@@ -1404,7 +1394,6 @@ client: {codex: {optimize-multi-agent-v2: false}}
"999999999999999999"
));
let rejected = [
("server/trusted-proxies", json!(["192.0.2.1/40"])),
("observability/pprof/addr", json!("bad-address")),
(
"oauth/providers/codex/live-media-relay/public-ip",
@@ -1449,7 +1438,6 @@ client: {codex: {optimize-multi-agent-v2: false}}
);
}
let accepted = [
("server/trusted-proxies", json!(["192.0.2.1/24", "::1/128"])),
("observability/pprof/addr", json!("[::1]:8316")),
(
"oauth/providers/codex/live-media-relay/public-ip",
@@ -1526,8 +1514,6 @@ client: {codex: {optimize-multi-agent-v2: false}}
fn extended_restart_hint_distinguishes_live_updates_from_startup_settings() {
let content = "config-version: 8\n";
for (path, value) in [
("server/discovery", json!({"enabled": true})),
("server/discovery/service-name", json!("office")),
(
"observability/pprof",
json!({"enable": true, "addr": "127.0.0.1:8316"}),
@@ -1541,7 +1527,6 @@ client: {codex: {optimize-multi-agent-v2: false}}
assert!(!result.restart_required, "{path} supports live updates");
}
for (path, value) in [
("server/trusted-proxies", json!(["127.0.0.1"])),
("management/allow-remote", json!(true)),
("oauth/auth-dir", json!("~/selected-oauth")),
] {
@@ -1549,13 +1534,13 @@ client: {codex: {optimize-multi-agent-v2: false}}
prepare_patch(content, &[change(content, path, value, false)]).unwrap();
assert!(result.restart_required, "{path} requires a restart");
}
let unchanged = "config-version: 8\nserver: {trusted-proxies: []}\n";
let unchanged = "config-version: 8\nmanagement: {allow-remote: false}\n";
let (_, result) = prepare_patch(
unchanged,
&[change(
unchanged,
"server/trusted-proxies",
json!([]),
"management/allow-remote",
json!(false),
false,
)],
)
+158
View File
@@ -1189,6 +1189,14 @@ pub(crate) fn core_config_uses_v8(document: &serde_norway::Value) -> bool {
|| yaml_mapping_value(root, "api-keys").is_some_and(serde_norway::Value::is_mapping)
}
pub(crate) fn core_config_declares_v8(document: &serde_norway::Value) -> bool {
document
.as_mapping()
.and_then(|root| yaml_mapping_value(root, "config-version"))
.and_then(serde_norway::Value::as_u64)
.is_some_and(|version| version >= 8)
}
pub(crate) fn set_core_yaml_path_value(
document: &mut serde_norway::Value,
path: &[&str],
@@ -1219,6 +1227,156 @@ pub(crate) fn set_core_yaml_path_value(
Ok(true)
}
pub(crate) fn migrate_legacy_core_config_to_v8(
template: &str,
legacy: &str,
) -> Result<String, String> {
let template_document = serde_norway::from_str::<serde_norway::Value>(template)
.map_err(|error| format!("Failed to parse new kernel configuration template: {error}"))?;
if !core_config_uses_v8(&template_document) {
return Err("New kernel configuration template is not v8".to_string());
}
let legacy_document = serde_norway::from_str::<serde_norway::Value>(legacy)
.map_err(|error| format!("Failed to parse existing kernel configuration: {error}"))?;
if core_config_declares_v8(&legacy_document) {
return Ok(legacy.to_string());
}
let legacy_root = legacy_document
.as_mapping()
.ok_or_else(|| "Existing kernel configuration root must be a YAML mapping".to_string())?;
let mut migrated = template_document.clone();
// Start from the new template so newly introduced defaults remain available,
// then overlay the complete legacy document. The explicit lifts below only
// move fields whose spelling changed between schemas; all other user-owned
// sections must survive the upgrade as-is.
merge_yaml_values(&mut migrated, legacy_document.clone());
if let Some(version) = yaml_mapping_value(
template_document
.as_mapping()
.ok_or_else(|| "New kernel configuration template root must be a YAML mapping".to_string())?,
"config-version",
).cloned() {
set_core_yaml_path_value(&mut migrated, &["config-version"], version)?;
} else {
remove_core_yaml_path_value(&mut migrated, &["config-version"]);
}
lift_legacy_core_config_fields(
&mut migrated,
legacy_root,
template_document
.as_mapping()
.ok_or_else(|| "New kernel configuration template root must be a YAML mapping".to_string())?,
)?;
render_yaml_value_changes(template, &template_document, &migrated)
}
fn lift_legacy_core_config_fields(
document: &mut serde_norway::Value,
legacy: &serde_norway::Mapping,
template: &serde_norway::Mapping,
) -> Result<(), String> {
let copy = |document: &mut serde_norway::Value, legacy_path: &[&str], v8_path: &[&str]| -> Result<(), String> {
let Some(value) = nested_yaml_value(legacy, legacy_path).cloned() else {
return Ok(());
};
// A partially migrated file can contain both spellings. Prefer the
// canonical v8 value in that case; otherwise move the legacy value.
if nested_yaml_value(legacy, v8_path).is_none() {
set_core_yaml_path_value(document, v8_path, value)?;
}
if nested_yaml_value(template, legacy_path).is_none() {
remove_core_yaml_path_value(document, legacy_path);
}
Ok(())
};
for (legacy_path, v8_path) in [
(&["host"][..], &["server", "host"][..]),
(&["port"], &["server", "port"]),
(&["commercial-mode"], &["server", "commercial-mode"]),
(&["auth-dir"], &["oauth", "auth-dir"]),
(&["debug"], &["observability", "logs", "debug"]),
(&["logging-to-file"], &["observability", "logs", "logging-to-file"]),
(&["logs-max-total-size-mb"], &["observability", "logs", "logs-max-total-size-mb"]),
(&["error-logs-max-files"], &["observability", "logs", "error-logs-max-files"]),
(&["request-log"], &["observability", "logs", "request-log"]),
(&["usage-statistics-enabled"], &["observability", "usage", "usage-statistics-enabled"]),
(&["redis-usage-queue-retention-seconds"], &["observability", "usage", "redis-usage-queue-retention-seconds"]),
(&["proxy-url"], &["requests", "proxy-url"]),
(&["disable-cooling"], &["routing", "cooldown", "disable-cooling"]),
(&["request-retry"], &["routing", "retry", "request-retry"]),
(&["max-retry-credentials"], &["routing", "retry", "max-retry-credentials"]),
(&["max-retry-interval"], &["routing", "retry", "max-retry-interval"]),
(&["streaming", "bootstrap-retries"], &["requests", "streaming", "bootstrap-retries"]),
(&["remote-management", "secret-key"], &["management", "secret-key"]),
(&["oauth-model-alias"], &["oauth", "model-alias"]),
(&["payload"], &["requests", "payload"]),
] {
copy(document, legacy_path, v8_path)?;
}
if let Some(tls) = yaml_mapping_value(legacy, "tls").filter(|value| value.is_mapping()).cloned() {
if nested_yaml_value(legacy, &["server", "tls"]).is_none() {
set_core_yaml_path_value(document, &["server", "tls"], tls)?;
}
if yaml_mapping_value(template, "tls").is_none() {
remove_core_yaml_path_value(document, &["tls"]);
}
}
if let Some(keys) = yaml_mapping_value(legacy, "api-keys").filter(|value| value.is_sequence()).cloned() {
if nested_yaml_value(legacy, &["access", "api-keys"]).is_none() {
set_core_yaml_path_value(document, &["access", "api-keys"], keys)?;
}
if let Some(template_upstreams) = yaml_mapping_value(template, "api-keys")
.filter(|value| value.is_mapping())
.cloned()
{
// A v8 template may already contain the upstream credential map at
// the root. The legacy client-key sequence must not replace it.
set_core_yaml_path_value(document, &["api-keys"], template_upstreams)?;
} else {
remove_core_yaml_path_value(document, &["api-keys"]);
}
}
let has_canonical_upstream_mapping = yaml_mapping_value(legacy, "api-keys")
.is_some_and(serde_norway::Value::is_mapping);
for (legacy_name, provider) in V8_PROVIDER_FAMILIES {
let Some(records) = yaml_mapping_value(legacy, legacy_name) else {
continue;
};
if !has_canonical_upstream_mapping
&& nested_yaml_value(legacy, &["api-keys", provider]).is_none()
{
let grouped = group_legacy_provider_records(provider, records)?;
set_core_yaml_path_value(document, &["api-keys", provider], grouped)?;
}
if yaml_mapping_value(template, legacy_name).is_none() {
remove_core_yaml_path_value(document, &[legacy_name]);
}
}
for (legacy_path, v8_path) in legacy_extended_config_paths() {
copy(document, &legacy_path, &v8_path)?;
}
Ok(())
}
fn remove_core_yaml_path_value(document: &mut serde_norway::Value, path: &[&str]) -> bool {
let Some((key, parents)) = path.split_last() else {
return false;
};
let Some(mut mapping) = document.as_mapping_mut() else {
return false;
};
for section in parents {
let Some(parent) = mapping.get_mut(yaml_key(section)) else {
return false;
};
let Some(parent) = parent.as_mapping_mut() else {
return false;
};
mapping = parent;
}
mapping.remove(yaml_key(key)).is_some()
}
pub(crate) fn set_core_yaml_schema_value(
document: &mut serde_norway::Value,
legacy_path: &[&str],
+5 -1
View File
@@ -1957,7 +1957,11 @@ pub(crate) fn migrate_core_config_for_update(
}
let template_document = serde_norway::from_str::<serde_norway::Value>(&template)
.map_err(|error| format!("Failed to parse new kernel configuration template: {error}"))?;
let migrated = if core_config_uses_v8(&template_document) {
let migrated = if (core_config_declares_v8(&template_document) || core_config_uses_v8(&template_document))
&& !core_config_declares_v8(&old_document)
{
migrate_legacy_core_config_to_v8(&template, &old_config)?
} else if core_config_uses_v8(&template_document) {
old_config
} else {
merge_core_config_fields(&template, Some(&old_config))?
+31 -9
View File
@@ -594,20 +594,36 @@ fn replacing_a_core_migrates_old_fields_into_the_new_template() {
}
#[test]
fn replacing_v7_with_v8_preserves_the_legacy_config_byte_for_byte() {
fn replacing_v7_with_v8_lifts_legacy_values_into_the_new_layout() {
let root = agent_test_home("core-config-v8-preserve");
let source = root.join("source");
let target = root.join("target");
fs::create_dir_all(&source).unwrap();
fs::create_dir_all(&target).unwrap();
let old_config = "# User v7 config\nhost: 127.0.0.1\nport: 9527\nremote-management:\n secret-key: user-secret\napi-keys: [client-key]\nproxy-url: direct\n";
let v8_template = "config-version: 8\nserver: {host: '', port: 8317}\nmanagement: {secret-key: ''}\naccess: {api-keys: [template-key]}\nrequests: {proxy-url: ''}\n";
let old_config = "# User v7 config\nhost: 127.0.0.1\nport: 9527\nremote-management:\n secret-key: user-secret\napi-keys: [client-key]\nproxy-url: direct\nrouting:\n strategy: fill-first\n session-affinity: true\n session-affinity-ttl: 2h\nplugins:\n configs:\n local:\n enabled: true\n store-auth:\n registry: stored-token\n";
let v8_template = "config-version: 8\nserver: {host: '', port: 8317}\nmanagement: {secret-key: ''}\naccess: {api-keys: [template-key]}\napi-keys: {codex: [{name: template-upstream, keys: []}]}\nrequests: {proxy-url: ''}\nrouting: {strategy: round-robin, session-affinity: false, session-affinity-ttl: 30m}\nplugins: {configs: {}, store-auth: {}}\n";
fs::write(source.join(CORE_CONFIG_FILE), old_config).unwrap();
fs::write(target.join(CORE_EXAMPLE_CONFIG_FILE), v8_template).unwrap();
migrate_core_config_for_update(&source, &target).unwrap();
assert_eq!(fs::read_to_string(target.join(CORE_CONFIG_FILE)).unwrap(), old_config);
let migrated = fs::read_to_string(target.join(CORE_CONFIG_FILE)).unwrap();
let document = serde_norway::from_str::<serde_norway::Value>(&migrated).unwrap();
let settings = core_config_settings_from_value(&document).unwrap();
assert_eq!(settings.host, "127.0.0.1");
assert_eq!(settings.port, 9527);
assert_eq!(settings.api_keys, vec!["client-key"]);
assert_eq!(settings.management_secret_key.as_deref(), Some("user-secret"));
assert_eq!(settings.proxy_url, "direct");
assert_eq!(document["routing"]["strategy"], "fill-first");
assert_eq!(document["routing"]["session-affinity"], true);
assert_eq!(document["routing"]["session-affinity-ttl"], "2h");
assert_eq!(document["plugins"]["configs"]["local"]["enabled"], true);
assert_eq!(document["plugins"]["store-auth"]["registry"], "stored-token");
assert_eq!(document["api-keys"]["codex"][0]["name"], "template-upstream");
assert!(migrated.starts_with("config-version:"));
assert!(document.get("host").is_none());
assert!(document.get("port").is_none());
fs::remove_dir_all(root).unwrap();
}
@@ -623,16 +639,17 @@ fn v8_core_updates_preserve_partially_migrated_config_and_credentials() {
let legacy = "# User configuration\nusage-statistics-enabled: true\nhost: 127.0.0.1\nport: 9527\nauth-dir: ../oauth\napi-keys: [client-key]\nremote-management: {secret-key: user-secret}\nproxy-url: direct\nrequest-retry: 9\ncodex-api-key: [{api-key: upstream-key, base-url: 'https://example.invalid/v1'}]\noauth-model-alias: {codex: [{name: model, alias: custom-model}]}\n";
let template = "observability: {usage: {usage-statistics-enabled: false}}\nserver: {host: '', port: 8317}\noauth: {auth-dir: '~/.cli-proxy-api'}\naccess: {api-keys: [your-api-key-1]}\nmanagement: {secret-key: ''}\nrequests: {proxy-url: ''}\nrouting: {retry: {request-retry: 3}}\n";
for prefix in ["", "config-version: 8\n", "server: {host: 127.0.0.1}\n", "config-version: 8\nserver: {host: 127.0.0.1}\n"] {
for prefix in ["", "server: {host: 127.0.0.1}\n"] {
let original = format!("{prefix}{legacy}");
fs::write(install_dir.join(CORE_CONFIG_FILE), &original).unwrap();
for template_prefix in ["config-version: 8\n", ""] {
fs::create_dir_all(&staging_dir).unwrap();
fs::write(staging_dir.join(CORE_EXAMPLE_CONFIG_FILE), format!("{template_prefix}{template}")).unwrap();
migrate_core_config_for_update(&install_dir, &staging_dir).unwrap();
let migrated = fs::read_to_string(staging_dir.join(CORE_CONFIG_FILE)).unwrap();
overlay_install_dir(&install_dir, &staging_dir).unwrap();
fs::write(install_dir.join(CORE_CONFIG_FILE), &original).unwrap();
let migrated = fs::read_to_string(install_dir.join(CORE_CONFIG_FILE)).unwrap();
let document = serde_norway::from_str::<serde_norway::Value>(&migrated).unwrap();
let settings = core_config_settings_from_value(&document).unwrap();
assert_eq!(settings.auth_dir, "../oauth", "credential directory changed during update");
@@ -642,7 +659,12 @@ fn v8_core_updates_preserve_partially_migrated_config_and_credentials() {
assert_eq!(settings.management_secret_key.as_deref(), Some("user-secret"));
assert_eq!(settings.proxy_url, "direct");
assert_eq!(settings.request_retry, 9);
assert_eq!(migrated, original, "update must preserve every existing field and comment");
assert!(migrated.contains("server:"), "legacy configuration must be lifted into the v8 layout: {migrated}");
assert!(document.get("host").is_none(), "{migrated}");
assert!(document.get("auth-dir").is_none());
assert!(document.get("codex-api-key").is_none());
assert_eq!(document["oauth"]["model-alias"]["codex"][0]["alias"], "custom-model");
assert_eq!(document["api-keys"]["codex"][0]["keys"][0]["api-key"], "upstream-key");
let mut gui = GuiConfigFile::default();
apply_core_settings_to_gui_config(&mut gui, &settings);
@@ -652,8 +674,8 @@ fn v8_core_updates_preserve_partially_migrated_config_and_credentials() {
let effective = core_config_settings_from_value(&startup).unwrap();
assert_eq!(auth_dir_path_for_core(&effective.auth_dir, &install_dir).unwrap(), root.join("oauth"));
assert!(effective.usage_statistics_enabled);
assert_eq!(startup["codex-api-key"], document["codex-api-key"]);
assert_eq!(startup["oauth-model-alias"], document["oauth-model-alias"]);
assert_eq!(startup["api-keys"]["codex"], document["api-keys"]["codex"]);
assert_eq!(startup["oauth"]["model-alias"], document["oauth"]["model-alias"]);
assert_eq!(fs::read(&credential_path).unwrap(), b"existing-credential");
}
}