diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 3b6e61fc2..c72db7dae 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9825,6 +9825,32 @@ }, "type": "object" }, + "AppConfig": { + "properties": { + "disabled_reason": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppDisabledReason" + }, + { + "type": "null" + } + ] + }, + "enabled": { + "default": true, + "type": "boolean" + } + }, + "type": "object" + }, + "AppDisabledReason": { + "enum": [ + "unknown", + "user" + ], + "type": "string" + }, "AppInfo": { "properties": { "description": { @@ -9874,6 +9900,9 @@ ], "type": "object" }, + "AppsConfig": { + "type": "object" + }, "AppsListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -10489,6 +10518,17 @@ } ] }, + "apps": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppsConfig" + }, + { + "type": "null" + } + ], + "default": null + }, "compact_prompt": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 96ce16be0..48af97827 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -17,6 +17,35 @@ }, "type": "object" }, + "AppConfig": { + "properties": { + "disabled_reason": { + "anyOf": [ + { + "$ref": "#/definitions/AppDisabledReason" + }, + { + "type": "null" + } + ] + }, + "enabled": { + "default": true, + "type": "boolean" + } + }, + "type": "object" + }, + "AppDisabledReason": { + "enum": [ + "unknown", + "user" + ], + "type": "string" + }, + "AppsConfig": { + "type": "object" + }, "AskForApproval": { "enum": [ "untrusted", @@ -49,6 +78,17 @@ } ] }, + "apps": { + "anyOf": [ + { + "$ref": "#/definitions/AppsConfig" + }, + { + "type": "null" + } + ], + "default": null + }, "compact_prompt": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppDisabledReason.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppDisabledReason.ts new file mode 100644 index 000000000..bc44835a7 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppDisabledReason.ts @@ -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 AppDisabledReason = "unknown" | "user"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts new file mode 100644 index 000000000..ae3e45520 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts @@ -0,0 +1,6 @@ +// 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 { AppDisabledReason } from "./AppDisabledReason"; + +export type AppsConfig = { [key in string]?: { enabled: boolean, disabled_reason: AppDisabledReason | null, } }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts index 22e84c8f3..00493b6e6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts @@ -8,10 +8,11 @@ import type { Verbosity } from "../Verbosity"; import type { WebSearchMode } from "../WebSearchMode"; import type { JsonValue } from "../serde_json/JsonValue"; import type { AnalyticsConfig } from "./AnalyticsConfig"; +import type { AppsConfig } from "./AppsConfig"; import type { AskForApproval } from "./AskForApproval"; import type { ProfileV2 } from "./ProfileV2"; import type { SandboxMode } from "./SandboxMode"; import type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite"; import type { ToolsV2 } from "./ToolsV2"; -export type Config = { model: string | null, review_model: string | null, model_context_window: bigint | null, model_auto_compact_token_limit: bigint | null, model_provider: string | null, approval_policy: AskForApproval | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, analytics: AnalyticsConfig | null, } & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +export type Config = { model: string | null, review_model: string | null, model_context_window: bigint | null, model_auto_compact_token_limit: bigint | null, model_provider: string | null, approval_policy: AskForApproval | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, analytics: AnalyticsConfig | null, apps: AppsConfig | null, } & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 65a12daa3..9e7547a9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -6,7 +6,9 @@ export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUp export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; export type { AgentMessageDeltaNotification } from "./AgentMessageDeltaNotification"; export type { AnalyticsConfig } from "./AnalyticsConfig"; +export type { AppDisabledReason } from "./AppDisabledReason"; export type { AppInfo } from "./AppInfo"; +export type { AppsConfig } from "./AppsConfig"; export type { AppsListParams } from "./AppsListParams"; export type { AppsListResponse } from "./AppsListResponse"; export type { AskForApproval } from "./AskForApproval"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index a978fec6d..756543328 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -374,6 +374,36 @@ pub struct AnalyticsConfig { pub additional: HashMap, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum AppDisabledReason { + Unknown, + User, +} + +#[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 disabled_reason: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppsConfig { + #[serde(default, flatten)] + #[schemars(with = "HashMap")] + pub apps: HashMap, +} + +const fn default_enabled() -> bool { + true +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(export_to = "v2/")] @@ -400,6 +430,8 @@ pub struct Config { pub model_reasoning_summary: Option, pub model_verbosity: Option, pub analytics: Option, + #[serde(default)] + pub apps: Option, #[serde(default, flatten)] pub additional: HashMap, } diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 8fbe2af18..fe4d2c449 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -3,6 +3,9 @@ use app_test_support::McpProcess; use app_test_support::test_path_buf_with_windows; use app_test_support::test_tmp_path_buf; use app_test_support::to_response; +use codex_app_server_protocol::AppConfig; +use codex_app_server_protocol::AppDisabledReason; +use codex_app_server_protocol::AppsConfig; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigEdit; @@ -146,6 +149,74 @@ view_image = false Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_includes_apps() -> Result<()> { + let codex_home = TempDir::new()?; + write_config( + &codex_home, + r#" +[apps.app1] +enabled = false +disabled_reason = "user" +"#, + )?; + let codex_home_path = codex_home.path().canonicalize()?; + let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: true, + cwd: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { + config, + origins, + layers, + } = to_response(resp)?; + + assert_eq!( + config.apps, + Some(AppsConfig { + apps: std::collections::HashMap::from([( + "app1".to_string(), + AppConfig { + enabled: false, + disabled_reason: Some(AppDisabledReason::User), + }, + )]), + }) + ); + assert_eq!( + origins.get("apps.app1.enabled").expect("origin").name, + ConfigLayerSource::User { + file: user_file.clone(), + } + ); + assert_eq!( + origins + .get("apps.app1.disabled_reason") + .expect("origin") + .name, + ConfigLayerSource::User { + file: user_file.clone(), + } + ); + + let layers = layers.expect("layers present"); + assert_layers_user_then_optional_system(&layers, user_file)?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_project_layers_for_cwd() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b944a4bfe..fac544240 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -55,6 +55,40 @@ }, "type": "object" }, + "AppConfig": { + "additionalProperties": false, + "description": "Config values for a single app/connector.", + "properties": { + "disabled_reason": { + "allOf": [ + { + "$ref": "#/definitions/AppDisabledReason" + } + ], + "description": "Reason this app was disabled." + }, + "enabled": { + "default": true, + "description": "When `false`, Codex does not surface this app.", + "type": "boolean" + } + }, + "type": "object" + }, + "AppDisabledReason": { + "enum": [ + "unknown", + "user" + ], + "type": "string" + }, + "AppsConfigToml": { + "additionalProperties": { + "$ref": "#/definitions/AppConfig" + }, + "description": "App/connector settings loaded from `config.toml`.", + "type": "object" + }, "AskForApproval": { "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", "oneOf": [ @@ -1135,6 +1169,15 @@ ], "description": "Default approval policy for executing commands." }, + "apps": { + "allOf": [ + { + "$ref": "#/definitions/AppsConfigToml" + } + ], + "default": null, + "description": "Settings for app-specific controls." + }, "chatgpt_base_url": { "description": "Base URL for requests to ChatGPT (as opposed to the OpenAI API).", "type": "string" diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index aade09581..4e2d08b23 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1,6 +1,7 @@ use crate::auth::AuthCredentialsStoreMode; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; +use crate::config::types::AppsConfigToml; use crate::config::types::DEFAULT_OTEL_ENVIRONMENT; use crate::config::types::History; use crate::config::types::McpServerConfig; @@ -1024,6 +1025,10 @@ pub struct ConfigToml { /// Defaults to `true`. pub feedback: Option, + /// Settings for app-specific controls. + #[serde(default)] + pub apps: Option, + /// OTEL configuration. pub otel: Option, diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index b72af4c0f..36c638063 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -700,6 +700,9 @@ fn find_effective_layer( mod tests { use super::*; use anyhow::Result; + use codex_app_server_protocol::AppConfig; + use codex_app_server_protocol::AppDisabledReason; + use codex_app_server_protocol::AppsConfig; use codex_app_server_protocol::AskForApproval; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -800,6 +803,62 @@ remote_models = true Ok(()) } + #[tokio::test] + async fn write_value_supports_nested_app_paths() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "")?; + + let service = ConfigService::new_with_defaults(tmp.path().to_path_buf()); + service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "apps".to_string(), + value: serde_json::json!({ + "app1": { + "enabled": false, + }, + }), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write apps succeeds"); + + service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "apps.app1.disabled_reason".to_string(), + value: serde_json::json!("user"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write apps.app1.disabled_reason succeeds"); + + let read = service + .read(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await + .expect("config read succeeds"); + + assert_eq!( + read.config.apps, + Some(AppsConfig { + apps: std::collections::HashMap::from([( + "app1".to_string(), + AppConfig { + enabled: false, + disabled_reason: Some(AppDisabledReason::User), + }, + )]), + }) + ); + + Ok(()) + } + #[tokio::test] async fn read_includes_origins_and_layers() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 08751a571..3bc71006d 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -340,6 +340,44 @@ pub struct FeedbackConfigToml { pub enabled: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum AppDisabledReason { + Unknown, + User, +} + +impl fmt::Display for AppDisabledReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AppDisabledReason::Unknown => write!(f, "unknown"), + AppDisabledReason::User => write!(f, "user"), + } + } +} + +/// 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, + + /// Reason this app was disabled. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disabled_reason: Option, +} + +/// App/connector settings loaded from `config.toml`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppsConfigToml { + /// Per-app settings keyed by app ID (for example `[apps.google_drive]`). + #[serde(default, flatten)] + pub apps: HashMap, +} + // ===== OTEL configuration ===== #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]