Remove the workspace settings gate for apps and plugins (#38994)

## What changed

- Stop fetching the ChatGPT workspace `enable_plugins` setting.
- Let the effective Codex feature configuration control app and plugin APIs,
  plugin-provided skills and hooks, and experimental feature reporting.

GitOrigin-RevId: 46aebf0f07cd47e093e48663e41207cdf9a38c9f
This commit is contained in:
Alex Zamoshchin authored and copyberry committed 2026-08-17 12:26:59 +00:00
1 parent ea10ae7d0f
commit 3666a46ea3
17 files changed
+10 -990

No files matched your search

@@ -14,7 +14,6 @@ use app_test_support::write_chatgpt_auth;
use axum::Json;
use axum::Router;
use axum::extract::State;
use axum::http::StatusCode;
use axum::routing::get;
use codex_app_server_protocol::AppsInstalledParams;
use codex_app_server_protocol::AppsInstalledResponse;
@@ -52,7 +51,6 @@ async fn installed_apps_force_refresh_only_refreshes_tools_snapshot() -> Result<
let initially_empty = send_installed_request(&mut app_server, /*force_refresh*/ false).await?;
assert_eq!(initially_empty, AppsInstalledResponse { apps: Vec::new() });
assert_eq!(fixture.list_tools_calls(), 0);
assert_eq!(fixture.workspace_settings_calls(), 1);
let refreshed = send_installed_request(&mut app_server, /*force_refresh*/ true).await?;
assert_eq!(
@@ -79,12 +77,10 @@ async fn installed_apps_force_refresh_only_refreshes_tools_snapshot() -> Result<
]
);
assert_eq!(fixture.list_tools_calls(), 1);
assert_eq!(fixture.workspace_settings_calls(), 1);
let cached = send_installed_request(&mut app_server, /*force_refresh*/ false).await?;
assert_eq!(cached, refreshed);
assert_eq!(fixture.list_tools_calls(), 1);
assert_eq!(fixture.workspace_settings_calls(), 1);
fixture.set_tools(Vec::new());
let empty = send_installed_request(&mut app_server, /*force_refresh*/ true).await?;
@@ -94,67 +90,10 @@ async fn installed_apps_force_refresh_only_refreshes_tools_snapshot() -> Result<
let cached_empty = send_installed_request(&mut app_server, /*force_refresh*/ false).await?;
assert_eq!(cached_empty, empty);
assert_eq!(fixture.list_tools_calls(), 2);
assert_eq!(fixture.workspace_settings_calls(), 1);
assert_eq!(fixture.directory_calls(), 0);
Ok(())
}
#[tokio::test]
async fn installed_apps_workspace_policy_retains_identities_as_disabled() -> Result<()> {
let fixture = InstalledAppsFixture::start().await?;
let codex_home = configured_codex_home(fixture.base_url())?;
let committed = {
let mut app_server = start_app_server(codex_home.path()).await?;
send_installed_request(&mut app_server, /*force_refresh*/ true).await?
};
let mut expected_disabled = committed;
for app in &mut expected_disabled.apps {
app.enabled = false;
app.callable = false;
}
fixture.set_workspace_plugins_enabled(/*enabled*/ false);
let mut app_server = start_app_server(codex_home.path()).await?;
let cold_cached = send_installed_request(&mut app_server, /*force_refresh*/ false).await?;
assert_eq!(cold_cached, expected_disabled);
assert_eq!(fixture.workspace_settings_calls(), 2);
let workspace_settings_calls = fixture.workspace_settings_calls();
let blocked = send_installed_request(&mut app_server, /*force_refresh*/ true).await?;
assert_eq!(blocked, expected_disabled);
assert_eq!(fixture.list_tools_calls(), 1);
assert_eq!(fixture.workspace_settings_calls(), workspace_settings_calls);
Ok(())
}
#[tokio::test]
async fn installed_apps_workspace_policy_failure_does_not_block_force_refresh() -> Result<()> {
let fixture = InstalledAppsFixture::start().await?;
fixture
.state
.fail_workspace_settings
.store(true, Ordering::SeqCst);
fixture.set_tools(vec![connector_tool("alpha", "Alpha Tool Name")?]);
let codex_home = configured_codex_home(fixture.base_url())?;
let mut app_server = start_app_server(codex_home.path()).await?;
let refreshed = send_installed_request(&mut app_server, /*force_refresh*/ true).await?;
assert_eq!(
refreshed,
AppsInstalledResponse {
apps: vec![InstalledApp {
id: "alpha".to_string(),
runtime_name: Some("Alpha Tool Name".to_string()),
enabled: true,
callable: true,
}],
}
);
assert_eq!(fixture.workspace_settings_calls(), 1);
assert_eq!(fixture.list_tools_calls(), 1);
Ok(())
}
#[tokio::test]
async fn installed_apps_global_disable_retains_tool_derived_identities() -> Result<()> {
let fixture = InstalledAppsFixture::start().await?;
@@ -179,7 +118,6 @@ async fn installed_apps_global_disable_retains_tool_derived_identities() -> Resu
let force_refresh = send_installed_request(&mut app_server, /*force_refresh*/ true).await?;
assert_eq!(force_refresh, cached);
assert_eq!(fixture.list_tools_calls(), 1);
assert_eq!(fixture.workspace_settings_calls(), 1);
Ok(())
}
@@ -345,9 +283,6 @@ struct InstalledAppsServerState {
tools: Mutex<Vec<Tool>>,
list_tools_calls: AtomicUsize,
directory_calls: AtomicUsize,
workspace_settings_calls: AtomicUsize,
workspace_plugins_enabled: AtomicBool,
fail_workspace_settings: AtomicBool,
fail_next: AtomicBool,
}
@@ -382,9 +317,6 @@ impl InstalledAppsFixture {
]),
list_tools_calls: AtomicUsize::new(0),
directory_calls: AtomicUsize::new(0),
workspace_settings_calls: AtomicUsize::new(0),
workspace_plugins_enabled: AtomicBool::new(true),
fail_workspace_settings: AtomicBool::new(false),
fail_next: AtomicBool::new(false),
});
let listener = TcpListener::bind("127.0.0.1:0").await?;
@@ -407,7 +339,6 @@ impl InstalledAppsFixture {
"/connectors/directory/list_workspace",
get(list_directory_apps),
)
.route("/accounts/account-123/settings", get(workspace_settings))
.nest_service("/api/codex/ps/mcp", mcp_service)
.with_state(Arc::clone(&state));
let handle = tokio::spawn(async move {
@@ -440,16 +371,6 @@ impl InstalledAppsFixture {
self.state.directory_calls.load(Ordering::SeqCst)
}
fn workspace_settings_calls(&self) -> usize {
self.state.workspace_settings_calls.load(Ordering::SeqCst)
}
fn set_workspace_plugins_enabled(&self, enabled: bool) {
self.state
.workspace_plugins_enabled
.store(enabled, Ordering::SeqCst);
}
fn fail_next_list_tools(&self) {
self.state.fail_next.store(true, Ordering::SeqCst);
}
@@ -467,23 +388,3 @@ async fn list_directory_apps(
state.directory_calls.fetch_add(1, Ordering::SeqCst);
Json(json!({ "apps": [], "next_token": null }))
}
async fn workspace_settings(
State(state): State<Arc<InstalledAppsServerState>>,
) -> (StatusCode, Json<serde_json::Value>) {
state
.workspace_settings_calls
.fetch_add(1, Ordering::SeqCst);
let enabled = state.workspace_plugins_enabled.load(Ordering::SeqCst);
let status = if state.fail_workspace_settings.load(Ordering::SeqCst) {
StatusCode::INTERNAL_SERVER_ERROR
} else {
StatusCode::OK
};
(
status,
Json(json!({
"beta_settings": { "enable_plugins": enabled }
})),
)
}
@@ -181,7 +181,6 @@ async fn list_apps_uses_external_chatgpt_auth() -> Result<()> {
tools,
Duration::ZERO,
Duration::ZERO,
/*workspace_plugins_enabled*/ true,
&access_token,
)
.await?;
@@ -226,69 +225,6 @@ async fn list_apps_uses_external_chatgpt_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,
icon_assets: None,
icon_dark_assets: 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 = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.without_managed_config()
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.await?;
let request_id = mcp
.send_apps_list_request(AppsListParams {
limit: Some(50),
cursor: None,
thread_id: None,
force_refetch: false,
})
.await?;
let AppsListResponse { data, next_cursor } =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
assert!(data.is_empty());
assert!(next_cursor.is_none());
server_handle.abort();
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn list_apps_includes_plugin_apps_for_chatgpt_auth() -> Result<()> {
let (server_url, server_handle) =
@@ -1521,7 +1457,6 @@ struct AppsServerState {
expected_account_id: String,
response: Arc<StdMutex<serde_json::Value>>,
directory_delay: Duration,
workspace_plugins_enabled: bool,
}
#[derive(Clone)]
@@ -1598,24 +1533,6 @@ pub(super) 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,
"chatgpt-token",
)
.await?;
Ok((server_url, server_handle))
}
async fn start_apps_server_with_delays_and_control(
connectors: Vec<AppInfo>,
tools: Vec<Tool>,
@@ -1627,7 +1544,6 @@ async fn start_apps_server_with_delays_and_control(
tools,
directory_delay,
tools_delay,
/*workspace_plugins_enabled*/ true,
"chatgpt-token",
)
.await
@@ -1638,7 +1554,6 @@ async fn start_apps_server_with_delays_and_control_inner(
tools: Vec<Tool>,
directory_delay: Duration,
tools_delay: Duration,
workspace_plugins_enabled: bool,
expected_bearer: &str,
) -> Result<(String, JoinHandle<()>, AppsServerControl)> {
let response = Arc::new(StdMutex::new(
@@ -1650,7 +1565,6 @@ async fn start_apps_server_with_delays_and_control_inner(
expected_account_id: "account-123".to_string(),
response: response.clone(),
directory_delay,
workspace_plugins_enabled,
};
let state = Arc::new(state);
let server_control = AppsServerControl {
@@ -1676,10 +1590,6 @@ async fn start_apps_server_with_delays_and_control_inner(
"/connectors/directory/list_workspace",
get(list_directory_connectors),
)
.route(
"/accounts/account-123/settings",
get(workspace_settings_response),
)
.with_state(state)
.nest_service("/api/codex/ps/mcp", mcp_service);
@@ -1690,30 +1600,6 @@ async fn start_apps_server_with_delays_and_control_inner(
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": {
"enable_plugins": state.workspace_plugins_enabled
}
})))
}
}
async fn list_directory_connectors(
State(state): State<Arc<AppsServerState>>,
headers: HeaderMap,
@@ -1,11 +1,9 @@
use std::time::Duration;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use app_test_support::create_mock_responses_server_repeating_assistant;
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;
@@ -19,7 +17,6 @@ use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_config::LoaderOverrides;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::config::ConfigBuilder;
use codex_features::FEATURES;
use codex_features::Stage;
@@ -29,12 +26,6 @@ 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);
@@ -102,68 +93,6 @@ 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":{"enable_plugins":false}}"#),
)
.mount(&server)
.await;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.without_managed_config()
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.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_list_resolves_thread_project_config() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
@@ -787,76 +787,6 @@ async fn plugin_install_rejects_remote_plugin_not_available() -> 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":{"enable_plugins":false}}"#),
)
.mount(&server)
.await;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.await?;
let request_id = mcp
.send_plugin_install_request(PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
install_attempt_id: 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()?;
@@ -2581,22 +2511,6 @@ 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"),
@@ -615,172 +615,6 @@ 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":{"enable_plugins":false}}"#),
)
.mount(&server)
.await;
let home = codex_home.path().to_string_lossy().into_owned();
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_managed_config()
.with_env_overrides(&[
("HOME", Some(home.as_str())),
("USERPROFILE", Some(home.as_str())),
])
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.await?;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
marketplace_kinds: None,
force_refetch: false,
})
.await?;
let response: PluginListResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
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":{"enable_plugins":true}}"#),
)
.mount(&server)
.await;
let home = codex_home.path().to_string_lossy().into_owned();
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_managed_config()
.with_env_overrides(&[
("HOME", Some(home.as_str())),
("USERPROFILE", Some(home.as_str())),
])
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.await?;
for _ in 0..2 {
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
marketplace_kinds: None,
force_refetch: false,
})
.await?;
let response: PluginListResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
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<()> {
@@ -4633,14 +4467,6 @@ 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,
@@ -81,47 +81,6 @@ 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(())
}
fn write_cached_remote_plugin_with_skill(
codex_home: &std::path::Path,
) -> Result<std::path::PathBuf> {
@@ -699,71 +658,6 @@ async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> 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":{"enable_plugins":false}}"#),
)
.mount(&server)
.await;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.without_managed_config()
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.await?;
let request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![repo_root.path().to_path_buf()],
force_reload: true,
})
.await?;
let SkillsListResponse { data } =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
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()?;