respect workspace option for disabling plugins (#18907)

Respects the workspace setting for plugins in Codex

Plugins menu disappears
Plugins do not load
Plugins do not load in composer

no plugins loaded
<img width="809" height="226" alt="Screenshot 2026-04-23 at 3 20 45 PM"
src="https://github.com/user-attachments/assets/3a4dba8e-69c3-4046-a77e-f13ab77f84b4"
/>


no plugins in menu
<img width="293" height="204" alt="Screenshot 2026-04-23 at 3 20 35 PM"
src="https://github.com/user-attachments/assets/5cb9bf52-ad72-488f-b90c-5eb457da09a3"
/>
This commit is contained in:
Alex Zamoshchin
2026-04-24 13:38:45 -04:00
committed by GitHub
Unverified
parent f802f0a391
commit bcc1caa920
11 changed files with 851 additions and 7 deletions
@@ -219,6 +219,7 @@ use codex_arg0::Arg0DispatchPaths;
use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType;
use codex_backend_client::Client as BackendClient;
use codex_chatgpt::connectors;
use codex_chatgpt::workspace_settings;
use codex_config::types::McpServerTransportConfig;
use codex_core::CodexThread;
use codex_core::CodexThreadTurnContextOverrides;
@@ -498,6 +499,7 @@ pub(crate) struct CodexMessageProcessor {
thread_state_manager: ThreadStateManager,
thread_watch_manager: ThreadWatchManager,
command_exec_manager: CommandExecManager,
workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
pending_fuzzy_searches: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,
fuzzy_search_sessions: Arc<Mutex<HashMap<String, FuzzyFileSearchSession>>>,
background_tasks: TaskTracker,
@@ -762,6 +764,9 @@ impl CodexMessageProcessor {
thread_state_manager: ThreadStateManager::new(),
thread_watch_manager: ThreadWatchManager::new_with_outgoing(outgoing),
command_exec_manager: CommandExecManager::default(),
workspace_settings_cache: Arc::new(
workspace_settings::WorkspaceSettingsCache::default(),
),
pending_fuzzy_searches: Arc::new(Mutex::new(HashMap::new())),
fuzzy_search_sessions: Arc::new(Mutex::new(HashMap::new())),
background_tasks: TaskTracker::new(),
@@ -784,6 +789,28 @@ impl CodexMessageProcessor {
})
}
async fn workspace_codex_plugins_enabled(
&self,
config: &Config,
auth: Option<&CodexAuth>,
) -> bool {
match workspace_settings::codex_plugins_enabled_for_workspace(
config,
auth,
Some(&self.workspace_settings_cache),
)
.await
{
Ok(enabled) => enabled,
Err(err) => {
warn!(
"failed to fetch workspace Codex plugins setting; allowing Codex plugins: {err:#}"
);
true
}
}
}
/// If a client sends `developer_instructions: null` during a mode switch,
/// use the built-in instructions for that mode.
fn normalize_turn_start_collaboration_mode(
@@ -5608,6 +5635,10 @@ impl CodexMessageProcessor {
return;
}
};
let auth = self.auth_manager.auth().await;
let workspace_codex_plugins_enabled = self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await;
let data = FEATURES
.iter()
@@ -5642,7 +5673,9 @@ impl CodexMessageProcessor {
display_name,
description,
announcement,
enabled: config.features.enabled(spec.id),
enabled: config.features.enabled(spec.id)
&& (workspace_codex_plugins_enabled
|| !matches!(spec.id, Feature::Apps | Feature::Plugins)),
default_enabled: spec.default_enabled,
}
})
@@ -6430,6 +6463,22 @@ impl CodexMessageProcessor {
return;
}
if !self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await
{
self.outgoing
.send_response(
request_id,
AppsListResponse {
data: Vec::new(),
next_cursor: None,
},
)
.await;
return;
}
let request = request_id.clone();
let outgoing = Arc::clone(&self.outgoing);
let environment_manager = self.thread_manager.environment_manager();
@@ -6674,6 +6723,10 @@ impl CodexMessageProcessor {
return;
}
};
let auth = self.auth_manager.auth().await;
let workspace_codex_plugins_enabled = self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await;
let skills_manager = self.thread_manager.skills_manager();
let plugins_manager = self.thread_manager.plugins_manager();
let fs = self
@@ -6723,7 +6776,7 @@ impl CodexMessageProcessor {
let effective_skill_roots = plugins_manager
.effective_skill_roots_for_layer_stack(
&config_layer_stack,
config.features.enabled(Feature::Plugins),
config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled,
)
.await;
let skills_input = codex_core::skills::SkillsLoadInput::new(
@@ -31,8 +31,24 @@ impl CodexMessageProcessor {
.await;
return;
}
plugins_manager.maybe_start_non_curated_plugin_cache_refresh(&roots);
let auth = self.auth_manager.auth().await;
if !self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await
{
self.outgoing
.send_response(
request_id,
PluginListResponse {
marketplaces: Vec::new(),
marketplace_load_errors: Vec::new(),
featured_plugin_ids: Vec::new(),
},
)
.await;
return;
}
plugins_manager.maybe_start_non_curated_plugin_cache_refresh(&roots);
let config_for_marketplace_listing = config.clone();
let plugins_manager_for_marketplace_listing = plugins_manager.clone();
@@ -378,6 +394,26 @@ impl CodexMessageProcessor {
}
};
let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf);
let config = match self.load_latest_config(config_cwd.clone()).await {
Ok(config) => config,
Err(err) => {
self.outgoing.send_error(request_id, err).await;
return;
}
};
let auth = self.auth_manager.auth().await;
if !self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await
{
self.send_invalid_request_error(
request_id,
"Codex plugins are disabled for this workspace".to_string(),
)
.await;
return;
}
let plugins_manager = self.thread_manager.plugins_manager();
let request = PluginInstallRequest {
@@ -395,7 +431,7 @@ impl CodexMessageProcessor {
warn!(
"failed to reload config after plugin install, using current config: {err:?}"
);
self.config.as_ref().clone()
config
}
};
@@ -151,6 +151,68 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn list_apps_returns_empty_when_workspace_codex_plugins_disabled() -> 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,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}];
let tools = vec![connector_tool("beta", "Beta App")?];
let (server_url, server_handle) = start_apps_server_with_workspace_plugins_enabled(
connectors, tools, /*workspace_plugins_enabled*/ false,
)
.await?;
let codex_home = TempDir::new()?;
write_connectors_config(codex_home.path(), &server_url)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123")
.plan_type("team"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_apps_list_request(AppsListParams {
limit: Some(50),
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, next_cursor } = to_response(response)?;
assert!(data.is_empty());
assert!(next_cursor.is_none());
server_handle.abort();
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Result<()> {
let connectors = vec![AppInfo {
@@ -1329,6 +1391,7 @@ struct AppsServerState {
expected_account_id: String,
response: Arc<StdMutex<serde_json::Value>>,
directory_delay: Duration,
workspace_plugins_enabled: bool,
}
#[derive(Clone)]
@@ -1412,11 +1475,45 @@ async fn start_apps_server_with_delays(
Ok((server_url, server_handle))
}
async fn start_apps_server_with_workspace_plugins_enabled(
connectors: Vec<AppInfo>,
tools: Vec<Tool>,
workspace_plugins_enabled: bool,
) -> Result<(String, JoinHandle<()>)> {
let (server_url, server_handle, _server_control) =
start_apps_server_with_delays_and_control_inner(
connectors,
tools,
Duration::ZERO,
Duration::ZERO,
workspace_plugins_enabled,
)
.await?;
Ok((server_url, server_handle))
}
async fn start_apps_server_with_delays_and_control(
connectors: Vec<AppInfo>,
tools: Vec<Tool>,
directory_delay: Duration,
tools_delay: Duration,
) -> Result<(String, JoinHandle<()>, AppsServerControl)> {
start_apps_server_with_delays_and_control_inner(
connectors,
tools,
directory_delay,
tools_delay,
/*workspace_plugins_enabled*/ true,
)
.await
}
async fn start_apps_server_with_delays_and_control_inner(
connectors: Vec<AppInfo>,
tools: Vec<Tool>,
directory_delay: Duration,
tools_delay: Duration,
workspace_plugins_enabled: bool,
) -> Result<(String, JoinHandle<()>, AppsServerControl)> {
let response = Arc::new(StdMutex::new(
json!({ "apps": connectors, "next_token": null }),
@@ -1427,6 +1524,7 @@ async fn start_apps_server_with_delays_and_control(
expected_account_id: "account-123".to_string(),
response: response.clone(),
directory_delay,
workspace_plugins_enabled,
};
let state = Arc::new(state);
let server_control = AppsServerControl {
@@ -1452,6 +1550,10 @@ async fn start_apps_server_with_delays_and_control(
"/connectors/directory/list_workspace",
get(list_directory_connectors),
)
.route(
"/accounts/account-123/settings",
get(workspace_settings_response),
)
.with_state(state)
.nest_service("/api/codex/apps", mcp_service);
@@ -1462,6 +1564,30 @@ async fn start_apps_server_with_delays_and_control(
Ok((format!("http://{addr}"), handle, server_control))
}
async fn workspace_settings_response(
State(state): State<Arc<AppsServerState>>,
headers: HeaderMap,
) -> Result<impl axum::response::IntoResponse, StatusCode> {
let bearer_ok = headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == state.expected_bearer);
let account_ok = headers
.get("chatgpt-account-id")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == state.expected_account_id);
if !bearer_ok || !account_ok {
Err(StatusCode::UNAUTHORIZED)
} else {
Ok(Json(json!({
"beta_settings": {
"plugins": state.workspace_plugins_enabled
}
})))
}
}
async fn list_directory_connectors(
State(state): State<Arc<AppsServerState>>,
headers: HeaderMap,
@@ -1,8 +1,10 @@
use std::time::Duration;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::McpProcess;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigReadResponse;
use codex_app_server_protocol::ExperimentalFeature;
@@ -14,6 +16,7 @@ use codex_app_server_protocol::ExperimentalFeatureStage;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::config::ConfigBuilder;
use codex_core::config_loader::LoaderOverrides;
use codex_features::FEATURES;
@@ -24,6 +27,12 @@ use serde_json::json;
use std::collections::BTreeMap;
use tempfile::TempDir;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
@@ -89,6 +98,63 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu
Ok(())
}
#[tokio::test]
async fn experimental_feature_list_marks_apps_and_plugins_disabled_by_workspace_policy()
-> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"chatgpt_base_url = "{}/backend-api/"
"#,
server.uri()
),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123")
.plan_type("team"),
AuthCredentialsStoreMode::File,
)?;
Mock::given(method("GET"))
.and(path("/backend-api/accounts/account-123/settings"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":false}}"#),
)
.mount(&server)
.await;
let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_experimental_feature_list_request(ExperimentalFeatureListParams::default())
.await?;
let actual = read_response::<ExperimentalFeatureListResponse>(&mut mcp, request_id).await?;
let apps = actual
.data
.iter()
.find(|feature| feature.name == "apps")
.expect("apps feature should be present");
let plugins = actual
.data
.iter()
.find(|feature| feature.name == "plugins")
.expect("plugins feature should be present");
assert!(!apps.enabled);
assert!(!plugins.enabled);
assert!(apps.default_enabled);
assert!(plugins.default_enabled);
Ok(())
}
#[tokio::test]
async fn experimental_feature_enablement_set_applies_to_global_and_thread_config_reads()
-> Result<()> {
@@ -308,6 +308,72 @@ async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let server = MockServer::start().await;
write_plugins_enabled_config_with_base_url(
codex_home.path(),
&format!("{}/backend-api/", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123")
.plan_type("team"),
AuthCredentialsStoreMode::File,
)?;
write_plugin_marketplace(
repo_root.path(),
"debug",
"sample-plugin",
"./sample-plugin",
/*install_policy*/ None,
/*auth_policy*/ None,
)?;
write_plugin_source(repo_root.path(), "sample-plugin", &[])?;
let marketplace_path =
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
Mock::given(method("GET"))
.and(path("/backend-api/accounts/account-123/settings"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":false}}"#),
)
.mount(&server)
.await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_install_request(PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
let err = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(err.error.code, -32600);
assert!(
err.error
.message
.contains("Codex plugins are disabled for this workspace")
);
Ok(())
}
#[tokio::test]
async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -907,6 +973,22 @@ connectors = true
)
}
fn write_plugins_enabled_config_with_base_url(
codex_home: &std::path::Path,
base_url: &str,
) -> std::io::Result<()> {
std::fs::write(
codex_home.join("config.toml"),
format!(
r#"chatgpt_base_url = "{base_url}"
[features]
plugins = true
"#,
),
)
}
fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> {
std::fs::write(
codex_home.join("config.toml"),
@@ -30,7 +30,7 @@ use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1";
const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json";
@@ -45,6 +45,22 @@ plugins = true
)
}
fn write_plugins_enabled_config_with_base_url(
codex_home: &std::path::Path,
base_url: &str,
) -> std::io::Result<()> {
std::fs::write(
codex_home.join("config.toml"),
format!(
r#"chatgpt_base_url = "{base_url}"
[features]
plugins = true
"#,
),
)
}
#[tokio::test]
async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -244,6 +260,158 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_
Ok(())
}
#[tokio::test]
async fn plugin_list_returns_empty_when_workspace_codex_plugins_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let server = MockServer::start().await;
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
write_plugins_enabled_config_with_base_url(
codex_home.path(),
&format!("{}/backend-api/", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123")
.plan_type("team"),
AuthCredentialsStoreMode::File,
)?;
std::fs::write(
repo_root.path().join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "demo-plugin",
"source": {
"source": "local",
"path": "./demo-plugin"
}
}
]
}"#,
)?;
Mock::given(method("GET"))
.and(path("/backend-api/accounts/account-123/settings"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":false}}"#),
)
.mount(&server)
.await;
let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
assert_eq!(
response,
PluginListResponse {
marketplaces: Vec::new(),
marketplace_load_errors: Vec::new(),
featured_plugin_ids: Vec::new(),
}
);
Ok(())
}
#[tokio::test]
async fn plugin_list_reuses_cached_workspace_codex_plugins_setting() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let server = MockServer::start().await;
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?;
write_plugins_enabled_config_with_base_url(
codex_home.path(),
&format!("{}/backend-api/", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123")
.plan_type("team"),
AuthCredentialsStoreMode::File,
)?;
std::fs::write(
repo_root.path().join(".agents/plugins/marketplace.json"),
r#"{
"name": "local-marketplace",
"plugins": [
{
"name": "demo-plugin",
"source": {
"source": "local",
"path": "./demo-plugin"
}
}
]
}"#,
)?;
std::fs::write(
repo_root
.path()
.join("demo-plugin/.codex-plugin/plugin.json"),
r#"{"name":"demo-plugin"}"#,
)?;
Mock::given(method("GET"))
.and(path("/backend-api/accounts/account-123/settings"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":true}}"#),
)
.mount(&server)
.await;
let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
for _ in 0..2 {
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
assert_eq!(response.marketplaces.len(), 1);
assert_eq!(response.marketplaces[0].name, "local-marketplace");
}
wait_for_workspace_settings_request_count(&server, /*expected_count*/ 1).await?;
Ok(())
}
#[tokio::test]
async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverable_plugins()
-> Result<()> {
@@ -1351,6 +1519,14 @@ async fn wait_for_featured_plugin_request_count(
wait_for_remote_plugin_request_count(server, "/plugins/featured", expected_count).await
}
async fn wait_for_workspace_settings_request_count(
server: &MockServer,
expected_count: usize,
) -> Result<()> {
wait_for_remote_plugin_request_count(server, "/accounts/account-123/settings", expected_count)
.await
}
async fn wait_for_remote_plugin_request_count(
server: &MockServer,
path_suffix: &str,
@@ -2,8 +2,10 @@ use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::McpProcess;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SkillsChangedNotification;
@@ -11,12 +13,19 @@ use codex_app_server_protocol::SkillsListExtraRootsForCwd;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_config::types::AuthCredentialsStoreMode;
use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const WATCHER_TIMEOUT: Duration = Duration::from_secs(20);
fn write_skill(root: &TempDir, name: &str) -> Result<()> {
@@ -27,6 +36,63 @@ fn write_skill(root: &TempDir, name: &str) -> Result<()> {
Ok(())
}
fn write_plugins_enabled_config_with_base_url(
codex_home: &std::path::Path,
base_url: &str,
) -> std::io::Result<()> {
std::fs::write(
codex_home.join("config.toml"),
format!(
r#"chatgpt_base_url = "{base_url}"
[features]
plugins = true
"#,
),
)
}
fn write_plugin_with_skill(
repo_root: &std::path::Path,
plugin_name: &str,
skill_name: &str,
) -> Result<()> {
std::fs::create_dir_all(repo_root.join(".git"))?;
std::fs::create_dir_all(repo_root.join(".agents/plugins"))?;
std::fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
format!(
r#"{{
"name": "local-marketplace",
"plugins": [
{{
"name": "{plugin_name}",
"source": {{
"source": "local",
"path": "./{plugin_name}"
}}
}}
]
}}"#
),
)?;
let plugin_root = repo_root.join(plugin_name);
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
format!(r#"{{"name":"{plugin_name}"}}"#),
)?;
let skill_dir = plugin_root.join("skills").join(skill_name);
std::fs::create_dir_all(&skill_dir)?;
std::fs::write(
skill_dir.join("SKILL.md"),
format!("---\nname: {skill_name}\ndescription: {skill_name} description\n---\n\n# Body\n"),
)?;
Ok(())
}
#[tokio::test]
async fn skills_list_includes_skills_from_per_cwd_extra_user_roots() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -65,6 +131,71 @@ async fn skills_list_includes_skills_from_per_cwd_extra_user_roots() -> Result<(
Ok(())
}
#[tokio::test]
async fn skills_list_excludes_plugin_skills_when_workspace_codex_plugins_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let server = MockServer::start().await;
write_skill(&codex_home, "home-skill")?;
write_plugin_with_skill(repo_root.path(), "demo-plugin", "plugin-skill")?;
write_plugins_enabled_config_with_base_url(
codex_home.path(),
&format!("{}/backend-api/", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123")
.plan_type("team"),
AuthCredentialsStoreMode::File,
)?;
Mock::given(method("GET"))
.and(path("/backend-api/accounts/account-123/settings"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":false}}"#),
)
.mount(&server)
.await;
let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![repo_root.path().to_path_buf()],
force_reload: true,
per_cwd_extra_user_roots: None,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let SkillsListResponse { data } = to_response(response)?;
assert_eq!(data.len(), 1);
assert!(
data[0]
.skills
.iter()
.any(|skill| skill.name == "home-skill"),
"non-plugin skills should remain available"
);
assert!(
data[0]
.skills
.iter()
.all(|skill| skill.name != "demo-plugin:plugin-skill"),
"plugin skills should be hidden when workspace Codex plugins are disabled"
);
Ok(())
}
#[tokio::test]
async fn skills_list_skips_cwd_roots_when_environment_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
+5 -1
View File
@@ -37,7 +37,11 @@ pub(crate) async fn chatgpt_get_request_with_timeout<T: DeserializeOwned>(
// Make direct HTTP request to ChatGPT backend API with the token
let client = create_client();
let url = format!("{chatgpt_base_url}{path}");
let url = format!(
"{}/{}",
chatgpt_base_url.trim_end_matches('/'),
path.trim_start_matches('/')
);
let mut request = client
.get(&url)
+1
View File
@@ -2,3 +2,4 @@ pub mod apply_command;
mod chatgpt_client;
pub mod connectors;
pub mod get_task;
pub mod workspace_settings;
+152
View File
@@ -0,0 +1,152 @@
use std::collections::HashMap;
use std::sync::RwLock;
use std::time::Duration;
use std::time::Instant;
use anyhow::Context;
use codex_core::config::Config;
use codex_login::CodexAuth;
use serde::Deserialize;
use crate::chatgpt_client::chatgpt_get_request_with_timeout;
const WORKSPACE_SETTINGS_TIMEOUT: Duration = Duration::from_secs(10);
const WORKSPACE_SETTINGS_CACHE_TTL: Duration = Duration::from_secs(15 * 60);
const CODEX_PLUGINS_BETA_SETTING: &str = "plugins";
#[derive(Debug, Deserialize)]
struct WorkspaceSettingsResponse {
#[serde(default)]
beta_settings: HashMap<String, bool>,
}
#[derive(Debug, Default)]
pub struct WorkspaceSettingsCache {
entry: RwLock<Option<CachedWorkspaceSettings>>,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct WorkspaceSettingsCacheKey {
chatgpt_base_url: String,
account_id: String,
}
#[derive(Clone, Debug)]
struct CachedWorkspaceSettings {
key: WorkspaceSettingsCacheKey,
expires_at: Instant,
codex_plugins_enabled: bool,
}
impl WorkspaceSettingsCache {
fn get_codex_plugins_enabled(&self, key: &WorkspaceSettingsCacheKey) -> Option<bool> {
{
let entry = match self.entry.read() {
Ok(entry) => entry,
Err(err) => err.into_inner(),
};
let now = Instant::now();
if let Some(cached) = entry.as_ref()
&& now < cached.expires_at
&& cached.key == *key
{
return Some(cached.codex_plugins_enabled);
}
}
let mut entry = match self.entry.write() {
Ok(entry) => entry,
Err(err) => err.into_inner(),
};
let now = Instant::now();
if entry
.as_ref()
.is_some_and(|cached| now >= cached.expires_at || cached.key != *key)
{
*entry = None;
}
None
}
fn set_codex_plugins_enabled(&self, key: WorkspaceSettingsCacheKey, enabled: bool) {
let mut entry = match self.entry.write() {
Ok(entry) => entry,
Err(err) => err.into_inner(),
};
*entry = Some(CachedWorkspaceSettings {
key,
expires_at: Instant::now() + WORKSPACE_SETTINGS_CACHE_TTL,
codex_plugins_enabled: enabled,
});
}
}
pub async fn codex_plugins_enabled_for_workspace(
config: &Config,
auth: Option<&CodexAuth>,
cache: Option<&WorkspaceSettingsCache>,
) -> anyhow::Result<bool> {
let Some(auth) = auth else {
return Ok(true);
};
if !auth.is_chatgpt_auth() {
return Ok(true);
}
let token_data = auth
.get_token_data()
.context("ChatGPT token data is not available")?;
if !token_data.id_token.is_workspace_account() {
return Ok(true);
}
let Some(account_id) = token_data.account_id.as_deref().filter(|id| !id.is_empty()) else {
return Ok(true);
};
let cache_key = WorkspaceSettingsCacheKey {
chatgpt_base_url: config.chatgpt_base_url.clone(),
account_id: account_id.to_string(),
};
if let Some(cache) = cache
&& let Some(enabled) = cache.get_codex_plugins_enabled(&cache_key)
{
return Ok(enabled);
}
let encoded_account_id = encode_path_segment(account_id);
let settings: WorkspaceSettingsResponse = chatgpt_get_request_with_timeout(
config,
format!("/accounts/{encoded_account_id}/settings"),
Some(WORKSPACE_SETTINGS_TIMEOUT),
)
.await?;
let codex_plugins_enabled = settings
.beta_settings
.get(CODEX_PLUGINS_BETA_SETTING)
.copied()
.unwrap_or(true);
if let Some(cache) = cache {
cache.set_codex_plugins_enabled(cache_key, codex_plugins_enabled);
}
Ok(codex_plugins_enabled)
}
fn encode_path_segment(value: &str) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
encoded.push(byte as char);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
#[cfg(test)]
#[path = "workspace_settings_tests.rs"]
mod tests;
@@ -0,0 +1,17 @@
use super::*;
#[test]
fn encode_path_segment_leaves_unreserved_ascii_unchanged() {
assert_eq!(
encode_path_segment("account-123_ABC.~"),
"account-123_ABC.~"
);
}
#[test]
fn encode_path_segment_escapes_path_separators_and_spaces() {
assert_eq!(
encode_path_segment("account/123 with space"),
"account%2F123%20with%20space"
);
}