Compare commits

...

9 Commits

Author SHA1 Message Date
Ahmed Ibrahim
4cc0d1c053 Add Bazel target for extensions crate
Co-authored-by: Codex <noreply@openai.com>
2026-03-18 05:46:47 +00:00
Ahmed Ibrahim
3848e8e43e Fix extensions extraction regressions
Co-authored-by: Codex <noreply@openai.com>
2026-03-18 05:36:22 +00:00
Ahmed Ibrahim
d174c6ad4c Restore plugin capability summary adapters
Co-authored-by: Codex <noreply@openai.com>
2026-03-18 05:25:51 +00:00
Ahmed Ibrahim
bb9dcc5982 codex: fix CI failure on PR #15028
Co-authored-by: Codex <noreply@openai.com>
2026-03-18 05:16:41 +00:00
Ahmed Ibrahim
342b8e40a1 Extract codex-extensions from codex-core
Co-authored-by: Codex <noreply@openai.com>
2026-03-18 05:06:44 +00:00
Ahmed Ibrahim
1cf68f940c codex: fix CI failure on PR #15010
Co-authored-by: Codex <noreply@openai.com>
2026-03-18 04:05:05 +00:00
Ahmed Ibrahim
0f406c3de0 codex: address PR review feedback (#15010)
Co-authored-by: Codex <noreply@openai.com>
2026-03-18 03:49:51 +00:00
Ahmed Ibrahim
8b3fc35e0b fix: unblock config loader split CI
Co-authored-by: Codex <noreply@openai.com>
2026-03-18 03:34:46 +00:00
Ahmed Ibrahim
38a28973a8 refactor: move config loader internals into codex-config
Extract config-layer IO and managed requirements loading into codex-config so codex-core keeps a thinner config loader facade.

Co-authored-by: Codex <noreply@openai.com>
2026-03-18 02:30:22 +00:00
28 changed files with 1443 additions and 1462 deletions

18
codex-rs/Cargo.lock generated
View File

@@ -1786,10 +1786,12 @@ name = "codex-config"
version = "0.0.0"
dependencies = [
"anyhow",
"base64 0.22.1",
"codex-app-server-protocol",
"codex-execpolicy",
"codex-protocol",
"codex-utils-absolute-path",
"core-foundation 0.9.4",
"futures",
"multimap",
"pretty_assertions",
@@ -1802,6 +1804,7 @@ dependencies = [
"toml 0.9.11+spec-1.1.0",
"toml_edit 0.24.0+spec-1.1.0",
"tracing",
"windows-sys 0.52.0",
]
[[package]]
@@ -1842,6 +1845,7 @@ dependencies = [
"codex-config",
"codex-connectors",
"codex-execpolicy",
"codex-extensions",
"codex-file-search",
"codex-git",
"codex-hooks",
@@ -1866,7 +1870,6 @@ dependencies = [
"codex-utils-stream-parser",
"codex-utils-string",
"codex-windows-sandbox",
"core-foundation 0.9.4",
"core_test_support",
"csv",
"ctor 0.6.3",
@@ -1926,7 +1929,6 @@ dependencies = [
"walkdir",
"which",
"wildmatch",
"windows-sys 0.52.0",
"wiremock",
"zip",
"zstd",
@@ -2035,6 +2037,18 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "codex-extensions"
version = "0.0.0"
dependencies = [
"codex-protocol",
"codex-utils-absolute-path",
"serde",
"serde_json",
"thiserror 2.0.18",
"tracing",
]
[[package]]
name = "codex-feedback"
version = "0.0.0"

View File

@@ -27,6 +27,7 @@ members = [
"exec",
"execpolicy",
"execpolicy-legacy",
"extensions",
"keyring-store",
"file-search",
"linux-sandbox",
@@ -105,6 +106,7 @@ codex-config = { path = "config" }
codex-core = { path = "core" }
codex-exec = { path = "exec" }
codex-execpolicy = { path = "execpolicy" }
codex-extensions = { path = "extensions" }
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
codex-feedback = { path = "feedback" }
codex-file-search = { path = "file-search" }

View File

@@ -4,10 +4,14 @@ version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
doctest = false
[lints]
workspace = true
[dependencies]
base64 = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-execpolicy = { workspace = true }
codex-protocol = { workspace = true }
@@ -24,6 +28,16 @@ toml = { workspace = true }
toml_edit = { workspace = true }
tracing = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.9"
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.52", features = [
"Win32_Foundation",
"Win32_System_Com",
"Win32_UI_Shell",
] }
[dev-dependencies]
anyhow = { workspace = true }
pretty_assertions = { workspace = true }

View File

@@ -1,10 +1,10 @@
use super::LoaderOverrides;
use crate::LoaderOverrides;
use crate::config_error_from_toml;
use crate::io_error_from_config_error;
#[cfg(target_os = "macos")]
use super::macos::ManagedAdminConfigLayer;
use crate::macos::ManagedAdminConfigLayer;
#[cfg(target_os = "macos")]
use super::macos::load_managed_admin_config_layer;
use codex_config::config_error_from_toml;
use codex_config::io_error_from_config_error;
use crate::macos::load_managed_admin_config_layer;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::io;
use std::path::Path;
@@ -16,26 +16,26 @@ use toml::Value as TomlValue;
const CODEX_MANAGED_CONFIG_SYSTEM_PATH: &str = "/etc/codex/managed_config.toml";
#[derive(Debug, Clone)]
pub(super) struct MangedConfigFromFile {
pub struct ManagedConfigFromFile {
pub managed_config: TomlValue,
pub file: AbsolutePathBuf,
}
#[derive(Debug, Clone)]
pub(super) struct ManagedConfigFromMdm {
pub struct ManagedConfigFromMdm {
pub managed_config: TomlValue,
pub raw_toml: String,
}
#[derive(Debug, Clone)]
pub(super) struct LoadedConfigLayers {
pub struct LoadedConfigLayers {
/// If present, data read from a file such as `/etc/codex/managed_config.toml`.
pub managed_config: Option<MangedConfigFromFile>,
pub managed_config: Option<ManagedConfigFromFile>,
/// If present, data read from managed preferences (macOS only).
pub managed_config_from_mdm: Option<ManagedConfigFromMdm>,
}
pub(super) async fn load_config_layers_internal(
pub async fn load_config_layers_internal(
codex_home: &Path,
overrides: LoaderOverrides,
) -> io::Result<LoadedConfigLayers> {
@@ -59,7 +59,7 @@ pub(super) async fn load_config_layers_internal(
let managed_config =
read_config_from_path(&managed_config_path, /*log_missing_as_info*/ false)
.await?
.map(|managed_config| MangedConfigFromFile {
.map(|managed_config| ManagedConfigFromFile {
managed_config,
file: managed_config_path.clone(),
});
@@ -88,7 +88,7 @@ fn map_managed_admin_layer(layer: ManagedAdminConfigLayer) -> ManagedConfigFromM
}
}
pub(super) async fn read_config_from_path(
async fn read_config_from_path(
path: impl AsRef<Path>,
log_missing_as_info: bool,
) -> io::Result<Option<TomlValue>> {
@@ -120,8 +120,7 @@ pub(super) async fn read_config_from_path(
}
}
/// Return the default managed config path.
pub(super) fn managed_config_default_path(codex_home: &Path) -> PathBuf {
fn managed_config_default_path(codex_home: &Path) -> PathBuf {
#[cfg(unix)]
{
let _ = codex_home;

View File

@@ -3,6 +3,10 @@ mod config_requirements;
mod constraint;
mod diagnostics;
mod fingerprint;
mod layer_io;
mod loader;
#[cfg(target_os = "macos")]
mod macos;
mod merge;
mod overrides;
mod requirements_exec_policy;
@@ -44,6 +48,15 @@ pub use diagnostics::format_config_error;
pub use diagnostics::format_config_error_with_source;
pub use diagnostics::io_error_from_config_error;
pub use fingerprint::version_for_toml;
pub use layer_io::LoadedConfigLayers;
pub use layer_io::ManagedConfigFromFile;
pub use layer_io::ManagedConfigFromMdm;
pub use layer_io::load_config_layers_internal;
pub use loader::load_managed_admin_requirements;
pub use loader::load_requirements_from_legacy_scheme;
pub use loader::load_requirements_toml;
pub use loader::system_config_toml_file;
pub use loader::system_requirements_toml_file;
pub use merge::merge_toml_values;
pub use overrides::build_cli_overrides_layer;
pub use requirements_exec_policy::RequirementsExecPolicy;

View File

@@ -0,0 +1,236 @@
use crate::ConfigRequirementsToml;
use crate::ConfigRequirementsWithSources;
use crate::LoadedConfigLayers;
use crate::RequirementSource;
#[cfg(target_os = "macos")]
use crate::macos::load_managed_admin_requirements_toml;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::protocol::AskForApproval;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use std::io;
use std::path::Path;
#[cfg(windows)]
use std::path::PathBuf;
#[cfg(unix)]
pub const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml";
#[cfg(windows)]
const DEFAULT_PROGRAM_DATA_DIR_WINDOWS: &str = r"C:\ProgramData";
pub async fn load_requirements_toml(
config_requirements_toml: &mut ConfigRequirementsWithSources,
requirements_toml_file: impl AsRef<Path>,
) -> io::Result<()> {
let requirements_toml_file =
AbsolutePathBuf::from_absolute_path(requirements_toml_file.as_ref())?;
match tokio::fs::read_to_string(&requirements_toml_file).await {
Ok(contents) => {
let requirements_config: ConfigRequirementsToml =
toml::from_str(&contents).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidData,
format!(
"Error parsing requirements file {}: {err}",
requirements_toml_file.as_ref().display(),
),
)
})?;
config_requirements_toml.merge_unset_fields(
RequirementSource::SystemRequirementsToml {
file: requirements_toml_file.clone(),
},
requirements_config,
);
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
Err(err) => {
return Err(io::Error::new(
err.kind(),
format!(
"Failed to read requirements file {}: {err}",
requirements_toml_file.as_ref().display(),
),
));
}
}
Ok(())
}
pub async fn load_managed_admin_requirements(
config_requirements_toml: &mut ConfigRequirementsWithSources,
managed_config_requirements_base64: Option<&str>,
) -> io::Result<()> {
#[cfg(target_os = "macos")]
{
load_managed_admin_requirements_toml(
config_requirements_toml,
managed_config_requirements_base64,
)
.await
}
#[cfg(not(target_os = "macos"))]
{
let _ = config_requirements_toml;
let _ = managed_config_requirements_base64;
Ok(())
}
}
#[cfg(unix)]
pub fn system_requirements_toml_file() -> io::Result<AbsolutePathBuf> {
AbsolutePathBuf::from_absolute_path(Path::new("/etc/codex/requirements.toml"))
}
#[cfg(windows)]
pub fn system_requirements_toml_file() -> io::Result<AbsolutePathBuf> {
windows_system_requirements_toml_file()
}
#[cfg(unix)]
pub fn system_config_toml_file() -> io::Result<AbsolutePathBuf> {
AbsolutePathBuf::from_absolute_path(Path::new(SYSTEM_CONFIG_TOML_FILE_UNIX))
}
#[cfg(windows)]
pub fn system_config_toml_file() -> io::Result<AbsolutePathBuf> {
windows_system_config_toml_file()
}
#[cfg(windows)]
fn windows_codex_system_dir() -> PathBuf {
let program_data = windows_program_data_dir_from_known_folder().unwrap_or_else(|err| {
tracing::warn!(
error = %err,
"Failed to resolve ProgramData known folder; using default path"
);
PathBuf::from(DEFAULT_PROGRAM_DATA_DIR_WINDOWS)
});
program_data.join("OpenAI").join("Codex")
}
#[cfg(windows)]
fn windows_system_requirements_toml_file() -> io::Result<AbsolutePathBuf> {
let requirements_toml_file = windows_codex_system_dir().join("requirements.toml");
AbsolutePathBuf::try_from(requirements_toml_file)
}
#[cfg(windows)]
fn windows_system_config_toml_file() -> io::Result<AbsolutePathBuf> {
let config_toml_file = windows_codex_system_dir().join("config.toml");
AbsolutePathBuf::try_from(config_toml_file)
}
#[cfg(windows)]
fn windows_program_data_dir_from_known_folder() -> io::Result<PathBuf> {
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt;
use windows_sys::Win32::System::Com::CoTaskMemFree;
use windows_sys::Win32::UI::Shell::FOLDERID_ProgramData;
use windows_sys::Win32::UI::Shell::KF_FLAG_DEFAULT;
use windows_sys::Win32::UI::Shell::SHGetKnownFolderPath;
let mut path_ptr = std::ptr::null_mut::<u16>();
let known_folder_flags = u32::try_from(KF_FLAG_DEFAULT).map_err(|_| {
io::Error::other(format!(
"KF_FLAG_DEFAULT did not fit in u32: {KF_FLAG_DEFAULT}"
))
})?;
let hr = unsafe {
SHGetKnownFolderPath(&FOLDERID_ProgramData, known_folder_flags, 0, &mut path_ptr)
};
if hr != 0 {
return Err(io::Error::other(format!(
"SHGetKnownFolderPath(FOLDERID_ProgramData) failed with HRESULT {hr:#010x}"
)));
}
if path_ptr.is_null() {
return Err(io::Error::other(
"SHGetKnownFolderPath(FOLDERID_ProgramData) returned a null pointer",
));
}
let path = unsafe {
let mut len = 0usize;
while *path_ptr.add(len) != 0 {
len += 1;
}
let wide = std::slice::from_raw_parts(path_ptr, len);
let path = PathBuf::from(OsString::from_wide(wide));
CoTaskMemFree(path_ptr.cast());
path
};
Ok(path)
}
pub async fn load_requirements_from_legacy_scheme(
config_requirements_toml: &mut ConfigRequirementsWithSources,
loaded_config_layers: LoadedConfigLayers,
) -> io::Result<()> {
let LoadedConfigLayers {
managed_config,
managed_config_from_mdm,
} = loaded_config_layers;
for (source, config) in managed_config_from_mdm
.map(|config| {
(
RequirementSource::LegacyManagedConfigTomlFromMdm,
config.managed_config,
)
})
.into_iter()
.chain(managed_config.map(|config| {
(
RequirementSource::LegacyManagedConfigTomlFromFile { file: config.file },
config.managed_config,
)
}))
{
let legacy_config: LegacyManagedConfigToml =
config.try_into().map_err(|err: toml::de::Error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Failed to parse config requirements as TOML: {err}"),
)
})?;
let requirements = ConfigRequirementsToml::from(legacy_config);
config_requirements_toml.merge_unset_fields(source, requirements);
}
Ok(())
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
struct LegacyManagedConfigToml {
approval_policy: Option<AskForApproval>,
sandbox_mode: Option<SandboxMode>,
}
impl From<LegacyManagedConfigToml> for ConfigRequirementsToml {
fn from(legacy: LegacyManagedConfigToml) -> Self {
let mut config_requirements_toml = ConfigRequirementsToml::default();
let LegacyManagedConfigToml {
approval_policy,
sandbox_mode,
} = legacy;
if let Some(approval_policy) = approval_policy {
config_requirements_toml.allowed_approval_policies = Some(vec![approval_policy]);
}
if let Some(sandbox_mode) = sandbox_mode {
let required_mode = sandbox_mode.into();
let mut allowed_modes = vec![crate::SandboxModeRequirement::ReadOnly];
if required_mode != crate::SandboxModeRequirement::ReadOnly {
allowed_modes.push(required_mode);
}
config_requirements_toml.allowed_sandbox_modes = Some(allowed_modes);
}
config_requirements_toml
}
}

View File

@@ -1,6 +1,6 @@
use super::ConfigRequirementsToml;
use super::ConfigRequirementsWithSources;
use super::RequirementSource;
use crate::ConfigRequirementsToml;
use crate::ConfigRequirementsWithSources;
use crate::RequirementSource;
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use core_foundation::base::TCFType;
@@ -16,19 +16,19 @@ const MANAGED_PREFERENCES_CONFIG_KEY: &str = "config_toml_base64";
const MANAGED_PREFERENCES_REQUIREMENTS_KEY: &str = "requirements_toml_base64";
#[derive(Debug, Clone)]
pub(super) struct ManagedAdminConfigLayer {
pub struct ManagedAdminConfigLayer {
pub config: TomlValue,
pub raw_toml: String,
}
pub(super) fn managed_preferences_requirements_source() -> RequirementSource {
fn managed_preferences_requirements_source() -> RequirementSource {
RequirementSource::MdmManagedPreferences {
domain: MANAGED_PREFERENCES_APPLICATION_ID.to_string(),
key: MANAGED_PREFERENCES_REQUIREMENTS_KEY.to_string(),
}
}
pub(crate) async fn load_managed_admin_config_layer(
pub async fn load_managed_admin_config_layer(
override_base64: Option<&str>,
) -> io::Result<Option<ManagedAdminConfigLayer>> {
if let Some(encoded) = override_base64 {
@@ -61,7 +61,7 @@ fn load_managed_admin_config() -> io::Result<Option<ManagedAdminConfigLayer>> {
.transpose()
}
pub(crate) async fn load_managed_admin_requirements_toml(
pub async fn load_managed_admin_requirements_toml(
target: &mut ConfigRequirementsWithSources,
override_base64: Option<&str>,
) -> io::Result<()> {

View File

@@ -37,6 +37,7 @@ codex-config = { workspace = true }
codex-shell-command = { workspace = true }
codex-skills = { workspace = true }
codex-execpolicy = { workspace = true }
codex-extensions = { workspace = true }
codex-file-search = { workspace = true }
codex-git = { workspace = true }
codex-hooks = { workspace = true }
@@ -123,7 +124,6 @@ landlock = { workspace = true }
seccompiler = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.9"
keyring = { workspace = true, features = ["apple-native"] }
# Build OpenSSL from source for musl builds.
@@ -136,11 +136,6 @@ openssl-sys = { workspace = true, features = ["vendored"] }
[target.'cfg(target_os = "windows")'.dependencies]
keyring = { workspace = true, features = ["windows-native"] }
windows-sys = { version = "0.52", features = [
"Win32_Foundation",
"Win32_System_Com",
"Win32_UI_Shell",
] }
[target.'cfg(any(target_os = "freebsd", target_os = "openbsd"))'.dependencies]
keyring = { workspace = true, features = ["sync-secret-service"] }

View File

@@ -1,27 +1,18 @@
mod layer_io;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(test)]
mod tests;
use crate::config::ConfigToml;
use crate::config_loader::layer_io::LoadedConfigLayers;
use crate::git_info::resolve_root_git_project_for_trust;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigRequirementsWithSources;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::protocol::AskForApproval;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use dunce::canonicalize as normalize_path;
use serde::Deserialize;
use std::io;
use std::path::Path;
#[cfg(windows)]
use std::path::PathBuf;
use toml::Value as TomlValue;
pub use codex_config::AppRequirementToml;
@@ -38,6 +29,7 @@ pub use codex_config::ConfigRequirements;
pub use codex_config::ConfigRequirementsToml;
pub use codex_config::ConstrainedWithSource;
pub use codex_config::FeatureRequirementsToml;
use codex_config::LoadedConfigLayers;
pub use codex_config::LoaderOverrides;
pub use codex_config::McpServerIdentity;
pub use codex_config::McpServerRequirement;
@@ -55,18 +47,16 @@ pub(crate) use codex_config::config_error_from_toml;
pub use codex_config::format_config_error;
pub use codex_config::format_config_error_with_source;
pub(crate) use codex_config::io_error_from_config_error;
use codex_config::load_config_layers_internal;
use codex_config::load_managed_admin_requirements;
use codex_config::load_requirements_from_legacy_scheme;
pub(crate) use codex_config::load_requirements_toml;
pub use codex_config::merge_toml_values;
use codex_config::system_config_toml_file;
use codex_config::system_requirements_toml_file;
#[cfg(test)]
pub(crate) use codex_config::version_for_toml;
/// On Unix systems, load default settings from this file path, if present.
/// Note that /etc/codex/ is treated as a "config folder," so subfolders such
/// as skills/ and rules/ will also be honored.
pub const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml";
#[cfg(windows)]
const DEFAULT_PROGRAM_DATA_DIR_WINDOWS: &str = r"C:\ProgramData";
const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"];
pub(crate) async fn first_layer_config_error(layers: &ConfigLayerStack) -> Option<ConfigError> {
@@ -125,8 +115,7 @@ pub async fn load_config_layers_state(
.merge_unset_fields(RequirementSource::CloudRequirements, requirements);
}
#[cfg(target_os = "macos")]
macos::load_managed_admin_requirements_toml(
load_managed_admin_requirements(
&mut config_requirements_toml,
overrides
.macos_managed_config_requirements_base64
@@ -140,7 +129,7 @@ pub async fn load_config_layers_state(
// Make a best-effort to support the legacy `managed_config.toml` as a
// requirements specification.
let loaded_config_layers = layer_io::load_config_layers_internal(codex_home, overrides).await?;
let loaded_config_layers = load_config_layers_internal(codex_home, overrides).await?;
load_requirements_from_legacy_scheme(
&mut config_requirements_toml,
loaded_config_layers.clone(),
@@ -343,185 +332,6 @@ async fn load_config_toml_for_required_layer(
Ok(create_entry(toml_value))
}
/// If available, apply requirements from the platform system
/// `requirements.toml` location to `config_requirements_toml` by filling in
/// any unset fields.
async fn load_requirements_toml(
config_requirements_toml: &mut ConfigRequirementsWithSources,
requirements_toml_file: impl AsRef<Path>,
) -> io::Result<()> {
let requirements_toml_file =
AbsolutePathBuf::from_absolute_path(requirements_toml_file.as_ref())?;
match tokio::fs::read_to_string(&requirements_toml_file).await {
Ok(contents) => {
let requirements_config: ConfigRequirementsToml =
toml::from_str(&contents).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!(
"Error parsing requirements file {}: {e}",
requirements_toml_file.as_ref().display(),
),
)
})?;
config_requirements_toml.merge_unset_fields(
RequirementSource::SystemRequirementsToml {
file: requirements_toml_file.clone(),
},
requirements_config,
);
}
Err(e) => {
if e.kind() != io::ErrorKind::NotFound {
return Err(io::Error::new(
e.kind(),
format!(
"Failed to read requirements file {}: {e}",
requirements_toml_file.as_ref().display(),
),
));
}
}
}
Ok(())
}
#[cfg(unix)]
fn system_requirements_toml_file() -> io::Result<AbsolutePathBuf> {
AbsolutePathBuf::from_absolute_path(Path::new("/etc/codex/requirements.toml"))
}
#[cfg(windows)]
fn system_requirements_toml_file() -> io::Result<AbsolutePathBuf> {
windows_system_requirements_toml_file()
}
#[cfg(unix)]
fn system_config_toml_file() -> io::Result<AbsolutePathBuf> {
AbsolutePathBuf::from_absolute_path(Path::new(SYSTEM_CONFIG_TOML_FILE_UNIX))
}
#[cfg(windows)]
fn system_config_toml_file() -> io::Result<AbsolutePathBuf> {
windows_system_config_toml_file()
}
#[cfg(windows)]
fn windows_codex_system_dir() -> PathBuf {
let program_data = windows_program_data_dir_from_known_folder().unwrap_or_else(|err| {
tracing::warn!(
error = %err,
"Failed to resolve ProgramData known folder; using default path"
);
PathBuf::from(DEFAULT_PROGRAM_DATA_DIR_WINDOWS)
});
program_data.join("OpenAI").join("Codex")
}
#[cfg(windows)]
fn windows_system_requirements_toml_file() -> io::Result<AbsolutePathBuf> {
let requirements_toml_file = windows_codex_system_dir().join("requirements.toml");
AbsolutePathBuf::try_from(requirements_toml_file)
}
#[cfg(windows)]
fn windows_system_config_toml_file() -> io::Result<AbsolutePathBuf> {
let config_toml_file = windows_codex_system_dir().join("config.toml");
AbsolutePathBuf::try_from(config_toml_file)
}
#[cfg(windows)]
fn windows_program_data_dir_from_known_folder() -> io::Result<PathBuf> {
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt;
use windows_sys::Win32::System::Com::CoTaskMemFree;
use windows_sys::Win32::UI::Shell::FOLDERID_ProgramData;
use windows_sys::Win32::UI::Shell::KF_FLAG_DEFAULT;
use windows_sys::Win32::UI::Shell::SHGetKnownFolderPath;
let mut path_ptr = std::ptr::null_mut::<u16>();
let known_folder_flags = u32::try_from(KF_FLAG_DEFAULT).map_err(|_| {
io::Error::other(format!(
"KF_FLAG_DEFAULT did not fit in u32: {KF_FLAG_DEFAULT}"
))
})?;
// Known folder IDs reference:
// https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid
// SAFETY: SHGetKnownFolderPath initializes path_ptr with a CoTaskMem-allocated,
// null-terminated UTF-16 string on success.
let hr = unsafe {
SHGetKnownFolderPath(&FOLDERID_ProgramData, known_folder_flags, 0, &mut path_ptr)
};
if hr != 0 {
return Err(io::Error::other(format!(
"SHGetKnownFolderPath(FOLDERID_ProgramData) failed with HRESULT {hr:#010x}"
)));
}
if path_ptr.is_null() {
return Err(io::Error::other(
"SHGetKnownFolderPath(FOLDERID_ProgramData) returned a null pointer",
));
}
// SAFETY: path_ptr is a valid null-terminated UTF-16 string allocated by
// SHGetKnownFolderPath and must be freed with CoTaskMemFree.
let path = unsafe {
let mut len = 0usize;
while *path_ptr.add(len) != 0 {
len += 1;
}
let wide = std::slice::from_raw_parts(path_ptr, len);
let path = PathBuf::from(OsString::from_wide(wide));
CoTaskMemFree(path_ptr.cast());
path
};
Ok(path)
}
async fn load_requirements_from_legacy_scheme(
config_requirements_toml: &mut ConfigRequirementsWithSources,
loaded_config_layers: LoadedConfigLayers,
) -> io::Result<()> {
// In this implementation, earlier layers cannot be overwritten by later
// layers, so list managed_config_from_mdm first because it has the highest
// precedence.
let LoadedConfigLayers {
managed_config,
managed_config_from_mdm,
} = loaded_config_layers;
for (source, config) in managed_config_from_mdm
.map(|config| {
(
RequirementSource::LegacyManagedConfigTomlFromMdm,
config.managed_config,
)
})
.into_iter()
.chain(managed_config.map(|c| {
(
RequirementSource::LegacyManagedConfigTomlFromFile { file: c.file },
c.managed_config,
)
}))
{
let legacy_config: LegacyManagedConfigToml =
config.try_into().map_err(|err: toml::de::Error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Failed to parse config requirements as TOML: {err}"),
)
})?;
let new_requirements_toml = ConfigRequirementsToml::from(legacy_config);
config_requirements_toml.merge_unset_fields(source, new_requirements_toml);
}
Ok(())
}
/// Reads `project_root_markers` from the [toml::Value] produced by merging
/// `config.toml` from the config layers in the stack preceding
/// [ConfigLayerSource::Project].
@@ -895,51 +705,12 @@ async fn load_project_layers(
Ok(layers)
}
/// The legacy mechanism for specifying admin-enforced configuration is to read
/// from a file like `/etc/codex/managed_config.toml` that has the same
/// structure as `config.toml` where fields like `approval_policy` can specify
/// exactly one value rather than a list of allowed values.
///
/// If present, re-interpret `managed_config.toml` as a `requirements.toml`
/// where each specified field is treated as a constraint allowing only that
/// value.
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
struct LegacyManagedConfigToml {
approval_policy: Option<AskForApproval>,
sandbox_mode: Option<SandboxMode>,
}
impl From<LegacyManagedConfigToml> for ConfigRequirementsToml {
fn from(legacy: LegacyManagedConfigToml) -> Self {
let mut config_requirements_toml = ConfigRequirementsToml::default();
let LegacyManagedConfigToml {
approval_policy,
sandbox_mode,
} = legacy;
if let Some(approval_policy) = approval_policy {
config_requirements_toml.allowed_approval_policies = Some(vec![approval_policy]);
}
if let Some(sandbox_mode) = sandbox_mode {
let required_mode: SandboxModeRequirement = sandbox_mode.into();
// Allowing read-only is a requirement for Codex to function correctly.
// So in this backfill path, we append read-only if it's not already specified.
let mut allowed_modes = vec![SandboxModeRequirement::ReadOnly];
if required_mode != SandboxModeRequirement::ReadOnly {
allowed_modes.push(required_mode);
}
config_requirements_toml.allowed_sandbox_modes = Some(allowed_modes);
}
config_requirements_toml
}
}
// Cannot name this `mod tests` because of tests.rs in this folder.
#[cfg(test)]
mod unit_tests {
use super::*;
#[cfg(windows)]
use std::path::Path;
use codex_config::ManagedConfigFromFile;
use codex_protocol::protocol::SandboxPolicy;
use tempfile::tempdir;
#[test]
@@ -979,65 +750,81 @@ foo = "xyzzy"
Ok(())
}
#[test]
fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() {
let legacy = LegacyManagedConfigToml {
approval_policy: None,
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
#[tokio::test]
async fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() {
let tmp = tempdir().expect("tempdir");
let managed_path = AbsolutePathBuf::try_from(tmp.path().join("managed_config.toml"))
.expect("managed path");
let loaded_layers = LoadedConfigLayers {
managed_config: Some(ManagedConfigFromFile {
managed_config: toml::toml! {
sandbox_mode = "workspace-write"
}
.into(),
file: managed_path.clone(),
}),
managed_config_from_mdm: None,
};
let requirements = ConfigRequirementsToml::from(legacy);
let mut requirements_with_sources = ConfigRequirementsWithSources::default();
load_requirements_from_legacy_scheme(&mut requirements_with_sources, loaded_layers)
.await
.expect("load legacy requirements");
let requirements: ConfigRequirements = requirements_with_sources
.try_into()
.expect("requirements parse");
assert_eq!(
requirements.allowed_sandbox_modes,
Some(vec![
SandboxModeRequirement::ReadOnly,
SandboxModeRequirement::WorkspaceWrite
])
requirements.sandbox_policy.get(),
&SandboxPolicy::new_read_only_policy()
);
assert!(
requirements
.sandbox_policy
.can_set(&SandboxPolicy::new_workspace_write_policy())
.is_ok()
);
assert_eq!(
requirements
.sandbox_policy
.can_set(&SandboxPolicy::DangerFullAccess),
Err(codex_config::ConstraintError::InvalidValue {
field_name: "sandbox_mode",
candidate: "DangerFullAccess".into(),
allowed: "[ReadOnly, WorkspaceWrite]".into(),
requirement_source: RequirementSource::LegacyManagedConfigTomlFromFile {
file: managed_path,
},
})
);
}
#[cfg(windows)]
#[test]
fn windows_system_requirements_toml_file_uses_expected_suffix() {
let expected = windows_program_data_dir_from_known_folder()
.unwrap_or_else(|_| PathBuf::from(DEFAULT_PROGRAM_DATA_DIR_WINDOWS))
.join("OpenAI")
.join("Codex")
.join("requirements.toml");
assert_eq!(
windows_system_requirements_toml_file()
.expect("requirements.toml path")
.as_path(),
expected.as_path()
);
assert!(
windows_system_requirements_toml_file()
system_requirements_toml_file()
.expect("requirements.toml path")
.as_path()
.ends_with(Path::new("OpenAI").join("Codex").join("requirements.toml"))
.ends_with(
std::path::Path::new("OpenAI")
.join("Codex")
.join("requirements.toml")
)
);
}
#[cfg(windows)]
#[test]
fn windows_system_config_toml_file_uses_expected_suffix() {
let expected = windows_program_data_dir_from_known_folder()
.unwrap_or_else(|_| PathBuf::from(DEFAULT_PROGRAM_DATA_DIR_WINDOWS))
.join("OpenAI")
.join("Codex")
.join("config.toml");
assert_eq!(
windows_system_config_toml_file()
.expect("config.toml path")
.as_path(),
expected.as_path()
);
assert!(
windows_system_config_toml_file()
system_config_toml_file()
.expect("config.toml path")
.as_path()
.ends_with(Path::new("OpenAI").join("Codex").join("config.toml"))
.ends_with(
std::path::Path::new("OpenAI")
.join("Codex")
.join("config.toml")
)
);
}
}

View File

@@ -46,6 +46,9 @@ use crate::skills::loader::SkillRoot;
use crate::skills::loader::load_skills_from_roots;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::MergeStrategy;
use codex_extensions::plugins::AppConnectorId;
use codex_extensions::plugins::PluginCapabilitySummary;
use codex_extensions::plugins::PluginTelemetryMetadata;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
@@ -70,10 +73,7 @@ const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AppConnectorId(pub String);
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 140;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginInstallRequest {
@@ -157,98 +157,6 @@ impl LoadedPlugin {
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginCapabilitySummary {
pub config_name: String,
pub display_name: String,
pub description: Option<String>,
pub has_skills: bool,
pub mcp_server_names: Vec<String>,
pub app_connector_ids: Vec<AppConnectorId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginTelemetryMetadata {
pub plugin_id: PluginId,
pub capability_summary: Option<PluginCapabilitySummary>,
}
impl PluginTelemetryMetadata {
pub fn from_plugin_id(plugin_id: &PluginId) -> Self {
Self {
plugin_id: plugin_id.clone(),
capability_summary: None,
}
}
}
impl PluginCapabilitySummary {
fn from_plugin(plugin: &LoadedPlugin) -> Option<Self> {
if !plugin.is_active() {
return None;
}
let mut mcp_server_names: Vec<String> = plugin.mcp_servers.keys().cloned().collect();
mcp_server_names.sort_unstable();
let summary = Self {
config_name: plugin.config_name.clone(),
display_name: plugin
.manifest_name
.clone()
.unwrap_or_else(|| plugin.config_name.clone()),
description: prompt_safe_plugin_description(plugin.manifest_description.as_deref()),
has_skills: !plugin.skill_roots.is_empty(),
mcp_server_names,
app_connector_ids: plugin.apps.clone(),
};
(summary.has_skills
|| !summary.mcp_server_names.is_empty()
|| !summary.app_connector_ids.is_empty())
.then_some(summary)
}
pub fn telemetry_metadata(&self) -> Option<PluginTelemetryMetadata> {
PluginId::parse(&self.config_name)
.ok()
.map(|plugin_id| PluginTelemetryMetadata {
plugin_id,
capability_summary: Some(self.clone()),
})
}
}
impl From<PluginDetailSummary> for PluginCapabilitySummary {
fn from(value: PluginDetailSummary) -> Self {
Self {
config_name: value.id,
display_name: value.name,
description: prompt_safe_plugin_description(value.description.as_deref()),
has_skills: !value.skills.is_empty(),
mcp_server_names: value.mcp_server_names,
app_connector_ids: value.apps,
}
}
}
fn prompt_safe_plugin_description(description: Option<&str>) -> Option<String> {
let description = description?
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if description.is_empty() {
return None;
}
Some(
description
.chars()
.take(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN)
.collect(),
)
}
#[derive(Debug, Clone, PartialEq)]
pub struct PluginLoadOutcome {
plugins: Vec<LoadedPlugin>,
@@ -265,7 +173,7 @@ impl PluginLoadOutcome {
fn from_plugins(plugins: Vec<LoadedPlugin>) -> Self {
let capability_summaries = plugins
.iter()
.filter_map(PluginCapabilitySummary::from_plugin)
.filter_map(plugin_capability_summary_from_loaded_plugin)
.collect::<Vec<_>>();
Self {
plugins,
@@ -321,6 +229,64 @@ impl PluginLoadOutcome {
}
}
fn plugin_capability_summary_from_loaded_plugin(
plugin: &LoadedPlugin,
) -> Option<PluginCapabilitySummary> {
if !plugin.is_active() {
return None;
}
let mut mcp_server_names: Vec<String> = plugin.mcp_servers.keys().cloned().collect();
mcp_server_names.sort_unstable();
let summary = PluginCapabilitySummary {
config_name: plugin.config_name.clone(),
display_name: plugin
.manifest_name
.clone()
.unwrap_or_else(|| plugin.config_name.clone()),
description: prompt_safe_plugin_description(plugin.manifest_description.as_deref()),
has_skills: !plugin.skill_roots.is_empty(),
mcp_server_names,
app_connector_ids: plugin.apps.clone(),
};
(summary.has_skills
|| !summary.mcp_server_names.is_empty()
|| !summary.app_connector_ids.is_empty())
.then_some(summary)
}
impl From<PluginDetailSummary> for PluginCapabilitySummary {
fn from(value: PluginDetailSummary) -> Self {
Self {
config_name: value.id,
display_name: value.name,
description: prompt_safe_plugin_description(value.description.as_deref()),
has_skills: !value.skills.is_empty(),
mcp_server_names: value.mcp_server_names,
app_connector_ids: value.apps,
}
}
}
fn prompt_safe_plugin_description(description: Option<&str>) -> Option<String> {
let description = description?
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if description.is_empty() {
return None;
}
Some(
description
.chars()
.take(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN)
.collect(),
)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RemotePluginSyncResult {
/// Plugin ids newly installed into the local plugin cache.

View File

@@ -1,454 +1,6 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde_json::Value as JsonValue;
use std::fs;
use std::path::Component;
use std::path::Path;
pub(crate) const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
const MAX_DEFAULT_PROMPT_COUNT: usize = 3;
const MAX_DEFAULT_PROMPT_LEN: usize = 128;
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PluginManifest {
#[serde(default)]
pub(crate) name: String,
#[serde(default)]
pub(crate) description: Option<String>,
// Keep manifest paths as raw strings so we can validate the required `./...` syntax before
// resolving them under the plugin root.
#[serde(default)]
skills: Option<String>,
#[serde(default)]
mcp_servers: Option<String>,
#[serde(default)]
apps: Option<String>,
#[serde(default)]
interface: Option<PluginManifestInterface>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginManifestPaths {
pub skills: Option<AbsolutePathBuf>,
pub mcp_servers: Option<AbsolutePathBuf>,
pub apps: Option<AbsolutePathBuf>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginManifestInterfaceSummary {
pub display_name: Option<String>,
pub short_description: Option<String>,
pub long_description: Option<String>,
pub developer_name: Option<String>,
pub category: Option<String>,
pub capabilities: Vec<String>,
pub website_url: Option<String>,
pub privacy_policy_url: Option<String>,
pub terms_of_service_url: Option<String>,
pub default_prompt: Option<Vec<String>>,
pub brand_color: Option<String>,
pub composer_icon: Option<AbsolutePathBuf>,
pub logo: Option<AbsolutePathBuf>,
pub screenshots: Vec<AbsolutePathBuf>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PluginManifestInterface {
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
short_description: Option<String>,
#[serde(default)]
long_description: Option<String>,
#[serde(default)]
developer_name: Option<String>,
#[serde(default)]
category: Option<String>,
#[serde(default)]
capabilities: Vec<String>,
#[serde(default)]
#[serde(alias = "websiteURL")]
website_url: Option<String>,
#[serde(default)]
#[serde(alias = "privacyPolicyURL")]
privacy_policy_url: Option<String>,
#[serde(default)]
#[serde(alias = "termsOfServiceURL")]
terms_of_service_url: Option<String>,
#[serde(default)]
default_prompt: Option<PluginManifestDefaultPrompt>,
#[serde(default)]
brand_color: Option<String>,
#[serde(default)]
composer_icon: Option<String>,
#[serde(default)]
logo: Option<String>,
#[serde(default)]
screenshots: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PluginManifestDefaultPrompt {
String(String),
List(Vec<PluginManifestDefaultPromptEntry>),
Invalid(JsonValue),
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PluginManifestDefaultPromptEntry {
String(String),
Invalid(JsonValue),
}
pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
if !manifest_path.is_file() {
return None;
}
let contents = fs::read_to_string(&manifest_path).ok()?;
match serde_json::from_str(&contents) {
Ok(manifest) => Some(manifest),
Err(err) => {
tracing::warn!(
path = %manifest_path.display(),
"failed to parse plugin manifest: {err}"
);
None
}
}
}
pub(crate) fn plugin_manifest_name(manifest: &PluginManifest, plugin_root: &Path) -> String {
plugin_root
.file_name()
.and_then(|name| name.to_str())
.filter(|_| manifest.name.trim().is_empty())
.unwrap_or(&manifest.name)
.to_string()
}
pub(crate) fn plugin_manifest_interface(
manifest: &PluginManifest,
plugin_root: &Path,
) -> Option<PluginManifestInterfaceSummary> {
let interface = manifest.interface.as_ref()?;
let interface = PluginManifestInterfaceSummary {
display_name: interface.display_name.clone(),
short_description: interface.short_description.clone(),
long_description: interface.long_description.clone(),
developer_name: interface.developer_name.clone(),
category: interface.category.clone(),
capabilities: interface.capabilities.clone(),
website_url: interface.website_url.clone(),
privacy_policy_url: interface.privacy_policy_url.clone(),
terms_of_service_url: interface.terms_of_service_url.clone(),
default_prompt: resolve_default_prompts(plugin_root, interface.default_prompt.as_ref()),
brand_color: interface.brand_color.clone(),
composer_icon: resolve_interface_asset_path(
plugin_root,
"interface.composerIcon",
interface.composer_icon.as_deref(),
),
logo: resolve_interface_asset_path(
plugin_root,
"interface.logo",
interface.logo.as_deref(),
),
screenshots: interface
.screenshots
.iter()
.filter_map(|screenshot| {
resolve_interface_asset_path(plugin_root, "interface.screenshots", Some(screenshot))
})
.collect(),
};
let has_fields = interface.display_name.is_some()
|| interface.short_description.is_some()
|| interface.long_description.is_some()
|| interface.developer_name.is_some()
|| interface.category.is_some()
|| !interface.capabilities.is_empty()
|| interface.website_url.is_some()
|| interface.privacy_policy_url.is_some()
|| interface.terms_of_service_url.is_some()
|| interface.default_prompt.is_some()
|| interface.brand_color.is_some()
|| interface.composer_icon.is_some()
|| interface.logo.is_some()
|| !interface.screenshots.is_empty();
has_fields.then_some(interface)
}
pub(crate) fn plugin_manifest_paths(
manifest: &PluginManifest,
plugin_root: &Path,
) -> PluginManifestPaths {
PluginManifestPaths {
skills: resolve_manifest_path(plugin_root, "skills", manifest.skills.as_deref()),
mcp_servers: resolve_manifest_path(
plugin_root,
"mcpServers",
manifest.mcp_servers.as_deref(),
),
apps: resolve_manifest_path(plugin_root, "apps", manifest.apps.as_deref()),
}
}
fn resolve_interface_asset_path(
plugin_root: &Path,
field: &'static str,
path: Option<&str>,
) -> Option<AbsolutePathBuf> {
resolve_manifest_path(plugin_root, field, path)
}
fn resolve_default_prompts(
plugin_root: &Path,
value: Option<&PluginManifestDefaultPrompt>,
) -> Option<Vec<String>> {
match value? {
PluginManifestDefaultPrompt::String(prompt) => {
resolve_default_prompt_str(plugin_root, "interface.defaultPrompt", prompt)
.map(|prompt| vec![prompt])
}
PluginManifestDefaultPrompt::List(values) => {
let mut prompts = Vec::new();
for (index, item) in values.iter().enumerate() {
if prompts.len() >= MAX_DEFAULT_PROMPT_COUNT {
warn_invalid_default_prompt(
plugin_root,
"interface.defaultPrompt",
&format!("maximum of {MAX_DEFAULT_PROMPT_COUNT} prompts is supported"),
);
break;
}
match item {
PluginManifestDefaultPromptEntry::String(prompt) => {
let field = format!("interface.defaultPrompt[{index}]");
if let Some(prompt) =
resolve_default_prompt_str(plugin_root, &field, prompt)
{
prompts.push(prompt);
}
}
PluginManifestDefaultPromptEntry::Invalid(value) => {
let field = format!("interface.defaultPrompt[{index}]");
warn_invalid_default_prompt(
plugin_root,
&field,
&format!("expected a string, found {}", json_value_type(value)),
);
}
}
}
(!prompts.is_empty()).then_some(prompts)
}
PluginManifestDefaultPrompt::Invalid(value) => {
warn_invalid_default_prompt(
plugin_root,
"interface.defaultPrompt",
&format!(
"expected a string or array of strings, found {}",
json_value_type(value)
),
);
None
}
}
}
fn resolve_default_prompt_str(plugin_root: &Path, field: &str, prompt: &str) -> Option<String> {
let prompt = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
if prompt.is_empty() {
warn_invalid_default_prompt(plugin_root, field, "prompt must not be empty");
return None;
}
if prompt.chars().count() > MAX_DEFAULT_PROMPT_LEN {
warn_invalid_default_prompt(
plugin_root,
field,
&format!("prompt must be at most {MAX_DEFAULT_PROMPT_LEN} characters"),
);
return None;
}
Some(prompt)
}
fn warn_invalid_default_prompt(plugin_root: &Path, field: &str, message: &str) {
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
tracing::warn!(
path = %manifest_path.display(),
"ignoring {field}: {message}"
);
}
fn json_value_type(value: &JsonValue) -> &'static str {
match value {
JsonValue::Null => "null",
JsonValue::Bool(_) => "boolean",
JsonValue::Number(_) => "number",
JsonValue::String(_) => "string",
JsonValue::Array(_) => "array",
JsonValue::Object(_) => "object",
}
}
fn resolve_manifest_path(
plugin_root: &Path,
field: &'static str,
path: Option<&str>,
) -> Option<AbsolutePathBuf> {
// `plugin.json` paths are required to be relative to the plugin root and we return the
// normalized absolute path to the rest of the system.
let path = path?;
if path.is_empty() {
return None;
}
let Some(relative_path) = path.strip_prefix("./") else {
tracing::warn!("ignoring {field}: path must start with `./` relative to plugin root");
return None;
};
if relative_path.is_empty() {
tracing::warn!("ignoring {field}: path must not be `./`");
return None;
}
let mut normalized = std::path::PathBuf::new();
for component in Path::new(relative_path).components() {
match component {
Component::Normal(component) => normalized.push(component),
Component::ParentDir => {
tracing::warn!("ignoring {field}: path must not contain '..'");
return None;
}
_ => {
tracing::warn!("ignoring {field}: path must stay within the plugin root");
return None;
}
}
}
AbsolutePathBuf::try_from(plugin_root.join(normalized))
.map_err(|err| {
tracing::warn!("ignoring {field}: path must resolve to an absolute path: {err}");
err
})
.ok()
}
#[cfg(test)]
mod tests {
use super::MAX_DEFAULT_PROMPT_LEN;
use super::PluginManifest;
use super::plugin_manifest_interface;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::Path;
use tempfile::tempdir;
fn write_manifest(plugin_root: &Path, interface: &str) {
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir");
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
format!(
r#"{{
"name": "demo-plugin",
"interface": {interface}
}}"#
),
)
.expect("write manifest");
}
fn load_manifest(plugin_root: &Path) -> PluginManifest {
let manifest_path = plugin_root.join(".codex-plugin/plugin.json");
let contents = fs::read_to_string(manifest_path).expect("read manifest");
serde_json::from_str(&contents).expect("parse manifest")
}
#[test]
fn plugin_manifest_interface_accepts_legacy_default_prompt_string() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("demo-plugin");
write_manifest(
&plugin_root,
r#"{
"displayName": "Demo Plugin",
"defaultPrompt": " Summarize my inbox "
}"#,
);
let manifest = load_manifest(&plugin_root);
let interface =
plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface");
assert_eq!(
interface.default_prompt,
Some(vec!["Summarize my inbox".to_string()])
);
}
#[test]
fn plugin_manifest_interface_normalizes_default_prompt_array() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("demo-plugin");
let too_long = "x".repeat(MAX_DEFAULT_PROMPT_LEN + 1);
write_manifest(
&plugin_root,
&format!(
r#"{{
"displayName": "Demo Plugin",
"defaultPrompt": [
" Summarize my inbox ",
123,
"{too_long}",
" ",
"Draft the reply ",
"Find my next action",
"Archive old mail"
]
}}"#
),
);
let manifest = load_manifest(&plugin_root);
let interface =
plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface");
assert_eq!(
interface.default_prompt,
Some(vec![
"Summarize my inbox".to_string(),
"Draft the reply".to_string(),
"Find my next action".to_string(),
])
);
}
#[test]
fn plugin_manifest_interface_ignores_invalid_default_prompt_shape() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("demo-plugin");
write_manifest(
&plugin_root,
r#"{
"displayName": "Demo Plugin",
"defaultPrompt": { "text": "Summarize my inbox" }
}"#,
);
let manifest = load_manifest(&plugin_root);
let interface =
plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface");
assert_eq!(interface.default_prompt, None);
}
}
pub use codex_extensions::plugins::PluginManifestInterfaceSummary;
pub(crate) use codex_extensions::plugins::PluginManifestPaths;
pub(crate) use codex_extensions::plugins::load_plugin_manifest;
pub(crate) use codex_extensions::plugins::plugin_manifest_interface;
pub(crate) use codex_extensions::plugins::plugin_manifest_name;
pub(crate) use codex_extensions::plugins::plugin_manifest_paths;

View File

@@ -11,17 +11,20 @@ mod store;
pub(crate) mod test_support;
mod toggles;
pub use codex_extensions::plugins::AppConnectorId;
pub use codex_extensions::plugins::PluginCapabilitySummary;
pub use codex_extensions::plugins::PluginId;
pub use codex_extensions::plugins::PluginManifestInterfaceSummary;
pub use codex_extensions::plugins::PluginTelemetryMetadata;
pub(crate) use curated_repo::curated_plugins_repo_path;
pub(crate) use curated_repo::read_curated_plugins_sha;
pub(crate) use curated_repo::sync_openai_plugins_repo;
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
pub(crate) use injection::build_plugin_injections;
pub use manager::AppConnectorId;
pub use manager::ConfiguredMarketplacePluginSummary;
pub use manager::ConfiguredMarketplaceSummary;
pub use manager::LoadedPlugin;
pub use manager::OPENAI_CURATED_MARKETPLACE_NAME;
pub use manager::PluginCapabilitySummary;
pub use manager::PluginDetailSummary;
pub use manager::PluginInstallError;
pub use manager::PluginInstallOutcome;
@@ -30,7 +33,6 @@ pub use manager::PluginLoadOutcome;
pub use manager::PluginReadOutcome;
pub use manager::PluginReadRequest;
pub use manager::PluginRemoteSyncError;
pub use manager::PluginTelemetryMetadata;
pub use manager::PluginUninstallError;
pub use manager::PluginsManager;
pub use manager::RemotePluginSyncResult;
@@ -38,7 +40,6 @@ pub use manager::installed_plugin_telemetry_metadata;
pub use manager::load_plugin_apps;
pub(crate) use manager::plugin_namespace_for_skill_path;
pub use manager::plugin_telemetry_metadata_from_root;
pub use manifest::PluginManifestInterfaceSummary;
pub(crate) use manifest::PluginManifestPaths;
pub(crate) use manifest::load_plugin_manifest;
pub(crate) use manifest::plugin_manifest_interface;
@@ -50,5 +51,4 @@ pub use marketplace::MarketplacePluginInstallPolicy;
pub use marketplace::MarketplacePluginSourceSummary;
pub(crate) use render::render_explicit_plugin_instructions;
pub(crate) use render::render_plugins_section;
pub use store::PluginId;
pub use toggles::collect_plugin_enabled_candidates;

View File

@@ -1,91 +1,5 @@
use crate::plugins::PluginCapabilitySummary;
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG;
pub(crate) fn render_plugins_section(plugins: &[PluginCapabilitySummary]) -> Option<String> {
if plugins.is_empty() {
return None;
}
let mut lines = vec![
"## Plugins".to_string(),
"A plugin is a local bundle of skills, MCP servers, and apps. Below is the list of plugins that are enabled and available in this session.".to_string(),
"### Available plugins".to_string(),
];
lines.extend(
plugins
.iter()
.map(|plugin| match plugin.description.as_deref() {
Some(description) => format!("- `{}`: {description}", plugin.display_name),
None => format!("- `{}`", plugin.display_name),
}),
);
lines.push("### How to use plugins".to_string());
lines.push(
r###"- Discovery: The list above is the plugins available in this session.
- Skill naming: If a plugin contributes skills, those skill entries are prefixed with `plugin_name:` in the Skills list.
- Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn.
- Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task.
- Preference: When a relevant plugin is available, prefer using capabilities associated with that plugin over standalone capabilities that provide similar functionality.
- Missing/blocked: If the user requests a plugin that is not listed above, or the plugin does not have relevant callable capabilities for the task, say so briefly and continue with the best fallback."###
.to_string(),
);
let body = lines.join("\n");
Some(format!(
"{PLUGINS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{PLUGINS_INSTRUCTIONS_CLOSE_TAG}"
))
}
pub(crate) fn render_explicit_plugin_instructions(
plugin: &PluginCapabilitySummary,
available_mcp_servers: &[String],
available_apps: &[String],
) -> Option<String> {
let mut lines = vec![format!(
"Capabilities from the `{}` plugin:",
plugin.display_name
)];
if plugin.has_skills {
lines.push(format!(
"- Skills from this plugin are prefixed with `{}:`.",
plugin.display_name
));
}
if !available_mcp_servers.is_empty() {
lines.push(format!(
"- MCP servers from this plugin available in this session: {}.",
available_mcp_servers
.iter()
.map(|server| format!("`{server}`"))
.collect::<Vec<_>>()
.join(", ")
));
}
if !available_apps.is_empty() {
lines.push(format!(
"- Apps from this plugin available in this session: {}.",
available_apps
.iter()
.map(|app| format!("`{app}`"))
.collect::<Vec<_>>()
.join(", ")
));
}
if lines.len() == 1 {
return None;
}
lines.push("Use these plugin-associated capabilities to help solve the task.".to_string());
Some(lines.join("\n"))
}
pub(crate) use codex_extensions::plugins::render_explicit_plugin_instructions;
pub(crate) use codex_extensions::plugins::render_plugins_section;
#[cfg(test)]
#[path = "render_tests.rs"]

View File

@@ -1,346 +1,6 @@
use super::load_plugin_manifest;
use super::manifest::PLUGIN_MANIFEST_PATH;
use super::plugin_manifest_name;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
pub(crate) const DEFAULT_PLUGIN_VERSION: &str = "local";
pub(crate) const PLUGINS_CACHE_DIR: &str = "plugins/cache";
#[derive(Debug, thiserror::Error)]
pub enum PluginIdError {
#[error("{0}")]
Invalid(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginId {
pub plugin_name: String,
pub marketplace_name: String,
}
impl PluginId {
pub fn new(plugin_name: String, marketplace_name: String) -> Result<Self, PluginIdError> {
validate_plugin_segment(&plugin_name, "plugin name").map_err(PluginIdError::Invalid)?;
validate_plugin_segment(&marketplace_name, "marketplace name")
.map_err(PluginIdError::Invalid)?;
Ok(Self {
plugin_name,
marketplace_name,
})
}
pub fn parse(plugin_key: &str) -> Result<Self, PluginIdError> {
let Some((plugin_name, marketplace_name)) = plugin_key.rsplit_once('@') else {
return Err(PluginIdError::Invalid(format!(
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
)));
};
if plugin_name.is_empty() || marketplace_name.is_empty() {
return Err(PluginIdError::Invalid(format!(
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
)));
}
Self::new(plugin_name.to_string(), marketplace_name.to_string()).map_err(|err| match err {
PluginIdError::Invalid(message) => {
PluginIdError::Invalid(format!("{message} in `{plugin_key}`"))
}
})
}
pub fn as_key(&self) -> String {
format!("{}@{}", self.plugin_name, self.marketplace_name)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginInstallResult {
pub plugin_id: PluginId,
pub plugin_version: String,
pub installed_path: AbsolutePathBuf,
}
#[derive(Debug, Clone)]
pub struct PluginStore {
root: AbsolutePathBuf,
}
impl PluginStore {
pub fn new(codex_home: PathBuf) -> Self {
Self {
root: AbsolutePathBuf::try_from(codex_home.join(PLUGINS_CACHE_DIR))
.unwrap_or_else(|err| panic!("plugin cache root should be absolute: {err}")),
}
}
pub fn root(&self) -> &AbsolutePathBuf {
&self.root
}
pub fn plugin_base_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf {
AbsolutePathBuf::try_from(
self.root
.as_path()
.join(&plugin_id.marketplace_name)
.join(&plugin_id.plugin_name),
)
.unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}"))
}
pub fn plugin_root(&self, plugin_id: &PluginId, plugin_version: &str) -> AbsolutePathBuf {
AbsolutePathBuf::try_from(
self.plugin_base_root(plugin_id)
.as_path()
.join(plugin_version),
)
.unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}"))
}
pub fn active_plugin_version(&self, plugin_id: &PluginId) -> Option<String> {
let mut discovered_versions = fs::read_dir(self.plugin_base_root(plugin_id).as_path())
.ok()?
.filter_map(Result::ok)
.filter_map(|entry| {
entry.file_type().ok().filter(std::fs::FileType::is_dir)?;
entry.file_name().into_string().ok()
})
.filter(|version| validate_plugin_segment(version, "plugin version").is_ok())
.collect::<Vec<_>>();
discovered_versions.sort_unstable();
if discovered_versions.len() == 1 {
discovered_versions.pop()
} else {
None
}
}
pub fn active_plugin_root(&self, plugin_id: &PluginId) -> Option<AbsolutePathBuf> {
self.active_plugin_version(plugin_id)
.map(|plugin_version| self.plugin_root(plugin_id, &plugin_version))
}
pub fn is_installed(&self, plugin_id: &PluginId) -> bool {
self.active_plugin_version(plugin_id).is_some()
}
pub fn install(
&self,
source_path: AbsolutePathBuf,
plugin_id: PluginId,
) -> Result<PluginInstallResult, PluginStoreError> {
self.install_with_version(source_path, plugin_id, DEFAULT_PLUGIN_VERSION.to_string())
}
pub fn install_with_version(
&self,
source_path: AbsolutePathBuf,
plugin_id: PluginId,
plugin_version: String,
) -> Result<PluginInstallResult, PluginStoreError> {
if !source_path.as_path().is_dir() {
return Err(PluginStoreError::Invalid(format!(
"plugin source path is not a directory: {}",
source_path.display()
)));
}
let plugin_name = plugin_name_for_source(source_path.as_path())?;
if plugin_name != plugin_id.plugin_name {
return Err(PluginStoreError::Invalid(format!(
"plugin manifest name `{plugin_name}` does not match marketplace plugin name `{}`",
plugin_id.plugin_name
)));
}
validate_plugin_segment(&plugin_version, "plugin version")
.map_err(PluginStoreError::Invalid)?;
let installed_path = self.plugin_root(&plugin_id, &plugin_version);
replace_plugin_root_atomically(
source_path.as_path(),
self.plugin_base_root(&plugin_id).as_path(),
&plugin_version,
)?;
Ok(PluginInstallResult {
plugin_id,
plugin_version,
installed_path,
})
}
pub fn uninstall(&self, plugin_id: &PluginId) -> Result<(), PluginStoreError> {
remove_existing_target(self.plugin_base_root(plugin_id).as_path())
}
}
#[derive(Debug, thiserror::Error)]
pub enum PluginStoreError {
#[error("{context}: {source}")]
Io {
context: &'static str,
#[source]
source: io::Error,
},
#[error("{0}")]
Invalid(String),
}
impl PluginStoreError {
fn io(context: &'static str, source: io::Error) -> Self {
Self::Io { context, source }
}
}
fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError> {
let manifest_path = source_path.join(PLUGIN_MANIFEST_PATH);
if !manifest_path.is_file() {
return Err(PluginStoreError::Invalid(format!(
"missing plugin manifest: {}",
manifest_path.display()
)));
}
let manifest = load_plugin_manifest(source_path).ok_or_else(|| {
PluginStoreError::Invalid(format!(
"missing or invalid plugin manifest: {}",
manifest_path.display()
))
})?;
let plugin_name = plugin_manifest_name(&manifest, source_path);
validate_plugin_segment(&plugin_name, "plugin name")
.map_err(PluginStoreError::Invalid)
.map(|_| plugin_name)
}
fn validate_plugin_segment(segment: &str, kind: &str) -> Result<(), String> {
if segment.is_empty() {
return Err(format!("invalid {kind}: must not be empty"));
}
if !segment
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
{
return Err(format!(
"invalid {kind}: only ASCII letters, digits, `_`, and `-` are allowed"
));
}
Ok(())
}
fn remove_existing_target(path: &Path) -> Result<(), PluginStoreError> {
if !path.exists() {
return Ok(());
}
if path.is_dir() {
fs::remove_dir_all(path).map_err(|err| {
PluginStoreError::io("failed to remove existing plugin cache entry", err)
})
} else {
fs::remove_file(path).map_err(|err| {
PluginStoreError::io("failed to remove existing plugin cache entry", err)
})
}
}
fn replace_plugin_root_atomically(
source: &Path,
target_root: &Path,
plugin_version: &str,
) -> Result<(), PluginStoreError> {
let Some(parent) = target_root.parent() else {
return Err(PluginStoreError::Invalid(format!(
"plugin cache path has no parent: {}",
target_root.display()
)));
};
fs::create_dir_all(parent)
.map_err(|err| PluginStoreError::io("failed to create plugin cache directory", err))?;
let Some(plugin_dir_name) = target_root.file_name() else {
return Err(PluginStoreError::Invalid(format!(
"plugin cache path has no directory name: {}",
target_root.display()
)));
};
let staged_dir = tempfile::Builder::new()
.prefix("plugin-install-")
.tempdir_in(parent)
.map_err(|err| {
PluginStoreError::io("failed to create temporary plugin cache directory", err)
})?;
let staged_root = staged_dir.path().join(plugin_dir_name);
let staged_version_root = staged_root.join(plugin_version);
copy_dir_recursive(source, &staged_version_root)?;
if target_root.exists() {
let backup_dir = tempfile::Builder::new()
.prefix("plugin-backup-")
.tempdir_in(parent)
.map_err(|err| {
PluginStoreError::io("failed to create plugin cache backup directory", err)
})?;
let backup_root = backup_dir.path().join(plugin_dir_name);
fs::rename(target_root, &backup_root)
.map_err(|err| PluginStoreError::io("failed to back up plugin cache entry", err))?;
if let Err(err) = fs::rename(&staged_root, target_root) {
let rollback_result = fs::rename(&backup_root, target_root);
return match rollback_result {
Ok(()) => Err(PluginStoreError::io(
"failed to activate updated plugin cache entry",
err,
)),
Err(rollback_err) => {
let backup_path = backup_dir.keep().join(plugin_dir_name);
Err(PluginStoreError::Invalid(format!(
"failed to activate updated plugin cache entry at {}: {err}; failed to restore previous cache entry (left at {}): {rollback_err}",
target_root.display(),
backup_path.display()
)))
}
};
}
} else {
fs::rename(&staged_root, target_root)
.map_err(|err| PluginStoreError::io("failed to activate plugin cache entry", err))?;
}
Ok(())
}
fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
fs::create_dir_all(target)
.map_err(|err| PluginStoreError::io("failed to create plugin target directory", err))?;
for entry in fs::read_dir(source)
.map_err(|err| PluginStoreError::io("failed to read plugin source directory", err))?
{
let entry =
entry.map_err(|err| PluginStoreError::io("failed to enumerate plugin source", err))?;
let source_path = entry.path();
let target_path = target.join(entry.file_name());
let file_type = entry
.file_type()
.map_err(|err| PluginStoreError::io("failed to inspect plugin source entry", err))?;
if file_type.is_dir() {
copy_dir_recursive(&source_path, &target_path)?;
} else if file_type.is_file() {
fs::copy(&source_path, &target_path)
.map_err(|err| PluginStoreError::io("failed to copy plugin file", err))?;
}
}
Ok(())
}
#[cfg(test)]
#[path = "store_tests.rs"]
mod tests;
pub(crate) use codex_extensions::plugins::DEFAULT_PLUGIN_VERSION;
pub use codex_extensions::plugins::PluginId;
pub use codex_extensions::plugins::PluginIdError;
pub(crate) use codex_extensions::plugins::PluginInstallResult;
pub(crate) use codex_extensions::plugins::PluginStore;
pub use codex_extensions::plugins::PluginStoreError;

View File

@@ -8,6 +8,10 @@ pub mod remote;
pub mod render;
pub mod system;
pub use codex_extensions::skills::SkillError;
pub use codex_extensions::skills::SkillLoadOutcome;
pub use codex_extensions::skills::SkillMetadata;
pub use codex_extensions::skills::SkillPolicy;
pub(crate) use env_var_dependencies::collect_env_var_dependencies;
pub(crate) use env_var_dependencies::resolve_skill_dependencies_for_turn;
pub(crate) use injection::SkillInjections;
@@ -16,8 +20,4 @@ pub(crate) use injection::collect_explicit_skill_mentions;
pub(crate) use invocation_utils::build_implicit_skill_path_indexes;
pub(crate) use invocation_utils::maybe_emit_implicit_skill_invocation;
pub use manager::SkillsManager;
pub use model::SkillError;
pub use model::SkillLoadOutcome;
pub use model::SkillMetadata;
pub use model::SkillPolicy;
pub use render::render_skills_section;

View File

@@ -1,113 +1 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::SkillScope;
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct SkillManagedNetworkOverride {
pub allowed_domains: Option<Vec<String>>,
pub denied_domains: Option<Vec<String>>,
}
impl SkillManagedNetworkOverride {
pub fn has_domain_overrides(&self) -> bool {
self.allowed_domains.is_some() || self.denied_domains.is_some()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SkillMetadata {
pub name: String,
pub description: String,
pub short_description: Option<String>,
pub interface: Option<SkillInterface>,
pub dependencies: Option<SkillDependencies>,
pub policy: Option<SkillPolicy>,
pub permission_profile: Option<PermissionProfile>,
pub managed_network_override: Option<SkillManagedNetworkOverride>,
/// Path to the SKILLS.md file that declares this skill.
pub path_to_skills_md: PathBuf,
pub scope: SkillScope,
}
impl SkillMetadata {
fn allow_implicit_invocation(&self) -> bool {
self.policy
.as_ref()
.and_then(|policy| policy.allow_implicit_invocation)
.unwrap_or(true)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SkillPolicy {
pub allow_implicit_invocation: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillInterface {
pub display_name: Option<String>,
pub short_description: Option<String>,
pub icon_small: Option<PathBuf>,
pub icon_large: Option<PathBuf>,
pub brand_color: Option<String>,
pub default_prompt: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillDependencies {
pub tools: Vec<SkillToolDependency>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillToolDependency {
pub r#type: String,
pub value: String,
pub description: Option<String>,
pub transport: Option<String>,
pub command: Option<String>,
pub url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillError {
pub path: PathBuf,
pub message: String,
}
#[derive(Debug, Clone, Default)]
pub struct SkillLoadOutcome {
pub skills: Vec<SkillMetadata>,
pub errors: Vec<SkillError>,
pub disabled_paths: HashSet<PathBuf>,
pub(crate) implicit_skills_by_scripts_dir: Arc<HashMap<PathBuf, SkillMetadata>>,
pub(crate) implicit_skills_by_doc_path: Arc<HashMap<PathBuf, SkillMetadata>>,
}
impl SkillLoadOutcome {
pub fn is_skill_enabled(&self, skill: &SkillMetadata) -> bool {
!self.disabled_paths.contains(&skill.path_to_skills_md)
}
pub fn is_skill_allowed_for_implicit_invocation(&self, skill: &SkillMetadata) -> bool {
self.is_skill_enabled(skill) && skill.allow_implicit_invocation()
}
pub fn allowed_skills_for_implicit_invocation(&self) -> Vec<SkillMetadata> {
self.skills
.iter()
.filter(|skill| self.is_skill_allowed_for_implicit_invocation(skill))
.cloned()
.collect()
}
pub fn skills_with_enabled(&self) -> impl Iterator<Item = (&SkillMetadata, bool)> {
self.skills
.iter()
.map(|skill| (skill, self.is_skill_enabled(skill)))
}
}
pub use codex_extensions::skills::model::*;

View File

@@ -1,48 +1 @@
use crate::skills::model::SkillMetadata;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
if skills.is_empty() {
return None;
}
let mut lines: Vec<String> = Vec::new();
lines.push("## Skills".to_string());
lines.push("A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.".to_string());
lines.push("### Available skills".to_string());
for skill in skills {
let path_str = skill.path_to_skills_md.to_string_lossy().replace('\\', "/");
let name = skill.name.as_str();
let description = skill.description.as_str();
lines.push(format!("- {name}: {description} (file: {path_str})"));
}
lines.push("### How to use skills".to_string());
lines.push(
r###"- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
- How to use a skill (progressive disclosure):
1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.
2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed.
3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.
4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
5) If `assets/` or templates exist, reuse them instead of recreating from scratch.
- Coordination and sequencing:
- If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
- Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
- Context hygiene:
- Keep context small: summarize long sections instead of pasting them; only load extra files when needed.
- Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
- When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###
.to_string(),
);
let body = lines.join("\n");
Some(format!(
"{SKILLS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{SKILLS_INSTRUCTIONS_CLOSE_TAG}"
))
}
pub use codex_extensions::skills::render_skills_section;

View File

@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "extensions",
crate_name = "codex_extensions",
)

View File

@@ -0,0 +1,21 @@
[package]
name = "codex-extensions"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
doctest = false
name = "codex_extensions"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }

View File

@@ -0,0 +1,2 @@
pub mod plugins;
pub mod skills;

View File

@@ -0,0 +1,328 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde_json::Value as JsonValue;
use std::fs;
use std::path::Component;
use std::path::Path;
pub const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
const MAX_DEFAULT_PROMPT_COUNT: usize = 3;
const MAX_DEFAULT_PROMPT_LEN: usize = 128;
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginManifest {
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
skills: Option<String>,
#[serde(default)]
mcp_servers: Option<String>,
#[serde(default)]
apps: Option<String>,
#[serde(default)]
interface: Option<PluginManifestInterface>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginManifestPaths {
pub skills: Option<AbsolutePathBuf>,
pub mcp_servers: Option<AbsolutePathBuf>,
pub apps: Option<AbsolutePathBuf>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginManifestInterfaceSummary {
pub display_name: Option<String>,
pub short_description: Option<String>,
pub long_description: Option<String>,
pub developer_name: Option<String>,
pub category: Option<String>,
pub capabilities: Vec<String>,
pub website_url: Option<String>,
pub privacy_policy_url: Option<String>,
pub terms_of_service_url: Option<String>,
pub default_prompt: Option<Vec<String>>,
pub brand_color: Option<String>,
pub composer_icon: Option<AbsolutePathBuf>,
pub logo: Option<AbsolutePathBuf>,
pub screenshots: Vec<AbsolutePathBuf>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PluginManifestInterface {
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
short_description: Option<String>,
#[serde(default)]
long_description: Option<String>,
#[serde(default)]
developer_name: Option<String>,
#[serde(default)]
category: Option<String>,
#[serde(default)]
capabilities: Vec<String>,
#[serde(default, alias = "websiteURL")]
website_url: Option<String>,
#[serde(default, alias = "privacyPolicyURL")]
privacy_policy_url: Option<String>,
#[serde(default, alias = "termsOfServiceURL")]
terms_of_service_url: Option<String>,
#[serde(default)]
default_prompt: Option<PluginManifestDefaultPrompt>,
#[serde(default)]
brand_color: Option<String>,
#[serde(default)]
composer_icon: Option<String>,
#[serde(default)]
logo: Option<String>,
#[serde(default)]
screenshots: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PluginManifestDefaultPrompt {
String(String),
List(Vec<PluginManifestDefaultPromptEntry>),
Invalid(JsonValue),
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PluginManifestDefaultPromptEntry {
String(String),
Invalid(JsonValue),
}
pub fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
if !manifest_path.is_file() {
return None;
}
let contents = fs::read_to_string(&manifest_path).ok()?;
match serde_json::from_str(&contents) {
Ok(manifest) => Some(manifest),
Err(err) => {
tracing::warn!(
path = %manifest_path.display(),
"failed to parse plugin manifest: {err}"
);
None
}
}
}
pub fn plugin_manifest_name(manifest: &PluginManifest, plugin_root: &Path) -> String {
plugin_root
.file_name()
.and_then(|name| name.to_str())
.filter(|_| manifest.name.trim().is_empty())
.unwrap_or(&manifest.name)
.to_string()
}
pub fn plugin_manifest_interface(
manifest: &PluginManifest,
plugin_root: &Path,
) -> Option<PluginManifestInterfaceSummary> {
let interface = manifest.interface.as_ref()?;
let interface = PluginManifestInterfaceSummary {
display_name: interface.display_name.clone(),
short_description: interface.short_description.clone(),
long_description: interface.long_description.clone(),
developer_name: interface.developer_name.clone(),
category: interface.category.clone(),
capabilities: interface.capabilities.clone(),
website_url: interface.website_url.clone(),
privacy_policy_url: interface.privacy_policy_url.clone(),
terms_of_service_url: interface.terms_of_service_url.clone(),
default_prompt: resolve_default_prompts(plugin_root, interface.default_prompt.as_ref()),
brand_color: interface.brand_color.clone(),
composer_icon: resolve_interface_asset_path(
plugin_root,
"interface.composerIcon",
interface.composer_icon.as_deref(),
),
logo: resolve_interface_asset_path(
plugin_root,
"interface.logo",
interface.logo.as_deref(),
),
screenshots: interface
.screenshots
.iter()
.filter_map(|screenshot| {
resolve_interface_asset_path(plugin_root, "interface.screenshots", Some(screenshot))
})
.collect(),
};
let has_fields = interface.display_name.is_some()
|| interface.short_description.is_some()
|| interface.long_description.is_some()
|| interface.developer_name.is_some()
|| interface.category.is_some()
|| !interface.capabilities.is_empty()
|| interface.website_url.is_some()
|| interface.privacy_policy_url.is_some()
|| interface.terms_of_service_url.is_some()
|| interface.default_prompt.is_some()
|| interface.brand_color.is_some()
|| interface.composer_icon.is_some()
|| interface.logo.is_some()
|| !interface.screenshots.is_empty();
has_fields.then_some(interface)
}
pub fn plugin_manifest_paths(manifest: &PluginManifest, plugin_root: &Path) -> PluginManifestPaths {
PluginManifestPaths {
skills: resolve_manifest_path(plugin_root, "skills", manifest.skills.as_deref()),
mcp_servers: resolve_manifest_path(
plugin_root,
"mcpServers",
manifest.mcp_servers.as_deref(),
),
apps: resolve_manifest_path(plugin_root, "apps", manifest.apps.as_deref()),
}
}
fn resolve_interface_asset_path(
plugin_root: &Path,
field: &'static str,
path: Option<&str>,
) -> Option<AbsolutePathBuf> {
resolve_manifest_path(plugin_root, field, path)
}
fn resolve_default_prompts(
plugin_root: &Path,
value: Option<&PluginManifestDefaultPrompt>,
) -> Option<Vec<String>> {
match value? {
PluginManifestDefaultPrompt::String(prompt) => {
resolve_default_prompt_str(plugin_root, "interface.defaultPrompt", prompt)
.map(|prompt| vec![prompt])
}
PluginManifestDefaultPrompt::List(values) => {
let mut prompts = Vec::new();
for (index, item) in values.iter().enumerate() {
if prompts.len() >= MAX_DEFAULT_PROMPT_COUNT {
warn_invalid_default_prompt(
plugin_root,
"interface.defaultPrompt",
&format!("maximum of {MAX_DEFAULT_PROMPT_COUNT} prompts is supported"),
);
break;
}
match item {
PluginManifestDefaultPromptEntry::String(prompt) => {
let field = format!("interface.defaultPrompt[{index}]");
if let Some(prompt) =
resolve_default_prompt_str(plugin_root, &field, prompt)
{
prompts.push(prompt);
}
}
PluginManifestDefaultPromptEntry::Invalid(value) => {
let field = format!("interface.defaultPrompt[{index}]");
warn_invalid_default_prompt(
plugin_root,
&field,
&format!("expected a string, found {}", json_value_type(value)),
);
}
}
}
(!prompts.is_empty()).then_some(prompts)
}
PluginManifestDefaultPrompt::Invalid(value) => {
warn_invalid_default_prompt(
plugin_root,
"interface.defaultPrompt",
&format!(
"expected a string or array of strings, found {}",
json_value_type(value)
),
);
None
}
}
}
fn resolve_default_prompt_str(plugin_root: &Path, field: &str, prompt: &str) -> Option<String> {
let prompt = prompt.trim();
if prompt.is_empty() {
warn_invalid_default_prompt(plugin_root, field, "must not be empty");
return None;
}
let prompt = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
if prompt.chars().count() > MAX_DEFAULT_PROMPT_LEN {
warn_invalid_default_prompt(
plugin_root,
field,
&format!("maximum length is {MAX_DEFAULT_PROMPT_LEN} characters"),
);
return None;
}
Some(prompt)
}
fn resolve_manifest_path(
plugin_root: &Path,
field: &'static str,
value: Option<&str>,
) -> Option<AbsolutePathBuf> {
let value = value?;
if !value.starts_with("./") {
tracing::warn!(
path = %plugin_root.display(),
"ignoring invalid plugin manifest path for {field}; expected ./relative/path"
);
return None;
}
let relative = Path::new(value);
if relative.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
}) {
tracing::warn!(
path = %plugin_root.display(),
"ignoring invalid plugin manifest path for {field}; path must stay within plugin root"
);
return None;
}
let resolved = plugin_root.join(relative);
AbsolutePathBuf::try_from(resolved).ok()
}
fn warn_invalid_default_prompt(plugin_root: &Path, field: &str, reason: &str) {
tracing::warn!(
path = %plugin_root.display(),
"ignoring invalid plugin manifest {field}: {reason}"
);
}
fn json_value_type(value: &JsonValue) -> &'static str {
match value {
JsonValue::Null => "null",
JsonValue::Bool(_) => "boolean",
JsonValue::Number(_) => "number",
JsonValue::String(_) => "string",
JsonValue::Array(_) => "array",
JsonValue::Object(_) => "object",
}
}

View File

@@ -0,0 +1,25 @@
mod manifest;
mod render;
mod store;
mod types;
pub use manifest::PLUGIN_MANIFEST_PATH;
pub use manifest::PluginManifest;
pub use manifest::PluginManifestInterfaceSummary;
pub use manifest::PluginManifestPaths;
pub use manifest::load_plugin_manifest;
pub use manifest::plugin_manifest_interface;
pub use manifest::plugin_manifest_name;
pub use manifest::plugin_manifest_paths;
pub use render::render_explicit_plugin_instructions;
pub use render::render_plugins_section;
pub use store::DEFAULT_PLUGIN_VERSION;
pub use store::PLUGINS_CACHE_DIR;
pub use store::PluginId;
pub use store::PluginIdError;
pub use store::PluginInstallResult;
pub use store::PluginStore;
pub use store::PluginStoreError;
pub use types::AppConnectorId;
pub use types::PluginCapabilitySummary;
pub use types::PluginTelemetryMetadata;

View File

@@ -0,0 +1,88 @@
use crate::plugins::PluginCapabilitySummary;
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG;
pub fn render_plugins_section(plugins: &[PluginCapabilitySummary]) -> Option<String> {
if plugins.is_empty() {
return None;
}
let mut lines = vec![
"## Plugins".to_string(),
"A plugin is a local bundle of skills, MCP servers, and apps. Below is the list of plugins that are enabled and available in this session.".to_string(),
"### Available plugins".to_string(),
];
lines.extend(
plugins
.iter()
.map(|plugin| match plugin.description.as_deref() {
Some(description) => format!("- `{}`: {description}", plugin.display_name),
None => format!("- `{}`", plugin.display_name),
}),
);
lines.push("### How to use plugins".to_string());
lines.push(
r###"- Discovery: The list above is the plugins available in this session.
- Skill naming: If a plugin contributes skills, those skill entries are prefixed with `plugin_name:` in the Skills list.
- Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn.
- Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task.
- Preference: When a relevant plugin is available, prefer using capabilities associated with that plugin over standalone capabilities that provide similar functionality.
- Missing/blocked: If the user requests a plugin that is not listed above, or the plugin does not have relevant callable capabilities for the task, say so briefly and continue with the best fallback."###
.to_string(),
);
let body = lines.join("\n");
Some(format!(
"{PLUGINS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{PLUGINS_INSTRUCTIONS_CLOSE_TAG}"
))
}
pub fn render_explicit_plugin_instructions(
plugin: &PluginCapabilitySummary,
available_mcp_servers: &[String],
available_apps: &[String],
) -> Option<String> {
let mut lines = vec![format!(
"Capabilities from the `{}` plugin:",
plugin.display_name
)];
if plugin.has_skills {
lines.push(format!(
"- Skills from this plugin are prefixed with `{}:`.",
plugin.display_name
));
}
if !available_mcp_servers.is_empty() {
lines.push(format!(
"- MCP servers from this plugin available in this session: {}.",
available_mcp_servers
.iter()
.map(|server| format!("`{server}`"))
.collect::<Vec<_>>()
.join(", ")
));
}
if !available_apps.is_empty() {
lines.push(format!(
"- Apps from this plugin available in this session: {}.",
available_apps
.iter()
.map(|app| format!("`{app}`"))
.collect::<Vec<_>>()
.join(", ")
));
}
if lines.len() == 1 {
return None;
}
lines.push("Use these plugin-associated capabilities to help solve the task.".to_string());
Some(lines.join("\n"))
}

View File

@@ -0,0 +1,306 @@
use crate::plugins::PLUGIN_MANIFEST_PATH;
use crate::plugins::load_plugin_manifest;
use crate::plugins::plugin_manifest_name;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
pub const DEFAULT_PLUGIN_VERSION: &str = "local";
pub const PLUGINS_CACHE_DIR: &str = "plugins/cache";
#[derive(Debug, thiserror::Error)]
pub enum PluginIdError {
#[error("{0}")]
Invalid(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginId {
pub plugin_name: String,
pub marketplace_name: String,
}
impl PluginId {
pub fn new(plugin_name: String, marketplace_name: String) -> Result<Self, PluginIdError> {
validate_plugin_segment(&plugin_name, "plugin name").map_err(PluginIdError::Invalid)?;
validate_plugin_segment(&marketplace_name, "marketplace name")
.map_err(PluginIdError::Invalid)?;
Ok(Self {
plugin_name,
marketplace_name,
})
}
pub fn parse(plugin_key: &str) -> Result<Self, PluginIdError> {
let Some((plugin_name, marketplace_name)) = plugin_key.rsplit_once('@') else {
return Err(PluginIdError::Invalid(format!(
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
)));
};
if plugin_name.is_empty() || marketplace_name.is_empty() {
return Err(PluginIdError::Invalid(format!(
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
)));
}
Self::new(plugin_name.to_string(), marketplace_name.to_string()).map_err(|err| match err {
PluginIdError::Invalid(message) => {
PluginIdError::Invalid(format!("{message} in `{plugin_key}`"))
}
})
}
pub fn as_key(&self) -> String {
format!("{}@{}", self.plugin_name, self.marketplace_name)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginInstallResult {
pub plugin_id: PluginId,
pub plugin_version: String,
pub installed_path: AbsolutePathBuf,
}
#[derive(Debug, Clone)]
pub struct PluginStore {
root: AbsolutePathBuf,
}
impl PluginStore {
pub fn new(codex_home: PathBuf) -> Self {
Self {
root: AbsolutePathBuf::try_from(codex_home.join(PLUGINS_CACHE_DIR))
.unwrap_or_else(|err| panic!("plugin cache root should be absolute: {err}")),
}
}
pub fn root(&self) -> &AbsolutePathBuf {
&self.root
}
pub fn plugin_base_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf {
AbsolutePathBuf::try_from(
self.root
.as_path()
.join(&plugin_id.marketplace_name)
.join(&plugin_id.plugin_name),
)
.unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}"))
}
pub fn plugin_root(&self, plugin_id: &PluginId, plugin_version: &str) -> AbsolutePathBuf {
AbsolutePathBuf::try_from(
self.plugin_base_root(plugin_id)
.as_path()
.join(plugin_version),
)
.unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}"))
}
pub fn active_plugin_version(&self, plugin_id: &PluginId) -> Option<String> {
let mut discovered_versions = fs::read_dir(self.plugin_base_root(plugin_id).as_path())
.ok()?
.filter_map(Result::ok)
.filter_map(|entry| {
entry.file_type().ok().filter(std::fs::FileType::is_dir)?;
entry.file_name().into_string().ok()
})
.filter(|version| validate_plugin_segment(version, "plugin version").is_ok())
.collect::<Vec<_>>();
discovered_versions.sort_unstable();
if discovered_versions.len() == 1 {
discovered_versions.pop()
} else {
None
}
}
pub fn active_plugin_root(&self, plugin_id: &PluginId) -> Option<AbsolutePathBuf> {
self.active_plugin_version(plugin_id)
.map(|plugin_version| self.plugin_root(plugin_id, &plugin_version))
}
pub fn is_installed(&self, plugin_id: &PluginId) -> bool {
self.active_plugin_version(plugin_id).is_some()
}
pub fn install(
&self,
source_path: AbsolutePathBuf,
plugin_id: PluginId,
) -> Result<PluginInstallResult, PluginStoreError> {
self.install_with_version(source_path, plugin_id, DEFAULT_PLUGIN_VERSION.to_string())
}
pub fn install_with_version(
&self,
source_path: AbsolutePathBuf,
plugin_id: PluginId,
plugin_version: String,
) -> Result<PluginInstallResult, PluginStoreError> {
if !source_path.as_path().is_dir() {
return Err(PluginStoreError::Invalid(format!(
"plugin source path is not a directory: {}",
source_path.display()
)));
}
let plugin_name = plugin_name_for_source(source_path.as_path())?;
if plugin_name != plugin_id.plugin_name {
return Err(PluginStoreError::Invalid(format!(
"plugin manifest name `{plugin_name}` does not match marketplace plugin name `{}`",
plugin_id.plugin_name
)));
}
validate_plugin_segment(&plugin_version, "plugin version")
.map_err(PluginStoreError::Invalid)?;
let installed_path = self.plugin_root(&plugin_id, &plugin_version);
replace_plugin_root_atomically(
source_path.as_path(),
self.plugin_base_root(&plugin_id).as_path(),
&plugin_version,
)?;
Ok(PluginInstallResult {
plugin_id,
plugin_version,
installed_path,
})
}
pub fn uninstall(&self, plugin_id: &PluginId) -> Result<(), PluginStoreError> {
remove_existing_target(self.plugin_base_root(plugin_id).as_path())
}
}
#[derive(Debug, thiserror::Error)]
pub enum PluginStoreError {
#[error("{context}: {source}")]
Io {
context: &'static str,
#[source]
source: io::Error,
},
#[error("{0}")]
Invalid(String),
}
impl PluginStoreError {
fn io(context: &'static str, source: io::Error) -> Self {
Self::Io { context, source }
}
}
fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError> {
let manifest_path = source_path.join(PLUGIN_MANIFEST_PATH);
if !manifest_path.is_file() {
return Err(PluginStoreError::Invalid(format!(
"missing plugin manifest: {}",
manifest_path.display()
)));
}
let manifest = load_plugin_manifest(source_path).ok_or_else(|| {
PluginStoreError::Invalid(format!(
"missing or invalid plugin manifest: {}",
manifest_path.display()
))
})?;
let plugin_name = plugin_manifest_name(&manifest, source_path);
validate_plugin_segment(&plugin_name, "plugin name")
.map_err(PluginStoreError::Invalid)
.map(|_| plugin_name)
}
fn validate_plugin_segment(segment: &str, kind: &str) -> Result<(), String> {
if segment.is_empty() {
return Err(format!("invalid {kind}: must not be empty"));
}
if !segment
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
{
return Err(format!(
"invalid {kind}: only ASCII letters, digits, `_`, and `-` are allowed"
));
}
Ok(())
}
fn remove_existing_target(path: &Path) -> Result<(), PluginStoreError> {
if !path.exists() {
return Ok(());
}
if path.is_dir() {
fs::remove_dir_all(path).map_err(|err| {
PluginStoreError::io("failed to remove existing plugin cache entry", err)
})
} else {
fs::remove_file(path).map_err(|err| {
PluginStoreError::io("failed to remove existing plugin cache entry", err)
})
}
}
fn replace_plugin_root_atomically(
source: &Path,
target_root: &Path,
plugin_version: &str,
) -> Result<(), PluginStoreError> {
let Some(parent) = target_root.parent() else {
return Err(PluginStoreError::Invalid(format!(
"plugin cache path has no parent: {}",
target_root.display()
)));
};
fs::create_dir_all(parent)
.map_err(|err| PluginStoreError::io("failed to create plugin cache parent", err))?;
let target = parent.join(format!(
".{}-{}.next",
target_root
.file_name()
.and_then(|file_name| file_name.to_str())
.ok_or_else(|| PluginStoreError::Invalid(format!(
"plugin cache path has no terminal directory name: {}",
target_root.display()
)))?,
plugin_version
));
let staging = target.join(plugin_version);
remove_existing_target(target.as_path())?;
copy_dir_recursive(source, staging.as_path())?;
remove_existing_target(target_root)?;
fs::rename(target.as_path(), target_root)
.map_err(|err| PluginStoreError::io("failed to activate plugin cache entry", err))
}
fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
fs::create_dir_all(target)
.map_err(|err| PluginStoreError::io("failed to create plugin cache directory", err))?;
for entry in fs::read_dir(source)
.map_err(|err| PluginStoreError::io("failed to read plugin source directory", err))?
{
let entry =
entry.map_err(|err| PluginStoreError::io("failed to read plugin source entry", err))?;
let source_path = entry.path();
let target_path = target.join(entry.file_name());
let file_type = entry
.file_type()
.map_err(|err| PluginStoreError::io("failed to read plugin source file type", err))?;
if file_type.is_dir() {
copy_dir_recursive(source_path.as_path(), target_path.as_path())?;
} else if file_type.is_file() {
fs::copy(source_path.as_path(), target_path.as_path())
.map_err(|err| PluginStoreError::io("failed to copy plugin source file", err))?;
}
}
Ok(())
}

View File

@@ -0,0 +1,40 @@
use crate::plugins::PluginId;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AppConnectorId(pub String);
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginCapabilitySummary {
pub config_name: String,
pub display_name: String,
pub description: Option<String>,
pub has_skills: bool,
pub mcp_server_names: Vec<String>,
pub app_connector_ids: Vec<AppConnectorId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginTelemetryMetadata {
pub plugin_id: PluginId,
pub capability_summary: Option<PluginCapabilitySummary>,
}
impl PluginTelemetryMetadata {
pub fn from_plugin_id(plugin_id: &PluginId) -> Self {
Self {
plugin_id: plugin_id.clone(),
capability_summary: None,
}
}
}
impl PluginCapabilitySummary {
pub fn telemetry_metadata(&self) -> Option<PluginTelemetryMetadata> {
PluginId::parse(&self.config_name)
.ok()
.map(|plugin_id| PluginTelemetryMetadata {
plugin_id,
capability_summary: Some(self.clone()),
})
}
}

View File

@@ -0,0 +1,12 @@
pub mod model;
mod render;
pub use model::SkillDependencies;
pub use model::SkillError;
pub use model::SkillInterface;
pub use model::SkillLoadOutcome;
pub use model::SkillManagedNetworkOverride;
pub use model::SkillMetadata;
pub use model::SkillPolicy;
pub use model::SkillToolDependency;
pub use render::render_skills_section;

View File

@@ -0,0 +1,112 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::SkillScope;
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct SkillManagedNetworkOverride {
pub allowed_domains: Option<Vec<String>>,
pub denied_domains: Option<Vec<String>>,
}
impl SkillManagedNetworkOverride {
pub fn has_domain_overrides(&self) -> bool {
self.allowed_domains.is_some() || self.denied_domains.is_some()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SkillMetadata {
pub name: String,
pub description: String,
pub short_description: Option<String>,
pub interface: Option<SkillInterface>,
pub dependencies: Option<SkillDependencies>,
pub policy: Option<SkillPolicy>,
pub permission_profile: Option<PermissionProfile>,
pub managed_network_override: Option<SkillManagedNetworkOverride>,
pub path_to_skills_md: PathBuf,
pub scope: SkillScope,
}
impl SkillMetadata {
fn allow_implicit_invocation(&self) -> bool {
self.policy
.as_ref()
.and_then(|policy| policy.allow_implicit_invocation)
.unwrap_or(true)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SkillPolicy {
pub allow_implicit_invocation: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillInterface {
pub display_name: Option<String>,
pub short_description: Option<String>,
pub icon_small: Option<PathBuf>,
pub icon_large: Option<PathBuf>,
pub brand_color: Option<String>,
pub default_prompt: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillDependencies {
pub tools: Vec<SkillToolDependency>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillToolDependency {
pub r#type: String,
pub value: String,
pub description: Option<String>,
pub transport: Option<String>,
pub command: Option<String>,
pub url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillError {
pub path: PathBuf,
pub message: String,
}
#[derive(Debug, Clone, Default)]
pub struct SkillLoadOutcome {
pub skills: Vec<SkillMetadata>,
pub errors: Vec<SkillError>,
pub disabled_paths: HashSet<PathBuf>,
pub implicit_skills_by_scripts_dir: Arc<HashMap<PathBuf, SkillMetadata>>,
pub implicit_skills_by_doc_path: Arc<HashMap<PathBuf, SkillMetadata>>,
}
impl SkillLoadOutcome {
pub fn is_skill_enabled(&self, skill: &SkillMetadata) -> bool {
!self.disabled_paths.contains(&skill.path_to_skills_md)
}
pub fn is_skill_allowed_for_implicit_invocation(&self, skill: &SkillMetadata) -> bool {
self.is_skill_enabled(skill) && skill.allow_implicit_invocation()
}
pub fn allowed_skills_for_implicit_invocation(&self) -> Vec<SkillMetadata> {
self.skills
.iter()
.filter(|skill| self.is_skill_allowed_for_implicit_invocation(skill))
.cloned()
.collect()
}
pub fn skills_with_enabled(&self) -> impl Iterator<Item = (&SkillMetadata, bool)> {
self.skills
.iter()
.map(|skill| (skill, self.is_skill_enabled(skill)))
}
}

View File

@@ -0,0 +1,48 @@
use crate::skills::model::SkillMetadata;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
if skills.is_empty() {
return None;
}
let mut lines: Vec<String> = Vec::new();
lines.push("## Skills".to_string());
lines.push("A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.".to_string());
lines.push("### Available skills".to_string());
for skill in skills {
let path_str = skill.path_to_skills_md.to_string_lossy().replace('\\', "/");
let name = skill.name.as_str();
let description = skill.description.as_str();
lines.push(format!("- {name}: {description} (file: {path_str})"));
}
lines.push("### How to use skills".to_string());
lines.push(
r###"- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
- How to use a skill (progressive disclosure):
1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.
2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed.
3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.
4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
5) If `assets/` or templates exist, reuse them instead of recreating from scratch.
- Coordination and sequencing:
- If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
- Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
- Context hygiene:
- Keep context small: summarize long sections instead of pasting them; only load extra files when needed.
- Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
- When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###
.to_string(),
);
let body = lines.join("\n");
Some(format!(
"{SKILLS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{SKILLS_INSTRUCTIONS_CLOSE_TAG}"
))
}