Thread first-party plugin execution context

This commit is contained in:
Kyle Brown
2026-06-17 20:59:19 +00:00
parent a0586ad12d
commit 8c6f3501af
9 changed files with 214 additions and 3 deletions
+59 -1
View File
@@ -1,6 +1,7 @@
use crate::app_mcp_routing::apply_app_mcp_routing_policy;
use crate::app_mcp_routing::apps_route_available;
use crate::is_openai_curated_marketplace_name;
use crate::manifest::PluginManifest;
use crate::manifest::PluginManifestHooks;
use crate::manifest::PluginManifestMcpServers;
use crate::manifest::PluginManifestPaths;
@@ -10,6 +11,8 @@ use crate::marketplace::list_marketplaces;
use crate::marketplace::load_marketplace;
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use crate::remote::RemoteInstalledPlugin;
use crate::startup_sync::curated_plugins_repo_path;
use crate::startup_sync::read_curated_plugins_sha;
use crate::store::PluginStore;
use crate::store::plugin_version_for_source;
use codex_app_server_protocol::AuthMode;
@@ -654,7 +657,7 @@ async fn load_plugin(
let active_plugin_root = plugin_id
.as_ref()
.ok()
.and_then(|plugin_id| store.active_plugin_root(plugin_id));
.and_then(|plugin_id| active_plugin_root(plugin_id, store));
let root = active_plugin_root
.clone()
.unwrap_or_else(|| match &plugin_id {
@@ -665,6 +668,7 @@ async fn load_plugin(
config_name,
manifest_name: None,
manifest_description: None,
is_first_party: false,
root,
enabled: plugin.enabled,
skill_roots: Vec::new(),
@@ -705,6 +709,7 @@ async fn load_plugin(
return loaded_plugin;
};
loaded_plugin.is_first_party = is_openai_curated_plugin(&loaded_plugin_id, &manifest, store);
let manifest_paths = &manifest.paths;
match scope {
PluginLoadScope::AllCapabilities {
@@ -746,6 +751,59 @@ async fn load_plugin(
loaded_plugin
}
fn active_plugin_root(plugin_id: &PluginId, store: &PluginStore) -> Option<AbsolutePathBuf> {
if is_openai_curated_marketplace_name(&plugin_id.marketplace_name)
&& let Some(curated_sha) = read_curated_plugins_sha(store.codex_home())
{
let expected_version = curated_plugin_cache_version(&curated_sha);
let expected_root = store.plugin_root(plugin_id, &expected_version);
if expected_root.as_path().is_dir() {
return Some(expected_root);
}
}
store.active_plugin_root(plugin_id)
}
fn is_openai_curated_plugin(
plugin_id: &PluginId,
manifest: &PluginManifest,
store: &PluginStore,
) -> bool {
if !is_openai_curated_marketplace_name(&plugin_id.marketplace_name)
|| manifest
.interface
.as_ref()
.and_then(|interface| interface.developer_name.as_deref())
!= Some("OpenAI")
{
return false;
}
let Some(curated_sha) = read_curated_plugins_sha(store.codex_home()) else {
return false;
};
let expected_version = curated_plugin_cache_version(&curated_sha);
if !store
.plugin_root(plugin_id, &expected_version)
.as_path()
.is_dir()
{
return false;
}
let marketplace_path =
curated_plugins_repo_path(store.codex_home()).join(".agents/plugins/marketplace.json");
let Ok(marketplace_path) = AbsolutePathBuf::try_from(marketplace_path) else {
return false;
};
load_marketplace(&marketplace_path).is_ok_and(|marketplace| {
marketplace
.plugins
.iter()
.any(|plugin| plugin.name == plugin_id.plugin_name)
})
}
fn apply_plugin_mcp_server_policy(config: &mut McpServerConfig, policy: &PluginMcpServerConfig) {
config.enabled = policy.enabled;
if let Some(approval_mode) = policy.default_tools_approval_mode {
+30
View File
@@ -2,6 +2,7 @@ use super::LoadedPlugin;
use super::PluginLoadOutcome;
use crate::app_mcp_routing::apply_app_mcp_routing_policy;
use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack;
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
use crate::is_openai_curated_marketplace_name;
use crate::loader::PluginHookLoadOutcome;
use crate::loader::configured_curated_plugin_ids_from_codex_home;
@@ -1091,6 +1092,7 @@ impl PluginsManager {
return Err(err.into());
}
};
self.validate_curated_marketplace_install_source(&request.marketplace_path, &resolved)?;
let plugin_id = resolved.plugin_id.clone();
match self.install_resolved_plugin(resolved).await {
Ok(outcome) => Ok(outcome),
@@ -1122,6 +1124,7 @@ impl PluginsManager {
return Err(err.into());
}
};
self.validate_curated_marketplace_install_source(&request.marketplace_path, &resolved)?;
let plugin_id = resolved.plugin_id.as_key();
// This only forwards the backend mutation before the local install flow.
if let Err(err) = crate::remote_legacy::enable_remote_plugin(
@@ -1208,6 +1211,33 @@ impl PluginsManager {
}
}
fn validate_curated_marketplace_install_source(
&self,
marketplace_path: &AbsolutePathBuf,
resolved: &ResolvedMarketplacePlugin,
) -> Result<(), PluginInstallError> {
if resolved.plugin_id.marketplace_name != OPENAI_CURATED_MARKETPLACE_NAME {
return Ok(());
}
let expected_path = curated_plugins_repo_path(self.codex_home.as_path())
.join(".agents/plugins/marketplace.json");
let expected_path = AbsolutePathBuf::try_from(expected_path)
.ok()
.and_then(|path| path.canonicalize().ok());
let requested_path = marketplace_path.canonicalize().ok();
if requested_path
.zip(expected_path)
.is_none_or(|(requested, expected)| requested != expected)
{
return Err(PluginStoreError::Invalid(format!(
"marketplace '{OPENAI_CURATED_MARKETPLACE_NAME}' can only be installed from the synced curated marketplace"
))
.into());
}
Ok(())
}
async fn install_resolved_plugin(
&self,
resolved: ResolvedMarketplacePlugin,
+39 -1
View File
@@ -574,7 +574,10 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "sample",
"description": "Plugin that includes the sample MCP server and Skills"
"description": "Plugin that includes the sample MCP server and Skills",
"interface": {
"developerName": "OpenAI"
}
}"#,
);
write_file(
@@ -622,6 +625,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
manifest_description: Some(
"Plugin that includes the sample MCP server and Skills".to_string(),
),
is_first_party: false,
root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(),
enabled: true,
skill_roots: vec![plugin_root.join("skills").abs()],
@@ -1586,6 +1590,7 @@ async fn load_plugins_preserves_disabled_plugins_without_effective_contributions
config_name: "sample@test".to_string(),
manifest_name: None,
manifest_description: None,
is_first_party: false,
root: AbsolutePathBuf::try_from(plugin_root).unwrap(),
enabled: false,
skill_roots: Vec::new(),
@@ -1754,6 +1759,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
config_name: config_name.to_string(),
manifest_name: Some(manifest_name.to_string()),
manifest_description: None,
is_first_party: false,
root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(),
enabled: true,
skill_roots: Vec::new(),
@@ -2077,6 +2083,38 @@ async fn install_openai_curated_plugin_uses_short_sha_cache_version() {
);
}
#[tokio::test]
async fn install_plugin_rejects_spoofed_openai_curated_marketplace() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
let spoofed_root = tmp.path().join("spoofed-marketplace");
write_openai_curated_marketplace(&spoofed_root, &["slack"]);
let err = PluginsManager::new(tmp.path().to_path_buf())
.install_plugin(PluginInstallRequest {
plugin_name: "slack".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
spoofed_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
})
.await
.unwrap_err();
assert!(err.is_invalid_request());
assert!(
err.to_string()
.contains("can only be installed from the synced curated marketplace")
);
assert!(
!tmp.path()
.join("plugins/cache/openai-curated/slack")
.exists()
);
}
#[tokio::test]
async fn install_plugin_uses_manifest_version_for_non_curated_plugins() {
let tmp = tempfile::tempdir().unwrap();
+12 -1
View File
@@ -26,6 +26,7 @@ pub struct PluginInstallResult {
#[derive(Debug, Clone)]
pub struct PluginStore {
codex_home: AbsolutePathBuf,
root: AbsolutePathBuf,
data_root: AbsolutePathBuf,
}
@@ -42,8 +43,18 @@ impl PluginStore {
let data_root =
AbsolutePathBuf::from_absolute_path_checked(codex_home.join(PLUGINS_DATA_DIR))
.map_err(|err| PluginStoreError::io("failed to resolve plugin data root", err))?;
let codex_home = AbsolutePathBuf::from_absolute_path_checked(codex_home)
.map_err(|err| PluginStoreError::io("failed to resolve Codex home", err))?;
Ok(Self { root, data_root })
Ok(Self {
codex_home,
root,
data_root,
})
}
pub fn codex_home(&self) -> &AbsolutePathBuf {
&self.codex_home
}
pub fn root(&self) -> &AbsolutePathBuf {
+1
View File
@@ -136,6 +136,7 @@ pub(super) async fn spawn_review_thread(
turn_metadata_state,
extension_data,
turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.snapshot.clone()),
first_party_plugin_roots: Arc::clone(&parent_turn_context.first_party_plugin_roots),
turn_timing_state: Arc::new(TurnTimingState::default()),
terminal_error: Arc::new(Mutex::new(None)),
server_model_warning_emitted: AtomicBool::new(false),
+4
View File
@@ -5048,6 +5048,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
.plugins_manager
.plugins_for_config(&per_turn_config.plugins_config_input())
.await;
let first_party_plugin_roots = plugin_outcome.effective_first_party_plugin_roots();
let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots();
let skills_input =
crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots);
@@ -5075,6 +5076,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
session_configuration.cwd().clone(),
"turn_id".to_string(),
skills_snapshot,
first_party_plugin_roots,
);
let session = Session {
@@ -7091,6 +7093,7 @@ where
.plugins_manager
.plugins_for_config(&per_turn_config.plugins_config_input())
.await;
let first_party_plugin_roots = plugin_outcome.effective_first_party_plugin_roots();
let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots();
let skills_input =
crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots);
@@ -7118,6 +7121,7 @@ where
session_configuration.cwd().clone(),
"turn_id".to_string(),
skills_snapshot,
first_party_plugin_roots,
));
let session = Arc::new(Session {
@@ -6,6 +6,7 @@ use codex_core_skills::HostSkillsSnapshot;
use codex_file_system::FileSystemSandboxContext;
use codex_model_provider::SharedModelProvider;
use codex_model_provider::create_model_provider;
use codex_plugin::FirstPartyPluginRoot;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::models::AdditionalPermissionProfile;
@@ -138,6 +139,7 @@ pub struct TurnContext {
pub(crate) turn_metadata_state: Arc<TurnMetadataState>,
pub(crate) extension_data: Arc<codex_extension_api::ExtensionData>,
pub(crate) turn_skills: TurnSkillsContext,
pub(crate) first_party_plugin_roots: Arc<Vec<FirstPartyPluginRoot>>,
pub(crate) turn_timing_state: Arc<TurnTimingState>,
pub(crate) terminal_error: Arc<Mutex<Option<String>>>,
pub(crate) server_model_warning_emitted: AtomicBool,
@@ -289,6 +291,7 @@ impl TurnContext {
turn_metadata_state: self.turn_metadata_state.clone(),
extension_data: Arc::clone(&self.extension_data),
turn_skills: self.turn_skills.clone(),
first_party_plugin_roots: Arc::clone(&self.first_party_plugin_roots),
turn_timing_state: Arc::clone(&self.turn_timing_state),
terminal_error: Arc::clone(&self.terminal_error),
server_model_warning_emitted: AtomicBool::new(
@@ -494,6 +497,7 @@ impl Session {
cwd: AbsolutePathBuf,
sub_id: String,
skills_snapshot: HostSkillsSnapshot,
first_party_plugin_roots: Vec<FirstPartyPluginRoot>,
) -> TurnContext {
let reasoning_effort = session_configuration.collaboration_mode.reasoning_effort();
let reasoning_summary = session_configuration
@@ -575,6 +579,7 @@ impl Session {
turn_metadata_state,
extension_data,
turn_skills: TurnSkillsContext::new(skills_snapshot),
first_party_plugin_roots: Arc::new(first_party_plugin_roots),
turn_timing_state: Arc::new(TurnTimingState::default()),
terminal_error: Arc::new(Mutex::new(None)),
server_model_warning_emitted: AtomicBool::new(false),
@@ -727,6 +732,7 @@ impl Session {
.plugins_manager
.plugins_for_config(&per_turn_config.plugins_config_input())
.await;
let first_party_plugin_roots = plugin_outcome.effective_first_party_plugin_roots();
let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots();
let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots);
let fs = primary_turn_environment
@@ -764,6 +770,7 @@ impl Session {
cwd,
sub_id,
skills_snapshot,
first_party_plugin_roots,
);
turn_context.realtime_active = self.conversation.running_state().await.is_some();
+1
View File
@@ -13,6 +13,7 @@ mod provider;
use codex_config::HookEventsToml;
use codex_utils_absolute_path::AbsolutePathBuf;
pub use load_outcome::EffectiveSkillRoots;
pub use load_outcome::FirstPartyPluginRoot;
pub use load_outcome::LoadedPlugin;
pub use load_outcome::PluginLoadOutcome;
pub use load_outcome::prompt_safe_plugin_description;
+61
View File
@@ -11,6 +11,11 @@ use crate::PluginHookSource;
use crate::app_connector_ids_from_declarations;
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FirstPartyPluginRoot {
pub plugin_id: String,
pub plugin_root: AbsolutePathBuf,
}
/// A plugin that was loaded from disk, including merged MCP server definitions.
#[derive(Debug, Clone, PartialEq)]
@@ -18,6 +23,7 @@ pub struct LoadedPlugin<M> {
pub config_name: String,
pub manifest_name: Option<String>,
pub manifest_description: Option<String>,
pub is_first_party: bool,
pub root: AbsolutePathBuf,
pub enabled: bool,
pub skill_roots: Vec<AbsolutePathBuf>,
@@ -141,6 +147,26 @@ impl<M: Clone> PluginLoadOutcome<M> {
skill_roots
}
pub fn effective_first_party_plugin_roots(&self) -> Vec<FirstPartyPluginRoot> {
let mut plugin_roots = Vec::new();
let mut seen_paths = HashSet::new();
for plugin in self
.plugins
.iter()
.filter(|plugin| plugin.is_active() && plugin.is_first_party)
{
if seen_paths.insert(plugin.root.clone()) {
plugin_roots.push(FirstPartyPluginRoot {
plugin_id: plugin.config_name.clone(),
plugin_root: plugin.root.clone(),
});
}
}
plugin_roots.sort_unstable_by(|a, b| a.plugin_root.cmp(&b.plugin_root));
plugin_roots
}
pub fn effective_mcp_servers(&self) -> HashMap<String, M> {
let mut mcp_servers = HashMap::new();
for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
@@ -219,6 +245,7 @@ mod tests {
config_name: config_name.to_string(),
manifest_name: None,
manifest_description: None,
is_first_party: false,
root: test_path(config_name),
enabled: true,
skill_roots,
@@ -249,4 +276,38 @@ mod tests {
}]
);
}
#[test]
fn effective_first_party_plugin_roots_only_includes_active_openai_plugins() {
let shared_root = test_path("shared-plugin");
let mut first_party = loaded_plugin("first@test", Vec::new());
first_party.root = shared_root.clone();
first_party.is_first_party = true;
let mut duplicate = loaded_plugin("duplicate@test", Vec::new());
duplicate.root = shared_root.clone();
duplicate.is_first_party = true;
let third_party = loaded_plugin("third@test", Vec::new());
let mut disabled = loaded_plugin("disabled@test", Vec::new());
disabled.is_first_party = true;
disabled.enabled = false;
let mut broken = loaded_plugin("broken@test", Vec::new());
broken.is_first_party = true;
broken.error = Some("broken".to_string());
let outcome = PluginLoadOutcome::from_plugins(vec![
first_party,
duplicate,
third_party,
disabled,
broken,
]);
assert_eq!(
outcome.effective_first_party_plugin_roots(),
vec![FirstPartyPluginRoot {
plugin_id: "first@test".to_string(),
plugin_root: shared_root,
}]
);
}
}