mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Load selected executor skills through extensions (#27184)
## Why
CCA is moving toward a split runtime where the orchestrator may not have
a filesystem, while executors can expose preinstalled plugins and
skills. A thread therefore needs to select capabilities without asking
app-server or core to interpret executor-owned paths through the
orchestrator's filesystem.
The longer-term model is broader than executor skills:
- A plugin is a bundle of skills, MCP servers, connectors/apps, and
hooks.
- A plugin root can be local, executor-owned, or hosted by a backend.
- Components inside one plugin can use different access and execution
mechanisms. A skill may be read from a filesystem or through backend
tools; an HTTP MCP server can run without an executor; a stdio MCP
server or hook needs an execution environment.
- Core should carry generic extension initialization data. The extension
that owns a component should discover it, expose it to the model, and
invoke it through the appropriate runtime.
This PR establishes that architecture through one complete vertical:
selecting a root on an executor, discovering the skills beneath it,
exposing those skills to the model, and reading an explicitly invoked
`SKILL.md` through the same executor.
## Contract
`thread/start` gains an experimental `selectedCapabilityRoots` field:
```json
{
"selectedCapabilityRoots": [
{
"id": "deploy-plugin@1",
"location": {
"type": "environment",
"environmentId": "workspace",
"path": "/opt/codex/plugins/deploy"
}
}
]
}
```
The root is intentionally not classified as a "plugin" or "skill" in the
API. It can point at a standalone skill, a directory containing several
skills, or a plugin containing skills and other components. This PR only
teaches the skills extension how to consume it; later extensions can
resolve MCP, connector, and hook components from the same selection.
The platform-supplied `id` is stable selection identity. The location
says which runtime owns the root and gives that runtime an opaque path.
App-server does not inspect or canonicalize the path.
## What changed
### Generic thread extension initialization
App-server converts selected roots into `ExtensionDataInit`. Core
carries that generic initialization value until the final thread ID is
known, then creates thread-scoped `ExtensionData` before lifecycle
contributors run.
This keeps `Session` and core independent of the capability-selection
contract. The initialization value is consumed during construction; it
is not retained as another long-lived `Session` field.
### Executor-backed skills
The skills extension now owns an `ExecutorSkillProvider` that:
- resolves the selected environment through `EnvironmentManager`
- discovers, canonicalizes, and reads skills through that environment's
`ExecutorFileSystem`
- contributes the bounded selected-skill catalog as stable developer
context
- reads an explicitly invoked skill body through the authority that
listed it
- warns when an environment or root is unavailable
- never falls back to the orchestrator filesystem for an executor-owned
root
Skill catalog and instruction fragments have hard byte bounds, which
also bound them below the 10K-token per-item context limit. If a
selected executor skill has the same name as a legacy local skill, the
executor selection owns that invocation and the local body is not
injected a second time.
Existing local and bundled skill loading remains in place. Omitting
`selectedCapabilityRoots` therefore preserves current local-only
behavior.
## Current semantics
- Only environment-owned locations are represented in this first
contract.
- Roots are resolved by the destination extension, not by app-server or
core.
- An unavailable executor or invalid root produces a warning and no
capabilities from that root; it does not trigger a local-filesystem
fallback.
- Selection applies to a newly started active thread.
- MCP servers, connectors, and hooks beneath a selected plugin root are
not activated yet.
- Selection is not yet persisted or inherited across resume, fork, or
subagent creation. Existing local capabilities continue to behave as
they do today in those flows.
## Planned vertical follow-ups
1. **Hosted HTTP MCP:** add an extension-backed HTTP MCP source that
works without an executor, then replace the special-purpose MCP plugins
loader with that implementation.
2. **Executor MCP:** register and execute stdio MCP servers through the
environment that owns the selected plugin root.
3. **Backend skills:** add a hosted skill source whose catalog and
bodies are accessed through extension tools rather than a filesystem.
4. **Connectors and hooks:** activate those components through their
owning extensions, using the same selected-root boundary and
component-specific runtime.
5. **Durable selection:** define the desired-selection lifecycle,
persist it, and make resume, fork, and subagent inheritance explicit
rather than accidental.
6. **Local convergence:** incrementally route existing local plugin,
skill, and MCP loading through the same extension model while preserving
current local behavior.
Each follow-up remains reviewable as an end-to-end capability. The
platform selects roots, generic thread extension data carries the
selection, and the owning extension resolves and operates its component.
## Verification
Coverage added for:
- app-server end-to-end discovery and explicit invocation of a skill
inside an executor-selected plugin root
- exclusive invocation when a selected executor skill collides with a
local skill name
- executor filesystem authority for discovery, canonicalization, and
reads
- thread extension initialization before lifecycle contributors run
- stable executor catalog context, explicit invocation, context
rebuilding, hidden skills, and preserved host/remote catalog behavior
Targeted protocol, core-skills, skills-extension, core lifecycle, and
app-server executor-skill tests were run during development.
This commit is contained in:
46 files changed
+1460
-127
No files matched your search
@@ -0,0 +1,197 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_core_skills::SkillMetadata;
|
||||
use codex_core_skills::filter_skill_load_outcome_for_product;
|
||||
use codex_core_skills::loader::SkillRoot;
|
||||
use codex_core_skills::loader::load_skills_from_roots;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_exec_server::EnvironmentPathRef;
|
||||
use codex_protocol::capabilities::CapabilityRootLocation;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use crate::catalog::SkillAuthority;
|
||||
use crate::catalog::SkillCatalog;
|
||||
use crate::catalog::SkillCatalogEntry;
|
||||
use crate::catalog::SkillPackageId;
|
||||
use crate::catalog::SkillProviderError;
|
||||
use crate::catalog::SkillReadResult;
|
||||
use crate::catalog::SkillResourceId;
|
||||
use crate::catalog::SkillSearchResult;
|
||||
use crate::catalog::SkillSourceKind;
|
||||
use crate::provider::SkillListQuery;
|
||||
use crate::provider::SkillProvider;
|
||||
use crate::provider::SkillProviderFuture;
|
||||
use crate::provider::SkillReadRequest;
|
||||
use crate::provider::SkillSearchRequest;
|
||||
|
||||
/// Discovers and reads skills through the filesystem owned by an execution environment.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExecutorSkillProvider {
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
restriction_product: Option<Product>,
|
||||
}
|
||||
|
||||
impl ExecutorSkillProvider {
|
||||
pub fn new_with_restriction_product(
|
||||
environment_manager: Arc<EnvironmentManager>,
|
||||
restriction_product: Option<Product>,
|
||||
) -> Self {
|
||||
Self {
|
||||
environment_manager,
|
||||
restriction_product,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SkillProvider for ExecutorSkillProvider {
|
||||
fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> {
|
||||
Box::pin(async move {
|
||||
let mut catalog = SkillCatalog::default();
|
||||
for selected_root in query.executor_roots {
|
||||
let selected_root_id = selected_root.id;
|
||||
let CapabilityRootLocation::Environment {
|
||||
environment_id,
|
||||
path,
|
||||
} = selected_root.location;
|
||||
let authority =
|
||||
SkillAuthority::new(SkillSourceKind::Executor, selected_root_id.clone());
|
||||
let Some(environment) = self.environment_manager.get_environment(&environment_id)
|
||||
else {
|
||||
catalog.warnings.push(format!(
|
||||
"Selected capability root `{selected_root_id}` references unavailable environment `{environment_id}`."
|
||||
));
|
||||
continue;
|
||||
};
|
||||
let root_path = match executor_absolute_path(&path) {
|
||||
Ok(root_path) => root_path,
|
||||
Err(err) => {
|
||||
catalog.warnings.push(format!(
|
||||
"Selected capability root `{selected_root_id}` has invalid path `{path}`: {err}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let file_system = environment.get_filesystem();
|
||||
let outcome = filter_skill_load_outcome_for_product(
|
||||
load_skills_from_roots([SkillRoot {
|
||||
path: root_path.clone(),
|
||||
scope: SkillScope::User,
|
||||
file_system: Arc::clone(&file_system),
|
||||
plugin_id: None,
|
||||
plugin_root: None,
|
||||
}])
|
||||
.await,
|
||||
self.restriction_product,
|
||||
);
|
||||
catalog.warnings.extend(outcome.errors.iter().map(|err| {
|
||||
format!(
|
||||
"Failed to load executor skill at {}: {}",
|
||||
err.path.display(),
|
||||
err.message
|
||||
)
|
||||
}));
|
||||
for (skill, enabled) in outcome.skills_with_enabled() {
|
||||
catalog.push_entry(catalog_entry_from_skill(
|
||||
skill,
|
||||
enabled,
|
||||
authority.clone(),
|
||||
&selected_root_id,
|
||||
Arc::clone(&file_system),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(catalog)
|
||||
})
|
||||
}
|
||||
|
||||
fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult> {
|
||||
Box::pin(async move {
|
||||
if request.authority.kind != SkillSourceKind::Executor {
|
||||
return Err(SkillProviderError::new(format!(
|
||||
"executor skill provider cannot read {} resources",
|
||||
request.authority.kind
|
||||
)));
|
||||
}
|
||||
if request.package.0 != request.resource.as_str() {
|
||||
return Err(SkillProviderError::new(
|
||||
"executor skill resource does not match its package",
|
||||
));
|
||||
}
|
||||
let Some(resource_path) = request.resource.environment_path() else {
|
||||
return Err(SkillProviderError::new(
|
||||
"executor skill resource is not bound to an environment",
|
||||
));
|
||||
};
|
||||
let contents = resource_path
|
||||
.read_to_string(/*sandbox*/ None)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
SkillProviderError::new(format!(
|
||||
"failed to read executor skill resource {}: {err}",
|
||||
request.resource.as_str()
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(SkillReadResult {
|
||||
resource: request.resource,
|
||||
contents,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn search(&self, _request: SkillSearchRequest) -> SkillProviderFuture<'_, SkillSearchResult> {
|
||||
Box::pin(async { Ok(SkillSearchResult::default()) })
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_entry_from_skill(
|
||||
skill: &SkillMetadata,
|
||||
enabled: bool,
|
||||
authority: SkillAuthority,
|
||||
selected_root_id: &str,
|
||||
file_system: Arc<dyn codex_exec_server::ExecutorFileSystem>,
|
||||
) -> SkillCatalogEntry {
|
||||
let skill_path = skill.path_to_skills_md.to_string_lossy().into_owned();
|
||||
let normalized_path = skill_path.replace('\\', "/");
|
||||
let display_path = format!(
|
||||
"skill://{selected_root_id}/{}",
|
||||
normalized_path.trim_start_matches('/')
|
||||
);
|
||||
let mut entry = SkillCatalogEntry::new(
|
||||
SkillPackageId(display_path.clone()),
|
||||
authority,
|
||||
skill.name.clone(),
|
||||
skill.description.clone(),
|
||||
SkillResourceId::environment(
|
||||
display_path.clone(),
|
||||
EnvironmentPathRef::new(file_system, skill.path_to_skills_md.clone()),
|
||||
),
|
||||
)
|
||||
.with_short_description(skill.short_description.clone())
|
||||
.with_display_path(display_path)
|
||||
.with_dependencies(skill.dependencies.clone());
|
||||
|
||||
if !enabled {
|
||||
entry = entry.disabled();
|
||||
}
|
||||
if !skill.allows_implicit_invocation() {
|
||||
entry = entry.hidden_from_prompt();
|
||||
}
|
||||
|
||||
entry
|
||||
}
|
||||
|
||||
fn executor_absolute_path(path: &str) -> std::io::Result<AbsolutePathBuf> {
|
||||
let path = PathBuf::from(path);
|
||||
if !path.is_absolute() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"executor path must be absolute",
|
||||
));
|
||||
}
|
||||
AbsolutePathBuf::from_absolute_path_checked(path)
|
||||
}
|
||||
@@ -55,12 +55,12 @@ impl SkillProvider for HostSkillProvider {
|
||||
};
|
||||
let Some(skill) = host_loaded_skills.outcome().skills.iter().find(|skill| {
|
||||
let skill_path = skill.path_to_skills_md.to_string_lossy();
|
||||
skill_path == request.resource.0.as_str()
|
||||
|| skill_path.replace('\\', "/") == request.resource.0
|
||||
skill_path == request.resource.as_str()
|
||||
|| skill_path.replace('\\', "/") == request.resource.as_str()
|
||||
}) else {
|
||||
return Err(SkillProviderError::new(format!(
|
||||
"host skill resource is not loaded: {}",
|
||||
request.resource.0
|
||||
request.resource.as_str()
|
||||
)));
|
||||
};
|
||||
|
||||
@@ -70,7 +70,7 @@ impl SkillProvider for HostSkillProvider {
|
||||
.map_err(|err| {
|
||||
SkillProviderError::new(format!(
|
||||
"failed to read host skill resource {}: {err}",
|
||||
request.resource.0
|
||||
request.resource.as_str()
|
||||
))
|
||||
})?;
|
||||
|
||||
@@ -117,7 +117,7 @@ fn catalog_entry_from_skill(skill: &SkillMetadata, enabled: bool) -> SkillCatalo
|
||||
SkillAuthority::new(SkillSourceKind::Host, HOST_AUTHORITY_ID),
|
||||
skill.name.clone(),
|
||||
skill.description.clone(),
|
||||
SkillResourceId(skill_path),
|
||||
SkillResourceId::new(skill_path),
|
||||
)
|
||||
.with_short_description(skill.short_description.clone())
|
||||
.with_display_path(display_path)
|
||||
|
||||
Reference in new issue
Block a user