Load skill interface metadata in the host loader (#36903)

## What changed

- Move skill interface validation and asset-path resolution into `codex-skills` so both skill loaders share the same behavior.
- Populate host-loaded skills with validated display metadata, prompts, brand colors, and local icons from `agents/openai.yaml`.
- Allow plugin skills to reference icons under the plugin's shared `assets` directory while rejecting absolute paths and paths that escape permitted asset roots.

## Testing

- Add resolver tests for valid, invalid, local, and plugin-shared interface assets.
- Add host loader coverage for interface fields and asset-path restrictions.

GitOrigin-RevId: 3f316191752eeebd15d11ac0bee82201574320ec
This commit is contained in:
felixxia-oai authored and copyberry committed 2026-08-04 14:51:08 +00:00
1 parent f93109615f
commit 49b0aebd6f
10 files changed
+634 -456

No files matched your search

+9 -169
View File
@@ -24,8 +24,11 @@ use codex_exec_server::LOCAL_FS;
use codex_protocol::protocol::Product;
use codex_protocol::protocol::SkillScope;
use codex_skills::ParsedSkillFrontmatter;
use codex_skills::SkillInterfaceAssetPolicy;
use codex_skills::SkillInterfaceFile;
use codex_skills::SkillParseError;
use codex_skills::parse_skill_frontmatter_metadata;
use codex_skills::resolve_skill_interface;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use codex_utils_path_uri::PathUri;
@@ -49,9 +52,6 @@ use std::collections::HashSet;
use std::error::Error;
use std::fmt;
use std::io;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Semaphore;
use toml::Value as TomlValue;
@@ -63,7 +63,7 @@ pub const MAX_CONCURRENT_ROOT_SCANS: usize = 8;
#[derive(Debug, Default, Deserialize)]
struct SkillMetadataFile {
#[serde(default)]
interface: Option<Interface>,
interface: Option<SkillInterfaceFile>,
#[serde(default)]
dependencies: Option<Dependencies>,
#[serde(default)]
@@ -77,16 +77,6 @@ struct LoadedSkillMetadata {
policy: Option<SkillPolicy>,
}
#[derive(Debug, Default, Deserialize)]
struct Interface {
display_name: Option<String>,
short_description: Option<String>,
icon_small: Option<PathBuf>,
icon_large: Option<PathBuf>,
brand_color: Option<String>,
default_prompt: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct Dependencies {
#[serde(default)]
@@ -120,8 +110,6 @@ const SKILLS_DIR_NAME: &str = "skills";
const MAX_NAME_LEN: usize = 64;
const MAX_QUALIFIED_NAME_LEN: usize = 128;
const MAX_DESCRIPTION_LEN: usize = 1024;
const MAX_SHORT_DESCRIPTION_LEN: usize = MAX_DESCRIPTION_LEN;
const MAX_DEFAULT_PROMPT_LEN: usize = MAX_DESCRIPTION_LEN;
const MAX_DEPENDENCY_TYPE_LEN: usize = MAX_NAME_LEN;
const MAX_DEPENDENCY_TRANSPORT_LEN: usize = MAX_NAME_LEN;
const MAX_DEPENDENCY_VALUE_LEN: usize = MAX_DESCRIPTION_LEN;
@@ -784,58 +772,17 @@ async fn load_skill_metadata(
dependencies,
policy,
} = parsed;
let asset_policy = match plugin_root {
Some(plugin_root) => SkillInterfaceAssetPolicy::PluginShared { plugin_root },
None => SkillInterfaceAssetPolicy::LocalOnly,
};
LoadedSkillMetadata {
interface: resolve_interface(interface, &skill_dir, plugin_root),
interface: resolve_skill_interface(interface, &skill_dir, asset_policy),
dependencies: resolve_dependencies(dependencies),
policy: resolve_policy(policy),
}
}
fn resolve_interface(
interface: Option<Interface>,
skill_dir: &AbsolutePathBuf,
plugin_root: Option<&AbsolutePathBuf>,
) -> Option<SkillInterface> {
let interface = interface?;
let interface = SkillInterface {
display_name: resolve_str(
interface.display_name,
MAX_NAME_LEN,
"interface.display_name",
),
short_description: resolve_str(
interface.short_description,
MAX_SHORT_DESCRIPTION_LEN,
"interface.short_description",
),
icon_small: resolve_asset_path(
skill_dir,
plugin_root,
"interface.icon_small",
interface.icon_small,
),
icon_large: resolve_asset_path(
skill_dir,
plugin_root,
"interface.icon_large",
interface.icon_large,
),
brand_color: resolve_color_str(interface.brand_color, "interface.brand_color"),
default_prompt: resolve_str(
interface.default_prompt,
MAX_DEFAULT_PROMPT_LEN,
"interface.default_prompt",
),
};
let has_fields = interface.display_name.is_some()
|| interface.short_description.is_some()
|| interface.icon_small.is_some()
|| interface.icon_large.is_some()
|| interface.brand_color.is_some()
|| interface.default_prompt.is_some();
if has_fields { Some(interface) } else { None }
}
fn resolve_dependencies(dependencies: Option<Dependencies>) -> Option<SkillDependencies> {
let dependencies = dependencies?;
let tools: Vec<SkillToolDependency> = dependencies
@@ -895,97 +842,6 @@ fn resolve_dependency_tool(tool: DependencyTool) -> Option<SkillToolDependency>
})
}
fn resolve_asset_path(
skill_dir: &AbsolutePathBuf,
plugin_root: Option<&AbsolutePathBuf>,
field: &'static str,
path: Option<PathBuf>,
) -> Option<AbsolutePathBuf> {
// Icons must stay under the skill's assets directory. Plugin skills may
// also share icons from the plugin-level assets directory.
let path = path?;
if path.as_os_str().is_empty() {
return None;
}
let assets_dir = skill_dir.join("assets");
if path.is_absolute() {
tracing::warn!(
"ignoring {field}: icon must be a relative assets path (not {})",
assets_dir.display()
);
return None;
}
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::Normal(component) => normalized.push(component),
Component::ParentDir => {
return resolve_plugin_shared_asset_path(skill_dir, plugin_root, field, &path);
}
_ => {
tracing::warn!("ignoring {field}: icon path must be under assets/");
return None;
}
}
}
let mut components = normalized.components();
match components.next() {
Some(Component::Normal(component)) if component == "assets" => {}
_ => {
tracing::warn!("ignoring {field}: icon path must be under assets/");
return None;
}
}
Some(skill_dir.join(normalized))
}
fn resolve_plugin_shared_asset_path(
skill_dir: &AbsolutePathBuf,
plugin_root: Option<&AbsolutePathBuf>,
field: &'static str,
path: &Path,
) -> Option<AbsolutePathBuf> {
let Some(plugin_root) = plugin_root else {
tracing::warn!("ignoring {field}: icon path must not contain '..'");
return None;
};
let plugin_assets_dir = lexically_normalize(plugin_root.join("assets").as_path());
let resolved = lexically_normalize(skill_dir.join(path).as_path());
if !resolved.starts_with(&plugin_assets_dir) {
tracing::warn!("ignoring {field}: icon path with '..' must resolve under plugin assets/");
return None;
}
AbsolutePathBuf::try_from(resolved)
.map_err(|err| {
tracing::warn!("ignoring {field}: icon path must resolve to an absolute path: {err}");
err
})
.ok()
}
fn lexically_normalize(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
normalized.push(component.as_os_str());
}
}
}
normalized
}
fn sanitize_single_line(raw: &str) -> String {
raw.split_whitespace().collect::<Vec<_>>().join(" ")
}
@@ -1033,22 +889,6 @@ fn resolve_required_str(
resolve_str(Some(value), max_len, field)
}
fn resolve_color_str(value: Option<String>, field: &'static str) -> Option<String> {
let value = value?;
let value = value.trim();
if value.is_empty() {
tracing::warn!("ignoring {field}: value is empty");
return None;
}
let mut chars = value.chars();
if value.len() == 7 && chars.next() == Some('#') && chars.all(|c| c.is_ascii_hexdigit()) {
Some(value.to_string())
} else {
tracing::warn!("ignoring {field}: expected #RRGGBB, got {value}");
None
}
}
#[cfg(test)]
pub(crate) async fn skill_roots_from_layer_stack(
fs: Arc<dyn ExecutorFileSystem>,
+1 -252
View File
@@ -847,198 +847,6 @@ policy:
);
}
#[tokio::test]
async fn accepts_icon_paths_under_assets_dir() {
let codex_home = tempfile::tempdir().expect("tempdir");
let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json");
let skill_dir = skill_path.parent().expect("skill dir");
let normalized_skill_dir = normalized(skill_dir);
write_skill_interface_at(
skill_dir,
r#"
{
"interface": {
"display_name": "UI Skill",
"icon_small": "assets/icon.png",
"icon_large": "./assets/logo.svg"
}
}
"#,
);
let cfg = make_config(&codex_home).await;
let outcome = load_skills_for_test(&cfg).await;
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "ui-skill".to_string(),
description: "from json".to_string(),
short_description: None,
interface: Some(SkillInterface {
display_name: Some("UI Skill".to_string()),
short_description: None,
icon_small: Some(normalized_skill_dir.join("assets/icon.png")),
icon_large: Some(normalized_skill_dir.join("assets/logo.svg")),
brand_color: None,
default_prompt: None,
}),
dependencies: None,
policy: None,
path_to_skills_md: normalized(&skill_path),
scope: SkillScope::User,
plugin_id: None,
remote_plugin_id: None,
}]
);
}
#[tokio::test]
async fn ignores_invalid_brand_color() {
let codex_home = tempfile::tempdir().expect("tempdir");
let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json");
let skill_dir = skill_path.parent().expect("skill dir");
write_skill_interface_at(
skill_dir,
r#"
{
"interface": {
"brand_color": "blue"
}
}
"#,
);
let cfg = make_config(&codex_home).await;
let outcome = load_skills_for_test(&cfg).await;
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "ui-skill".to_string(),
description: "from json".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: normalized(&skill_path),
scope: SkillScope::User,
plugin_id: None,
remote_plugin_id: None,
}]
);
}
#[tokio::test]
async fn ignores_default_prompt_over_max_length() {
let codex_home = tempfile::tempdir().expect("tempdir");
let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json");
let skill_dir = skill_path.parent().expect("skill dir");
let normalized_skill_dir = normalized(skill_dir);
let too_long = "x".repeat(MAX_DEFAULT_PROMPT_LEN + 1);
write_skill_interface_at(
skill_dir,
&format!(
r##"
{{
"interface": {{
"display_name": "UI Skill",
"icon_small": "./assets/small-400px.png",
"default_prompt": "{too_long}"
}}
}}
"##
),
);
let cfg = make_config(&codex_home).await;
let outcome = load_skills_for_test(&cfg).await;
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "ui-skill".to_string(),
description: "from json".to_string(),
short_description: None,
interface: Some(SkillInterface {
display_name: Some("UI Skill".to_string()),
short_description: None,
icon_small: Some(normalized_skill_dir.join("assets/small-400px.png")),
icon_large: None,
brand_color: None,
default_prompt: None,
}),
dependencies: None,
policy: None,
path_to_skills_md: normalized(&skill_path),
scope: SkillScope::User,
plugin_id: None,
remote_plugin_id: None,
}]
);
}
#[tokio::test]
async fn drops_interface_when_icons_are_invalid() {
let codex_home = tempfile::tempdir().expect("tempdir");
let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json");
let skill_dir = skill_path.parent().expect("skill dir");
write_skill_interface_at(
skill_dir,
r#"
{
"interface": {
"icon_small": "icon.png",
"icon_large": "./assets/../logo.svg"
}
}
"#,
);
let cfg = make_config(&codex_home).await;
let outcome = load_skills_for_test(&cfg).await;
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "ui-skill".to_string(),
description: "from json".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: normalized(&skill_path),
scope: SkillScope::User,
plugin_id: None,
remote_plugin_id: None,
}]
);
}
#[tokio::test]
async fn loads_plugin_skill_interface_icons_from_shared_plugin_assets() {
let root = tempfile::tempdir().expect("tempdir");
@@ -1110,65 +918,6 @@ interface:
);
}
#[tokio::test]
async fn drops_plugin_skill_interface_icons_that_escape_shared_plugin_assets() {
let root = tempfile::tempdir().expect("tempdir");
let plugin_root = root.path().join("plugins/twilio-developer-kit");
let skill_path = write_skill_at(
&plugin_root.join("skills"),
"twilio-send-message",
"send-message",
"send messages",
);
let skill_dir = skill_path.parent().expect("skill dir");
write_skill_interface_at(
skill_dir,
r##"
interface:
icon_small: "../../other/logo.svg"
"##,
);
let outcome = load_skills_from_roots(
[SkillRoot {
path: plugin_root.join("skills").abs(),
scope: SkillScope::User,
file_system: Arc::clone(&LOCAL_FS),
plugin_identity: Some(PluginIdentity {
plugin_id: "twilio-developer-kit@test".to_string(),
remote_plugin_id: None,
}),
plugin_namespace: None,
plugin_root: Some(plugin_root.abs()),
discovery_mode: SkillDiscoveryMode::Recursive,
}],
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
)
.await;
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "send-message".to_string(),
description: "send messages".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: normalized(&skill_path),
scope: SkillScope::User,
plugin_id: Some("twilio-developer-kit@test".to_string()),
remote_plugin_id: None,
}]
);
}
#[cfg(unix)]
fn symlink_dir(target: &Path, link: &Path) {
std::os::unix::fs::symlink(target, link).unwrap();
@@ -2141,7 +1890,7 @@ async fn preserves_overlong_short_descriptions() {
let codex_home = tempfile::tempdir().expect("tempdir");
let skill_dir = codex_home.path().join("skills/demo");
fs::create_dir_all(&skill_dir).unwrap();
let too_long = "x".repeat(MAX_SHORT_DESCRIPTION_LEN + 1);
let too_long = "x".repeat(MAX_DESCRIPTION_LEN + 1);
let contents = format!(
"---\nname: demo-skill\ndescription: long description\nmetadata:\n short-description: {too_long}\n---\n\n# Body\n"
);