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
parent f802f0a391
commit bcc1caa920
11 changed files with 851 additions and 7 deletions

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)

View File

@@ -2,3 +2,4 @@ pub mod apply_command;
mod chatgpt_client;
pub mod connectors;
pub mod get_task;
pub mod workspace_settings;

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;

View File

@@ -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"
);
}