diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 211f0ba3757..edd3aefa745 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4,7 +4,10 @@ use std::path::PathBuf; use crate::protocol::common::AuthMode; use codex_protocol::account::PlanType; use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; +use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::SandboxMode as CoreSandboxMode; +use codex_protocol::config_types::Verbosity; use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::models::ResponseItem; @@ -12,6 +15,7 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; +use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; @@ -122,17 +126,68 @@ impl From for CodexErrorInfo { } } -v2_enum_from_core!( - pub enum AskForApproval from codex_protocol::protocol::AskForApproval { - UnlessTrusted, OnFailure, OnRequest, Never +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum AskForApproval { + #[serde(rename = "untrusted")] + #[ts(rename = "untrusted")] + UnlessTrusted, + OnFailure, + OnRequest, + Never, +} + +impl AskForApproval { + pub fn to_core(self) -> CoreAskForApproval { + match self { + AskForApproval::UnlessTrusted => CoreAskForApproval::UnlessTrusted, + AskForApproval::OnFailure => CoreAskForApproval::OnFailure, + AskForApproval::OnRequest => CoreAskForApproval::OnRequest, + AskForApproval::Never => CoreAskForApproval::Never, + } } -); +} -v2_enum_from_core!( - pub enum SandboxMode from codex_protocol::config_types::SandboxMode { - ReadOnly, WorkspaceWrite, DangerFullAccess +impl From for AskForApproval { + fn from(value: CoreAskForApproval) -> Self { + match value { + CoreAskForApproval::UnlessTrusted => AskForApproval::UnlessTrusted, + CoreAskForApproval::OnFailure => AskForApproval::OnFailure, + CoreAskForApproval::OnRequest => AskForApproval::OnRequest, + CoreAskForApproval::Never => AskForApproval::Never, + } } -); +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum SandboxMode { + ReadOnly, + WorkspaceWrite, + DangerFullAccess, +} + +impl SandboxMode { + pub fn to_core(self) -> CoreSandboxMode { + match self { + SandboxMode::ReadOnly => CoreSandboxMode::ReadOnly, + SandboxMode::WorkspaceWrite => CoreSandboxMode::WorkspaceWrite, + SandboxMode::DangerFullAccess => CoreSandboxMode::DangerFullAccess, + } + } +} + +impl From for SandboxMode { + fn from(value: CoreSandboxMode) -> Self { + match value { + CoreSandboxMode::ReadOnly => SandboxMode::ReadOnly, + CoreSandboxMode::WorkspaceWrite => SandboxMode::WorkspaceWrite, + CoreSandboxMode::DangerFullAccess => SandboxMode::DangerFullAccess, + } + } +} v2_enum_from_core!( pub enum ReviewDelivery from codex_protocol::protocol::ReviewDelivery { @@ -159,6 +214,72 @@ pub enum ConfigLayerName { User, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct SandboxWorkspaceWrite { + #[serde(default)] + pub writable_roots: Vec, + #[serde(default)] + pub network_access: bool, + #[serde(default)] + pub exclude_tmpdir_env_var: bool, + #[serde(default)] + pub exclude_slash_tmp: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct ToolsV2 { + #[serde(alias = "web_search_request")] + pub web_search: Option, + pub view_image: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct ProfileV2 { + pub model: Option, + pub model_provider: Option, + pub approval_policy: Option, + pub model_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + pub chatgpt_base_url: Option, + #[serde(default, flatten)] + pub additional: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct Config { + pub model: Option, + pub review_model: Option, + pub model_context_window: Option, + pub model_auto_compact_token_limit: Option, + pub model_provider: Option, + pub approval_policy: Option, + pub sandbox_mode: Option, + pub sandbox_workspace_write: Option, + pub forced_chatgpt_workspace_id: Option, + pub forced_login_method: Option, + pub tools: Option, + pub profile: Option, + #[serde(default)] + pub profiles: HashMap, + pub instructions: Option, + pub developer_instructions: Option, + pub compact_prompt: Option, + pub model_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + #[serde(default, flatten)] + pub additional: HashMap, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -237,7 +358,7 @@ pub struct ConfigReadParams { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ConfigReadResponse { - pub config: JsonValue, + pub config: Config, pub origins: HashMap, #[serde(skip_serializing_if = "Option::is_none")] pub layers: Option>, diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 98fe93fb259..c1eaf62d26f 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -1,5 +1,6 @@ use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use codex_app_server_protocol::Config; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigLayer; use codex_app_server_protocol::ConfigLayerMetadata; @@ -75,8 +76,10 @@ impl ConfigApi { let effective = layers.effective_config(); validate_config(&effective).map_err(|err| internal_error("invalid configuration", err))?; + let config: Config = serde_json::from_value(to_json_value(&effective)) + .map_err(|err| internal_error("failed to deserialize configuration", err))?; let response = ConfigReadResponse { - config: to_json_value(&effective), + config, origins: layers.origins(), layers: params.include_layers.then(|| layers.layers_high_to_low()), }; @@ -773,6 +776,7 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> mod tests { use super::*; use anyhow::Result; + use codex_app_server_protocol::AskForApproval; use pretty_assertions::assert_eq; use tempfile::tempdir; @@ -895,10 +899,7 @@ remote_compaction = true .await .expect("response"); - assert_eq!( - response.config.get("approval_policy"), - Some(&json!("never")) - ); + assert_eq!(response.config.approval_policy, Some(AskForApproval::Never)); assert_eq!( response @@ -953,8 +954,10 @@ remote_compaction = true }) .await .expect("read"); - let config_object = read_after.config.as_object().expect("object"); - assert_eq!(config_object.get("approval_policy"), Some(&json!("never"))); + assert_eq!( + read_after.config.approval_policy, + Some(AskForApproval::Never) + ); assert_eq!( read_after .origins @@ -1093,7 +1096,7 @@ remote_compaction = true .await .expect("response"); - assert_eq!(response.config.get("model"), Some(&json!("system"))); + assert_eq!(response.config.model.as_deref(), Some("system")); assert_eq!( response.origins.get("model").expect("origin").name, ConfigLayerName::System diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index eb3ece64b29..b6615ef6679 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -1,6 +1,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; +use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigEdit; use codex_app_server_protocol::ConfigLayerName; @@ -12,9 +13,12 @@ use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxMode; +use codex_app_server_protocol::ToolsV2; use codex_app_server_protocol::WriteStatus; use pretty_assertions::assert_eq; use serde_json::json; +use std::path::PathBuf; use tempfile::TempDir; use tokio::time::timeout; @@ -57,7 +61,7 @@ sandbox_mode = "workspace-write" layers, } = to_response(resp)?; - assert_eq!(config.get("model"), Some(&json!("gpt-user"))); + assert_eq!(config.model.as_deref(), Some("gpt-user")); assert_eq!( origins.get("model").expect("origin").name, ConfigLayerName::User @@ -70,6 +74,64 @@ sandbox_mode = "workspace-write" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_includes_tools() -> Result<()> { + let codex_home = TempDir::new()?; + write_config( + &codex_home, + r#" +model = "gpt-user" + +[tools] +web_search = true +view_image = false +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: true, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { + config, + origins, + layers, + } = to_response(resp)?; + + let tools = config.tools.expect("tools present"); + assert_eq!( + tools, + ToolsV2 { + web_search: Some(true), + view_image: Some(false), + } + ); + assert_eq!( + origins.get("tools.web_search").expect("origin").name, + ConfigLayerName::User + ); + assert_eq!( + origins.get("tools.view_image").expect("origin").name, + ConfigLayerName::User + ); + + let layers = layers.expect("layers present"); + assert_eq!(layers.len(), 2); + assert_eq!(layers[0].name, ConfigLayerName::SessionFlags); + assert_eq!(layers[1].name, ConfigLayerName::User); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_system_layer_and_overrides() -> Result<()> { let codex_home = TempDir::new()?; @@ -123,30 +185,29 @@ writable_roots = ["/system"] layers, } = to_response(resp)?; - assert_eq!(config.get("model"), Some(&json!("gpt-system"))); + assert_eq!(config.model.as_deref(), Some("gpt-system")); assert_eq!( origins.get("model").expect("origin").name, ConfigLayerName::System ); - assert_eq!(config.get("approval_policy"), Some(&json!("never"))); + assert_eq!(config.approval_policy, Some(AskForApproval::Never)); assert_eq!( origins.get("approval_policy").expect("origin").name, ConfigLayerName::System ); - assert_eq!(config.get("sandbox_mode"), Some(&json!("workspace-write"))); + assert_eq!(config.sandbox_mode, Some(SandboxMode::WorkspaceWrite)); assert_eq!( origins.get("sandbox_mode").expect("origin").name, ConfigLayerName::User ); - assert_eq!( - config - .get("sandbox_workspace_write") - .and_then(|v| v.get("writable_roots")), - Some(&json!(["/system"])) - ); + let sandbox = config + .sandbox_workspace_write + .as_ref() + .expect("sandbox workspace write"); + assert_eq!(sandbox.writable_roots, vec![PathBuf::from("/system")]); assert_eq!( origins .get("sandbox_workspace_write.writable_roots.0") @@ -155,12 +216,7 @@ writable_roots = ["/system"] ConfigLayerName::System ); - assert_eq!( - config - .get("sandbox_workspace_write") - .and_then(|v| v.get("network_access")), - Some(&json!(true)) - ); + assert!(sandbox.network_access); assert_eq!( origins .get("sandbox_workspace_write.network_access") @@ -242,7 +298,7 @@ model = "gpt-old" ) .await??; let verify: ConfigReadResponse = to_response(verify_resp)?; - assert_eq!(verify.config.get("model"), Some(&json!("gpt-new"))); + assert_eq!(verify.config.model.as_deref(), Some("gpt-new")); Ok(()) } @@ -342,22 +398,14 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { ) .await??; let read: ConfigReadResponse = to_response(read_resp)?; - assert_eq!( - read.config.get("sandbox_mode"), - Some(&json!("workspace-write")) - ); - assert_eq!( - read.config - .get("sandbox_workspace_write") - .and_then(|v| v.get("writable_roots")), - Some(&json!(["/tmp"])) - ); - assert_eq!( - read.config - .get("sandbox_workspace_write") - .and_then(|v| v.get("network_access")), - Some(&json!(false)) - ); + assert_eq!(read.config.sandbox_mode, Some(SandboxMode::WorkspaceWrite)); + let sandbox = read + .config + .sandbox_workspace_write + .as_ref() + .expect("sandbox workspace write"); + assert_eq!(sandbox.writable_roots, vec![PathBuf::from("/tmp")]); + assert!(!sandbox.network_access); Ok(()) }