Compare commits

...

1 Commits

Author SHA1 Message Date
Owen Lin
ec8baaf645 [app-server] feat: add config/schema/read API 2025-11-30 09:58:23 -08:00
18 changed files with 207 additions and 26 deletions

2
codex-rs/Cargo.lock generated
View File

@@ -1183,6 +1183,7 @@ dependencies = [
"regex",
"regex-lite",
"reqwest",
"schemars 0.8.22",
"seccompiler",
"serde",
"serde_json",
@@ -1534,6 +1535,7 @@ dependencies = [
"pretty_assertions",
"reqwest",
"rmcp",
"schemars 0.8.22",
"serde",
"serde_json",
"serial_test",

View File

@@ -168,6 +168,10 @@ client_request_definitions! {
params: v2::ConfigReadParams,
response: v2::ConfigReadResponse,
},
ConfigSchemaRead => "config/schema/read" {
params: v2::ConfigSchemaReadParams,
response: v2::ConfigSchemaReadResponse,
},
ConfigValueWrite => "config/value/write" {
params: v2::ConfigValueWriteParams,
response: v2::ConfigWriteResponse,

View File

@@ -228,6 +228,18 @@ pub struct ConfigReadResponse {
pub layers: Option<Vec<ConfigLayer>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Default)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ConfigSchemaReadParams {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ConfigSchemaReadResponse {
pub schema: JsonValue,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]

View File

@@ -471,6 +471,7 @@ impl CodexMessageProcessor {
self.exec_one_off_command(request_id, params).await;
}
ClientRequest::ConfigRead { .. }
| ClientRequest::ConfigSchemaRead { .. }
| ClientRequest::ConfigValueWrite { .. }
| ClientRequest::ConfigBatchWrite { .. } => {
warn!("Config request reached CodexMessageProcessor unexpectedly");

View File

@@ -7,6 +7,8 @@ use codex_app_server_protocol::ConfigLayerMetadata;
use codex_app_server_protocol::ConfigLayerName;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigReadResponse;
use codex_app_server_protocol::ConfigSchemaReadParams;
use codex_app_server_protocol::ConfigSchemaReadResponse;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWriteErrorCode;
use codex_app_server_protocol::ConfigWriteResponse;
@@ -15,6 +17,7 @@ use codex_app_server_protocol::MergeStrategy;
use codex_app_server_protocol::OverriddenMetadata;
use codex_app_server_protocol::WriteStatus;
use codex_core::config::ConfigToml;
use codex_core::config::schema::config_json_schema_value;
use codex_core::config_loader::LoadedConfigLayers;
use codex_core::config_loader::LoaderOverrides;
use codex_core::config_loader::load_config_layers_with_overrides;
@@ -84,6 +87,15 @@ impl ConfigApi {
Ok(response)
}
pub(crate) async fn schema_read(
&self,
_params: ConfigSchemaReadParams,
) -> Result<ConfigSchemaReadResponse, JSONRPCErrorError> {
Ok(ConfigSchemaReadResponse {
schema: config_json_schema_value(),
})
}
pub(crate) async fn write_value(
&self,
params: ConfigValueWriteParams,

View File

@@ -9,6 +9,7 @@ use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigSchemaReadParams;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::InitializeResponse;
use codex_app_server_protocol::JSONRPCError;
@@ -148,6 +149,9 @@ impl MessageProcessor {
ClientRequest::ConfigRead { request_id, params } => {
self.handle_config_read(request_id, params).await;
}
ClientRequest::ConfigSchemaRead { request_id, params } => {
self.handle_config_schema_read(request_id, params).await;
}
ClientRequest::ConfigValueWrite { request_id, params } => {
self.handle_config_value_write(request_id, params).await;
}
@@ -196,6 +200,17 @@ impl MessageProcessor {
}
}
async fn handle_config_schema_read(
&self,
request_id: RequestId,
params: ConfigSchemaReadParams,
) {
match self.config_api.schema_read(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_config_batch_write(
&self,
request_id: RequestId,

View File

@@ -20,6 +20,7 @@ use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigSchemaReadParams;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::FeedbackUploadParams;
use codex_app_server_protocol::GetAccountParams;
@@ -412,6 +413,14 @@ impl McpProcess {
self.send_request("config/read", params).await
}
pub async fn send_config_schema_read_request(
&mut self,
params: ConfigSchemaReadParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("config/schema/read", params).await
}
pub async fn send_config_value_write_request(
&mut self,
params: ConfigValueWriteParams,

View File

@@ -6,6 +6,8 @@ use codex_app_server_protocol::ConfigEdit;
use codex_app_server_protocol::ConfigLayerName;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigReadResponse;
use codex_app_server_protocol::ConfigSchemaReadParams;
use codex_app_server_protocol::ConfigSchemaReadResponse;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::JSONRPCError;
@@ -70,6 +72,48 @@ sandbox_mode = "workspace-write"
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_schema_read_returns_schema() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_config_schema_read_request(ConfigSchemaReadParams::default())
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ConfigSchemaReadResponse { schema } = to_response(resp)?;
let properties = schema
.get("definitions")
.and_then(|defs| defs.get("ConfigToml"))
.and_then(|v| v.get("properties"))
.cloned()
.expect("schema properties present");
assert!(
properties
.get("mcp_servers")
.and_then(|v| v.get("additionalProperties"))
.is_some()
);
assert!(
properties
.get("sandbox_mode")
.and_then(|v| v.get("enum"))
.and_then(|vals| vals.as_array())
.map(|vals| vals.contains(&json!("workspace-write")))
.unwrap_or(false)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_system_layer_and_overrides() -> Result<()> {
let codex_home = TempDir::new()?;

View File

@@ -50,6 +50,7 @@ os_info = { workspace = true }
rand = { workspace = true }
regex-lite = { workspace = true }
reqwest = { workspace = true, features = ["json", "stream"] }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha1 = { workspace = true }

View File

@@ -1,5 +1,6 @@
use chrono::DateTime;
use chrono::Utc;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use sha2::Digest;
@@ -21,7 +22,7 @@ use codex_keyring_store::DefaultKeyringStore;
use codex_keyring_store::KeyringStore;
/// Determine where Codex should store CLI auth credentials.
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum AuthCredentialsStoreMode {
#[default]

View File

@@ -45,6 +45,7 @@ use codex_protocol::config_types::Verbosity;
use codex_rmcp_client::OAuthCredentialsStoreMode;
use dirs::home_dir;
use dunce::canonicalize;
use schemars::JsonSchema;
use serde::Deserialize;
use similar::DiffableStr;
use std::collections::BTreeMap;
@@ -59,6 +60,7 @@ use toml_edit::DocumentMut;
pub mod edit;
pub mod profile;
pub mod schema;
pub mod types;
pub const OPENAI_DEFAULT_MODEL: &str = "gpt-5.1-codex";
@@ -559,7 +561,7 @@ fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) {
}
/// Base config deserialized from ~/.codex/config.toml.
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
#[derive(Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
pub struct ConfigToml {
/// Optional override of model selection.
pub model: Option<String>,
@@ -746,7 +748,7 @@ impl From<ConfigToml> for UserSavedConfig {
}
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[derive(Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
pub struct ProjectConfig {
pub trust_level: Option<TrustLevel>,
}
@@ -761,7 +763,7 @@ impl ProjectConfig {
}
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
#[derive(Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
pub struct ToolsToml {
#[serde(default, alias = "web_search_request")]
pub web_search: Option<bool>,

View File

@@ -6,10 +6,11 @@ use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::Verbosity;
use schemars::JsonSchema;
/// Collection of common configuration options that a user can define as a unit
/// in `config.toml`.
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[derive(Debug, Clone, Default, PartialEq, Deserialize, JsonSchema)]
pub struct ConfigProfile {
pub model: Option<String>,
/// The key in the `model_providers` map identifying the

View File

@@ -0,0 +1,70 @@
use schemars::schema::RootSchema;
use schemars::schema_for;
use serde_json::Value;
use super::ConfigToml;
pub fn config_json_schema() -> RootSchema {
schema_for!(ConfigToml)
}
pub fn config_json_schema_value() -> Value {
serde_json::to_value(config_json_schema()).expect("serialize config schema")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_includes_core_fields_and_shapes() {
let schema = config_json_schema_value();
let config_schema = schema
.get("definitions")
.and_then(|defs| defs.get("ConfigToml"))
.or_else(|| schema.get("schema"))
.unwrap_or(&schema);
let Some(props) = config_schema.get("properties") else {
panic!("config schema missing properties");
};
let model_schema = props.get("model").expect("model schema present");
let model_is_string = match model_schema.get("type") {
Some(Value::String(t)) => t == "string",
Some(Value::Array(types)) => types
.iter()
.any(|t| t.as_str().is_some_and(|inner| inner == "string")),
_ => false,
};
assert!(model_is_string);
let sandbox_mode_definition = schema
.get("definitions")
.and_then(|defs| defs.get("SandboxMode"))
.expect("SandboxMode schema present");
let sandbox_enums = sandbox_mode_definition
.get("enum")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let sandbox_values = sandbox_enums
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>();
assert!(sandbox_values.contains(&"read-only"));
assert!(sandbox_values.contains(&"workspace-write"));
assert!(sandbox_values.contains(&"danger-full-access"));
assert!(
props
.get("mcp_servers")
.and_then(|v| v.get("additionalProperties"))
.is_some()
);
assert!(
props
.get("profiles")
.and_then(|v| v.get("additionalProperties"))
.is_some()
);
}
}

View File

@@ -9,13 +9,14 @@ use std::path::PathBuf;
use std::time::Duration;
use wildmatch::WildMatchPattern;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use serde::de::Error as SerdeError;
pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev";
#[derive(Serialize, Debug, Clone, PartialEq)]
#[derive(Serialize, Debug, Clone, PartialEq, JsonSchema)]
pub struct McpServerConfig {
#[serde(flatten)]
pub transport: McpServerTransportConfig,
@@ -30,10 +31,12 @@ pub struct McpServerConfig {
with = "option_duration_secs",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<f64>")]
pub startup_timeout_sec: Option<Duration>,
/// Default timeout for MCP tool calls initiated via this server.
#[serde(default, with = "option_duration_secs")]
#[schemars(with = "Option<f64>")]
pub tool_timeout_sec: Option<Duration>,
/// Explicit allow-list of tools exposed from this server. When set, only these tools will be registered.
@@ -161,7 +164,7 @@ const fn default_enabled() -> bool {
true
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
#[serde(untagged, deny_unknown_fields, rename_all = "snake_case")]
pub enum McpServerTransportConfig {
/// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio
@@ -219,7 +222,7 @@ mod option_duration_secs {
}
}
#[derive(Deserialize, Debug, Copy, Clone, PartialEq)]
#[derive(Deserialize, Debug, Copy, Clone, PartialEq, JsonSchema)]
pub enum UriBasedFileOpener {
#[serde(rename = "vscode")]
VsCode,
@@ -251,7 +254,7 @@ impl UriBasedFileOpener {
}
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
#[derive(Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
pub struct History {
/// If true, history entries will not be written to disk.
pub persistence: HistoryPersistence,
@@ -261,7 +264,7 @@ pub struct History {
pub max_bytes: Option<usize>,
}
#[derive(Deserialize, Debug, Copy, Clone, PartialEq, Default)]
#[derive(Deserialize, Debug, Copy, Clone, PartialEq, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum HistoryPersistence {
/// Save all history entries to disk.
@@ -273,7 +276,7 @@ pub enum HistoryPersistence {
// ===== OTEL configuration =====
#[derive(Deserialize, Debug, Clone, PartialEq)]
#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum OtelHttpProtocol {
/// Binary payload
@@ -282,7 +285,7 @@ pub enum OtelHttpProtocol {
Json,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
#[derive(Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct OtelTlsConfig {
pub ca_certificate: Option<PathBuf>,
@@ -291,7 +294,7 @@ pub struct OtelTlsConfig {
}
/// Which OTEL exporter to use.
#[derive(Deserialize, Debug, Clone, PartialEq)]
#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum OtelExporterKind {
None,
@@ -313,7 +316,7 @@ pub enum OtelExporterKind {
}
/// OTEL settings loaded from config.toml. Fields are optional so we can apply defaults.
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
#[derive(Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
pub struct OtelConfigToml {
/// Log user prompt in traces
pub log_user_prompt: Option<bool>,
@@ -326,7 +329,7 @@ pub struct OtelConfigToml {
}
/// Effective OTEL settings after defaults are applied.
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, JsonSchema)]
pub struct OtelConfig {
pub log_user_prompt: bool,
pub environment: String,
@@ -343,7 +346,7 @@ impl Default for OtelConfig {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum Notifications {
Enabled(bool),
@@ -357,7 +360,7 @@ impl Default for Notifications {
}
/// Collection of settings that are specific to the TUI.
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
#[derive(Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
pub struct Tui {
/// Enable desktop notifications from the TUI when the terminal is unfocused.
/// Defaults to `true`.
@@ -377,7 +380,7 @@ const fn default_true() -> bool {
/// Settings for notices we display to users via the tui and app-server clients
/// (primarily the Codex IDE extension). NOTE: these are different from
/// notifications - notices are warnings, NUX screens, acknowledgements, etc.
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
#[derive(Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
pub struct Notice {
/// Tracks whether the user has acknowledged the full access warning prompt.
pub hide_full_access_warning: Option<bool>,
@@ -397,7 +400,7 @@ impl Notice {
pub(crate) const TABLE_KEY: &'static str = "notice";
}
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
#[derive(Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
pub struct SandboxWorkspaceWrite {
#[serde(default)]
pub writable_roots: Vec<PathBuf>,
@@ -420,7 +423,7 @@ impl From<SandboxWorkspaceWrite> for codex_app_server_protocol::SandboxSettings
}
}
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
#[derive(Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum ShellEnvironmentPolicyInherit {
/// "Core" environment variables for the platform. On UNIX, this would
@@ -437,7 +440,7 @@ pub enum ShellEnvironmentPolicyInherit {
/// Policy for building the `env` when spawning a process via either the
/// `shell` or `local_shell` tool.
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
#[derive(Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
pub struct ShellEnvironmentPolicyToml {
pub inherit: Option<ShellEnvironmentPolicyInherit>,
@@ -516,7 +519,7 @@ impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
}
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Default, Hash)]
#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Default, Hash, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum ReasoningSummaryFormat {
#[default]

View File

@@ -7,6 +7,7 @@
use crate::config::ConfigToml;
use crate::config::profile::ConfigProfile;
use schemars::JsonSchema;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
@@ -236,7 +237,7 @@ pub fn is_known_feature_key(key: &str) -> bool {
}
/// Deserializable features table for TOML.
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
#[derive(Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
pub struct FeaturesToml {
#[serde(flatten)]
pub entries: BTreeMap<String, bool>,

View File

@@ -12,6 +12,7 @@ use codex_app_server_protocol::AuthMode;
use http::HeaderMap;
use http::header::HeaderName;
use http::header::HeaderValue;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use std::collections::HashMap;
@@ -33,7 +34,7 @@ const MAX_REQUEST_MAX_RETRIES: u64 = 100;
/// *Responses* API. The two protocols use different request/response shapes
/// and *cannot* be auto-detected at runtime, therefore each provider entry
/// must declare which one it expects.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum WireApi {
/// The Responses API exposed by OpenAI at `/v1/responses`.
@@ -45,7 +46,7 @@ pub enum WireApi {
}
/// Serializable representation of a provider definition.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
pub struct ModelProviderInfo {
/// Friendly display name.
pub name: String,

View File

@@ -36,6 +36,7 @@ rmcp = { workspace = true, default-features = false, features = [
"transport-streamable-http-client-reqwest",
"transport-streamable-http-server",
] }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }

View File

@@ -26,6 +26,7 @@ use oauth2::Scope;
use oauth2::TokenResponse;
use oauth2::basic::BasicTokenType;
use rmcp::transport::auth::OAuthTokenResponse;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
@@ -63,7 +64,7 @@ pub struct StoredOAuthTokens {
}
/// Determine where Codex should store and read MCP credentials.
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum OAuthCredentialsStoreMode {
/// `Keyring` when available; otherwise, `File`.