mirror of
https://github.com/openai/codex.git
synced 2026-04-26 17:31:02 +03:00
Compare commits
9 Commits
codex-debu
...
codex/spli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cc0d1c053 | ||
|
|
3848e8e43e | ||
|
|
d174c6ad4c | ||
|
|
bb9dcc5982 | ||
|
|
342b8e40a1 | ||
|
|
1cf68f940c | ||
|
|
0f406c3de0 | ||
|
|
8b3fc35e0b | ||
|
|
38a28973a8 |
18
codex-rs/Cargo.lock
generated
18
codex-rs/Cargo.lock
generated
@@ -1786,10 +1786,12 @@ name = "codex-config"
|
|||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"base64 0.22.1",
|
||||||
"codex-app-server-protocol",
|
"codex-app-server-protocol",
|
||||||
"codex-execpolicy",
|
"codex-execpolicy",
|
||||||
"codex-protocol",
|
"codex-protocol",
|
||||||
"codex-utils-absolute-path",
|
"codex-utils-absolute-path",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
"futures",
|
"futures",
|
||||||
"multimap",
|
"multimap",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
@@ -1802,6 +1804,7 @@ dependencies = [
|
|||||||
"toml 0.9.11+spec-1.1.0",
|
"toml 0.9.11+spec-1.1.0",
|
||||||
"toml_edit 0.24.0+spec-1.1.0",
|
"toml_edit 0.24.0+spec-1.1.0",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1842,6 +1845,7 @@ dependencies = [
|
|||||||
"codex-config",
|
"codex-config",
|
||||||
"codex-connectors",
|
"codex-connectors",
|
||||||
"codex-execpolicy",
|
"codex-execpolicy",
|
||||||
|
"codex-extensions",
|
||||||
"codex-file-search",
|
"codex-file-search",
|
||||||
"codex-git",
|
"codex-git",
|
||||||
"codex-hooks",
|
"codex-hooks",
|
||||||
@@ -1866,7 +1870,6 @@ dependencies = [
|
|||||||
"codex-utils-stream-parser",
|
"codex-utils-stream-parser",
|
||||||
"codex-utils-string",
|
"codex-utils-string",
|
||||||
"codex-windows-sandbox",
|
"codex-windows-sandbox",
|
||||||
"core-foundation 0.9.4",
|
|
||||||
"core_test_support",
|
"core_test_support",
|
||||||
"csv",
|
"csv",
|
||||||
"ctor 0.6.3",
|
"ctor 0.6.3",
|
||||||
@@ -1926,7 +1929,6 @@ dependencies = [
|
|||||||
"walkdir",
|
"walkdir",
|
||||||
"which",
|
"which",
|
||||||
"wildmatch",
|
"wildmatch",
|
||||||
"windows-sys 0.52.0",
|
|
||||||
"wiremock",
|
"wiremock",
|
||||||
"zip",
|
"zip",
|
||||||
"zstd",
|
"zstd",
|
||||||
@@ -2035,6 +2037,18 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"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]]
|
[[package]]
|
||||||
name = "codex-feedback"
|
name = "codex-feedback"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ members = [
|
|||||||
"exec",
|
"exec",
|
||||||
"execpolicy",
|
"execpolicy",
|
||||||
"execpolicy-legacy",
|
"execpolicy-legacy",
|
||||||
|
"extensions",
|
||||||
"keyring-store",
|
"keyring-store",
|
||||||
"file-search",
|
"file-search",
|
||||||
"linux-sandbox",
|
"linux-sandbox",
|
||||||
@@ -105,6 +106,7 @@ codex-config = { path = "config" }
|
|||||||
codex-core = { path = "core" }
|
codex-core = { path = "core" }
|
||||||
codex-exec = { path = "exec" }
|
codex-exec = { path = "exec" }
|
||||||
codex-execpolicy = { path = "execpolicy" }
|
codex-execpolicy = { path = "execpolicy" }
|
||||||
|
codex-extensions = { path = "extensions" }
|
||||||
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
|
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
|
||||||
codex-feedback = { path = "feedback" }
|
codex-feedback = { path = "feedback" }
|
||||||
codex-file-search = { path = "file-search" }
|
codex-file-search = { path = "file-search" }
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ version.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
doctest = false
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64 = { workspace = true }
|
||||||
codex-app-server-protocol = { workspace = true }
|
codex-app-server-protocol = { workspace = true }
|
||||||
codex-execpolicy = { workspace = true }
|
codex-execpolicy = { workspace = true }
|
||||||
codex-protocol = { workspace = true }
|
codex-protocol = { workspace = true }
|
||||||
@@ -24,6 +28,16 @@ toml = { workspace = true }
|
|||||||
toml_edit = { workspace = true }
|
toml_edit = { workspace = true }
|
||||||
tracing = { 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]
|
[dev-dependencies]
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
pretty_assertions = { workspace = true }
|
pretty_assertions = { workspace = true }
|
||||||
|
|||||||
@@ -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")]
|
#[cfg(target_os = "macos")]
|
||||||
use super::macos::ManagedAdminConfigLayer;
|
use crate::macos::ManagedAdminConfigLayer;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use super::macos::load_managed_admin_config_layer;
|
use crate::macos::load_managed_admin_config_layer;
|
||||||
use codex_config::config_error_from_toml;
|
|
||||||
use codex_config::io_error_from_config_error;
|
|
||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::Path;
|
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";
|
const CODEX_MANAGED_CONFIG_SYSTEM_PATH: &str = "/etc/codex/managed_config.toml";
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(super) struct MangedConfigFromFile {
|
pub struct ManagedConfigFromFile {
|
||||||
pub managed_config: TomlValue,
|
pub managed_config: TomlValue,
|
||||||
pub file: AbsolutePathBuf,
|
pub file: AbsolutePathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(super) struct ManagedConfigFromMdm {
|
pub struct ManagedConfigFromMdm {
|
||||||
pub managed_config: TomlValue,
|
pub managed_config: TomlValue,
|
||||||
pub raw_toml: String,
|
pub raw_toml: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(super) struct LoadedConfigLayers {
|
pub struct LoadedConfigLayers {
|
||||||
/// If present, data read from a file such as `/etc/codex/managed_config.toml`.
|
/// 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).
|
/// If present, data read from managed preferences (macOS only).
|
||||||
pub managed_config_from_mdm: Option<ManagedConfigFromMdm>,
|
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,
|
codex_home: &Path,
|
||||||
overrides: LoaderOverrides,
|
overrides: LoaderOverrides,
|
||||||
) -> io::Result<LoadedConfigLayers> {
|
) -> io::Result<LoadedConfigLayers> {
|
||||||
@@ -59,7 +59,7 @@ pub(super) async fn load_config_layers_internal(
|
|||||||
let managed_config =
|
let managed_config =
|
||||||
read_config_from_path(&managed_config_path, /*log_missing_as_info*/ false)
|
read_config_from_path(&managed_config_path, /*log_missing_as_info*/ false)
|
||||||
.await?
|
.await?
|
||||||
.map(|managed_config| MangedConfigFromFile {
|
.map(|managed_config| ManagedConfigFromFile {
|
||||||
managed_config,
|
managed_config,
|
||||||
file: managed_config_path.clone(),
|
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>,
|
path: impl AsRef<Path>,
|
||||||
log_missing_as_info: bool,
|
log_missing_as_info: bool,
|
||||||
) -> io::Result<Option<TomlValue>> {
|
) -> io::Result<Option<TomlValue>> {
|
||||||
@@ -120,8 +120,7 @@ pub(super) async fn read_config_from_path(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the default managed config path.
|
fn managed_config_default_path(codex_home: &Path) -> PathBuf {
|
||||||
pub(super) fn managed_config_default_path(codex_home: &Path) -> PathBuf {
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
let _ = codex_home;
|
let _ = codex_home;
|
||||||
@@ -3,6 +3,10 @@ mod config_requirements;
|
|||||||
mod constraint;
|
mod constraint;
|
||||||
mod diagnostics;
|
mod diagnostics;
|
||||||
mod fingerprint;
|
mod fingerprint;
|
||||||
|
mod layer_io;
|
||||||
|
mod loader;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod macos;
|
||||||
mod merge;
|
mod merge;
|
||||||
mod overrides;
|
mod overrides;
|
||||||
mod requirements_exec_policy;
|
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::format_config_error_with_source;
|
||||||
pub use diagnostics::io_error_from_config_error;
|
pub use diagnostics::io_error_from_config_error;
|
||||||
pub use fingerprint::version_for_toml;
|
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 merge::merge_toml_values;
|
||||||
pub use overrides::build_cli_overrides_layer;
|
pub use overrides::build_cli_overrides_layer;
|
||||||
pub use requirements_exec_policy::RequirementsExecPolicy;
|
pub use requirements_exec_policy::RequirementsExecPolicy;
|
||||||
|
|||||||
236
codex-rs/config/src/loader.rs
Normal file
236
codex-rs/config/src/loader.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use super::ConfigRequirementsToml;
|
use crate::ConfigRequirementsToml;
|
||||||
use super::ConfigRequirementsWithSources;
|
use crate::ConfigRequirementsWithSources;
|
||||||
use super::RequirementSource;
|
use crate::RequirementSource;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use base64::prelude::BASE64_STANDARD;
|
use base64::prelude::BASE64_STANDARD;
|
||||||
use core_foundation::base::TCFType;
|
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";
|
const MANAGED_PREFERENCES_REQUIREMENTS_KEY: &str = "requirements_toml_base64";
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(super) struct ManagedAdminConfigLayer {
|
pub struct ManagedAdminConfigLayer {
|
||||||
pub config: TomlValue,
|
pub config: TomlValue,
|
||||||
pub raw_toml: String,
|
pub raw_toml: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn managed_preferences_requirements_source() -> RequirementSource {
|
fn managed_preferences_requirements_source() -> RequirementSource {
|
||||||
RequirementSource::MdmManagedPreferences {
|
RequirementSource::MdmManagedPreferences {
|
||||||
domain: MANAGED_PREFERENCES_APPLICATION_ID.to_string(),
|
domain: MANAGED_PREFERENCES_APPLICATION_ID.to_string(),
|
||||||
key: MANAGED_PREFERENCES_REQUIREMENTS_KEY.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>,
|
override_base64: Option<&str>,
|
||||||
) -> io::Result<Option<ManagedAdminConfigLayer>> {
|
) -> io::Result<Option<ManagedAdminConfigLayer>> {
|
||||||
if let Some(encoded) = override_base64 {
|
if let Some(encoded) = override_base64 {
|
||||||
@@ -61,7 +61,7 @@ fn load_managed_admin_config() -> io::Result<Option<ManagedAdminConfigLayer>> {
|
|||||||
.transpose()
|
.transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn load_managed_admin_requirements_toml(
|
pub async fn load_managed_admin_requirements_toml(
|
||||||
target: &mut ConfigRequirementsWithSources,
|
target: &mut ConfigRequirementsWithSources,
|
||||||
override_base64: Option<&str>,
|
override_base64: Option<&str>,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
@@ -37,6 +37,7 @@ codex-config = { workspace = true }
|
|||||||
codex-shell-command = { workspace = true }
|
codex-shell-command = { workspace = true }
|
||||||
codex-skills = { workspace = true }
|
codex-skills = { workspace = true }
|
||||||
codex-execpolicy = { workspace = true }
|
codex-execpolicy = { workspace = true }
|
||||||
|
codex-extensions = { workspace = true }
|
||||||
codex-file-search = { workspace = true }
|
codex-file-search = { workspace = true }
|
||||||
codex-git = { workspace = true }
|
codex-git = { workspace = true }
|
||||||
codex-hooks = { workspace = true }
|
codex-hooks = { workspace = true }
|
||||||
@@ -123,7 +124,6 @@ landlock = { workspace = true }
|
|||||||
seccompiler = { workspace = true }
|
seccompiler = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
core-foundation = "0.9"
|
|
||||||
keyring = { workspace = true, features = ["apple-native"] }
|
keyring = { workspace = true, features = ["apple-native"] }
|
||||||
|
|
||||||
# Build OpenSSL from source for musl builds.
|
# Build OpenSSL from source for musl builds.
|
||||||
@@ -136,11 +136,6 @@ openssl-sys = { workspace = true, features = ["vendored"] }
|
|||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
keyring = { workspace = true, features = ["windows-native"] }
|
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]
|
[target.'cfg(any(target_os = "freebsd", target_os = "openbsd"))'.dependencies]
|
||||||
keyring = { workspace = true, features = ["sync-secret-service"] }
|
keyring = { workspace = true, features = ["sync-secret-service"] }
|
||||||
|
|||||||
@@ -1,27 +1,18 @@
|
|||||||
mod layer_io;
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
mod macos;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
use crate::config::ConfigToml;
|
use crate::config::ConfigToml;
|
||||||
use crate::config_loader::layer_io::LoadedConfigLayers;
|
|
||||||
use crate::git_info::resolve_root_git_project_for_trust;
|
use crate::git_info::resolve_root_git_project_for_trust;
|
||||||
use codex_app_server_protocol::ConfigLayerSource;
|
use codex_app_server_protocol::ConfigLayerSource;
|
||||||
use codex_config::CONFIG_TOML_FILE;
|
use codex_config::CONFIG_TOML_FILE;
|
||||||
use codex_config::ConfigRequirementsWithSources;
|
use codex_config::ConfigRequirementsWithSources;
|
||||||
use codex_protocol::config_types::SandboxMode;
|
|
||||||
use codex_protocol::config_types::TrustLevel;
|
use codex_protocol::config_types::TrustLevel;
|
||||||
use codex_protocol::protocol::AskForApproval;
|
|
||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||||
use dunce::canonicalize as normalize_path;
|
use dunce::canonicalize as normalize_path;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
#[cfg(windows)]
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use toml::Value as TomlValue;
|
use toml::Value as TomlValue;
|
||||||
|
|
||||||
pub use codex_config::AppRequirementToml;
|
pub use codex_config::AppRequirementToml;
|
||||||
@@ -38,6 +29,7 @@ pub use codex_config::ConfigRequirements;
|
|||||||
pub use codex_config::ConfigRequirementsToml;
|
pub use codex_config::ConfigRequirementsToml;
|
||||||
pub use codex_config::ConstrainedWithSource;
|
pub use codex_config::ConstrainedWithSource;
|
||||||
pub use codex_config::FeatureRequirementsToml;
|
pub use codex_config::FeatureRequirementsToml;
|
||||||
|
use codex_config::LoadedConfigLayers;
|
||||||
pub use codex_config::LoaderOverrides;
|
pub use codex_config::LoaderOverrides;
|
||||||
pub use codex_config::McpServerIdentity;
|
pub use codex_config::McpServerIdentity;
|
||||||
pub use codex_config::McpServerRequirement;
|
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;
|
||||||
pub use codex_config::format_config_error_with_source;
|
pub use codex_config::format_config_error_with_source;
|
||||||
pub(crate) use codex_config::io_error_from_config_error;
|
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;
|
pub use codex_config::merge_toml_values;
|
||||||
|
use codex_config::system_config_toml_file;
|
||||||
|
use codex_config::system_requirements_toml_file;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use codex_config::version_for_toml;
|
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"];
|
const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"];
|
||||||
|
|
||||||
pub(crate) async fn first_layer_config_error(layers: &ConfigLayerStack) -> Option<ConfigError> {
|
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);
|
.merge_unset_fields(RequirementSource::CloudRequirements, requirements);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
load_managed_admin_requirements(
|
||||||
macos::load_managed_admin_requirements_toml(
|
|
||||||
&mut config_requirements_toml,
|
&mut config_requirements_toml,
|
||||||
overrides
|
overrides
|
||||||
.macos_managed_config_requirements_base64
|
.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
|
// Make a best-effort to support the legacy `managed_config.toml` as a
|
||||||
// requirements specification.
|
// 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(
|
load_requirements_from_legacy_scheme(
|
||||||
&mut config_requirements_toml,
|
&mut config_requirements_toml,
|
||||||
loaded_config_layers.clone(),
|
loaded_config_layers.clone(),
|
||||||
@@ -343,185 +332,6 @@ async fn load_config_toml_for_required_layer(
|
|||||||
Ok(create_entry(toml_value))
|
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
|
/// Reads `project_root_markers` from the [toml::Value] produced by merging
|
||||||
/// `config.toml` from the config layers in the stack preceding
|
/// `config.toml` from the config layers in the stack preceding
|
||||||
/// [ConfigLayerSource::Project].
|
/// [ConfigLayerSource::Project].
|
||||||
@@ -895,51 +705,12 @@ async fn load_project_layers(
|
|||||||
Ok(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.
|
// Cannot name this `mod tests` because of tests.rs in this folder.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod unit_tests {
|
mod unit_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
#[cfg(windows)]
|
use codex_config::ManagedConfigFromFile;
|
||||||
use std::path::Path;
|
use codex_protocol::protocol::SandboxPolicy;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -979,65 +750,81 @@ foo = "xyzzy"
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() {
|
async fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() {
|
||||||
let legacy = LegacyManagedConfigToml {
|
let tmp = tempdir().expect("tempdir");
|
||||||
approval_policy: None,
|
let managed_path = AbsolutePathBuf::try_from(tmp.path().join("managed_config.toml"))
|
||||||
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
|
.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 mut requirements_with_sources = ConfigRequirementsWithSources::default();
|
||||||
let requirements = ConfigRequirementsToml::from(legacy);
|
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!(
|
assert_eq!(
|
||||||
requirements.allowed_sandbox_modes,
|
requirements.sandbox_policy.get(),
|
||||||
Some(vec![
|
&SandboxPolicy::new_read_only_policy()
|
||||||
SandboxModeRequirement::ReadOnly,
|
);
|
||||||
SandboxModeRequirement::WorkspaceWrite
|
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)]
|
#[cfg(windows)]
|
||||||
#[test]
|
#[test]
|
||||||
fn windows_system_requirements_toml_file_uses_expected_suffix() {
|
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!(
|
assert!(
|
||||||
windows_system_requirements_toml_file()
|
system_requirements_toml_file()
|
||||||
.expect("requirements.toml path")
|
.expect("requirements.toml path")
|
||||||
.as_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)]
|
#[cfg(windows)]
|
||||||
#[test]
|
#[test]
|
||||||
fn windows_system_config_toml_file_uses_expected_suffix() {
|
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!(
|
assert!(
|
||||||
windows_system_config_toml_file()
|
system_config_toml_file()
|
||||||
.expect("config.toml path")
|
.expect("config.toml path")
|
||||||
.as_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")
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ use crate::skills::loader::SkillRoot;
|
|||||||
use crate::skills::loader::load_skills_from_roots;
|
use crate::skills::loader::load_skills_from_roots;
|
||||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||||
use codex_app_server_protocol::MergeStrategy;
|
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_protocol::protocol::SkillScope;
|
||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -70,10 +73,7 @@ const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
|
|||||||
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
|
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
|
||||||
pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
|
pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
|
||||||
static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
|
static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
|
||||||
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
|
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 140;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
||||||
pub struct AppConnectorId(pub String);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct PluginInstallRequest {
|
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)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct PluginLoadOutcome {
|
pub struct PluginLoadOutcome {
|
||||||
plugins: Vec<LoadedPlugin>,
|
plugins: Vec<LoadedPlugin>,
|
||||||
@@ -265,7 +173,7 @@ impl PluginLoadOutcome {
|
|||||||
fn from_plugins(plugins: Vec<LoadedPlugin>) -> Self {
|
fn from_plugins(plugins: Vec<LoadedPlugin>) -> Self {
|
||||||
let capability_summaries = plugins
|
let capability_summaries = plugins
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(PluginCapabilitySummary::from_plugin)
|
.filter_map(plugin_capability_summary_from_loaded_plugin)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
Self {
|
Self {
|
||||||
plugins,
|
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)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct RemotePluginSyncResult {
|
pub struct RemotePluginSyncResult {
|
||||||
/// Plugin ids newly installed into the local plugin cache.
|
/// Plugin ids newly installed into the local plugin cache.
|
||||||
|
|||||||
@@ -1,454 +1,6 @@
|
|||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
pub use codex_extensions::plugins::PluginManifestInterfaceSummary;
|
||||||
use serde::Deserialize;
|
pub(crate) use codex_extensions::plugins::PluginManifestPaths;
|
||||||
use serde_json::Value as JsonValue;
|
pub(crate) use codex_extensions::plugins::load_plugin_manifest;
|
||||||
use std::fs;
|
pub(crate) use codex_extensions::plugins::plugin_manifest_interface;
|
||||||
use std::path::Component;
|
pub(crate) use codex_extensions::plugins::plugin_manifest_name;
|
||||||
use std::path::Path;
|
pub(crate) use codex_extensions::plugins::plugin_manifest_paths;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,17 +11,20 @@ mod store;
|
|||||||
pub(crate) mod test_support;
|
pub(crate) mod test_support;
|
||||||
mod toggles;
|
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::curated_plugins_repo_path;
|
||||||
pub(crate) use curated_repo::read_curated_plugins_sha;
|
pub(crate) use curated_repo::read_curated_plugins_sha;
|
||||||
pub(crate) use curated_repo::sync_openai_plugins_repo;
|
pub(crate) use curated_repo::sync_openai_plugins_repo;
|
||||||
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
|
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
|
||||||
pub(crate) use injection::build_plugin_injections;
|
pub(crate) use injection::build_plugin_injections;
|
||||||
pub use manager::AppConnectorId;
|
|
||||||
pub use manager::ConfiguredMarketplacePluginSummary;
|
pub use manager::ConfiguredMarketplacePluginSummary;
|
||||||
pub use manager::ConfiguredMarketplaceSummary;
|
pub use manager::ConfiguredMarketplaceSummary;
|
||||||
pub use manager::LoadedPlugin;
|
pub use manager::LoadedPlugin;
|
||||||
pub use manager::OPENAI_CURATED_MARKETPLACE_NAME;
|
pub use manager::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||||
pub use manager::PluginCapabilitySummary;
|
|
||||||
pub use manager::PluginDetailSummary;
|
pub use manager::PluginDetailSummary;
|
||||||
pub use manager::PluginInstallError;
|
pub use manager::PluginInstallError;
|
||||||
pub use manager::PluginInstallOutcome;
|
pub use manager::PluginInstallOutcome;
|
||||||
@@ -30,7 +33,6 @@ pub use manager::PluginLoadOutcome;
|
|||||||
pub use manager::PluginReadOutcome;
|
pub use manager::PluginReadOutcome;
|
||||||
pub use manager::PluginReadRequest;
|
pub use manager::PluginReadRequest;
|
||||||
pub use manager::PluginRemoteSyncError;
|
pub use manager::PluginRemoteSyncError;
|
||||||
pub use manager::PluginTelemetryMetadata;
|
|
||||||
pub use manager::PluginUninstallError;
|
pub use manager::PluginUninstallError;
|
||||||
pub use manager::PluginsManager;
|
pub use manager::PluginsManager;
|
||||||
pub use manager::RemotePluginSyncResult;
|
pub use manager::RemotePluginSyncResult;
|
||||||
@@ -38,7 +40,6 @@ pub use manager::installed_plugin_telemetry_metadata;
|
|||||||
pub use manager::load_plugin_apps;
|
pub use manager::load_plugin_apps;
|
||||||
pub(crate) use manager::plugin_namespace_for_skill_path;
|
pub(crate) use manager::plugin_namespace_for_skill_path;
|
||||||
pub use manager::plugin_telemetry_metadata_from_root;
|
pub use manager::plugin_telemetry_metadata_from_root;
|
||||||
pub use manifest::PluginManifestInterfaceSummary;
|
|
||||||
pub(crate) use manifest::PluginManifestPaths;
|
pub(crate) use manifest::PluginManifestPaths;
|
||||||
pub(crate) use manifest::load_plugin_manifest;
|
pub(crate) use manifest::load_plugin_manifest;
|
||||||
pub(crate) use manifest::plugin_manifest_interface;
|
pub(crate) use manifest::plugin_manifest_interface;
|
||||||
@@ -50,5 +51,4 @@ pub use marketplace::MarketplacePluginInstallPolicy;
|
|||||||
pub use marketplace::MarketplacePluginSourceSummary;
|
pub use marketplace::MarketplacePluginSourceSummary;
|
||||||
pub(crate) use render::render_explicit_plugin_instructions;
|
pub(crate) use render::render_explicit_plugin_instructions;
|
||||||
pub(crate) use render::render_plugins_section;
|
pub(crate) use render::render_plugins_section;
|
||||||
pub use store::PluginId;
|
|
||||||
pub use toggles::collect_plugin_enabled_candidates;
|
pub use toggles::collect_plugin_enabled_candidates;
|
||||||
|
|||||||
@@ -1,91 +1,5 @@
|
|||||||
use crate::plugins::PluginCapabilitySummary;
|
pub(crate) use codex_extensions::plugins::render_explicit_plugin_instructions;
|
||||||
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_CLOSE_TAG;
|
pub(crate) use codex_extensions::plugins::render_plugins_section;
|
||||||
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"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "render_tests.rs"]
|
#[path = "render_tests.rs"]
|
||||||
|
|||||||
@@ -1,346 +1,6 @@
|
|||||||
use super::load_plugin_manifest;
|
pub(crate) use codex_extensions::plugins::DEFAULT_PLUGIN_VERSION;
|
||||||
use super::manifest::PLUGIN_MANIFEST_PATH;
|
pub use codex_extensions::plugins::PluginId;
|
||||||
use super::plugin_manifest_name;
|
pub use codex_extensions::plugins::PluginIdError;
|
||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
pub(crate) use codex_extensions::plugins::PluginInstallResult;
|
||||||
use std::fs;
|
pub(crate) use codex_extensions::plugins::PluginStore;
|
||||||
use std::io;
|
pub use codex_extensions::plugins::PluginStoreError;
|
||||||
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;
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ pub mod remote;
|
|||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod system;
|
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::collect_env_var_dependencies;
|
||||||
pub(crate) use env_var_dependencies::resolve_skill_dependencies_for_turn;
|
pub(crate) use env_var_dependencies::resolve_skill_dependencies_for_turn;
|
||||||
pub(crate) use injection::SkillInjections;
|
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::build_implicit_skill_path_indexes;
|
||||||
pub(crate) use invocation_utils::maybe_emit_implicit_skill_invocation;
|
pub(crate) use invocation_utils::maybe_emit_implicit_skill_invocation;
|
||||||
pub use manager::SkillsManager;
|
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;
|
pub use render::render_skills_section;
|
||||||
|
|||||||
@@ -1,113 +1 @@
|
|||||||
use std::collections::HashMap;
|
pub use codex_extensions::skills::model::*;
|
||||||
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)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,48 +1 @@
|
|||||||
use crate::skills::model::SkillMetadata;
|
pub use codex_extensions::skills::render_skills_section;
|
||||||
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}"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|||||||
6
codex-rs/extensions/BUILD.bazel
Normal file
6
codex-rs/extensions/BUILD.bazel
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
load("//:defs.bzl", "codex_rust_crate")
|
||||||
|
|
||||||
|
codex_rust_crate(
|
||||||
|
name = "extensions",
|
||||||
|
crate_name = "codex_extensions",
|
||||||
|
)
|
||||||
21
codex-rs/extensions/Cargo.toml
Normal file
21
codex-rs/extensions/Cargo.toml
Normal 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 }
|
||||||
2
codex-rs/extensions/src/lib.rs
Normal file
2
codex-rs/extensions/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod plugins;
|
||||||
|
pub mod skills;
|
||||||
328
codex-rs/extensions/src/plugins/manifest.rs
Normal file
328
codex-rs/extensions/src/plugins/manifest.rs
Normal 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
25
codex-rs/extensions/src/plugins/mod.rs
Normal file
25
codex-rs/extensions/src/plugins/mod.rs
Normal 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;
|
||||||
88
codex-rs/extensions/src/plugins/render.rs
Normal file
88
codex-rs/extensions/src/plugins/render.rs
Normal 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"))
|
||||||
|
}
|
||||||
306
codex-rs/extensions/src/plugins/store.rs
Normal file
306
codex-rs/extensions/src/plugins/store.rs
Normal 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(())
|
||||||
|
}
|
||||||
40
codex-rs/extensions/src/plugins/types.rs
Normal file
40
codex-rs/extensions/src/plugins/types.rs
Normal 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()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
12
codex-rs/extensions/src/skills/mod.rs
Normal file
12
codex-rs/extensions/src/skills/mod.rs
Normal 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;
|
||||||
112
codex-rs/extensions/src/skills/model.rs
Normal file
112
codex-rs/extensions/src/skills/model.rs
Normal 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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
48
codex-rs/extensions/src/skills/render.rs
Normal file
48
codex-rs/extensions/src/skills/render.rs
Normal 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}"
|
||||||
|
))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user