Compare commits

...

6 Commits

Author SHA1 Message Date
Matthew Zeng
5185567b62 update 2026-02-11 21:12:39 -08:00
Matthew Zeng
3ee49b872b Merge branch 'main' of https://github.com/openai/codex into dev/mzeng/app_info_is_enabled 2026-02-11 19:56:09 -08:00
Matthew Zeng
81addf0670 update 2026-02-11 16:31:16 -08:00
Matthew Zeng
55d45f3fcf update 2026-02-11 11:56:41 -08:00
Matthew Zeng
5c1b97837f update 2026-02-10 23:19:45 -08:00
Matthew Zeng
b6d9c5a808 update 2026-02-10 22:57:23 -08:00
32 changed files with 1397 additions and 142 deletions

View File

@@ -193,6 +193,10 @@
"default": false,
"type": "boolean"
},
"isEnabled": {
"description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```",
"type": "boolean"
},
"logoUrl": {
"type": [
"string",
@@ -211,6 +215,7 @@
},
"required": [
"id",
"isEnabled",
"name"
],
"type": "object"

View File

@@ -10071,6 +10071,18 @@
},
"AppConfig": {
"properties": {
"disable_destructive": {
"type": [
"boolean",
"null"
]
},
"disable_open_world": {
"type": [
"boolean",
"null"
]
},
"disabled_reason": {
"anyOf": [
{
@@ -10082,8 +10094,19 @@
]
},
"enabled": {
"default": true,
"type": "boolean"
"type": [
"boolean",
"null"
]
},
"tools": {
"additionalProperties": {
"$ref": "#/definitions/v2/AppToolConfig"
},
"type": [
"object",
"null"
]
}
},
"type": "object"
@@ -10123,6 +10146,10 @@
"default": false,
"type": "boolean"
},
"isEnabled": {
"description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```",
"type": "boolean"
},
"logoUrl": {
"type": [
"string",
@@ -10141,6 +10168,7 @@
},
"required": [
"id",
"isEnabled",
"name"
],
"type": "object"
@@ -10162,6 +10190,53 @@
"title": "AppListUpdatedNotification",
"type": "object"
},
"AppToolApproval": {
"enum": [
"auto",
"prompt",
"approve"
],
"type": "string"
},
"AppToolConfig": {
"properties": {
"approval": {
"anyOf": [
{
"$ref": "#/definitions/v2/AppToolApproval"
},
{
"type": "null"
}
]
},
"disabled_reason": {
"anyOf": [
{
"$ref": "#/definitions/v2/AppToolDisabledReason"
},
{
"type": "null"
}
]
},
"enabled": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
},
"AppToolDisabledReason": {
"enum": [
"unknown",
"user",
"admin_policy"
],
"type": "string"
},
"AppsConfig": {
"type": "object"
},

View File

@@ -29,6 +29,10 @@
"default": false,
"type": "boolean"
},
"isEnabled": {
"description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```",
"type": "boolean"
},
"logoUrl": {
"type": [
"string",
@@ -47,6 +51,7 @@
},
"required": [
"id",
"isEnabled",
"name"
],
"type": "object"

View File

@@ -29,6 +29,10 @@
"default": false,
"type": "boolean"
},
"isEnabled": {
"description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```",
"type": "boolean"
},
"logoUrl": {
"type": [
"string",
@@ -47,6 +51,7 @@
},
"required": [
"id",
"isEnabled",
"name"
],
"type": "object"

View File

@@ -19,6 +19,18 @@
},
"AppConfig": {
"properties": {
"disable_destructive": {
"type": [
"boolean",
"null"
]
},
"disable_open_world": {
"type": [
"boolean",
"null"
]
},
"disabled_reason": {
"anyOf": [
{
@@ -30,8 +42,19 @@
]
},
"enabled": {
"default": true,
"type": "boolean"
"type": [
"boolean",
"null"
]
},
"tools": {
"additionalProperties": {
"$ref": "#/definitions/AppToolConfig"
},
"type": [
"object",
"null"
]
}
},
"type": "object"
@@ -43,6 +66,53 @@
],
"type": "string"
},
"AppToolApproval": {
"enum": [
"auto",
"prompt",
"approve"
],
"type": "string"
},
"AppToolConfig": {
"properties": {
"approval": {
"anyOf": [
{
"$ref": "#/definitions/AppToolApproval"
},
{
"type": "null"
}
]
},
"disabled_reason": {
"anyOf": [
{
"$ref": "#/definitions/AppToolDisabledReason"
},
{
"type": "null"
}
]
},
"enabled": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
},
"AppToolDisabledReason": {
"enum": [
"unknown",
"user",
"admin_policy"
],
"type": "string"
},
"AppsConfig": {
"type": "object"
},

View File

@@ -5,4 +5,13 @@
/**
* EXPERIMENTAL - app metadata returned by app-list APIs.
*/
export type AppInfo = { id: string, name: string, description: string | null, logoUrl: string | null, logoUrlDark: string | null, distributionChannel: string | null, installUrl: string | null, isAccessible: boolean, };
export type AppInfo = { id: string, name: string, description: string | null, logoUrl: string | null, logoUrlDark: string | null, distributionChannel: string | null, installUrl: string | null, isAccessible: boolean,
/**
* Whether this app is enabled in config.toml.
* Example:
* ```toml
* [apps.bad_app]
* enabled = false
* ```
*/
isEnabled: boolean, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AppToolApproval = "auto" | "prompt" | "approve";

View File

@@ -0,0 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AppToolApproval } from "./AppToolApproval";
import type { AppToolDisabledReason } from "./AppToolDisabledReason";
export type AppToolConfig = { enabled: boolean | null, disabled_reason: AppToolDisabledReason | null, approval: AppToolApproval | null, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AppToolDisabledReason = "unknown" | "user" | "admin_policy";

View File

@@ -2,5 +2,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AppDisabledReason } from "./AppDisabledReason";
import type { AppToolConfig } from "./AppToolConfig";
export type AppsConfig = { [key in string]?: { enabled: boolean, disabled_reason: AppDisabledReason | null, } };
export type AppsConfig = { [key in string]?: { enabled: boolean | null, disabled_reason: AppDisabledReason | null, disable_destructive: boolean | null, disable_open_world: boolean | null, tools: { [key in string]?: AppToolConfig } | null, } };

View File

@@ -9,6 +9,9 @@ export type { AnalyticsConfig } from "./AnalyticsConfig";
export type { AppDisabledReason } from "./AppDisabledReason";
export type { AppInfo } from "./AppInfo";
export type { AppListUpdatedNotification } from "./AppListUpdatedNotification";
export type { AppToolApproval } from "./AppToolApproval";
export type { AppToolConfig } from "./AppToolConfig";
export type { AppToolDisabledReason } from "./AppToolDisabledReason";
export type { AppsConfig } from "./AppsConfig";
export type { AppsListParams } from "./AppsListParams";
export type { AppsListResponse } from "./AppsListResponse";

View File

@@ -374,13 +374,42 @@ pub enum AppDisabledReason {
User,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
pub enum AppToolDisabledReason {
Unknown,
User,
AdminPolicy,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export_to = "v2/")]
pub enum AppToolApproval {
Auto,
Prompt,
Approve,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
pub struct AppToolConfig {
pub enabled: Option<bool>,
pub disabled_reason: Option<AppToolDisabledReason>,
pub approval: Option<AppToolApproval>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
pub struct AppConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
pub enabled: Option<bool>,
pub disabled_reason: Option<AppDisabledReason>,
pub disable_destructive: Option<bool>,
pub disable_open_world: Option<bool>,
pub tools: Option<HashMap<String, AppToolConfig>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -392,10 +421,6 @@ pub struct AppsConfig {
pub apps: HashMap<String, AppConfig>,
}
const fn default_enabled() -> bool {
true
}
const fn default_include_platform_defaults() -> bool {
true
}
@@ -1288,6 +1313,13 @@ pub struct AppInfo {
pub install_url: Option<String>,
#[serde(default)]
pub is_accessible: bool,
/// Whether this app is enabled in config.toml.
/// Example:
/// ```toml
/// [apps.bad_app]
/// enabled = false
/// ```
pub is_enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]

View File

@@ -761,7 +761,7 @@ To enable or disable a skill by path:
## Apps
Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, and whether it is currently accessible.
Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, whether it is currently accessible, and whether it is enabled in config.
```json
{ "method": "app/list", "id": 50, "params": {
@@ -780,7 +780,8 @@ Use `app/list` to fetch available apps (connectors). Each entry includes metadat
"logoUrlDark": null,
"distributionChannel": null,
"installUrl": "https://chatgpt.com/apps/demo-app/demo-app",
"isAccessible": true
"isAccessible": true,
"isEnabled": true
}
],
"nextCursor": null
@@ -806,7 +807,8 @@ The server also emits `app/list/updated` notifications whenever either source (a
"logoUrlDark": null,
"distributionChannel": null,
"installUrl": "https://chatgpt.com/apps/demo-app/demo-app",
"isAccessible": true
"isAccessible": true,
"isEnabled": true
}
]
}

View File

@@ -4570,8 +4570,9 @@ impl CodexMessageProcessor {
let _ = accessible_tx.send(AppListLoadResult::Accessible(result));
});
let all_config = config.clone();
tokio::spawn(async move {
let result = connectors::list_all_connectors_with_options(&config, force_refetch)
let result = connectors::list_all_connectors_with_options(&all_config, force_refetch)
.await
.map_err(|err| format!("failed to list apps: {err}"));
let _ = tx.send(AppListLoadResult::Directory(result));
@@ -4634,9 +4635,12 @@ impl CodexMessageProcessor {
}
}
let merged = Self::merge_loaded_apps(
all_connectors.as_deref(),
accessible_connectors.as_deref(),
let merged = connectors::with_app_enabled_state(
Self::merge_loaded_apps(
all_connectors.as_deref(),
accessible_connectors.as_deref(),
),
&config,
);
Self::send_app_list_updated_notification(&outgoing, merged.clone()).await;

View File

@@ -86,6 +86,7 @@ async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Resu
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
}];
let tools = vec![connector_tool("beta", "Beta App")?];
let (server_url, server_handle) =
@@ -173,6 +174,78 @@ connectors = false
Ok(())
}
#[tokio::test]
async fn list_apps_reports_is_enabled_from_config() -> Result<()> {
let connectors = vec![AppInfo {
id: "beta".to_string(),
name: "Beta".to_string(),
description: Some("Beta connector".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
}];
let tools = vec![connector_tool("beta", "Beta App")?];
let (server_url, server_handle) =
start_apps_server_with_delays(connectors, tools, Duration::ZERO, Duration::ZERO).await?;
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"
chatgpt_base_url = "{server_url}"
[features]
connectors = true
[apps.beta]
enabled = false
"#
),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_apps_list_request(AppsListParams {
limit: None,
cursor: None,
thread_id: None,
force_refetch: false,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let AppsListResponse {
data: response_data,
next_cursor,
} = to_response(response)?;
assert!(next_cursor.is_none());
assert_eq!(response_data.len(), 1);
assert_eq!(response_data[0].id, "beta");
assert!(!response_data[0].is_enabled);
server_handle.abort();
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<()> {
let connectors = vec![
@@ -185,6 +258,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
},
AppInfo {
id: "beta".to_string(),
@@ -195,6 +269,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
},
];
@@ -239,6 +314,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
is_accessible: true,
is_enabled: true,
}];
let first_update = read_app_list_updated_notification(&mut mcp).await?;
@@ -254,6 +330,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
is_accessible: true,
is_enabled: true,
},
AppInfo {
id: "alpha".to_string(),
@@ -264,6 +341,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
is_enabled: true,
},
];
@@ -300,6 +378,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
},
AppInfo {
id: "beta".to_string(),
@@ -310,6 +389,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
},
];
@@ -358,6 +438,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
is_enabled: true,
},
AppInfo {
id: "beta".to_string(),
@@ -368,6 +449,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
is_accessible: false,
is_enabled: true,
},
]
);
@@ -382,6 +464,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
is_accessible: true,
is_enabled: true,
},
AppInfo {
id: "alpha".to_string(),
@@ -392,6 +475,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
is_enabled: true,
},
];
@@ -423,6 +507,7 @@ async fn list_apps_paginates_results() -> Result<()> {
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
},
AppInfo {
id: "beta".to_string(),
@@ -433,6 +518,7 @@ async fn list_apps_paginates_results() -> Result<()> {
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
},
];
@@ -486,6 +572,7 @@ async fn list_apps_paginates_results() -> Result<()> {
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
is_accessible: true,
is_enabled: true,
}];
assert_eq!(first_page, expected_first);
@@ -525,6 +612,7 @@ async fn list_apps_paginates_results() -> Result<()> {
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
is_enabled: true,
}];
assert_eq!(second_page, expected_second);
@@ -545,6 +633,7 @@ async fn list_apps_force_refetch_preserves_previous_cache_on_failure() -> Result
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
}];
let tools = vec![connector_tool("beta", "Beta App")?];
let (server_url, server_handle) =

View File

@@ -188,8 +188,11 @@ disabled_reason = "user"
apps: std::collections::HashMap::from([(
"app1".to_string(),
AppConfig {
enabled: false,
enabled: Some(false),
disabled_reason: Some(AppDisabledReason::User),
disable_destructive: None,
disable_open_world: None,
tools: None,
},
)]),
})

View File

@@ -20,6 +20,7 @@ use codex_core::connectors::connector_install_url;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options;
use codex_core::connectors::merge_connectors;
pub use codex_core::connectors::with_app_enabled_state;
#[derive(Debug, Deserialize)]
struct DirectoryListResponse {
@@ -72,7 +73,10 @@ pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
);
let connectors = connectors_result?;
let accessible = accessible_result?;
Ok(merge_connectors_with_accessible(connectors, accessible))
Ok(with_app_enabled_state(
merge_connectors_with_accessible(connectors, accessible),
config,
))
}
pub async fn list_all_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
@@ -283,6 +287,7 @@ fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo {
distribution_channel: app.distribution_channel,
install_url: None,
is_accessible: false,
is_enabled: true,
}
}
@@ -341,6 +346,7 @@ mod tests {
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
}
}

View File

@@ -59,6 +59,14 @@
"additionalProperties": false,
"description": "Config values for a single app/connector.",
"properties": {
"disable_destructive": {
"description": "When `true`, tools marked as destructive are disabled for this app.",
"type": "boolean"
},
"disable_open_world": {
"description": "When `true`, tools marked as open-world are disabled for this app.",
"type": "boolean"
},
"disabled_reason": {
"allOf": [
{
@@ -68,9 +76,15 @@
"description": "Reason this app was disabled."
},
"enabled": {
"default": true,
"description": "When `false`, Codex does not surface this app.",
"type": "boolean"
},
"tools": {
"additionalProperties": {
"$ref": "#/definitions/AppToolConfig"
},
"description": "Per-tool settings keyed by tool name. Supports a `_default` entry.",
"type": "object"
}
},
"type": "object"
@@ -82,6 +96,49 @@
],
"type": "string"
},
"AppToolApproval": {
"enum": [
"auto",
"prompt",
"approve"
],
"type": "string"
},
"AppToolConfig": {
"additionalProperties": false,
"description": "Config values for a single app MCP tool.",
"properties": {
"approval": {
"allOf": [
{
"$ref": "#/definitions/AppToolApproval"
}
],
"description": "Tool-level MCP approval behavior."
},
"disabled_reason": {
"allOf": [
{
"$ref": "#/definitions/AppToolDisabledReason"
}
],
"description": "Reason this app tool was disabled."
},
"enabled": {
"description": "When `false`, Codex does not expose this app tool.",
"type": "boolean"
}
},
"type": "object"
},
"AppToolDisabledReason": {
"enum": [
"unknown",
"user",
"admin_policy"
],
"type": "string"
},
"AppsConfigToml": {
"additionalProperties": {
"$ref": "#/definitions/AppConfig"

View File

@@ -4001,7 +4001,10 @@ pub(crate) async fn run_turn(
Ok(mcp_tools) => mcp_tools,
Err(_) => return None,
};
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,
);
build_connector_slug_counts(&connectors)
} else {
HashMap::new()
@@ -4287,6 +4290,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();
@@ -4370,6 +4381,29 @@ fn filter_codex_apps_mcp_tools(
mcp_tools
}
fn filter_disallowed_codex_apps_tools(
mut mcp_tools: HashMap<String, crate::mcp_connection_manager::ToolInfo>,
config: &Config,
) -> HashMap<String, crate::mcp_connection_manager::ToolInfo> {
mcp_tools.retain(|_, tool| {
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
return true;
}
let Some(connector_id) = codex_apps_connector_id(tool) else {
return false;
};
connectors::codex_apps_tool_policy(
config,
connector_id,
&tool.tool_name,
tool.tool.annotations.as_ref(),
)
.allowed
});
mcp_tools
}
fn codex_apps_connector_id(tool: &crate::mcp_connection_manager::ToolInfo) -> Option<&str> {
tool.connector_id.as_deref()
}
@@ -4520,7 +4554,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,
@@ -4544,9 +4581,11 @@ async fn built_tools(
selected_mcp_tools.extend(apps_mcp_tools);
}
mcp_tools = selected_mcp_tools;
mcp_tools =
filter_disallowed_codex_apps_tools(selected_mcp_tools, turn_context.config.as_ref());
} else if let Some(connectors) = connectors_for_tools.as_ref() {
mcp_tools = filter_codex_apps_mcp_tools(mcp_tools, connectors);
mcp_tools = filter_disallowed_codex_apps_tools(mcp_tools, turn_context.config.as_ref());
}
Ok(Arc::new(ToolRouter::from_config(
@@ -5341,6 +5380,7 @@ mod tests {
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
use rmcp::model::Tool;
use rmcp::model::ToolAnnotations;
use serde::Deserialize;
use serde_json::json;
use std::path::PathBuf;
@@ -5374,6 +5414,7 @@ mod tests {
distribution_channel: None,
install_url: None,
is_accessible: true,
is_enabled: true,
}
}
@@ -5495,6 +5536,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 search_tool_selection_keeps_codex_apps_tools_without_mentions() {
let selected_tool_names = vec![
@@ -5584,6 +5641,82 @@ mod tests {
);
}
#[tokio::test]
async fn filter_disallowed_codex_apps_tools_respects_apps_config() {
let codex_home = tempfile::tempdir().expect("tempdir");
std::fs::write(
codex_home.path().join("config.toml"),
r#"
[apps._default]
disable_destructive = true
[apps.connector_123.tools._default]
enabled = true
approval = "prompt"
[apps.connector_123.tools."issues/create"]
enabled = false
"#,
)
.expect("write config");
let config = build_test_config(codex_home.path()).await;
let mut destructive_tool = make_mcp_tool(
CODEX_APPS_MCP_SERVER_NAME,
"files/delete",
Some("connector_123"),
Some("Connector 123"),
);
destructive_tool.tool.annotations = Some(ToolAnnotations {
destructive_hint: Some(true),
idempotent_hint: None,
open_world_hint: None,
read_only_hint: Some(false),
title: None,
});
let mcp_tools = HashMap::from([
(
"mcp__codex_apps__repos_list".to_string(),
make_mcp_tool(
CODEX_APPS_MCP_SERVER_NAME,
"repos/list",
Some("connector_123"),
Some("Connector 123"),
),
),
(
"mcp__codex_apps__issues_create".to_string(),
make_mcp_tool(
CODEX_APPS_MCP_SERVER_NAME,
"issues/create",
Some("connector_123"),
Some("Connector 123"),
),
),
(
"mcp__codex_apps__files_delete".to_string(),
destructive_tool,
),
(
"mcp__rmcp__echo".to_string(),
make_mcp_tool("rmcp", "echo", None, None),
),
]);
let filtered = filter_disallowed_codex_apps_tools(mcp_tools, &config);
let mut tool_names: Vec<String> = filtered.into_keys().collect();
tool_names.sort();
assert_eq!(
tool_names,
vec![
"mcp__codex_apps__repos_list".to_string(),
"mcp__rmcp__echo".to_string(),
]
);
}
#[tokio::test]
async fn reconstruct_history_matches_live_compactions() {
let (session, turn_context) = make_session_and_context().await;

View File

@@ -849,8 +849,11 @@ remote_models = true
apps: std::collections::HashMap::from([(
"app1".to_string(),
AppConfig {
enabled: false,
enabled: Some(false),
disabled_reason: Some(AppDisabledReason::User),
disable_destructive: None,
disable_open_world: None,
tools: None,
},
)]),
})

View File

@@ -369,17 +369,71 @@ impl fmt::Display for AppDisabledReason {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AppToolDisabledReason {
Unknown,
User,
AdminPolicy,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum AppToolApproval {
Auto,
#[default]
Prompt,
Approve,
}
impl fmt::Display for AppToolApproval {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppToolApproval::Auto => write!(f, "auto"),
AppToolApproval::Prompt => write!(f, "prompt"),
AppToolApproval::Approve => write!(f, "approve"),
}
}
}
/// Config values for a single app MCP tool.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AppToolConfig {
/// When `false`, Codex does not expose this app tool.
pub enabled: Option<bool>,
/// Reason this app tool was disabled.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disabled_reason: Option<AppToolDisabledReason>,
/// Tool-level MCP approval behavior.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval: Option<AppToolApproval>,
}
/// Config values for a single app/connector.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AppConfig {
/// When `false`, Codex does not surface this app.
#[serde(default = "default_enabled")]
pub enabled: bool,
pub enabled: Option<bool>,
/// Reason this app was disabled.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disabled_reason: Option<AppDisabledReason>,
/// When `true`, tools marked as destructive are disabled for this app.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disable_destructive: Option<bool>,
/// When `true`, tools marked as open-world are disabled for this app.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disable_open_world: Option<bool>,
/// Per-tool settings keyed by tool name. Supports a `_default` entry.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tools: Option<HashMap<String, AppToolConfig>>,
}
/// App/connector settings loaded from `config.toml`.
@@ -387,6 +441,7 @@ pub struct AppConfig {
#[schemars(deny_unknown_fields)]
pub struct AppsConfigToml {
/// Per-app settings keyed by app ID (for example `[apps.google_drive]`).
/// A `_default` key may be used to configure defaults for all apps.
#[serde(default, flatten)]
pub apps: HashMap<String, AppConfig>,
}
@@ -1021,4 +1076,64 @@ mod tests {
"unexpected error: {err}"
);
}
#[test]
fn deserialize_apps_config_with_defaults_and_tool_overrides() {
#[derive(Deserialize)]
struct Wrapper {
apps: AppsConfigToml,
}
let cfg: Wrapper = toml::from_str(
r#"
[apps._default]
enabled = false
disable_destructive = false
[apps.connector_123]
enabled = true
disable_open_world = true
[apps.connector_123.tools._default]
enabled = true
approval = "prompt"
[apps.connector_123.tools."repos/list"]
approval = "auto"
[apps.connector_123.tools."issues/create"]
enabled = false
disabled_reason = "admin_policy"
approval = "approve"
"#,
)
.expect("apps config parses");
let defaults = cfg
.apps
.apps
.get("_default")
.expect("apps._default present");
assert_eq!(defaults.enabled, Some(false));
assert_eq!(defaults.disable_destructive, Some(false));
assert_eq!(defaults.disable_open_world, None);
let connector = cfg
.apps
.apps
.get("connector_123")
.expect("connector_123 present");
assert_eq!(connector.enabled, Some(true));
assert_eq!(connector.disable_open_world, Some(true));
let tools = connector.tools.as_ref().expect("connector tools present");
let tool_default = tools.get("_default").expect("tool _default present");
assert_eq!(tool_default.approval, Some(AppToolApproval::Prompt));
let issues_create = tools.get("issues/create").expect("issues/create present");
assert_eq!(issues_create.enabled, Some(false));
assert_eq!(
issues_create.disabled_reason,
Some(AppToolDisabledReason::AdminPolicy)
);
assert_eq!(issues_create.approval, Some(AppToolApproval::Approve));
}
}

View File

@@ -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,8 @@ use crate::AuthManager;
use crate::CodexAuth;
use crate::SandboxState;
use crate::config::Config;
use crate::config::types::AppToolApproval;
use crate::config::types::AppsConfigToml;
use crate::features::Feature;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp::auth::compute_auth_statuses;
@@ -23,8 +26,17 @@ use crate::mcp::with_codex_apps_mcp;
use crate::mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::token_data::TokenData;
use rmcp::model::ToolAnnotations;
pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600);
const APPS_DEFAULT_KEY: &str = "_default";
const APP_TOOLS_DEFAULT_KEY: &str = "_default";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AppToolPolicy {
pub(crate) allowed: bool,
pub(crate) approval: Option<AppToolApproval>,
}
#[derive(Clone, PartialEq, Eq)]
struct AccessibleConnectorsCacheKey {
@@ -265,6 +277,135 @@ pub fn merge_connectors(
merged
}
pub fn with_app_enabled_state(mut connectors: Vec<AppInfo>, config: &Config) -> Vec<AppInfo> {
let apps_config = read_apps_config(config);
for connector in &mut connectors {
connector.is_enabled = app_enabled_for_connector(apps_config.as_ref(), &connector.id);
}
connectors
}
pub(crate) fn codex_apps_tool_policy(
config: &Config,
connector_id: &str,
tool_name: &str,
annotations: Option<&ToolAnnotations>,
) -> AppToolPolicy {
let apps_config = read_apps_config(config);
codex_apps_tool_policy_from_apps_config(
apps_config.as_ref(),
connector_id,
tool_name,
annotations,
)
}
pub(crate) fn codex_apps_tool_policy_from_apps_config(
apps_config: Option<&AppsConfigToml>,
connector_id: &str,
tool_name: &str,
annotations: Option<&ToolAnnotations>,
) -> AppToolPolicy {
let app_enabled = app_enabled_for_connector(apps_config, connector_id);
let disable_destructive = apps_config
.and_then(|apps_config| {
app_setting_or_default(apps_config, connector_id, |app| app.disable_destructive)
})
.unwrap_or(false);
let disable_open_world = apps_config
.and_then(|apps_config| {
app_setting_or_default(apps_config, connector_id, |app| app.disable_open_world)
})
.unwrap_or(false);
let tool_enabled = apps_config
.and_then(|apps_config| {
app_tool_setting_or_default(apps_config, connector_id, tool_name, |tool| tool.enabled)
})
.unwrap_or(true);
let approval = apps_config.and_then(|apps_config| {
app_tool_setting_or_default(apps_config, connector_id, tool_name, |tool| tool.approval)
});
let destructive_blocked = disable_destructive
&& annotations.is_some_and(|annotations| annotations.destructive_hint == Some(true));
let open_world_blocked = disable_open_world
&& annotations.is_some_and(|annotations| annotations.open_world_hint == Some(true));
AppToolPolicy {
allowed: app_enabled && tool_enabled && !destructive_blocked && !open_world_blocked,
approval,
}
}
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 app_enabled_for_connector(apps_config: Option<&AppsConfigToml>, connector_id: &str) -> bool {
apps_config
.and_then(|apps_config| {
app_setting_or_default(apps_config, connector_id, |app| app.enabled)
})
.unwrap_or(true)
}
fn app_setting_or_default<T, F>(
apps_config: &AppsConfigToml,
connector_id: &str,
setting: F,
) -> Option<T>
where
T: Copy,
F: Fn(&crate::config::types::AppConfig) -> Option<T> + Copy,
{
apps_config
.apps
.get(connector_id)
.and_then(setting)
.or_else(|| apps_config.apps.get(APPS_DEFAULT_KEY).and_then(setting))
}
fn app_tool_setting_or_default<T, F>(
apps_config: &AppsConfigToml,
connector_id: &str,
tool_name: &str,
setting: F,
) -> Option<T>
where
T: Copy,
F: Fn(&crate::config::types::AppToolConfig) -> Option<T> + Copy,
{
let app_tools = apps_config
.apps
.get(connector_id)
.and_then(|app| app.tools.as_ref());
let default_tools = apps_config
.apps
.get(APPS_DEFAULT_KEY)
.and_then(|app| app.tools.as_ref());
app_tools
.and_then(|tools| tools.get(tool_name))
.and_then(setting)
.or_else(|| {
app_tools
.and_then(|tools| tools.get(APP_TOOLS_DEFAULT_KEY))
.and_then(setting)
})
.or_else(|| {
default_tools
.and_then(|tools| tools.get(tool_name))
.and_then(setting)
})
.or_else(|| {
default_tools
.and_then(|tools| tools.get(APP_TOOLS_DEFAULT_KEY))
.and_then(setting)
})
}
fn collect_accessible_connectors<I>(tools: I) -> Vec<AppInfo>
where
I: IntoIterator<Item = (String, Option<String>)>,
@@ -291,6 +432,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| {
@@ -335,3 +477,141 @@ pub fn connector_name_slug(name: &str) -> String {
fn format_connector_label(name: &str, _id: &str) -> String {
name.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn apps_config_from_toml(toml_src: &str) -> AppsConfigToml {
#[derive(Deserialize)]
struct Wrapper {
apps: AppsConfigToml,
}
toml::from_str::<Wrapper>(toml_src)
.expect("apps config parses")
.apps
}
fn annotations(destructive: Option<bool>, open_world: Option<bool>) -> ToolAnnotations {
ToolAnnotations {
destructive_hint: destructive,
idempotent_hint: None,
open_world_hint: open_world,
read_only_hint: Some(false),
title: None,
}
}
#[test]
fn app_enabled_inherits_and_overrides_default() {
let apps_config = apps_config_from_toml(
r#"
[apps._default]
enabled = false
[apps.calendar]
disable_open_world = true
[apps.gmail]
enabled = true
"#,
);
assert_eq!(
app_enabled_for_connector(Some(&apps_config), "calendar"),
false
);
assert_eq!(app_enabled_for_connector(Some(&apps_config), "gmail"), true);
assert_eq!(
app_enabled_for_connector(Some(&apps_config), "drive"),
false
);
}
#[test]
fn codex_apps_tool_policy_applies_nested_defaults_and_overrides() {
let apps_config = apps_config_from_toml(
r#"
[apps._default]
disable_destructive = true
disable_open_world = true
[apps.connector_123.tools._default]
enabled = true
approval = "prompt"
[apps.connector_123.tools."repos/list"]
approval = "auto"
[apps.connector_123.tools."issues/create"]
approval = "approve"
"#,
);
let repos_list = codex_apps_tool_policy_from_apps_config(
Some(&apps_config),
"connector_123",
"repos/list",
Some(&annotations(None, None)),
);
assert_eq!(
repos_list,
AppToolPolicy {
allowed: true,
approval: Some(AppToolApproval::Auto),
}
);
let issues_create = codex_apps_tool_policy_from_apps_config(
Some(&apps_config),
"connector_123",
"issues/create",
Some(&annotations(None, None)),
);
assert_eq!(
issues_create,
AppToolPolicy {
allowed: true,
approval: Some(AppToolApproval::Approve),
}
);
let destructive_tool = codex_apps_tool_policy_from_apps_config(
Some(&apps_config),
"connector_123",
"files/delete",
Some(&annotations(Some(true), None)),
);
assert_eq!(destructive_tool.allowed, false);
assert_eq!(destructive_tool.approval, Some(AppToolApproval::Prompt));
let open_world_tool = codex_apps_tool_policy_from_apps_config(
Some(&apps_config),
"connector_123",
"web/search",
Some(&annotations(None, Some(true))),
);
assert_eq!(open_world_tool.allowed, false);
assert_eq!(open_world_tool.approval, Some(AppToolApproval::Prompt));
}
#[test]
fn codex_apps_tool_policy_defaults_to_prompt_without_apps_config() {
let policy = codex_apps_tool_policy_from_apps_config(
None,
"connector_123",
"repos/list",
Some(&annotations(None, None)),
);
assert_eq!(
policy,
AppToolPolicy {
allowed: true,
approval: None,
}
);
}
}

View File

@@ -5,6 +5,7 @@ use tracing::error;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::config::types::AppToolApproval;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::protocol::EventMsg;
use crate::protocol::McpInvocation;
@@ -126,6 +127,16 @@ pub(crate) async fn handle_mcp_tool_call(
)
.await
}
McpToolApprovalDecision::BlockedByConfig(message) => {
notify_mcp_tool_call_skip(
sess.as_ref(),
turn_context,
&call_id,
invocation,
message,
)
.await
}
};
let status = if result.is_ok() { "ok" } else { "error" };
@@ -210,12 +221,13 @@ async fn notify_mcp_tool_call_event(sess: &Session, turn_context: &TurnContext,
sess.send_event(turn_context, event).await;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
enum McpToolApprovalDecision {
Accept,
AcceptAndRemember,
Decline,
Cancel,
BlockedByConfig(String),
}
struct McpToolApprovalMetadata {
@@ -245,25 +257,41 @@ async fn maybe_request_mcp_tool_approval(
server: &str,
tool_name: &str,
) -> Option<McpToolApprovalDecision> {
if is_full_access_mode(turn_context) {
return None;
}
if server != CODEX_APPS_MCP_SERVER_NAME {
return None;
}
let metadata = lookup_mcp_tool_metadata(sess, server, tool_name).await?;
if !requires_mcp_tool_approval(&metadata.annotations) {
return None;
let connector_id = metadata.connector_id.as_deref()?;
let tool_policy = crate::connectors::codex_apps_tool_policy(
turn_context.config.as_ref(),
connector_id,
tool_name,
Some(&metadata.annotations),
);
if !tool_policy.allowed {
return Some(McpToolApprovalDecision::BlockedByConfig(format!(
"MCP tool call `{tool_name}` is blocked by apps config for connector `{connector_id}`"
)));
}
let approval_key = metadata
.connector_id
.as_deref()
.map(|connector_id| McpToolApprovalKey {
server: server.to_string(),
connector_id: connector_id.to_string(),
tool_name: tool_name.to_string(),
});
match tool_policy.approval {
Some(AppToolApproval::Approve) => return None,
Some(AppToolApproval::Prompt) => {}
Some(AppToolApproval::Auto) | None => {
if is_full_access_mode(turn_context)
|| !requires_mcp_tool_approval(&metadata.annotations)
{
return None;
}
}
}
let approval_key = Some(McpToolApprovalKey {
server: server.to_string(),
connector_id: connector_id.to_string(),
tool_name: tool_name.to_string(),
});
if let Some(key) = approval_key.as_ref()
&& mcp_tool_approval_is_remembered(sess, key).await
{

View File

@@ -1598,19 +1598,24 @@ impl App {
tui.frame_requester().schedule_frame();
}
AppEvent::OpenAppLink {
app_id,
title,
description,
instructions,
url,
is_installed,
is_enabled,
} => {
self.chat_widget.open_app_link_view(
title,
description,
instructions,
url,
is_installed,
);
self.chat_widget
.open_app_link_view(crate::bottom_pane::AppLinkViewParams {
app_id,
title,
description,
instructions,
url,
is_installed,
is_enabled,
});
}
AppEvent::OpenUrlInBrowser { url } => {
self.open_url_in_browser(url);
@@ -2237,6 +2242,51 @@ impl App {
}
}
}
AppEvent::SetAppEnabled { id, enabled } => {
let edits = if enabled {
vec![
ConfigEdit::ClearPath {
segments: vec!["apps".to_string(), id.clone(), "enabled".to_string()],
},
ConfigEdit::ClearPath {
segments: vec![
"apps".to_string(),
id.clone(),
"disabled_reason".to_string(),
],
},
]
} else {
vec![
ConfigEdit::SetPath {
segments: vec!["apps".to_string(), id.clone(), "enabled".to_string()],
value: false.into(),
},
ConfigEdit::SetPath {
segments: vec![
"apps".to_string(),
id.clone(),
"disabled_reason".to_string(),
],
value: "user".into(),
},
]
};
match ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits(edits)
.apply()
.await
{
Ok(()) => {
self.chat_widget.update_connector_enabled(&id, enabled);
}
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to update app config for {id}: {err}"
));
}
}
}
AppEvent::OpenPermissionsPopup => {
self.chat_widget.open_permissions_popup();
}

View File

@@ -107,11 +107,13 @@ pub(crate) enum AppEvent {
/// Open the app link view in the bottom pane.
OpenAppLink {
app_id: String,
title: String,
description: Option<String>,
instructions: String,
url: String,
is_installed: bool,
is_enabled: bool,
},
/// Open the provided URL in the user's browser.
@@ -284,6 +286,12 @@ pub(crate) enum AppEvent {
enabled: bool,
},
/// Enable or disable an app by connector ID.
SetAppEnabled {
id: String,
enabled: bool,
},
/// Notify that the manage skills popup was closed.
ManageSkillsClosed,

View File

@@ -32,12 +32,24 @@ enum AppLinkScreen {
InstallConfirmation,
}
pub(crate) struct AppLinkViewParams {
pub(crate) app_id: String,
pub(crate) title: String,
pub(crate) description: Option<String>,
pub(crate) instructions: String,
pub(crate) url: String,
pub(crate) is_installed: bool,
pub(crate) is_enabled: bool,
}
pub(crate) struct AppLinkView {
app_id: String,
title: String,
description: Option<String>,
instructions: String,
url: String,
is_installed: bool,
is_enabled: bool,
app_event_tx: AppEventSender,
screen: AppLinkScreen,
selected_action: usize,
@@ -45,20 +57,24 @@ pub(crate) struct AppLinkView {
}
impl AppLinkView {
pub(crate) fn new(
title: String,
description: Option<String>,
instructions: String,
url: String,
is_installed: bool,
app_event_tx: AppEventSender,
) -> Self {
Self {
pub(crate) fn new(params: AppLinkViewParams, app_event_tx: AppEventSender) -> Self {
let AppLinkViewParams {
app_id,
title,
description,
instructions,
url,
is_installed,
is_enabled,
} = params;
Self {
app_id,
title,
description,
instructions,
url,
is_installed,
is_enabled,
app_event_tx,
screen: AppLinkScreen::Link,
selected_action: 0,
@@ -66,16 +82,24 @@ impl AppLinkView {
}
}
fn action_labels(&self) -> [&'static str; 2] {
fn action_labels(&self) -> Vec<&'static str> {
match self.screen {
AppLinkScreen::Link => {
if self.is_installed {
["Manage on ChatGPT", "Back"]
vec![
"Manage on ChatGPT",
if self.is_enabled {
"Disable app"
} else {
"Enable app"
},
"Back",
]
} else {
["Install on ChatGPT", "Back"]
vec!["Install on ChatGPT", "Back"]
}
}
AppLinkScreen::InstallConfirmation => ["I already Installed it", "Back"],
AppLinkScreen::InstallConfirmation => vec!["I already Installed it", "Back"],
}
}
@@ -87,42 +111,47 @@ impl AppLinkView {
self.selected_action = (self.selected_action + 1).min(self.action_labels().len() - 1);
}
fn handle_primary_action(&mut self) {
match self.screen {
AppLinkScreen::Link => {
self.app_event_tx.send(AppEvent::OpenUrlInBrowser {
url: self.url.clone(),
});
if !self.is_installed {
self.screen = AppLinkScreen::InstallConfirmation;
self.selected_action = 0;
}
}
AppLinkScreen::InstallConfirmation => {
self.app_event_tx.send(AppEvent::RefreshConnectors {
force_refetch: true,
});
self.complete = true;
}
fn open_chatgpt_link(&mut self) {
self.app_event_tx.send(AppEvent::OpenUrlInBrowser {
url: self.url.clone(),
});
if !self.is_installed {
self.screen = AppLinkScreen::InstallConfirmation;
self.selected_action = 0;
}
}
fn handle_secondary_action(&mut self) {
match self.screen {
AppLinkScreen::Link => {
self.complete = true;
}
AppLinkScreen::InstallConfirmation => {
self.screen = AppLinkScreen::Link;
self.selected_action = 0;
}
}
fn refresh_connectors_and_close(&mut self) {
self.app_event_tx.send(AppEvent::RefreshConnectors {
force_refetch: true,
});
self.complete = true;
}
fn back_to_link_screen(&mut self) {
self.screen = AppLinkScreen::Link;
self.selected_action = 0;
}
fn toggle_enabled(&mut self) {
self.is_enabled = !self.is_enabled;
self.app_event_tx.send(AppEvent::SetAppEnabled {
id: self.app_id.clone(),
enabled: self.is_enabled,
});
}
fn activate_selected_action(&mut self) {
match self.selected_action {
0 => self.handle_primary_action(),
_ => self.handle_secondary_action(),
match self.screen {
AppLinkScreen::Link => match self.selected_action {
0 => self.open_chatgpt_link(),
1 if self.is_installed => self.toggle_enabled(),
_ => self.complete = true,
},
AppLinkScreen::InstallConfirmation => match self.selected_action {
0 => self.refresh_connectors_and_close(),
_ => self.back_to_link_screen(),
},
}
}
@@ -308,20 +337,19 @@ impl BottomPaneView for AppLinkView {
..
} => self.move_selection_next(),
KeyEvent {
code: KeyCode::Char('1'),
code: KeyCode::Char(c),
modifiers: KeyModifiers::NONE,
..
} => {
self.selected_action = 0;
self.activate_selected_action();
}
KeyEvent {
code: KeyCode::Char('2'),
modifiers: KeyModifiers::NONE,
..
} => {
self.selected_action = 1;
self.activate_selected_action();
if let Some(index) = c
.to_digit(10)
.and_then(|digit| digit.checked_sub(1))
.map(|index| index as usize)
&& index < self.action_labels().len()
{
self.selected_action = index;
self.activate_selected_action();
}
}
KeyEvent {
code: KeyCode::Enter,
@@ -402,3 +430,67 @@ impl crate::render::renderable::Renderable for AppLinkView {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
use tokio::sync::mpsc::unbounded_channel;
#[test]
fn installed_app_has_toggle_action() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_1".to_string(),
title: "Notion".to_string(),
description: None,
instructions: "Manage app".to_string(),
url: "https://example.test/notion".to_string(),
is_installed: true,
is_enabled: true,
},
tx,
);
assert_eq!(
view.action_labels(),
vec!["Manage on ChatGPT", "Disable app", "Back"]
);
}
#[test]
fn toggle_action_sends_set_app_enabled_and_updates_label() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_1".to_string(),
title: "Notion".to_string(),
description: None,
instructions: "Manage app".to_string(),
url: "https://example.test/notion".to_string(),
is_installed: true,
is_enabled: true,
},
tx,
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
match rx.try_recv() {
Ok(AppEvent::SetAppEnabled { id, enabled }) => {
assert_eq!(id, "connector_1");
assert!(!enabled);
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
assert_eq!(
view.action_labels(),
vec!["Manage on ChatGPT", "Enable app", "Back"]
);
}
}

View File

@@ -3099,7 +3099,7 @@ impl ChatComposer {
&& let Some(snapshot) = self.connectors_snapshot.as_ref()
{
for connector in &snapshot.connectors {
if !connector.is_accessible {
if !connector.is_accessible || !connector.is_enabled {
continue;
}
let display_name = connectors::connector_display_label(connector);
@@ -4342,6 +4342,7 @@ mod tests {
distribution_channel: None,
install_url: Some("https://example.test/notion".to_string()),
is_accessible: true,
is_enabled: true,
}];
composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors }));
@@ -4355,6 +4356,36 @@ mod tests {
assert_eq!(mention.path, Some("app://connector_1".to_string()));
}
#[test]
fn set_connector_mentions_excludes_disabled_apps_from_mention_popup() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_connectors_enabled(true);
composer.set_text_content("$".to_string(), Vec::new(), Vec::new());
let connectors = vec![AppInfo {
id: "connector_1".to_string(),
name: "Notion".to_string(),
description: Some("Workspace docs".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some("https://example.test/notion".to_string()),
is_accessible: true,
is_enabled: false,
}];
composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors }));
assert!(matches!(composer.active_popup, ActivePopup::None));
}
#[test]
fn shortcut_overlay_persists_while_task_running() {
use crossterm::event::KeyCode;

View File

@@ -44,6 +44,7 @@ mod multi_select_picker;
mod request_user_input;
mod status_line_setup;
pub(crate) use app_link_view::AppLinkView;
pub(crate) use app_link_view::AppLinkViewParams;
pub(crate) use approval_overlay::ApprovalOverlay;
pub(crate) use approval_overlay::ApprovalRequest;
pub(crate) use request_user_input::RequestUserInputOverlay;

View File

@@ -1141,22 +1141,8 @@ impl ChatWidget {
self.request_redraw();
}
pub(crate) fn open_app_link_view(
&mut self,
title: String,
description: Option<String>,
instructions: String,
url: String,
is_installed: bool,
) {
let view = crate::bottom_pane::AppLinkView::new(
title,
description,
instructions,
url,
is_installed,
self.app_event_tx.clone(),
);
pub(crate) fn open_app_link_view(&mut self, params: crate::bottom_pane::AppLinkViewParams) {
let view = crate::bottom_pane::AppLinkView::new(params, self.app_event_tx.clone());
self.bottom_pane.show_view(Box::new(view));
self.request_redraw();
}
@@ -3753,7 +3739,7 @@ impl ChatWidget {
if !selected_app_ids.insert(app_id.to_string()) {
continue;
}
if let Some(app) = apps.iter().find(|app| app.id == app_id) {
if let Some(app) = apps.iter().find(|app| app.id == app_id && app.is_enabled) {
items.push(UserInput::Mention {
name: app.name.clone(),
path: binding.path.clone(),
@@ -6381,6 +6367,7 @@ impl ChatWidget {
let connector_title = connector_label.clone();
let link_description = Self::connector_description(connector);
let description = Self::connector_brief_description(connector);
let status_label = Self::connector_status_label(connector);
let search_value = format!("{connector_label} {}", connector.id);
let mut item = SelectionItem {
name: connector_label,
@@ -6389,42 +6376,47 @@ impl ChatWidget {
..Default::default()
};
let is_installed = connector.is_accessible;
let (selected_label, missing_label, instructions) = if connector.is_accessible {
(
"Press Enter to view the app link.",
"App link unavailable.",
"Manage this app in your browser.",
let selected_label = if is_installed {
format!(
"{status_label}. Press Enter to open the app page to install, manage, or enable/disable this app."
)
} else {
(
"Press Enter to view the install link.",
"Install link unavailable.",
"Install this app in your browser, then reload Codex.",
)
format!("{status_label}. Press Enter to open the app page to install this app.")
};
let missing_label = format!("{status_label}. App link unavailable.");
let instructions = if connector.is_accessible {
"Manage this app in your browser."
} else {
"Install this app in your browser, then reload Codex."
};
if let Some(install_url) = connector.install_url.clone() {
let app_id = connector.id.clone();
let is_enabled = connector.is_enabled;
let title = connector_title.clone();
let instructions = instructions.to_string();
let description = link_description.clone();
item.actions = vec![Box::new(move |tx| {
tx.send(AppEvent::OpenAppLink {
app_id: app_id.clone(),
title: title.clone(),
description: description.clone(),
instructions: instructions.clone(),
url: install_url.clone(),
is_installed,
is_enabled,
});
})];
item.dismiss_on_select = true;
item.selected_description = Some(selected_label.to_string());
item.selected_description = Some(selected_label);
} else {
let missing_label_for_action = missing_label.clone();
item.actions = vec![Box::new(move |tx| {
tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(missing_label.to_string(), None),
history_cell::new_info_event(missing_label_for_action.clone(), None),
)));
})];
item.dismiss_on_select = true;
item.selected_description = Some(missing_label.to_string());
item.selected_description = Some(missing_label);
}
items.push(item);
}
@@ -6457,17 +6449,25 @@ impl ChatWidget {
}
fn connector_brief_description(connector: &connectors::AppInfo) -> String {
let status_label = if connector.is_accessible {
"Connected"
} else {
"Can be installed"
};
let status_label = Self::connector_status_label(connector);
match Self::connector_description(connector) {
Some(description) => format!("{status_label} · {description}"),
None => status_label.to_string(),
}
}
fn connector_status_label(connector: &connectors::AppInfo) -> &'static str {
if connector.is_accessible {
if connector.is_enabled {
"Installed"
} else {
"Installed · Disabled"
}
} else {
"Can be installed"
}
}
fn connector_description(connector: &connectors::AppInfo) -> Option<String> {
connector
.description
@@ -6713,6 +6713,29 @@ impl ChatWidget {
}
}
pub(crate) fn update_connector_enabled(&mut self, connector_id: &str, enabled: bool) {
let ConnectorsCacheState::Ready(mut snapshot) = self.connectors_cache.clone() else {
return;
};
let mut changed = false;
for connector in &mut snapshot.connectors {
if connector.id == connector_id {
changed = connector.is_enabled != enabled;
connector.is_enabled = enabled;
break;
}
}
if !changed {
return;
}
self.refresh_connectors_popup_if_open(&snapshot.connectors);
self.connectors_cache = ConnectorsCacheState::Ready(snapshot.clone());
self.bottom_pane.set_connectors_snapshot(Some(snapshot));
}
pub(crate) fn open_review_popup(&mut self) {
let mut items: Vec<SelectionItem> = Vec::new();

View File

@@ -267,12 +267,12 @@ pub(crate) fn find_app_mentions(
}
let mut slug_counts: HashMap<String, usize> = HashMap::new();
for app in apps {
for app in apps.iter().filter(|app| app.is_enabled) {
let slug = connector_mention_slug(app);
*slug_counts.entry(slug).or_insert(0) += 1;
}
for app in apps {
for app in apps.iter().filter(|app| app.is_enabled) {
let slug = connector_mention_slug(app);
let slug_count = slug_counts.get(&slug).copied().unwrap_or(0);
if mentions.names.contains(&slug)
@@ -285,7 +285,7 @@ pub(crate) fn find_app_mentions(
}
apps.iter()
.filter(|app| selected_ids.contains(&app.id))
.filter(|app| app.is_enabled && selected_ids.contains(&app.id))
.cloned()
.collect()
}

View File

@@ -3852,6 +3852,7 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() {
distribution_channel: None,
install_url: Some("https://example.test/notion".to_string()),
is_accessible: true,
is_enabled: true,
}],
}),
false,
@@ -3867,6 +3868,10 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() {
before.contains("Installed 1 of 1 available apps."),
"expected initial apps popup snapshot, got:\n{before}"
);
assert!(
before.contains("Installed. Press Enter to open the app page"),
"expected selected app description to explain the app page action, got:\n{before}"
);
chat.on_connectors_loaded(
Ok(ConnectorsSnapshot {
@@ -3880,6 +3885,7 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() {
distribution_channel: None,
install_url: Some("https://example.test/notion".to_string()),
is_accessible: true,
is_enabled: true,
},
codex_chatgpt::connectors::AppInfo {
id: "connector_2".to_string(),
@@ -3890,6 +3896,7 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() {
distribution_channel: None,
install_url: Some("https://example.test/linear".to_string()),
is_accessible: true,
is_enabled: true,
},
],
}),
@@ -3923,6 +3930,7 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() {
distribution_channel: None,
install_url: Some("https://example.test/notion".to_string()),
is_accessible: true,
is_enabled: true,
},
codex_chatgpt::connectors::AppInfo {
id: "connector_2".to_string(),
@@ -3933,6 +3941,7 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() {
distribution_channel: None,
install_url: Some("https://example.test/linear".to_string()),
is_accessible: false,
is_enabled: true,
},
];
chat.on_connectors_loaded(
@@ -3953,6 +3962,7 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() {
distribution_channel: None,
install_url: Some("https://example.test/notion".to_string()),
is_accessible: true,
is_enabled: true,
}],
}),
false,
@@ -3972,6 +3982,76 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() {
);
}
#[tokio::test]
async fn apps_popup_shows_disabled_status_for_installed_but_disabled_apps() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.config.features.enable(Feature::Apps);
chat.bottom_pane.set_connectors_enabled(true);
chat.on_connectors_loaded(
Ok(ConnectorsSnapshot {
connectors: vec![codex_chatgpt::connectors::AppInfo {
id: "connector_1".to_string(),
name: "Notion".to_string(),
description: Some("Workspace docs".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some("https://example.test/notion".to_string()),
is_accessible: true,
is_enabled: false,
}],
}),
true,
);
chat.add_connectors_output();
let popup = render_bottom_popup(&chat, 80);
assert!(
popup.contains("Installed · Disabled. Press Enter to open the app page"),
"expected selected app description to include disabled status, got:\n{popup}"
);
assert!(
popup.contains("enable/disable this app."),
"expected selected app description to mention enable/disable action, got:\n{popup}"
);
}
#[tokio::test]
async fn apps_popup_for_not_installed_app_uses_install_only_selected_description() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.config.features.enable(Feature::Apps);
chat.bottom_pane.set_connectors_enabled(true);
chat.on_connectors_loaded(
Ok(ConnectorsSnapshot {
connectors: vec![codex_chatgpt::connectors::AppInfo {
id: "connector_2".to_string(),
name: "Linear".to_string(),
description: Some("Project tracking".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some("https://example.test/linear".to_string()),
is_accessible: false,
is_enabled: true,
}],
}),
true,
);
chat.add_connectors_output();
let popup = render_bottom_popup(&chat, 80);
assert!(
popup.contains("Can be installed. Press Enter to open the app page to install"),
"expected selected app description to be install-only for not-installed apps, got:\n{popup}"
);
assert!(
!popup.contains("enable/disable this app."),
"did not expect enable/disable text for not-installed apps, got:\n{popup}"
);
}
#[tokio::test]
async fn experimental_features_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;

View File

@@ -18,6 +18,34 @@ Use `$` in the composer to insert a ChatGPT connector; the popover lists accessi
apps. The `/apps` command lists available and installed apps. Connected apps appear first
and are labeled as connected; others are marked as can be installed.
`apps` supports layered defaults and per-tool settings:
```toml
[apps._default]
disable_destructive = false
disable_open_world = false
[apps.connector_123]
enabled = false
disabled_reason = "user"
disable_destructive = true
disable_open_world = true
[apps.connector_123.tools._default]
enabled = true
approval = "prompt" # "auto" | "prompt" | "approve"
[apps.connector_123.tools."repos/list"]
enabled = true
approval = "auto"
```
Tool approval modes:
- `auto`: Let the tool decide when to ask for approval.
- `prompt`: always ask for approval.
- `approve`: never ask for approval.
## Notify
Codex can run a notification hook when the agent finishes a turn. See the configuration reference for the latest notification settings: