mirror of
https://github.com/openai/codex.git
synced 2026-11-02 16:20:35 +00:00
[apps] Add is_enabled to app info. (#11417)
- [x] Add is_enabled to app info and the response of `app/list`. - [x] Update TUI to have Enable/Disable button on the app detail page.
This commit is contained in:
1 parent
8d97b5c246
commit
c37560069a
22 files changed
+1106
-113
No files matched your search
+120
-2
@@ -114,6 +114,7 @@ use crate::client_common::Prompt;
|
||||
use crate::client_common::ResponseEvent;
|
||||
use crate::codex_thread::ThreadConfigSnapshot;
|
||||
use crate::compact::collect_user_messages;
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config::Config;
|
||||
use crate::config::Constrained;
|
||||
use crate::config::ConstraintResult;
|
||||
@@ -246,6 +247,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_readiness::Readiness;
|
||||
use codex_utils_readiness::ReadinessFlag;
|
||||
|
||||
@@ -1765,6 +1767,48 @@ impl Session {
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn reload_user_config_layer(&self) {
|
||||
let config_toml_path = {
|
||||
let state = self.state.lock().await;
|
||||
state
|
||||
.session_configuration
|
||||
.codex_home
|
||||
.join(CONFIG_TOML_FILE)
|
||||
};
|
||||
|
||||
let user_config = match std::fs::read_to_string(&config_toml_path) {
|
||||
Ok(contents) => match toml::from_str::<toml::Value>(&contents) {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
warn!("failed to parse user config while reloading layer: {err}");
|
||||
return;
|
||||
}
|
||||
},
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
toml::Value::Table(Default::default())
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("failed to read user config while reloading layer: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let config_toml_path = match AbsolutePathBuf::try_from(config_toml_path) {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
warn!("failed to resolve user config path while reloading layer: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut state = self.state.lock().await;
|
||||
let mut config = (*state.session_configuration.original_config_do_not_use).clone();
|
||||
config.config_layer_stack = config
|
||||
.config_layer_stack
|
||||
.with_user_config(&config_toml_path, user_config);
|
||||
state.session_configuration.original_config_do_not_use = Arc::new(config);
|
||||
}
|
||||
|
||||
pub(crate) async fn new_default_turn_with_sub_id(&self, sub_id: String) -> Arc<TurnContext> {
|
||||
let session_configuration = {
|
||||
let state = self.state.lock().await;
|
||||
@@ -3050,6 +3094,9 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
Op::RefreshMcpServers { config } => {
|
||||
handlers::refresh_mcp_servers(&sess, config).await;
|
||||
}
|
||||
Op::ReloadUserConfig => {
|
||||
handlers::reload_user_config(&sess).await;
|
||||
}
|
||||
Op::ListCustomPrompts => {
|
||||
handlers::list_custom_prompts(&sess, sub.id.clone()).await;
|
||||
}
|
||||
@@ -3474,6 +3521,10 @@ mod handlers {
|
||||
*guard = Some(refresh_config);
|
||||
}
|
||||
|
||||
pub async fn reload_user_config(sess: &Arc<Session>) {
|
||||
sess.reload_user_config_layer().await;
|
||||
}
|
||||
|
||||
pub async fn list_mcp_tools(sess: &Session, config: &Arc<Config>, sub_id: String) {
|
||||
let mcp_connection_manager = sess.services.mcp_connection_manager.read().await;
|
||||
let auth = sess.services.auth_manager.auth().await;
|
||||
@@ -4114,7 +4165,10 @@ pub(crate) async fn run_turn(
|
||||
Ok(mcp_tools) => mcp_tools,
|
||||
Err(_) => return None,
|
||||
};
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools)
|
||||
connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn_context.config,
|
||||
)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -4464,6 +4518,14 @@ fn filter_connectors_for_input(
|
||||
explicitly_enabled_connectors: &HashSet<String>,
|
||||
skill_name_counts_lower: &HashMap<String, usize>,
|
||||
) -> Vec<connectors::AppInfo> {
|
||||
let connectors = connectors
|
||||
.into_iter()
|
||||
.filter(|connector| connector.is_enabled)
|
||||
.collect::<Vec<_>>();
|
||||
if connectors.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let user_messages = collect_user_messages(input);
|
||||
if user_messages.is_empty() && explicitly_enabled_connectors.is_empty() {
|
||||
return Vec::new();
|
||||
@@ -4710,7 +4772,10 @@ async fn built_tools(
|
||||
let skill_name_counts_lower = skills_outcome.map_or_else(HashMap::new, |outcome| {
|
||||
build_skill_name_counts(&outcome.skills, &outcome.disabled_paths).1
|
||||
});
|
||||
let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools);
|
||||
let connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn_context.config,
|
||||
);
|
||||
Some(filter_connectors_for_input(
|
||||
connectors,
|
||||
input,
|
||||
@@ -5577,6 +5642,7 @@ mod tests {
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5662,6 +5728,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_user_config_layer_updates_effective_apps_config() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
let codex_home = session.codex_home().await;
|
||||
std::fs::create_dir_all(&codex_home).expect("create codex home");
|
||||
let config_toml_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
std::fs::write(
|
||||
&config_toml_path,
|
||||
"[apps.calendar]\nenabled = false\ndisabled_reason = \"user\"\n",
|
||||
)
|
||||
.expect("write user config");
|
||||
|
||||
session.reload_user_config_layer().await;
|
||||
|
||||
let config = session.get_config().await;
|
||||
let apps_toml = config
|
||||
.config_layer_stack
|
||||
.effective_config()
|
||||
.as_table()
|
||||
.and_then(|table| table.get("apps"))
|
||||
.cloned()
|
||||
.expect("apps table");
|
||||
let apps = crate::config::types::AppsConfigToml::deserialize(apps_toml)
|
||||
.expect("deserialize apps config");
|
||||
let app = apps
|
||||
.apps
|
||||
.get("calendar")
|
||||
.expect("calendar app config exists");
|
||||
|
||||
assert!(!app.enabled);
|
||||
assert_eq!(
|
||||
app.disabled_reason,
|
||||
Some(crate::config::types::AppDisabledReason::User)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_connectors_for_input_skips_duplicate_slug_mentions() {
|
||||
let connectors = vec![
|
||||
@@ -5699,6 +5801,22 @@ mod tests {
|
||||
assert_eq!(selected, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_connectors_for_input_skips_disabled_connectors() {
|
||||
let mut connector = make_connector("calendar", "Calendar");
|
||||
connector.is_enabled = false;
|
||||
let input = vec![user_message("use $calendar")];
|
||||
let explicitly_enabled_connectors = HashSet::new();
|
||||
let selected = filter_connectors_for_input(
|
||||
vec![connector],
|
||||
&input,
|
||||
&explicitly_enabled_connectors,
|
||||
&HashMap::new(),
|
||||
);
|
||||
|
||||
assert_eq!(selected, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_app_ids_from_skill_items_includes_linked_mentions() {
|
||||
let connectors = vec![make_connector("calendar", "Calendar")];
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::time::Instant;
|
||||
use async_channel::unbounded;
|
||||
pub use codex_app_server_protocol::AppInfo;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use serde::Deserialize;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -16,6 +17,7 @@ use crate::AuthManager;
|
||||
use crate::CodexAuth;
|
||||
use crate::SandboxState;
|
||||
use crate::config::Config;
|
||||
use crate::config::types::AppsConfigToml;
|
||||
use crate::features::Feature;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
@@ -265,6 +267,22 @@ pub fn merge_connectors(
|
||||
merged
|
||||
}
|
||||
|
||||
pub fn with_app_enabled_state(mut connectors: Vec<AppInfo>, config: &Config) -> Vec<AppInfo> {
|
||||
let apps = read_apps_config(config).map(|apps_config| apps_config.apps);
|
||||
for connector in &mut connectors {
|
||||
if let Some(app) = apps.as_ref().and_then(|apps| apps.get(&connector.id)) {
|
||||
connector.is_enabled = app.enabled;
|
||||
}
|
||||
}
|
||||
connectors
|
||||
}
|
||||
|
||||
fn read_apps_config(config: &Config) -> Option<AppsConfigToml> {
|
||||
let effective_config = config.config_layer_stack.effective_config();
|
||||
let apps_config = effective_config.as_table()?.get("apps")?.clone();
|
||||
AppsConfigToml::deserialize(apps_config).ok()
|
||||
}
|
||||
|
||||
fn collect_accessible_connectors<I>(tools: I) -> Vec<AppInfo>
|
||||
where
|
||||
I: IntoIterator<Item = (String, Option<String>)>,
|
||||
@@ -291,6 +309,7 @@ where
|
||||
distribution_channel: None,
|
||||
install_url: Some(connector_install_url(&connector_name, &connector_id)),
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
})
|
||||
.collect();
|
||||
accessible.sort_by(|left, right| {
|
||||
|
||||
@@ -2,11 +2,17 @@ use async_trait::async_trait;
|
||||
use bm25::Document;
|
||||
use bm25::Language;
|
||||
use bm25::SearchEngineBuilder;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::connectors;
|
||||
use crate::features::Feature;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
@@ -76,7 +82,10 @@ impl ToolHandler for SearchToolBm25Handler {
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
payload, session, ..
|
||||
payload,
|
||||
session,
|
||||
turn,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
let arguments = match payload {
|
||||
@@ -111,6 +120,15 @@ impl ToolHandler for SearchToolBm25Handler {
|
||||
.await
|
||||
.list_all_tools()
|
||||
.await;
|
||||
let mcp_tools = if turn.config.features.enabled(Feature::Apps) {
|
||||
let connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn.config,
|
||||
);
|
||||
filter_codex_apps_mcp_tools(mcp_tools, &connectors)
|
||||
} else {
|
||||
mcp_tools
|
||||
};
|
||||
|
||||
let mut entries: Vec<ToolEntry> = mcp_tools
|
||||
.into_iter()
|
||||
@@ -178,6 +196,28 @@ impl ToolHandler for SearchToolBm25Handler {
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_codex_apps_mcp_tools(
|
||||
mut mcp_tools: HashMap<String, ToolInfo>,
|
||||
connectors: &[AppInfo],
|
||||
) -> HashMap<String, ToolInfo> {
|
||||
let enabled_connectors: HashSet<&str> = connectors
|
||||
.iter()
|
||||
.filter(|connector| connector.is_enabled)
|
||||
.map(|connector| connector.id.as_str())
|
||||
.collect();
|
||||
|
||||
mcp_tools.retain(|_, tool| {
|
||||
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
|
||||
return true;
|
||||
}
|
||||
|
||||
tool.connector_id
|
||||
.as_deref()
|
||||
.is_some_and(|connector_id| enabled_connectors.contains(connector_id))
|
||||
});
|
||||
mcp_tools
|
||||
}
|
||||
|
||||
fn build_search_text(name: &str, info: &ToolInfo, input_keys: &[String]) -> String {
|
||||
let mut parts = vec![
|
||||
name.to_string(),
|
||||
@@ -215,3 +255,112 @@ fn build_search_text(name: &str, info: &ToolInfo, input_keys: &[String]) -> Stri
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::JsonObject;
|
||||
use rmcp::model::Tool;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn make_connector(id: &str, enabled: bool) -> AppInfo {
|
||||
AppInfo {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: enabled,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_tool(
|
||||
qualified_name: &str,
|
||||
server_name: &str,
|
||||
tool_name: &str,
|
||||
connector_id: Option<&str>,
|
||||
) -> (String, ToolInfo) {
|
||||
(
|
||||
qualified_name.to_string(),
|
||||
ToolInfo {
|
||||
server_name: server_name.to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
tool: Tool {
|
||||
name: tool_name.to_string().into(),
|
||||
title: None,
|
||||
description: Some(format!("Test tool: {tool_name}").into()),
|
||||
input_schema: Arc::new(JsonObject::default()),
|
||||
output_schema: None,
|
||||
annotations: None,
|
||||
execution: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
},
|
||||
connector_id: connector_id.map(str::to_string),
|
||||
connector_name: connector_id.map(str::to_string),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_codex_apps_mcp_tools_keeps_non_apps_and_enabled_apps() {
|
||||
let mcp_tools = HashMap::from([
|
||||
make_tool(
|
||||
"mcp__codex_apps__calendar_create_event",
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"calendar_create_event",
|
||||
Some("calendar"),
|
||||
),
|
||||
make_tool(
|
||||
"mcp__codex_apps__drive_search",
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"drive_search",
|
||||
Some("drive"),
|
||||
),
|
||||
make_tool("mcp__rmcp__echo", "rmcp", "echo", None),
|
||||
]);
|
||||
let connectors = vec![
|
||||
make_connector("calendar", false),
|
||||
make_connector("drive", true),
|
||||
];
|
||||
|
||||
let mut filtered: Vec<String> = filter_codex_apps_mcp_tools(mcp_tools, &connectors)
|
||||
.into_keys()
|
||||
.collect();
|
||||
filtered.sort();
|
||||
|
||||
assert_eq!(
|
||||
filtered,
|
||||
vec![
|
||||
"mcp__codex_apps__drive_search".to_string(),
|
||||
"mcp__rmcp__echo".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_codex_apps_mcp_tools_drops_apps_without_connector_id() {
|
||||
let mcp_tools = HashMap::from([
|
||||
make_tool(
|
||||
"mcp__codex_apps__unknown",
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"unknown",
|
||||
None,
|
||||
),
|
||||
make_tool("mcp__rmcp__echo", "rmcp", "echo", None),
|
||||
]);
|
||||
|
||||
let mut filtered: Vec<String> =
|
||||
filter_codex_apps_mcp_tools(mcp_tools, &[make_connector("calendar", true)])
|
||||
.into_keys()
|
||||
.collect();
|
||||
filtered.sort();
|
||||
|
||||
assert_eq!(filtered, vec!["mcp__rmcp__echo".to_string()]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user