mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
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:
committed by
copyberry
parent
ea10ae7d0f
commit
3666a46ea3
@@ -66,7 +66,6 @@ use codex_app_server_protocol::JSONRPCRequest;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::experimental_required_message;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_chatgpt::workspace_settings;
|
||||
use codex_code_mode::CodeModeSessionProvider;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::config::Config;
|
||||
@@ -372,8 +371,6 @@ impl MessageProcessor {
|
||||
let thread_watch_manager =
|
||||
crate::thread_status::ThreadWatchManager::new_with_outgoing(outgoing.clone());
|
||||
let thread_list_state_permit = Arc::new(Semaphore::new(/*permits*/ 1));
|
||||
let workspace_settings_cache =
|
||||
Arc::new(workspace_settings::WorkspaceSettingsCache::default());
|
||||
let app_list_shutdown_token = CancellationToken::new();
|
||||
let request_serialization_queues = RequestSerializationQueues::default();
|
||||
let config_processor = ConfigRequestProcessor::new(
|
||||
@@ -402,17 +399,14 @@ impl MessageProcessor {
|
||||
Arc::clone(&thread_manager),
|
||||
outgoing.clone(),
|
||||
config_manager.clone(),
|
||||
Arc::clone(&workspace_settings_cache),
|
||||
app_list_shutdown_token,
|
||||
);
|
||||
let catalog_processor = CatalogRequestProcessor::new(
|
||||
outgoing.clone(),
|
||||
Arc::clone(&skills_watcher),
|
||||
auth_manager.clone(),
|
||||
Arc::clone(&thread_manager),
|
||||
Arc::clone(&config),
|
||||
config_manager.clone(),
|
||||
Arc::clone(&workspace_settings_cache),
|
||||
);
|
||||
let command_exec_processor = CommandExecRequestProcessor::new(
|
||||
arg0_paths.clone(),
|
||||
@@ -458,7 +452,6 @@ impl MessageProcessor {
|
||||
outgoing.clone(),
|
||||
analytics_events_client.clone(),
|
||||
config_manager.clone(),
|
||||
workspace_settings_cache,
|
||||
on_effective_plugins_changed,
|
||||
);
|
||||
let remote_control_processor = RemoteControlRequestProcessor::new(remote_control_handle);
|
||||
|
||||
@@ -324,7 +324,6 @@ use codex_backend_client::RateLimitResetCreditsDetails as BackendRateLimitResetC
|
||||
use codex_backend_client::RequestError as BackendRequestError;
|
||||
use codex_backend_client::TokenUsageProfile;
|
||||
use codex_chatgpt::connectors;
|
||||
use codex_chatgpt::workspace_settings;
|
||||
use codex_config::CloudConfigBundleLoadError;
|
||||
use codex_config::CloudConfigBundleLoadErrorCode;
|
||||
use codex_config::ConfigLayerStack;
|
||||
|
||||
@@ -12,7 +12,6 @@ pub(crate) struct AppsRequestProcessor {
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
config_manager: ConfigManager,
|
||||
workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
|
||||
shutdown_token: CancellationToken,
|
||||
_shutdown_drop_guard: DropGuard,
|
||||
}
|
||||
@@ -23,7 +22,6 @@ impl AppsRequestProcessor {
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
config_manager: ConfigManager,
|
||||
workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
|
||||
shutdown_token: CancellationToken,
|
||||
) -> Self {
|
||||
let shutdown_drop_guard = shutdown_token.clone().drop_guard();
|
||||
@@ -32,7 +30,6 @@ impl AppsRequestProcessor {
|
||||
thread_manager,
|
||||
outgoing,
|
||||
config_manager,
|
||||
workspace_settings_cache,
|
||||
shutdown_token,
|
||||
_shutdown_drop_guard: shutdown_drop_guard,
|
||||
}
|
||||
@@ -86,18 +83,6 @@ impl AppsRequestProcessor {
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
if !self
|
||||
.workspace_codex_plugins_enabled(&config, auth.as_ref())
|
||||
.await
|
||||
{
|
||||
let response = AppsListResponse {
|
||||
data: Vec::new(),
|
||||
next_cursor: None,
|
||||
};
|
||||
record_legacy_apps_installed_duration(installed_start, reload);
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
let request = request_id.clone();
|
||||
let outgoing = Arc::clone(&self.outgoing);
|
||||
let environment_manager = self.thread_manager.environment_manager();
|
||||
@@ -380,28 +365,6 @@ impl AppsRequestProcessor {
|
||||
.await
|
||||
.map_err(|err| internal_error(format!("failed to reload config: {err}")))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
|
||||
@@ -52,16 +52,10 @@ impl AppsRequestProcessor {
|
||||
.load_apps_config(params.thread_id.as_deref())
|
||||
.await?;
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let apps_enabled = config
|
||||
let runtime_enabled = config
|
||||
.features
|
||||
.apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend));
|
||||
|
||||
let workspace_enabled = apps_enabled
|
||||
&& self
|
||||
.workspace_codex_plugins_enabled(&config, auth.as_ref())
|
||||
.await;
|
||||
let runtime_enabled = apps_enabled && workspace_enabled;
|
||||
|
||||
let mcp_manager = self.thread_manager.mcp_manager();
|
||||
let mut mcp_config = mcp_manager.runtime_config(&config).await;
|
||||
// Installed-app discovery has no active turn or reviewer.
|
||||
@@ -153,11 +147,7 @@ impl AppsRequestProcessor {
|
||||
}
|
||||
} else {
|
||||
if force_refresh {
|
||||
refresh_disposition = if !apps_enabled {
|
||||
"skipped_apps_disabled"
|
||||
} else {
|
||||
"skipped_workspace_disabled"
|
||||
};
|
||||
refresh_disposition = "skipped_apps_disabled";
|
||||
retained_previous_snapshot = previous_snapshot.is_some();
|
||||
}
|
||||
previous_snapshot
|
||||
|
||||
@@ -31,9 +31,6 @@ impl AppsRequestProcessor {
|
||||
if !config
|
||||
.features
|
||||
.apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend))
|
||||
|| !self
|
||||
.workspace_codex_plugins_enabled(&config, auth.as_ref())
|
||||
.await
|
||||
{
|
||||
let response = AppsReadResponse {
|
||||
apps: Vec::new(),
|
||||
|
||||
@@ -7,11 +7,9 @@ use futures::StreamExt;
|
||||
pub(crate) struct CatalogRequestProcessor {
|
||||
pub(super) outgoing: Arc<OutgoingMessageSender>,
|
||||
pub(super) skills_watcher: Arc<SkillsWatcher>,
|
||||
pub(super) auth_manager: Arc<AuthManager>,
|
||||
pub(super) thread_manager: Arc<ThreadManager>,
|
||||
pub(super) config: Arc<Config>,
|
||||
pub(super) config_manager: ConfigManager,
|
||||
pub(super) workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
|
||||
}
|
||||
|
||||
const SKILLS_LIST_CWD_CONCURRENCY: usize = 5;
|
||||
@@ -117,20 +115,16 @@ impl CatalogRequestProcessor {
|
||||
pub(crate) fn new(
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
skills_watcher: Arc<SkillsWatcher>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
config: Arc<Config>,
|
||||
config_manager: ConfigManager,
|
||||
workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
|
||||
) -> Self {
|
||||
Self {
|
||||
outgoing,
|
||||
skills_watcher,
|
||||
auth_manager,
|
||||
thread_manager,
|
||||
config,
|
||||
config_manager,
|
||||
workspace_settings_cache,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,28 +238,6 @@ impl CatalogRequestProcessor {
|
||||
.map_err(|err| internal_error(format!("failed to reload config: {err}")))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_models(
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
http_client_factory: codex_http_client::HttpClientFactory,
|
||||
@@ -359,11 +331,6 @@ impl CatalogRequestProcessor {
|
||||
}
|
||||
None => self.load_latest_config(/*fallback_cwd*/ None).await?,
|
||||
};
|
||||
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()
|
||||
.map(|spec| {
|
||||
@@ -397,9 +364,7 @@ impl CatalogRequestProcessor {
|
||||
display_name,
|
||||
description,
|
||||
announcement,
|
||||
enabled: config.features.enabled(spec.id)
|
||||
&& (workspace_codex_plugins_enabled
|
||||
|| !matches!(spec.id, Feature::Apps | Feature::Plugins)),
|
||||
enabled: config.features.enabled(spec.id),
|
||||
default_enabled: spec.default_enabled,
|
||||
}
|
||||
})
|
||||
@@ -514,30 +479,18 @@ impl CatalogRequestProcessor {
|
||||
};
|
||||
|
||||
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let workspace_codex_plugins_enabled = self
|
||||
.workspace_codex_plugins_enabled(&config, auth.as_ref())
|
||||
.await;
|
||||
let skills_service = self.thread_manager.skills_service();
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
if force_reload
|
||||
&& workspace_codex_plugins_enabled
|
||||
&& config.features.enabled(Feature::Plugins)
|
||||
{
|
||||
if force_reload && config.features.enabled(Feature::Plugins) {
|
||||
plugins_manager.clear_cache();
|
||||
skills_service.clear_cache();
|
||||
}
|
||||
// Plugin configuration is user-scoped; workspace skill rules are applied below.
|
||||
let (effective_skill_roots, plugin_skill_snapshots) = if workspace_codex_plugins_enabled {
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let plugins = plugins_manager.plugins_for_config(&plugins_input).await;
|
||||
(
|
||||
plugins.effective_plugin_skill_roots(),
|
||||
plugins_manager.plugin_skill_snapshots_for_config(&plugins_input),
|
||||
)
|
||||
} else {
|
||||
(Vec::new(), None)
|
||||
};
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let plugins = plugins_manager.plugins_for_config(&plugins_input).await;
|
||||
let effective_skill_roots = plugins.effective_plugin_skill_roots();
|
||||
let plugin_skill_snapshots =
|
||||
plugins_manager.plugin_skill_snapshots_for_config(&plugins_input);
|
||||
let fs = self
|
||||
.thread_manager
|
||||
.environment_manager()
|
||||
@@ -628,7 +581,6 @@ impl CatalogRequestProcessor {
|
||||
cwds
|
||||
};
|
||||
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
let mut data = Vec::new();
|
||||
for cwd in cwds {
|
||||
@@ -656,12 +608,7 @@ impl CatalogRequestProcessor {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let workspace_codex_plugins_enabled = self
|
||||
.workspace_codex_plugins_enabled(&config, auth.as_ref())
|
||||
.await;
|
||||
let plugins_enabled =
|
||||
config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled;
|
||||
let plugin_hooks = if plugins_enabled {
|
||||
let plugin_hooks = if config.features.enabled(Feature::Plugins) {
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
|
||||
codex_core_plugins::PluginHookLoadOutcome {
|
||||
|
||||
@@ -54,7 +54,6 @@ pub(crate) struct PluginRequestProcessor {
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
analytics_events_client: AnalyticsEventsClient,
|
||||
config_manager: ConfigManager,
|
||||
workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
|
||||
on_effective_plugins_changed:
|
||||
Arc<dyn Fn(codex_core_plugins::EffectivePluginsChange) + Send + Sync>,
|
||||
}
|
||||
@@ -400,7 +399,6 @@ impl PluginRequestProcessor {
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
analytics_events_client: AnalyticsEventsClient,
|
||||
config_manager: ConfigManager,
|
||||
workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
|
||||
on_effective_plugins_changed: Arc<
|
||||
dyn Fn(codex_core_plugins::EffectivePluginsChange) + Send + Sync,
|
||||
>,
|
||||
@@ -411,7 +409,6 @@ impl PluginRequestProcessor {
|
||||
outgoing,
|
||||
analytics_events_client,
|
||||
config_manager,
|
||||
workspace_settings_cache,
|
||||
on_effective_plugins_changed,
|
||||
}
|
||||
}
|
||||
@@ -542,28 +539,6 @@ impl PluginRequestProcessor {
|
||||
.map_err(|err| internal_error(format!("failed to reload config: {err}")))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn plugin_list_response(
|
||||
&self,
|
||||
params: PluginListParams,
|
||||
@@ -591,12 +566,6 @@ impl PluginRequestProcessor {
|
||||
return Ok(empty_response());
|
||||
}
|
||||
let auth = self.auth_manager.auth().await;
|
||||
if !self
|
||||
.workspace_codex_plugins_enabled(&config, auth.as_ref())
|
||||
.await
|
||||
{
|
||||
return Ok(empty_response());
|
||||
}
|
||||
let auth_mode = auth.as_ref().map(CodexAuth::api_auth_mode);
|
||||
plugins_manager.set_auth_mode(auth_mode);
|
||||
let plugins_input = config.plugins_config_input();
|
||||
@@ -851,12 +820,6 @@ impl PluginRequestProcessor {
|
||||
return Ok(empty_response());
|
||||
}
|
||||
let auth = self.auth_manager.auth().await;
|
||||
if !self
|
||||
.workspace_codex_plugins_enabled(&config, auth.as_ref())
|
||||
.await
|
||||
{
|
||||
return Ok(empty_response());
|
||||
}
|
||||
let auth_mode = auth.as_ref().map(CodexAuth::api_auth_mode);
|
||||
plugins_manager.set_auth_mode(auth_mode);
|
||||
|
||||
@@ -1506,15 +1469,6 @@ impl PluginRequestProcessor {
|
||||
let config = self.load_latest_config(config_cwd.clone()).await?;
|
||||
let auth = self.auth_manager.auth().await;
|
||||
|
||||
if !self
|
||||
.workspace_codex_plugins_enabled(&config, auth.as_ref())
|
||||
.await
|
||||
{
|
||||
return Err(invalid_request(
|
||||
"Codex plugins are disabled for this workspace",
|
||||
));
|
||||
}
|
||||
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
let marketplace_display = marketplace_path.display().to_string();
|
||||
let plugin_name_for_log = plugin_name.clone();
|
||||
|
||||
@@ -49,13 +49,6 @@ impl PluginRequestProcessor {
|
||||
let plugin_sharing_enabled = config.features.enabled(Feature::PluginSharing);
|
||||
|
||||
let auth = self.auth_manager.auth().await;
|
||||
if !self
|
||||
.workspace_codex_plugins_enabled(&config, auth.as_ref())
|
||||
.await
|
||||
{
|
||||
return Ok(empty_response());
|
||||
}
|
||||
|
||||
let auth_mode = auth.as_ref().map(CodexAuth::api_auth_mode);
|
||||
self.thread_manager
|
||||
.plugins_manager()
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -2,4 +2,3 @@ pub mod apply_command;
|
||||
mod chatgpt_client;
|
||||
pub mod connectors;
|
||||
pub mod get_task;
|
||||
pub mod workspace_settings;
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
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 = "enable_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);
|
||||
}
|
||||
|
||||
if !auth.is_workspace_account() {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let Some(account_id) = auth.get_account_id().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.clone(),
|
||||
};
|
||||
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;
|
||||
@@ -1,17 +0,0 @@
|
||||
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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user