mirror of
https://github.com/openai/codex.git
synced 2026-04-19 12:14:48 +00:00
Compare commits
9 Commits
codex-debu
...
owen/confi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a70260a0ff | ||
|
|
b995e93f3b | ||
|
|
8cd7998dab | ||
|
|
ad958ed1aa | ||
|
|
0ceb38383e | ||
|
|
8950c882eb | ||
|
|
73a22df910 | ||
|
|
6379bcadf6 | ||
|
|
19146ee74e |
@@ -78,6 +78,9 @@ macro_rules! for_each_schema_type {
|
||||
$macro!(crate::LoginChatGptResponse);
|
||||
$macro!(crate::LogoutChatGptParams);
|
||||
$macro!(crate::LogoutChatGptResponse);
|
||||
$macro!(crate::McpOAuthCredentialsStoreMode);
|
||||
$macro!(crate::McpServerConfig);
|
||||
$macro!(crate::McpServerTransportConfig);
|
||||
$macro!(crate::NewConversationParams);
|
||||
$macro!(crate::NewConversationResponse);
|
||||
$macro!(crate::Profile);
|
||||
|
||||
@@ -130,6 +130,22 @@ client_request_definitions! {
|
||||
response: GetAccountResponse,
|
||||
},
|
||||
|
||||
#[serde(rename = "config/read")]
|
||||
#[ts(rename = "config/read")]
|
||||
GetConfig {
|
||||
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
||||
response: UserSavedConfig,
|
||||
},
|
||||
|
||||
#[serde(rename = "config/update")]
|
||||
#[ts(rename = "config/update")]
|
||||
UpdateConfig {
|
||||
params: UpdateConfigParams,
|
||||
// TODO(owen): or should we return UserSavedConfig directly?
|
||||
// First, figure out how we want to represent errors.
|
||||
response: UpdateConfigResponse,
|
||||
},
|
||||
|
||||
/// DEPRECATED APIs below
|
||||
Initialize {
|
||||
params: InitializeParams,
|
||||
@@ -409,6 +425,18 @@ pub struct LoginAccountResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogoutAccountResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateConfigParams {
|
||||
pub config: UserSavedConfig,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateConfigResponse {
|
||||
pub config: UserSavedConfig,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResumeConversationParams {
|
||||
@@ -626,6 +654,12 @@ pub struct UserSavedConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Tools>,
|
||||
|
||||
/// MCP servers
|
||||
#[serde(default)]
|
||||
pub mcp_servers: HashMap<String, McpServerConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mcp_oauth_credentials_store: Option<McpOAuthCredentialsStoreMode>,
|
||||
|
||||
/// Profiles
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<String>,
|
||||
@@ -647,6 +681,58 @@ pub struct Profile {
|
||||
pub model_verbosity: Option<Verbosity>,
|
||||
pub chatgpt_base_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpServerConfig {
|
||||
pub transport: McpServerTransportConfig,
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub startup_timeout_sec: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_timeout_sec: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled_tools: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disabled_tools: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum McpServerTransportConfig {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Stdio {
|
||||
command: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
args: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
env: Option<HashMap<String, String>>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
env_vars: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cwd: Option<PathBuf>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
StreamableHttp {
|
||||
url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
bearer_token_env_var: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
http_headers: Option<HashMap<String, String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
env_http_headers: Option<HashMap<String, String>>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum McpOAuthCredentialsStoreMode {
|
||||
#[default]
|
||||
Auto,
|
||||
File,
|
||||
Keyring,
|
||||
}
|
||||
/// MCP representation of a [`codex_core::config::ToolsToml`].
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -52,6 +52,8 @@ use codex_app_server_protocol::ServerRequestPayload;
|
||||
use codex_app_server_protocol::SessionConfiguredNotification;
|
||||
use codex_app_server_protocol::SetDefaultModelParams;
|
||||
use codex_app_server_protocol::SetDefaultModelResponse;
|
||||
use codex_app_server_protocol::UpdateConfigParams;
|
||||
use codex_app_server_protocol::UpdateConfigResponse;
|
||||
use codex_app_server_protocol::UserInfoResponse;
|
||||
use codex_app_server_protocol::UserSavedConfig;
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
@@ -74,6 +76,7 @@ use codex_core::config::load_config_as_toml;
|
||||
use codex_core::config_edit::CONFIG_KEY_EFFORT;
|
||||
use codex_core::config_edit::CONFIG_KEY_MODEL;
|
||||
use codex_core::config_edit::persist_overrides_and_clear_if_none;
|
||||
use codex_core::config_edit::persist_user_saved_config;
|
||||
use codex_core::default_client::get_codex_user_agent;
|
||||
use codex_core::exec::ExecParams;
|
||||
use codex_core::exec_env::create_env;
|
||||
@@ -197,6 +200,21 @@ impl CodexMessageProcessor {
|
||||
self.send_unimplemented_error(request_id, "account/read")
|
||||
.await;
|
||||
}
|
||||
ClientRequest::GetAccountRateLimits {
|
||||
request_id,
|
||||
params: _,
|
||||
} => {
|
||||
self.get_account_rate_limits(request_id).await;
|
||||
}
|
||||
ClientRequest::GetConfig {
|
||||
request_id,
|
||||
params: _,
|
||||
} => {
|
||||
self.get_user_saved_config(request_id, false).await;
|
||||
}
|
||||
ClientRequest::UpdateConfig { request_id, params } => {
|
||||
self.update_user_saved_config(request_id, params).await;
|
||||
}
|
||||
ClientRequest::ResumeConversation { request_id, params } => {
|
||||
self.handle_resume_conversation(request_id, params).await;
|
||||
}
|
||||
@@ -246,7 +264,7 @@ impl CodexMessageProcessor {
|
||||
request_id,
|
||||
params: _,
|
||||
} => {
|
||||
self.get_user_saved_config(request_id).await;
|
||||
self.get_user_saved_config(request_id, true).await;
|
||||
}
|
||||
ClientRequest::SetDefaultModel { request_id, params } => {
|
||||
self.set_default_model(request_id, params).await;
|
||||
@@ -269,12 +287,6 @@ impl CodexMessageProcessor {
|
||||
ClientRequest::ExecOneOffCommand { request_id, params } => {
|
||||
self.exec_one_off_command(request_id, params).await;
|
||||
}
|
||||
ClientRequest::GetAccountRateLimits {
|
||||
request_id,
|
||||
params: _,
|
||||
} => {
|
||||
self.get_account_rate_limits(request_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,7 +630,7 @@ impl CodexMessageProcessor {
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_user_saved_config(&self, request_id: RequestId) {
|
||||
async fn get_user_saved_config(&self, request_id: RequestId, wrap: bool) {
|
||||
let toml_value = match load_config_as_toml(&self.config.codex_home).await {
|
||||
Ok(val) => val,
|
||||
Err(err) => {
|
||||
@@ -647,9 +659,57 @@ impl CodexMessageProcessor {
|
||||
|
||||
let user_saved_config: UserSavedConfig = cfg.into();
|
||||
|
||||
let response = GetUserSavedConfigResponse {
|
||||
config: user_saved_config,
|
||||
if wrap {
|
||||
let response = GetUserSavedConfigResponse {
|
||||
config: user_saved_config,
|
||||
};
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
} else {
|
||||
self.outgoing
|
||||
.send_response(request_id, user_saved_config)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_user_saved_config(&self, request_id: RequestId, params: UpdateConfigParams) {
|
||||
let UpdateConfigParams { config } = params;
|
||||
if let Err(err) = persist_user_saved_config(&self.config.codex_home, &config).await {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to persist config.toml: {err}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
|
||||
let toml_value = match load_config_as_toml(&self.config.codex_home).await {
|
||||
Ok(val) => val,
|
||||
Err(err) => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to load config.toml: {err}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let cfg: ConfigToml = match toml_value.try_into() {
|
||||
Ok(cfg) => cfg,
|
||||
Err(err) => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to parse config.toml: {err}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let response = UpdateConfigResponse { config: cfg.into() };
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ use codex_app_server_protocol::SendUserMessageParams;
|
||||
use codex_app_server_protocol::SendUserTurnParams;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::SetDefaultModelParams;
|
||||
use codex_app_server_protocol::UpdateConfigParams;
|
||||
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
@@ -227,9 +228,18 @@ impl McpProcess {
|
||||
self.send_request("getAuthStatus", params).await
|
||||
}
|
||||
|
||||
/// Send a `getUserSavedConfig` JSON-RPC request.
|
||||
pub async fn send_get_user_saved_config_request(&mut self) -> anyhow::Result<i64> {
|
||||
self.send_request("getUserSavedConfig", None).await
|
||||
/// Send a `config/read` JSON-RPC request.
|
||||
pub async fn send_get_config_request(&mut self) -> anyhow::Result<i64> {
|
||||
self.send_request("config/read", None).await
|
||||
}
|
||||
|
||||
/// Send a `config/update` JSON-RPC request.
|
||||
pub async fn send_update_config_request(
|
||||
&mut self,
|
||||
params: UpdateConfigParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("config/update", params).await
|
||||
}
|
||||
|
||||
/// Send a `getUserAgent` JSON-RPC request.
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::GetUserSavedConfigResponse;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::McpOAuthCredentialsStoreMode;
|
||||
use codex_app_server_protocol::McpServerConfig as ProtocolMcpServerConfig;
|
||||
use codex_app_server_protocol::McpServerTransportConfig as ProtocolMcpServerTransportConfig;
|
||||
use codex_app_server_protocol::Profile;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::SandboxSettings;
|
||||
use codex_app_server_protocol::Tools;
|
||||
use codex_app_server_protocol::UpdateConfigParams;
|
||||
use codex_app_server_protocol::UpdateConfigResponse;
|
||||
use codex_app_server_protocol::UserSavedConfig;
|
||||
use codex_core::config::ConfigToml;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_protocol::config_types::ForcedLoginMethod;
|
||||
use codex_protocol::config_types::ReasoningEffort;
|
||||
@@ -36,6 +42,7 @@ model_verbosity = "medium"
|
||||
profile = "test"
|
||||
forced_chatgpt_workspace_id = "12345678-0000-0000-0000-000000000000"
|
||||
forced_login_method = "chatgpt"
|
||||
mcp_oauth_credentials_store = "keyring"
|
||||
|
||||
[sandbox_workspace_write]
|
||||
writable_roots = ["/tmp"]
|
||||
@@ -55,116 +62,330 @@ model_reasoning_summary = "detailed"
|
||||
model_verbosity = "medium"
|
||||
model_provider = "openai"
|
||||
chatgpt_base_url = "https://api.chatgpt.com"
|
||||
|
||||
[mcp_servers.docs]
|
||||
command = "codex-docs"
|
||||
args = ["serve"]
|
||||
env_vars = ["DOCS_TOKEN"]
|
||||
cwd = "/tmp/docs"
|
||||
startup_timeout_sec = 12.5
|
||||
tool_timeout_sec = 42.0
|
||||
enabled = false
|
||||
enabled_tools = ["read_docs"]
|
||||
disabled_tools = ["delete_docs"]
|
||||
|
||||
[mcp_servers.docs.env]
|
||||
PLAN = "gold"
|
||||
|
||||
[mcp_servers.issues]
|
||||
url = "https://example.com/mcp"
|
||||
bearer_token_env_var = "MCP_TOKEN"
|
||||
startup_timeout_sec = 30.0
|
||||
tool_timeout_sec = 15.0
|
||||
|
||||
[mcp_servers.issues.http_headers]
|
||||
"X-Test" = "42"
|
||||
|
||||
[mcp_servers.issues.env_http_headers]
|
||||
"X-Token" = "TOKEN_ENV"
|
||||
"#,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn get_config_toml_parses_all_fields() {
|
||||
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
|
||||
create_config_toml(codex_home.path()).expect("write config.toml");
|
||||
async fn get_config_toml_parses_all_fields() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path())
|
||||
.await
|
||||
.expect("spawn mcp process");
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
|
||||
.await
|
||||
.expect("init timeout")
|
||||
.expect("init failed");
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_get_user_saved_config_request()
|
||||
.await
|
||||
.expect("send getUserSavedConfig");
|
||||
let request_id = mcp.send_get_config_request().await?;
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await
|
||||
.expect("getUserSavedConfig timeout")
|
||||
.expect("getUserSavedConfig response");
|
||||
.await??;
|
||||
|
||||
let config: GetUserSavedConfigResponse = to_response(resp).expect("deserialize config");
|
||||
let expected = GetUserSavedConfigResponse {
|
||||
config: UserSavedConfig {
|
||||
approval_policy: Some(AskForApproval::OnRequest),
|
||||
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
|
||||
sandbox_settings: Some(SandboxSettings {
|
||||
writable_roots: vec!["/tmp".into()],
|
||||
network_access: Some(true),
|
||||
exclude_tmpdir_env_var: Some(true),
|
||||
exclude_slash_tmp: Some(true),
|
||||
}),
|
||||
forced_chatgpt_workspace_id: Some("12345678-0000-0000-0000-000000000000".into()),
|
||||
forced_login_method: Some(ForcedLoginMethod::Chatgpt),
|
||||
model: Some("gpt-5-codex".into()),
|
||||
model_reasoning_effort: Some(ReasoningEffort::High),
|
||||
model_reasoning_summary: Some(ReasoningSummary::Detailed),
|
||||
model_verbosity: Some(Verbosity::Medium),
|
||||
tools: Some(Tools {
|
||||
web_search: Some(false),
|
||||
view_image: Some(true),
|
||||
}),
|
||||
profile: Some("test".to_string()),
|
||||
profiles: HashMap::from([(
|
||||
"test".into(),
|
||||
Profile {
|
||||
model: Some("gpt-4o".into()),
|
||||
approval_policy: Some(AskForApproval::OnRequest),
|
||||
model_reasoning_effort: Some(ReasoningEffort::High),
|
||||
model_reasoning_summary: Some(ReasoningSummary::Detailed),
|
||||
model_verbosity: Some(Verbosity::Medium),
|
||||
model_provider: Some("openai".into()),
|
||||
chatgpt_base_url: Some("https://api.chatgpt.com".into()),
|
||||
let config: UserSavedConfig = to_response(resp)?;
|
||||
let expected = UserSavedConfig {
|
||||
approval_policy: Some(AskForApproval::OnRequest),
|
||||
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
|
||||
sandbox_settings: Some(SandboxSettings {
|
||||
writable_roots: vec!["/tmp".into()],
|
||||
network_access: Some(true),
|
||||
exclude_tmpdir_env_var: Some(true),
|
||||
exclude_slash_tmp: Some(true),
|
||||
}),
|
||||
forced_chatgpt_workspace_id: Some("12345678-0000-0000-0000-000000000000".into()),
|
||||
forced_login_method: Some(ForcedLoginMethod::Chatgpt),
|
||||
model: Some("gpt-5-codex".into()),
|
||||
model_reasoning_effort: Some(ReasoningEffort::High),
|
||||
model_reasoning_summary: Some(ReasoningSummary::Detailed),
|
||||
model_verbosity: Some(Verbosity::Medium),
|
||||
tools: Some(Tools {
|
||||
web_search: Some(false),
|
||||
view_image: Some(true),
|
||||
}),
|
||||
mcp_servers: HashMap::from([
|
||||
(
|
||||
"docs".into(),
|
||||
ProtocolMcpServerConfig {
|
||||
transport: ProtocolMcpServerTransportConfig::Stdio {
|
||||
command: "codex-docs".into(),
|
||||
args: vec!["serve".into()],
|
||||
env: Some(HashMap::from([("PLAN".into(), "gold".into())])),
|
||||
env_vars: vec!["DOCS_TOKEN".into()],
|
||||
cwd: Some("/tmp/docs".into()),
|
||||
},
|
||||
enabled: false,
|
||||
startup_timeout_sec: Some(12.5),
|
||||
tool_timeout_sec: Some(42.0),
|
||||
enabled_tools: Some(vec!["read_docs".into()]),
|
||||
disabled_tools: Some(vec!["delete_docs".into()]),
|
||||
},
|
||||
)]),
|
||||
},
|
||||
),
|
||||
(
|
||||
"issues".into(),
|
||||
ProtocolMcpServerConfig {
|
||||
transport: ProtocolMcpServerTransportConfig::StreamableHttp {
|
||||
url: "https://example.com/mcp".into(),
|
||||
bearer_token_env_var: Some("MCP_TOKEN".into()),
|
||||
http_headers: Some(HashMap::from([("X-Test".into(), "42".into())])),
|
||||
env_http_headers: Some(HashMap::from([(
|
||||
"X-Token".into(),
|
||||
"TOKEN_ENV".into(),
|
||||
)])),
|
||||
},
|
||||
enabled: true,
|
||||
startup_timeout_sec: Some(30.0),
|
||||
tool_timeout_sec: Some(15.0),
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
},
|
||||
),
|
||||
]),
|
||||
mcp_oauth_credentials_store: Some(McpOAuthCredentialsStoreMode::Keyring),
|
||||
profile: Some("test".to_string()),
|
||||
profiles: HashMap::from([(
|
||||
"test".into(),
|
||||
Profile {
|
||||
model: Some("gpt-4o".into()),
|
||||
approval_policy: Some(AskForApproval::OnRequest),
|
||||
model_reasoning_effort: Some(ReasoningEffort::High),
|
||||
model_reasoning_summary: Some(ReasoningSummary::Detailed),
|
||||
model_verbosity: Some(Verbosity::Medium),
|
||||
model_provider: Some("openai".into()),
|
||||
chatgpt_base_url: Some("https://api.chatgpt.com".into()),
|
||||
},
|
||||
)]),
|
||||
};
|
||||
|
||||
assert_eq!(config, expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_config_toml_empty() {
|
||||
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
|
||||
async fn get_config_toml_empty() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path())
|
||||
.await
|
||||
.expect("spawn mcp process");
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
|
||||
.await
|
||||
.expect("init timeout")
|
||||
.expect("init failed");
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_get_user_saved_config_request()
|
||||
.await
|
||||
.expect("send getUserSavedConfig");
|
||||
let request_id = mcp.send_get_config_request().await?;
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await
|
||||
.expect("getUserSavedConfig timeout")
|
||||
.expect("getUserSavedConfig response");
|
||||
.await??;
|
||||
|
||||
let config: GetUserSavedConfigResponse = to_response(resp).expect("deserialize config");
|
||||
let expected = GetUserSavedConfigResponse {
|
||||
config: UserSavedConfig {
|
||||
approval_policy: None,
|
||||
sandbox_mode: None,
|
||||
sandbox_settings: None,
|
||||
forced_chatgpt_workspace_id: None,
|
||||
forced_login_method: None,
|
||||
model: None,
|
||||
model_reasoning_effort: None,
|
||||
model_reasoning_summary: None,
|
||||
model_verbosity: None,
|
||||
tools: None,
|
||||
profile: None,
|
||||
profiles: HashMap::new(),
|
||||
},
|
||||
let config: UserSavedConfig = to_response(resp)?;
|
||||
let expected = UserSavedConfig {
|
||||
approval_policy: None,
|
||||
sandbox_mode: None,
|
||||
sandbox_settings: None,
|
||||
forced_chatgpt_workspace_id: None,
|
||||
forced_login_method: None,
|
||||
model: None,
|
||||
model_reasoning_effort: None,
|
||||
model_reasoning_summary: None,
|
||||
model_verbosity: None,
|
||||
tools: None,
|
||||
mcp_servers: HashMap::new(),
|
||||
mcp_oauth_credentials_store: None,
|
||||
profile: None,
|
||||
profiles: HashMap::new(),
|
||||
};
|
||||
|
||||
assert_eq!(config, expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn update_config_persists_all_fields() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let desired = sample_user_saved_config();
|
||||
|
||||
let request_id = mcp
|
||||
.send_update_config_request(UpdateConfigParams {
|
||||
config: desired.clone(),
|
||||
})
|
||||
.await?;
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: UpdateConfigResponse = to_response(resp)?;
|
||||
assert_eq!(response.config, desired);
|
||||
|
||||
let config_contents = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||||
let config_toml: ConfigToml = toml::from_str(&config_contents)?;
|
||||
let persisted: UserSavedConfig = config_toml.into();
|
||||
assert_eq!(persisted, desired);
|
||||
|
||||
let read_request_id = mcp.send_get_config_request().await?;
|
||||
let read_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_request_id)),
|
||||
)
|
||||
.await??;
|
||||
let read_config: UserSavedConfig = to_response(read_resp)?;
|
||||
assert_eq!(read_config, desired);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn update_config_clears_missing_fields() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let desired = empty_user_saved_config();
|
||||
let request_id = mcp
|
||||
.send_update_config_request(UpdateConfigParams {
|
||||
config: desired.clone(),
|
||||
})
|
||||
.await?;
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: UpdateConfigResponse = to_response(resp)?;
|
||||
assert_eq!(response.config, desired);
|
||||
|
||||
let config_contents = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||||
let config_toml: ConfigToml = toml::from_str(&config_contents)?;
|
||||
let persisted: UserSavedConfig = config_toml.into();
|
||||
assert_eq!(persisted, desired);
|
||||
|
||||
let read_request_id = mcp.send_get_config_request().await?;
|
||||
let read_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_request_id)),
|
||||
)
|
||||
.await??;
|
||||
let read_config: UserSavedConfig = to_response(read_resp)?;
|
||||
assert_eq!(read_config, desired);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn empty_user_saved_config() -> UserSavedConfig {
|
||||
UserSavedConfig {
|
||||
approval_policy: None,
|
||||
sandbox_mode: None,
|
||||
sandbox_settings: None,
|
||||
forced_chatgpt_workspace_id: None,
|
||||
forced_login_method: None,
|
||||
model: None,
|
||||
model_reasoning_effort: None,
|
||||
model_reasoning_summary: None,
|
||||
model_verbosity: None,
|
||||
tools: None,
|
||||
mcp_servers: HashMap::new(),
|
||||
mcp_oauth_credentials_store: None,
|
||||
profile: None,
|
||||
profiles: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_user_saved_config() -> UserSavedConfig {
|
||||
UserSavedConfig {
|
||||
approval_policy: Some(AskForApproval::OnRequest),
|
||||
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
|
||||
sandbox_settings: Some(SandboxSettings {
|
||||
writable_roots: vec!["/tmp".into()],
|
||||
network_access: Some(true),
|
||||
exclude_tmpdir_env_var: Some(true),
|
||||
exclude_slash_tmp: Some(true),
|
||||
}),
|
||||
forced_chatgpt_workspace_id: Some("12345678-0000-0000-0000-000000000000".into()),
|
||||
forced_login_method: Some(ForcedLoginMethod::Chatgpt),
|
||||
model: Some("gpt-5-codex".into()),
|
||||
model_reasoning_effort: Some(ReasoningEffort::High),
|
||||
model_reasoning_summary: Some(ReasoningSummary::Detailed),
|
||||
model_verbosity: Some(Verbosity::Medium),
|
||||
tools: Some(Tools {
|
||||
web_search: Some(false),
|
||||
view_image: Some(true),
|
||||
}),
|
||||
mcp_servers: HashMap::from([
|
||||
(
|
||||
"docs".into(),
|
||||
ProtocolMcpServerConfig {
|
||||
transport: ProtocolMcpServerTransportConfig::Stdio {
|
||||
command: "codex-docs".into(),
|
||||
args: vec!["serve".into()],
|
||||
env: Some(HashMap::from([("PLAN".into(), "gold".into())])),
|
||||
env_vars: vec!["DOCS_TOKEN".into()],
|
||||
cwd: Some("/tmp/docs".into()),
|
||||
},
|
||||
enabled: false,
|
||||
startup_timeout_sec: Some(12.5),
|
||||
tool_timeout_sec: Some(42.0),
|
||||
enabled_tools: Some(vec!["read_docs".into()]),
|
||||
disabled_tools: Some(vec!["delete_docs".into()]),
|
||||
},
|
||||
),
|
||||
(
|
||||
"issues".into(),
|
||||
ProtocolMcpServerConfig {
|
||||
transport: ProtocolMcpServerTransportConfig::StreamableHttp {
|
||||
url: "https://example.com/mcp".into(),
|
||||
bearer_token_env_var: Some("MCP_TOKEN".into()),
|
||||
http_headers: Some(HashMap::from([("X-Test".into(), "42".into())])),
|
||||
env_http_headers: Some(HashMap::from([(
|
||||
"X-Token".into(),
|
||||
"TOKEN_ENV".into(),
|
||||
)])),
|
||||
},
|
||||
enabled: true,
|
||||
startup_timeout_sec: Some(30.0),
|
||||
tool_timeout_sec: Some(15.0),
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
},
|
||||
),
|
||||
]),
|
||||
mcp_oauth_credentials_store: Some(McpOAuthCredentialsStoreMode::Keyring),
|
||||
profile: Some("test".into()),
|
||||
profiles: HashMap::from([(
|
||||
"test".into(),
|
||||
Profile {
|
||||
model: Some("gpt-4o".into()),
|
||||
approval_policy: Some(AskForApproval::OnRequest),
|
||||
model_reasoning_effort: Some(ReasoningEffort::High),
|
||||
model_reasoning_summary: Some(ReasoningSummary::Detailed),
|
||||
model_verbosity: Some(Verbosity::Medium),
|
||||
model_provider: Some("openai".into()),
|
||||
chatgpt_base_url: Some("https://api.chatgpt.com".into()),
|
||||
},
|
||||
)]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use anyhow::Context;
|
||||
use codex_app_server_protocol::McpOAuthCredentialsStoreMode;
|
||||
use codex_app_server_protocol::Tools;
|
||||
use codex_app_server_protocol::UserSavedConfig;
|
||||
use codex_protocol::config_types::ForcedLoginMethod;
|
||||
@@ -967,6 +968,11 @@ impl From<ConfigToml> for UserSavedConfig {
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, v.into()))
|
||||
.collect();
|
||||
let mcp_servers = config_toml
|
||||
.mcp_servers
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, v.into()))
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
approval_policy: config_toml.approval_policy,
|
||||
@@ -979,6 +985,10 @@ impl From<ConfigToml> for UserSavedConfig {
|
||||
model_reasoning_summary: config_toml.model_reasoning_summary,
|
||||
model_verbosity: config_toml.model_verbosity,
|
||||
tools: config_toml.tools.map(From::from),
|
||||
mcp_servers,
|
||||
mcp_oauth_credentials_store: config_toml
|
||||
.mcp_oauth_credentials_store
|
||||
.map(map_oauth_credentials_store_mode),
|
||||
profile: config_toml.profile,
|
||||
profiles,
|
||||
}
|
||||
@@ -1488,6 +1498,16 @@ fn default_review_model() -> String {
|
||||
OPENAI_DEFAULT_REVIEW_MODEL.to_string()
|
||||
}
|
||||
|
||||
fn map_oauth_credentials_store_mode(
|
||||
mode: OAuthCredentialsStoreMode,
|
||||
) -> McpOAuthCredentialsStoreMode {
|
||||
match mode {
|
||||
OAuthCredentialsStoreMode::Auto => McpOAuthCredentialsStoreMode::Auto,
|
||||
OAuthCredentialsStoreMode::File => McpOAuthCredentialsStoreMode::File,
|
||||
OAuthCredentialsStoreMode::Keyring => McpOAuthCredentialsStoreMode::Keyring,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the path to the Codex configuration directory, which can be
|
||||
/// specified by the `CODEX_HOME` environment variable. If not set, defaults to
|
||||
/// `~/.codex`.
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config_types::McpServerConfig;
|
||||
use crate::config_types::McpServerTransportConfig;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use codex_app_server_protocol::McpOAuthCredentialsStoreMode;
|
||||
use codex_app_server_protocol::McpServerConfig as ProtocolMcpServerConfig;
|
||||
use codex_app_server_protocol::McpServerTransportConfig as ProtocolMcpServerTransportConfig;
|
||||
use codex_app_server_protocol::Profile;
|
||||
use codex_app_server_protocol::SandboxSettings;
|
||||
use codex_app_server_protocol::Tools;
|
||||
use codex_app_server_protocol::UserSavedConfig;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tempfile::NamedTempFile;
|
||||
use toml_edit::Array as TomlArray;
|
||||
use toml_edit::DocumentMut;
|
||||
use toml_edit::Item as TomlItem;
|
||||
use toml_edit::Table as TomlTable;
|
||||
|
||||
pub const CONFIG_KEY_MODEL: &str = "model";
|
||||
pub const CONFIG_KEY_EFFORT: &str = "model_reasoning_effort";
|
||||
@@ -49,6 +67,87 @@ pub async fn persist_overrides_and_clear_if_none(
|
||||
persist_overrides_with_behavior(codex_home, profile, overrides, NoneBehavior::Remove).await
|
||||
}
|
||||
|
||||
pub async fn persist_user_saved_config(codex_home: &Path, config: &UserSavedConfig) -> Result<()> {
|
||||
let servers = convert_mcp_servers(&config.mcp_servers)?;
|
||||
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let existing = tokio::fs::read_to_string(&config_path).await;
|
||||
let mut doc = match existing {
|
||||
Ok(contents) => contents.parse::<DocumentMut>()?,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => DocumentMut::new(),
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
|
||||
{
|
||||
let root = doc.as_table_mut();
|
||||
set_string_option(
|
||||
root,
|
||||
"approval_policy",
|
||||
config.approval_policy.map(|value| value.to_string()),
|
||||
);
|
||||
set_string_option(
|
||||
root,
|
||||
"sandbox_mode",
|
||||
config.sandbox_mode.map(|mode| mode.to_string()),
|
||||
);
|
||||
set_sandbox_workspace_write(root, config.sandbox_settings.as_ref())?;
|
||||
set_string_option(
|
||||
root,
|
||||
"forced_chatgpt_workspace_id",
|
||||
config.forced_chatgpt_workspace_id.clone(),
|
||||
);
|
||||
set_string_option(
|
||||
root,
|
||||
"forced_login_method",
|
||||
config.forced_login_method.map(|mode| mode.to_string()),
|
||||
);
|
||||
set_string_option(root, "model", config.model.clone());
|
||||
set_string_option(
|
||||
root,
|
||||
"model_reasoning_effort",
|
||||
config
|
||||
.model_reasoning_effort
|
||||
.map(|effort| effort.to_string()),
|
||||
);
|
||||
set_string_option(
|
||||
root,
|
||||
"model_reasoning_summary",
|
||||
config
|
||||
.model_reasoning_summary
|
||||
.map(|summary| summary.to_string()),
|
||||
);
|
||||
set_string_option(
|
||||
root,
|
||||
"model_verbosity",
|
||||
config
|
||||
.model_verbosity
|
||||
.map(|verbosity| verbosity.to_string()),
|
||||
);
|
||||
set_tools(root, config.tools.as_ref())?;
|
||||
set_string_option(
|
||||
root,
|
||||
"mcp_oauth_credentials_store",
|
||||
config
|
||||
.mcp_oauth_credentials_store
|
||||
.map(protocol_oauth_mode_to_str)
|
||||
.map(String::from),
|
||||
);
|
||||
set_string_option(root, "profile", config.profile.clone());
|
||||
set_profiles(root, &config.profiles)?;
|
||||
set_mcp_servers(root, &servers)?;
|
||||
}
|
||||
|
||||
tokio::fs::create_dir_all(codex_home)
|
||||
.await
|
||||
.with_context(|| format!("failed to create Codex home at {}", codex_home.display()))?;
|
||||
|
||||
let tmp_file = NamedTempFile::new_in(codex_home)?;
|
||||
tokio::fs::write(tmp_file.path(), doc.to_string()).await?;
|
||||
tmp_file.persist(config_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply a single override onto a `toml_edit` document while preserving
|
||||
/// existing formatting/comments.
|
||||
/// The key is expressed as explicit segments to correctly handle keys that
|
||||
@@ -212,6 +311,340 @@ fn remove_toml_edit_segments(doc: &mut DocumentMut, segments: &[&str]) -> bool {
|
||||
current.remove(segments[segments.len() - 1]).is_some()
|
||||
}
|
||||
|
||||
fn set_string_option(table: &mut TomlTable, key: &str, value: Option<String>) {
|
||||
match value {
|
||||
Some(value) => {
|
||||
table[key] = toml_edit::value(value);
|
||||
}
|
||||
None => {
|
||||
table.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_sandbox_workspace_write(
|
||||
table: &mut TomlTable,
|
||||
settings: Option<&SandboxSettings>,
|
||||
) -> Result<()> {
|
||||
table.remove("sandbox_workspace_write");
|
||||
|
||||
let Some(settings) = settings else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut sandbox = TomlTable::new();
|
||||
sandbox.set_implicit(false);
|
||||
|
||||
if !settings.writable_roots.is_empty() {
|
||||
let mut roots = TomlArray::new();
|
||||
for root_path in &settings.writable_roots {
|
||||
roots.push(root_path.to_string_lossy().to_string());
|
||||
}
|
||||
sandbox["writable_roots"] = TomlItem::Value(roots.into());
|
||||
}
|
||||
|
||||
if let Some(network_access) = settings.network_access {
|
||||
sandbox["network_access"] = toml_edit::value(network_access);
|
||||
}
|
||||
|
||||
if let Some(exclude_tmpdir_env_var) = settings.exclude_tmpdir_env_var {
|
||||
sandbox["exclude_tmpdir_env_var"] = toml_edit::value(exclude_tmpdir_env_var);
|
||||
}
|
||||
|
||||
if let Some(exclude_slash_tmp) = settings.exclude_slash_tmp {
|
||||
sandbox["exclude_slash_tmp"] = toml_edit::value(exclude_slash_tmp);
|
||||
}
|
||||
|
||||
if sandbox.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
table.insert("sandbox_workspace_write", TomlItem::Table(sandbox));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_tools(table: &mut TomlTable, tools: Option<&Tools>) -> Result<()> {
|
||||
table.remove("tools");
|
||||
|
||||
let Some(tools) = tools else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut tools_table = TomlTable::new();
|
||||
tools_table.set_implicit(false);
|
||||
|
||||
if let Some(web_search) = tools.web_search {
|
||||
tools_table["web_search"] = toml_edit::value(web_search);
|
||||
}
|
||||
|
||||
if let Some(view_image) = tools.view_image {
|
||||
tools_table["view_image"] = toml_edit::value(view_image);
|
||||
}
|
||||
|
||||
if tools_table.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
table.insert("tools", TomlItem::Table(tools_table));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_profiles(table: &mut TomlTable, profiles: &HashMap<String, Profile>) -> Result<()> {
|
||||
table.remove("profiles");
|
||||
|
||||
if profiles.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut profiles_table = TomlTable::new();
|
||||
profiles_table.set_implicit(true);
|
||||
|
||||
let mut keys: Vec<_> = profiles.keys().cloned().collect();
|
||||
keys.sort();
|
||||
|
||||
for key in keys {
|
||||
let profile = profiles.get(&key).expect("profile key should exist");
|
||||
let mut profile_table = TomlTable::new();
|
||||
profile_table.set_implicit(false);
|
||||
|
||||
if let Some(model) = profile.model.clone() {
|
||||
profile_table["model"] = toml_edit::value(model);
|
||||
}
|
||||
|
||||
if let Some(model_provider) = profile.model_provider.clone() {
|
||||
profile_table["model_provider"] = toml_edit::value(model_provider);
|
||||
}
|
||||
|
||||
if let Some(approval_policy) = profile.approval_policy {
|
||||
profile_table["approval_policy"] = toml_edit::value(approval_policy.to_string());
|
||||
}
|
||||
|
||||
if let Some(effort) = profile.model_reasoning_effort {
|
||||
profile_table["model_reasoning_effort"] = toml_edit::value(effort.to_string());
|
||||
}
|
||||
|
||||
if let Some(summary) = profile.model_reasoning_summary {
|
||||
profile_table["model_reasoning_summary"] = toml_edit::value(summary.to_string());
|
||||
}
|
||||
|
||||
if let Some(verbosity) = profile.model_verbosity {
|
||||
profile_table["model_verbosity"] = toml_edit::value(verbosity.to_string());
|
||||
}
|
||||
|
||||
if let Some(chatgpt_base_url) = profile.chatgpt_base_url.clone() {
|
||||
profile_table["chatgpt_base_url"] = toml_edit::value(chatgpt_base_url);
|
||||
}
|
||||
|
||||
profiles_table.insert(&key, TomlItem::Table(profile_table));
|
||||
}
|
||||
|
||||
table.insert("profiles", TomlItem::Table(profiles_table));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_mcp_servers(
|
||||
table: &mut TomlTable,
|
||||
servers: &BTreeMap<String, McpServerConfig>,
|
||||
) -> Result<()> {
|
||||
table.remove("mcp_servers");
|
||||
|
||||
if servers.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut servers_table = TomlTable::new();
|
||||
servers_table.set_implicit(true);
|
||||
|
||||
for (name, config) in servers {
|
||||
let mut entry = TomlTable::new();
|
||||
entry.set_implicit(false);
|
||||
|
||||
match &config.transport {
|
||||
McpServerTransportConfig::Stdio {
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
env_vars,
|
||||
cwd,
|
||||
} => {
|
||||
entry["command"] = toml_edit::value(command.clone());
|
||||
|
||||
if !args.is_empty() {
|
||||
let mut args_array = TomlArray::new();
|
||||
for arg in args {
|
||||
args_array.push(arg.clone());
|
||||
}
|
||||
entry["args"] = TomlItem::Value(args_array.into());
|
||||
}
|
||||
|
||||
if let Some(env) = env
|
||||
&& !env.is_empty()
|
||||
{
|
||||
let mut env_table = TomlTable::new();
|
||||
env_table.set_implicit(false);
|
||||
let mut pairs: Vec<_> = env.iter().collect();
|
||||
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
for (key, value) in pairs {
|
||||
env_table.insert(key, toml_edit::value(value.clone()));
|
||||
}
|
||||
entry["env"] = TomlItem::Table(env_table);
|
||||
}
|
||||
|
||||
if !env_vars.is_empty() {
|
||||
let mut vars = TomlArray::new();
|
||||
for var in env_vars {
|
||||
vars.push(var.clone());
|
||||
}
|
||||
entry["env_vars"] = TomlItem::Value(vars.into());
|
||||
}
|
||||
|
||||
if let Some(cwd) = cwd {
|
||||
entry["cwd"] = toml_edit::value(cwd.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
McpServerTransportConfig::StreamableHttp {
|
||||
url,
|
||||
bearer_token_env_var,
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
} => {
|
||||
entry["url"] = toml_edit::value(url.clone());
|
||||
|
||||
if let Some(env_var) = bearer_token_env_var {
|
||||
entry["bearer_token_env_var"] = toml_edit::value(env_var.clone());
|
||||
}
|
||||
|
||||
if let Some(headers) = http_headers
|
||||
&& !headers.is_empty()
|
||||
{
|
||||
let mut headers_table = TomlTable::new();
|
||||
headers_table.set_implicit(false);
|
||||
let mut pairs: Vec<_> = headers.iter().collect();
|
||||
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
for (key, value) in pairs {
|
||||
headers_table.insert(key, toml_edit::value(value.clone()));
|
||||
}
|
||||
entry["http_headers"] = TomlItem::Table(headers_table);
|
||||
}
|
||||
|
||||
if let Some(headers) = env_http_headers
|
||||
&& !headers.is_empty()
|
||||
{
|
||||
let mut headers_table = TomlTable::new();
|
||||
headers_table.set_implicit(false);
|
||||
let mut pairs: Vec<_> = headers.iter().collect();
|
||||
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
for (key, value) in pairs {
|
||||
headers_table.insert(key, toml_edit::value(value.clone()));
|
||||
}
|
||||
entry["env_http_headers"] = TomlItem::Table(headers_table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entry["enabled"] = toml_edit::value(config.enabled);
|
||||
|
||||
if let Some(startup) = config.startup_timeout_sec {
|
||||
entry["startup_timeout_sec"] = toml_edit::value(startup.as_secs_f64());
|
||||
}
|
||||
|
||||
if let Some(tool_timeout) = config.tool_timeout_sec {
|
||||
entry["tool_timeout_sec"] = toml_edit::value(tool_timeout.as_secs_f64());
|
||||
}
|
||||
|
||||
if let Some(enabled_tools) = config.enabled_tools.as_ref()
|
||||
&& !enabled_tools.is_empty()
|
||||
{
|
||||
let mut tools = TomlArray::new();
|
||||
for tool in enabled_tools {
|
||||
tools.push(tool.clone());
|
||||
}
|
||||
entry["enabled_tools"] = TomlItem::Value(tools.into());
|
||||
}
|
||||
|
||||
if let Some(disabled_tools) = config.disabled_tools.as_ref()
|
||||
&& !disabled_tools.is_empty()
|
||||
{
|
||||
let mut tools = TomlArray::new();
|
||||
for tool in disabled_tools {
|
||||
tools.push(tool.clone());
|
||||
}
|
||||
entry["disabled_tools"] = TomlItem::Value(tools.into());
|
||||
}
|
||||
|
||||
servers_table.insert(name, TomlItem::Table(entry));
|
||||
}
|
||||
|
||||
table.insert("mcp_servers", TomlItem::Table(servers_table));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn convert_mcp_servers(
|
||||
servers: &HashMap<String, ProtocolMcpServerConfig>,
|
||||
) -> Result<BTreeMap<String, McpServerConfig>> {
|
||||
let mut result = BTreeMap::new();
|
||||
for (name, config) in servers {
|
||||
result.insert(name.clone(), convert_mcp_server_config(config)?);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn convert_mcp_server_config(config: &ProtocolMcpServerConfig) -> Result<McpServerConfig> {
|
||||
let transport = match &config.transport {
|
||||
ProtocolMcpServerTransportConfig::Stdio {
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
env_vars,
|
||||
cwd,
|
||||
} => McpServerTransportConfig::Stdio {
|
||||
command: command.clone(),
|
||||
args: args.clone(),
|
||||
env: env.clone(),
|
||||
env_vars: env_vars.clone(),
|
||||
cwd: cwd.clone(),
|
||||
},
|
||||
ProtocolMcpServerTransportConfig::StreamableHttp {
|
||||
url,
|
||||
bearer_token_env_var,
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
} => McpServerTransportConfig::StreamableHttp {
|
||||
url: url.clone(),
|
||||
bearer_token_env_var: bearer_token_env_var.clone(),
|
||||
http_headers: http_headers.clone(),
|
||||
env_http_headers: env_http_headers.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
Ok(McpServerConfig {
|
||||
transport,
|
||||
enabled: config.enabled,
|
||||
startup_timeout_sec: config
|
||||
.startup_timeout_sec
|
||||
.map(duration_from_secs)
|
||||
.transpose()?,
|
||||
tool_timeout_sec: config
|
||||
.tool_timeout_sec
|
||||
.map(duration_from_secs)
|
||||
.transpose()?,
|
||||
enabled_tools: config.enabled_tools.clone(),
|
||||
disabled_tools: config.disabled_tools.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn duration_from_secs(value: f64) -> Result<Duration> {
|
||||
Duration::try_from_secs_f64(value).map_err(|err| anyhow!(err))
|
||||
}
|
||||
|
||||
fn protocol_oauth_mode_to_str(mode: McpOAuthCredentialsStoreMode) -> &'static str {
|
||||
match mode {
|
||||
McpOAuthCredentialsStoreMode::Auto => "auto",
|
||||
McpOAuthCredentialsStoreMode::File => "file",
|
||||
McpOAuthCredentialsStoreMode::Keyring => "keyring",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -388,6 +388,52 @@ impl From<SandboxWorkspaceWrite> for codex_app_server_protocol::SandboxSettings
|
||||
}
|
||||
}
|
||||
|
||||
impl From<McpServerConfig> for codex_app_server_protocol::McpServerConfig {
|
||||
fn from(cfg: McpServerConfig) -> Self {
|
||||
Self {
|
||||
transport: cfg.transport.into(),
|
||||
enabled: cfg.enabled,
|
||||
startup_timeout_sec: cfg
|
||||
.startup_timeout_sec
|
||||
.map(|duration| duration.as_secs_f64()),
|
||||
tool_timeout_sec: cfg.tool_timeout_sec.map(|duration| duration.as_secs_f64()),
|
||||
enabled_tools: cfg.enabled_tools,
|
||||
disabled_tools: cfg.disabled_tools,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<McpServerTransportConfig> for codex_app_server_protocol::McpServerTransportConfig {
|
||||
fn from(cfg: McpServerTransportConfig) -> Self {
|
||||
match cfg {
|
||||
McpServerTransportConfig::Stdio {
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
env_vars,
|
||||
cwd,
|
||||
} => Self::Stdio {
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
env_vars,
|
||||
cwd,
|
||||
},
|
||||
McpServerTransportConfig::StreamableHttp {
|
||||
url,
|
||||
bearer_token_env_var,
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
} => Self::StreamableHttp {
|
||||
url,
|
||||
bearer_token_env_var,
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ShellEnvironmentPolicyInherit {
|
||||
|
||||
Reference in New Issue
Block a user