feat(runtime-api): serve the declared settings schema over HTTP

/v1/settings/schema projects SETTINGS_SCHEMA for GUI clients: every
declared row with resolved labels, current value from the owning store,
persisted state, row kind (setting/action/diagnostic/session), and the
TUI's conditional-visibility and editability rules. POST /v1/config's
unknown-key arm now delegates to Settings::set, so every declared
settings.toml key persists through the shared validator instead of a
curated allowlist.

Adds SettingRowKind to the schema, Settings::value/is_set accessors,
and live-server tests covering all 82 rows plus the write fallthrough.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
CodeWhale BotandDevin committed 2026-09-16 17:22:50 -07:00
1 parent 2037c2880c
commit 6e58fd1ab7
4 files changed
+720 -27

No files matched your search

+93 -24
View File
@@ -69,6 +69,19 @@ pub enum SettingKind {
String,
}
/// What a declared settings row is. `Setting` is a writable preference;
/// `Action` opens another surface (the provider/model pickers, module
/// links); `Diagnostic` is a read-only receipt or managed-policy fact;
/// `Session` is editable for the running session but does not persist
/// through the config store.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingRowKind {
Setting,
Action,
Diagnostic,
Session,
}
/// Where a setting appears, and what it says. Absent ⇒ no row.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SettingUi {
@@ -78,6 +91,11 @@ pub struct SettingUi {
pub label: &'static str,
/// Message key for the row's description sentence.
pub description: &'static str,
/// Whether the row is a writable preference, a link to another surface,
/// a read-only receipt, or a session-scoped override. Surfaces that
/// cannot open the target or scope the write render non-`Setting` rows
/// read-only instead of guessing.
pub row: SettingRowKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -381,6 +399,57 @@ const fn ui(
group,
label,
description,
row: SettingRowKind::Setting,
})
}
/// A row that opens another surface rather than editing a value in place.
const fn ui_action(
tab: &'static str,
group: &'static str,
label: &'static str,
description: &'static str,
) -> Option<SettingUi> {
Some(SettingUi {
tab,
group,
label,
description,
row: SettingRowKind::Action,
})
}
/// A read-only receipt row — a managed-policy fact, live route value, or
/// descriptive pointer, never a writable control.
const fn ui_diagnostic(
tab: &'static str,
group: &'static str,
label: &'static str,
description: &'static str,
) -> Option<SettingUi> {
Some(SettingUi {
tab,
group,
label,
description,
row: SettingRowKind::Diagnostic,
})
}
/// A row editable for the running session only; it does not persist through
/// the config store.
const fn ui_session(
tab: &'static str,
group: &'static str,
label: &'static str,
description: &'static str,
) -> Option<SettingUi> {
Some(SettingUi {
tab,
group,
label,
description,
row: SettingRowKind::Session,
})
}
@@ -600,7 +669,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"provider",
SettingKind::String,
"",
ui(
ui_action(
TAB_MODELS,
"provider",
"ConfigLabelProvider",
@@ -611,7 +680,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"provider_templates",
SettingKind::String,
"",
ui(
ui_action(
TAB_MODELS,
"provider",
"ConfigLabelProviderTemplates",
@@ -622,7 +691,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"model",
SettingKind::String,
"",
ui(TAB_MODELS, "model", "ConfigLabelModel", "ConfigHintModel"),
ui_action(TAB_MODELS, "model", "ConfigLabelModel", "ConfigHintModel"),
),
def(
"reasoning_effort",
@@ -642,7 +711,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"fleet.exec.max_spawn_depth",
SettingKind::Int,
"3",
ui(
ui_diagnostic(
TAB_MODELS,
"model",
"ConfigLabelFleetSpawnDepth",
@@ -807,7 +876,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"goal_command",
SettingKind::String,
"",
ui(
ui_diagnostic(
TAB_WORK,
"session",
"ConfigLabelGoalCommand",
@@ -818,7 +887,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"workflow",
SettingKind::String,
"",
ui(
ui_diagnostic(
TAB_WORK,
"workflow",
"ConfigLabelWorkflow",
@@ -1039,13 +1108,13 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"mcp_open",
SettingKind::String,
"",
ui(TAB_TOOLS, "mcp", "ConfigLabelMcpOpen", "ConfigHintMcpOpen"),
ui_action(TAB_TOOLS, "mcp", "ConfigLabelMcpOpen", "ConfigHintMcpOpen"),
),
def(
"mcp_reconnect",
SettingKind::String,
"",
ui(
ui_action(
TAB_TOOLS,
"mcp",
"ConfigLabelMcpReconnect",
@@ -1056,7 +1125,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"mcp_diagnose",
SettingKind::String,
"",
ui(
ui_action(
TAB_TOOLS,
"mcp",
"ConfigLabelMcpDiagnose",
@@ -1067,7 +1136,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"plugins_open",
SettingKind::String,
"",
ui(
ui_action(
TAB_TOOLS,
"mcp",
"ConfigLabelPluginsOpen",
@@ -1090,7 +1159,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"approval_mode",
SettingKind::Enum(APPROVAL_MODE),
"",
ui(
ui_session(
TAB_TRUST,
"permissions",
"ConfigLabelApprovalMode",
@@ -1123,7 +1192,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"managed_approval_policy",
SettingKind::String,
"",
ui(
ui_diagnostic(
TAB_TRUST,
"permissions",
"ConfigLabelManagedApprovalPolicy",
@@ -1156,7 +1225,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"managed_allow_shell",
SettingKind::String,
"",
ui(
ui_diagnostic(
TAB_TRUST,
"permissions",
"ConfigLabelManagedAllowShell",
@@ -1202,7 +1271,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"base_url",
SettingKind::String,
"",
ui(
ui_diagnostic(
TAB_ADVANCED,
"provider",
"ConfigLabelBaseUrlDeepseek",
@@ -1213,7 +1282,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"provider_url",
SettingKind::String,
"",
ui(
ui_diagnostic(
TAB_ADVANCED,
"provider",
"ConfigLabelProviderUrl",
@@ -1224,13 +1293,13 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"context_window",
SettingKind::Int,
"",
ui(TAB_ADVANCED, "provider", "", "ConfigHintContextWindow"),
ui_diagnostic(TAB_ADVANCED, "provider", "", "ConfigHintContextWindow"),
),
def(
"effective_context_window",
SettingKind::String,
"",
ui(
ui_diagnostic(
TAB_ADVANCED,
"provider",
"",
@@ -1243,7 +1312,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"external_credentials.openai-codex",
SettingKind::String,
"",
ui(
ui_diagnostic(
TAB_ADVANCED,
"provider",
"",
@@ -1254,7 +1323,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"external_credentials.xai",
SettingKind::String,
"",
ui(
ui_diagnostic(
TAB_ADVANCED,
"provider",
"",
@@ -1277,7 +1346,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"features.subagents",
SettingKind::String,
"",
ui(
ui_diagnostic(
TAB_ADVANCED,
"experimental",
"",
@@ -1288,7 +1357,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"features.web_search",
SettingKind::String,
"",
ui(
ui_diagnostic(
TAB_ADVANCED,
"experimental",
"",
@@ -1299,7 +1368,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"features.apply_patch",
SettingKind::String,
"",
ui(
ui_diagnostic(
TAB_ADVANCED,
"experimental",
"",
@@ -1310,13 +1379,13 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"features.mcp",
SettingKind::String,
"",
ui(TAB_ADVANCED, "experimental", "", "ConfigHintFeatureMcp"),
ui_diagnostic(TAB_ADVANCED, "experimental", "", "ConfigHintFeatureMcp"),
),
def(
"features.exec_policy",
SettingKind::String,
"",
ui(
ui_diagnostic(
TAB_ADVANCED,
"experimental",
"",
+417 -3
View File
@@ -1420,6 +1420,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
)
.route("/v1/config", get(get_config).post(set_config))
.route("/v1/config/reload", post(reload_config))
.route("/v1/settings/schema", get(get_settings_schema))
.route(
"/v1/threads/{id}/notifications/prepare",
post(notification_delivery::prepare),
@@ -7877,9 +7878,18 @@ async fn set_config(
config_persistence::persist_root_bool_key(config_path, "prompt_suggestion", enabled)
}
_ => {
return Err(ApiError::bad_request(format!(
"Unknown config key '{key}'. Supported keys: model, default_model, reasoning_effort, approval_mode, base_url, provider, provider_url, cost_currency, default_mode, auto_compact, allow_shell, mcp_config_path, show_thinking, thinking_default_expanded, thinking_highlight, show_tool_details, inline_diffs, locale, max_history, calm_mode, workspace_follow_symlinks, subagents_enabled, subagents_max_depth, sandbox_mode, strict_tool_mode, memory_enabled, search_provider, prompt_suggestion"
)));
// Every other declared settings.toml key persists through the
// shared validator rather than a curated list — the schema
// route advertises them, so a known setting must not die
// here. Unknown keys still 400 through `Settings::set`.
persist_runtime_tui_setting(&key, &value)?;
return Ok(Json(SetConfigResponse {
key,
value,
message: "Config persisted. Call /v1/config/reload to apply.".to_string(),
persisted: true,
requires_reload,
}));
}
};
@@ -7903,6 +7913,410 @@ async fn set_config(
}))
}
/// `GET /v1/settings/schema` — the Engine-declared settings surface.
///
/// `codewhale_config::SETTINGS_SCHEMA` is the single declaration table: one
/// entry per setting with kind, closed value set, default, and placement.
/// This route projects it for HTTP clients — current values resolved from
/// the owning store (settings.toml via [`crate::settings::Settings`],
/// config.toml, or the notifications table), labels and descriptions
/// resolved through the locale pack. Writes stay on `POST /v1/config`;
/// this route never invents a value, an option, or a validator.
#[derive(Debug, Serialize)]
struct SettingsSchemaResponse {
/// Payload version. Additive fields may appear without a bump; clients
/// must ignore fields and `kind`/`row` values they do not know.
version: u32,
tabs: Vec<SettingsSchemaTab>,
settings: Vec<SettingsSchemaRow>,
}
#[derive(Debug, Serialize)]
struct SettingsSchemaTab {
id: String,
/// Humanized tab id — tab labels have no message keys in the schema.
label: String,
}
#[derive(Debug, Serialize)]
struct SettingsSchemaRow {
key: &'static str,
/// `bool` | `int` | `enum` | `string`. Unknown kinds degrade to a text
/// field on the client; writes still validate server-side.
kind: &'static str,
tab: &'static str,
group: &'static str,
label: String,
description: String,
default: &'static str,
/// `setting` | `action` | `diagnostic` | `session` — from
/// [`codewhale_config::SettingRowKind`].
row: &'static str,
/// Current value in written-to-disk string form, when a store resolves
/// it. Absent for actions, unresolvable diagnostics, and session rows
/// the headless runtime cannot read.
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<String>,
/// Whether the value is a persisted user choice rather than an
/// inherited default. Absent where no store can prove either way.
#[serde(skip_serializing_if = "Option::is_none")]
persisted: Option<bool>,
/// Whether a generic client may offer a write control. Action,
/// diagnostic and session rows are never editable through this surface;
/// conditional rows (managed policy wins) report false.
editable: bool,
/// False for the hidden member of a conditional pair — e.g. a
/// `managed_*` row when no managed policy applies, or `base_url` when
/// the active route reads `provider_url`. Clients should not render
/// invisible rows.
visible: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
options: Vec<SettingsSchemaOption>,
}
#[derive(Debug, Serialize)]
struct SettingsSchemaOption {
value: &'static str,
#[serde(skip_serializing_if = "String::is_empty")]
label: String,
#[serde(skip_serializing_if = "String::is_empty")]
description: String,
}
/// "Turn a schema key or tab id into a title-case label" — the same
/// humanization the TUI applies to rows declared without a label message.
fn humanize_schema_key(key: &str) -> String {
key.split(['.', '_', '-'])
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
let Some(first) = chars.next() else {
return String::new();
};
let mut word = first.to_uppercase().collect::<String>();
word.push_str(chars.as_str());
word
})
.collect::<Vec<_>>()
.join(" ")
}
/// config.toml-owned keys `POST /v1/config` persists through curated arms.
/// Kept beside `set_config`'s match: a schema Setting row outside this list
/// and outside `Settings` has no write path and reports `editable: false`.
const RUNTIME_CONFIG_KEYS: &[&str] = &[
"model",
"default_model",
"reasoning_effort",
"approval_mode",
"approval_policy",
"base_url",
"provider",
"provider_url",
"provider_base_url",
"cost_currency",
"max_history",
"allow_shell",
"mcp_config_path",
"subagents_enabled",
"subagents_max_depth",
"sandbox_mode",
"strict_tool_mode",
"memory_enabled",
"search_provider",
"prompt_suggestion",
];
/// A dotted-path lookup over a TOML document — used to decide `persisted`
/// for config.toml-owned rows without trusting a decorated display string.
fn toml_value_at_path<'a>(document: &'a toml::Value, segments: &[&str]) -> Option<&'a toml::Value> {
let mut current = document;
for segment in segments {
current = current.as_table()?.get(*segment)?;
}
Some(current)
}
async fn get_settings_schema(
State(state): State<RuntimeApiState>,
) -> Result<Json<SettingsSchemaResponse>, ApiError> {
use codewhale_config::notifications::NotificationSetting;
use codewhale_config::settings_schema::{
SettingKind, SettingRowKind, schema_rows, schema_tabs,
};
use codewhale_localization::{MessageId, resolve_locale, tr, tr_key};
let config = state.config.read().clone();
let settings = crate::settings::Settings::load_persisted().unwrap_or_default();
let locale = resolve_locale(&settings.locale);
let notifications = config.notifications_config();
// Conditional pairs share the TUI's rule: exactly one member is shown,
// chosen by which store or policy owns the fact right now.
let permission_control = config.approval_policy_control(
state.config_path.as_deref(),
state.config_profile.as_deref(),
&state.workspace,
);
let shell_control = config.allow_shell_control(
state.config_path.as_deref(),
state.config_profile.as_deref(),
&state.workspace,
);
let base_url_row_key = match config.api_provider() {
ApiProvider::Deepseek | ApiProvider::DeepseekCN => "base_url",
_ => "provider_url",
};
let visible = |key: &str| -> bool {
match key {
"permission_posture" => matches!(
permission_control,
crate::config::ApprovalPolicyControl::Unset
),
"approval_policy" => matches!(
permission_control,
crate::config::ApprovalPolicyControl::RootConfig
),
"managed_approval_policy" => !matches!(
permission_control,
crate::config::ApprovalPolicyControl::Unset
| crate::config::ApprovalPolicyControl::RootConfig
),
"allow_shell" => shell_control.editable_root(),
"managed_allow_shell" => !shell_control.editable_root(),
"base_url" | "provider_url" => key == base_url_row_key,
_ => true,
}
};
// Raw config.toml for `persisted` on config-owned rows. A missing or
// unparsable file means nothing was persisted there — the live config
// still serves defaults through `value`.
let config_document = state
.config_path
.as_deref()
.and_then(|path| std::fs::read_to_string(path).ok())
.and_then(|body| toml::from_str::<toml::Value>(&body).ok());
let notifications_persisted = |key: &str| -> Option<bool> {
let setting = NotificationSetting::parse(key)?;
let document = config_document.as_ref()?;
Some(
toml_value_at_path(document, &setting.segments()).is_some()
// Legacy location the loader still honors.
|| (matches!(setting, NotificationSetting::Condition)
&& toml_value_at_path(document, &["tui", "notification_condition"]).is_some()),
)
};
let tabs = schema_tabs()
.into_iter()
.map(|id| SettingsSchemaTab {
id: id.to_string(),
label: humanize_schema_key(id),
})
.collect();
let settings_rows = schema_rows()
.map(|def| {
let ui = def.ui.as_ref().expect("schema_rows filters on ui");
let kind = match def.kind {
SettingKind::Bool(_) => "bool",
SettingKind::Int => "int",
SettingKind::Enum(_) => "enum",
SettingKind::String => "string",
};
let row = match ui.row {
SettingRowKind::Setting => "setting",
SettingRowKind::Action => "action",
SettingRowKind::Diagnostic => "diagnostic",
SettingRowKind::Session => "session",
};
let options = match def.kind {
SettingKind::Bool(options) | SettingKind::Enum(options) => options
.iter()
.map(|option| SettingsSchemaOption {
value: option.value,
label: if option.label.is_empty() {
String::new()
} else {
tr_key(locale, option.label).into_owned()
},
description: if option.description.is_empty() {
String::new()
} else {
tr_key(locale, option.description).into_owned()
},
})
.collect(),
SettingKind::Int | SettingKind::String => Vec::new(),
};
// Bool rows with an empty option slice carry the surface's
// default on/off labels — emit the bare values so clients can
// still build a labeled control.
let options = if options.is_empty() && matches!(def.kind, SettingKind::Bool(_)) {
vec![
SettingsSchemaOption {
value: "false",
label: tr_key(locale, "ConfigValueOff").into_owned(),
description: String::new(),
},
SettingsSchemaOption {
value: "true",
label: tr_key(locale, "ConfigValueOn").into_owned(),
description: String::new(),
},
]
} else {
options
};
let notification_owned = NotificationSetting::parse(def.key).is_some();
// `Settings::set` is the authority on which keys settings.toml
// owns — including `Option` fields whose unset value serializes
// to nothing (e.g. permission_posture). The probe reuses the
// write validator on the declared default, so `editable` cannot
// claim a key the real write path would reject.
let settings_writable = crate::settings::Settings::default()
.set(def.key, def.default)
.is_ok();
let (value, persisted) = if notification_owned {
let setting = NotificationSetting::parse(def.key).expect("checked above");
(
Some(notifications.display(setting)),
notifications_persisted(def.key),
)
} else if settings_writable {
// Effective = the persisted value or the declared default;
// `is_set` says which.
(
Some(
settings
.value(def.key)
.unwrap_or_else(|| def.default.to_string()),
),
Some(settings.is_set(def.key)),
)
} else if let Some(feature_key) = def.key.strip_prefix("features.") {
// Feature rows are diagnostics: the effective flag state plus
// whether config.toml names the leaf — no decorated phrasing,
// the client owns presentation of default-vs-configured.
let value = crate::features::FEATURES
.iter()
.find(|spec| spec.key == feature_key)
.map(|spec| config.features().enabled(spec.id).to_string());
let persisted = config_document.as_ref().map(|document| {
toml_value_at_path(document, &["features", feature_key]).is_some()
});
(value, persisted)
} else {
// Managed-policy receipts name the winning source rather than
// a writable value; everything else resolves from config.toml
// or stays absent for a diagnostic the runtime cannot read.
let managed_value = match def.key {
"managed_approval_policy" => match permission_control {
crate::config::ApprovalPolicyControl::Unset
| crate::config::ApprovalPolicyControl::RootConfig => None,
source => Some(source.label().to_string()),
},
"managed_allow_shell" if !shell_control.editable_root() => Some(format!(
"{} · {}",
config.allow_shell(),
shell_control.label()
)),
_ => None,
};
(
managed_value.or_else(|| config_schema_value(def.key, &config)),
None,
)
};
// `editable` means POST /v1/config accepts the key today:
// notifications.* through the namespace branch, settings.toml
// keys through the Settings::set fallthrough, and the curated
// config.toml arm list. A Setting row without a write path
// (e.g. telemetry, which persists through its own notice
// module) renders read-only rather than promising a 400. The
// endpoint rows stay receipts: writing a live route's base URL
// cannot mutate an already-running client, so the TUI marks
// them read-only and the schema agrees.
let endpoint_receipt = matches!(def.key, "base_url" | "provider_url");
// A managed or profile-owned approval policy freezes the
// session-level mode switch too, not just the saved row.
let session_locked = def.key == "approval_mode"
&& !matches!(
permission_control,
crate::config::ApprovalPolicyControl::Unset
);
let editable = !endpoint_receipt
&& !session_locked
&& matches!(ui.row, SettingRowKind::Setting | SettingRowKind::Session)
&& visible(def.key)
&& (notification_owned
|| settings_writable
|| RUNTIME_CONFIG_KEYS.contains(&def.key));
SettingsSchemaRow {
key: def.key,
kind,
tab: ui.tab,
group: ui.group,
label: if !ui.label.is_empty() {
tr_key(locale, ui.label).into_owned()
} else if def.key.starts_with("features.") {
tr(locale, MessageId::ConfigLabelFeaturePrefix).replace(
"{name}",
&humanize_schema_key(def.key.rsplit('.').next().unwrap_or(def.key)),
)
} else {
humanize_schema_key(def.key.rsplit('.').next().unwrap_or(def.key))
},
description: if ui.description.is_empty() {
String::new()
} else {
tr_key(locale, ui.description).into_owned()
},
default: def.default,
row,
value,
persisted,
editable,
visible: visible(def.key),
options,
}
})
.collect();
Ok(Json(SettingsSchemaResponse {
version: 1,
tabs,
settings: settings_rows,
}))
}
/// Current value of a config.toml-owned schema row, when one resolves
/// cheaply. Diagnostics that need per-route or credential computation are
/// omitted rather than approximated.
fn config_schema_value(key: &str, config: &Config) -> Option<String> {
match key {
"provider" => Some(config.provider_identity_for(config.api_provider())),
"model" => runtime_request_model(config, None).ok(),
"approval_policy" => config
.approval_policy
.clone()
.or_else(|| Some("suggest".to_string())),
"telemetry" => Some(crate::telemetry_notice::saved_preference_enabled(config).to_string()),
"allow_shell" => Some(config.allow_shell().to_string()),
"base_url" => Some(config.base_url_for_route(config.api_provider())),
"provider_url" => Some(config.base_url_for_route(config.api_provider())),
"mcp_config_path" => Some(config.mcp_config_path().display().to_string()),
"sandbox_mode" => config.sandbox_mode.clone(),
"fleet.exec.max_spawn_depth" => Some(config.subagent_max_spawn_depth().to_string()),
"reasoning_effort" => Some(config.reasoning_effort().unwrap_or("auto").to_string()),
_ => None,
}
}
fn normalize_runtime_config_model(
config: &Config,
provider: ApiProvider,
+173
View File
@@ -9129,6 +9129,179 @@ async fn get_provider_models(
.expect("GET /v1/providers/{id}/models should return valid JSON")
}
/// Helper: GET `/v1/settings/schema` and return the parsed response body.
async fn get_settings_schema(client: &reqwest::Client, addr: &SocketAddr) -> serde_json::Value {
client
.get(format!("http://{addr}/v1/settings/schema"))
.send()
.await
.expect("GET /v1/settings/schema should not fail at transport level")
.error_for_status()
.expect("GET /v1/settings/schema should return 200")
.json()
.await
.expect("GET /v1/settings/schema should return valid JSON")
}
#[tokio::test]
async fn settings_schema_serves_every_declared_row_with_runtime_state() -> Result<()> {
let _lock = lock_test_env();
let root = tempfile::tempdir()?;
let config_file = root.path().join("config.toml");
fs::write(
&config_file,
"provider = \"deepseek\"\napi_key = \"runtime-api-test-key\"\nbase_url = \"http://127.0.0.1:1/v1\"\ntelemetry = false\n\n[notifications]\ncondition = \"always\"\n",
)?;
fs::write(root.path().join("settings.toml"), "calm_mode = true\n")?;
let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &config_file);
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path(config_file.clone()).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let body = get_settings_schema(&client, &addr).await;
handle.abort();
assert_eq!(body["version"], 1);
let tabs = body["tabs"].as_array().expect("tabs array");
let tab_ids: Vec<_> = tabs.iter().filter_map(|t| t["id"].as_str()).collect();
assert_eq!(
tab_ids,
codewhale_config::settings_schema::schema_tabs(),
"tabs project the schema declaration order"
);
let rows = body["settings"].as_array().expect("settings array");
assert_eq!(
rows.len(),
codewhale_config::settings_schema::schema_rows().count(),
"every declared UI row is served"
);
let row = |key: &str| {
rows.iter()
.find(|row| row["key"] == key)
.unwrap_or_else(|| panic!("schema row {key} missing"))
};
for entry in rows {
let kind = entry["kind"].as_str().expect("kind");
assert!(
matches!(kind, "bool" | "int" | "enum" | "string"),
"unknown kind {kind}"
);
assert!(
matches!(
entry["row"].as_str(),
Some("setting" | "action" | "diagnostic" | "session")
),
"unknown row kind for {}",
entry["key"]
);
let label = entry["label"].as_str().expect("label");
assert!(
!label.is_empty() && !label.starts_with("Config"),
"label must be resolved prose, not a message key: {label}"
);
assert!(entry["visible"].is_boolean());
assert!(entry["editable"].is_boolean());
if kind == "enum" {
assert!(
!entry["options"]
.as_array()
.expect("enum options")
.is_empty(),
"enum row {} must serve its closed value set",
entry["key"]
);
}
}
// Values resolve from the owning store, not a decorated guess.
assert_eq!(row("calm_mode")["value"], "true");
assert_eq!(row("calm_mode")["persisted"], true);
assert_eq!(row("notifications.condition")["value"], "always");
assert_eq!(row("notifications.condition")["persisted"], true);
assert_eq!(row("telemetry")["value"], "false");
// Row kinds and editability agree with the TUI's own semantics:
// model/provider open pickers (actions), approval_mode is the
// session-scoped writable, endpoint rows are read-only receipts.
assert_eq!(row("model")["row"], "action");
assert_eq!(row("model")["editable"], false);
assert_eq!(row("approval_mode")["row"], "session");
assert_eq!(row("approval_mode")["editable"], true);
assert_eq!(row("provider_url")["editable"], false);
assert_eq!(row("telemetry")["editable"], false);
for action in ["provider_templates", "mcp_open", "plugins_open"] {
assert_eq!(row(action)["row"], "action");
assert_eq!(row(action)["editable"], false);
}
// The conditional triple resolves to exactly one visible member for a
// config with no managed policy: the editable TUI posture row.
assert_eq!(row("permission_posture")["visible"], true);
assert_eq!(row("approval_policy")["visible"], false);
assert_eq!(row("managed_approval_policy")["visible"], false);
Ok(())
}
#[tokio::test]
async fn settings_schema_write_fallthrough_persists_declared_settings_keys() -> Result<()> {
let _lock = lock_test_env();
let root = tempfile::tempdir()?;
let config_file = root.path().join("config.toml");
fs::write(
&config_file,
"provider = \"deepseek\"\napi_key = \"runtime-api-test-key\"\nbase_url = \"http://127.0.0.1:1/v1\"\n",
)?;
let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &config_file);
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path(config_file.clone()).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
// A schema-declared settings.toml key outside the curated arm list must
// persist through the shared validator rather than 400.
let response = client
.post(format!("http://{addr}/v1/config"))
.json(&json!({"key": "composer_density", "value": "compact", "persist": true}))
.send()
.await?
.error_for_status()
.expect("composer_density is a declared settings key")
.json::<serde_json::Value>()
.await?;
assert_eq!(response["persisted"], true);
assert_eq!(
crate::settings::Settings::load_persisted()?.composer_density,
"compact"
);
let body = get_settings_schema(&client, &addr).await;
let row = body["settings"]
.as_array()
.expect("settings array")
.iter()
.find(|row| row["key"] == "composer_density")
.expect("composer_density row");
assert_eq!(row["value"], "compact");
assert_eq!(row["persisted"], true);
// A key the schema does not declare still fails closed.
let status = client
.post(format!("http://{addr}/v1/config"))
.json(&json!({"key": "not_a_real_setting", "value": "x", "persist": true}))
.send()
.await?
.status();
assert_eq!(status, StatusCode::BAD_REQUEST);
handle.abort();
Ok(())
}
#[test]
fn provider_model_catalog_paginates_all_six_hundred_rows_without_truncation() {
let models = (0..600)
+37
View File
@@ -1059,6 +1059,43 @@ impl Settings {
.unwrap_or(Layer::Default)
}
/// The persisted field name behind a canonical schema key. Two schema
/// keys predate their persisted names and cannot be renamed without a
/// settings.toml migration.
fn persisted_field_name(canonical: &str) -> &str {
match canonical {
"tool_collapse" => "tool_collapse_mode",
"max_history" => "max_input_history",
other => other,
}
}
/// The value `key` currently holds in this store, in its written-to-disk
/// string form — `None` when the key is not a field of this store.
///
/// `Settings` serializes field-for-field to settings.toml, so a document
/// lookup on the serialized form shares `set`'s key vocabulary instead
/// of growing a second hand-keyed reader beside it.
pub fn value(&self, key: &str) -> Option<String> {
let canonical = Self::canonical_key(key).unwrap_or(key);
let field = Self::persisted_field_name(canonical);
let document = toml::Value::try_from(self).ok()?;
let value = document.as_table()?.get(field)?;
Some(match value {
toml::Value::String(text) => text.clone(),
other => other.to_string(),
})
}
/// Whether the loaded settings document explicitly named `key` — a
/// persisted user choice rather than an inherited default.
pub fn is_set(&self, key: &str) -> bool {
let canonical = Self::canonical_key(key).unwrap_or(key);
self.provenance
.get(Self::persisted_field_name(canonical))
.is_some_and(|layer| *layer == Layer::UserConfig)
}
/// Whether the user explicitly persisted an auto-compaction preference.
/// A threshold is intent to enable compaction unless an explicit boolean
/// says otherwise. When all three keys are absent, callers may choose a