mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Respect filesystem permissions during capability discovery (#36124)
## Why Executor capability discovery can traverse plugin and skill roots, including symlinks. Under restricted filesystem permissions, discovery must not expose files outside the permitted paths. ## What changed - Pass each environment's filesystem sandbox context through capability root discovery and apply it to metadata, directory walks, and file reads. - Enable discovery for restricted sessions so permitted executor skills remain available while inaccessible roots and symlink targets are omitted. - Key discovery caches by sandbox context and reject sandboxed discovery on executors that do not advertise support for it. - Split requests with more than 128 roots into supported-size batches. ## Testing - Cover permitted and denied external symlink targets. - Verify restricted skill listing excludes inaccessible skills. - Verify cache separation across permission contexts and discovery of 129 roots. GitOrigin-RevId: 44d16468ca003403bdb8b71a04ae8c9ff94ed494
This commit is contained in:
@@ -36,6 +36,7 @@ const SKILL_NAME: &str = "demo-plugin:deploy";
|
||||
const SKILL_MARKER: &str = "EXECUTOR_SKILL_BODY_MARKER";
|
||||
const LOCAL_SKILL_MARKER: &str = "LOCAL_SKILL_BODY_MARKER";
|
||||
const REFERENCE_MARKER: &str = "EXECUTOR_SKILL_REFERENCE_MARKER";
|
||||
const DENIED_SKILL_NAME: &str = "demo-plugin:denied";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum ExecutorSkillScenario {
|
||||
@@ -43,6 +44,7 @@ enum ExecutorSkillScenario {
|
||||
ExplicitOnly,
|
||||
RestrictedPermittedReference,
|
||||
RestrictedDeniedReference,
|
||||
RestrictedVisible,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -66,11 +68,17 @@ async fn restricted_executor_skill_rejects_reference_until_permission_approved()
|
||||
exercise_executor_skill(ExecutorSkillScenario::RestrictedDeniedReference).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restricted_executor_skill_is_listed_only_when_permitted() -> Result<()> {
|
||||
exercise_executor_skill(ExecutorSkillScenario::RestrictedVisible).await
|
||||
}
|
||||
|
||||
async fn exercise_executor_skill(scenario: ExecutorSkillScenario) -> Result<()> {
|
||||
let restricted = matches!(
|
||||
scenario,
|
||||
ExecutorSkillScenario::RestrictedPermittedReference
|
||||
| ExecutorSkillScenario::RestrictedDeniedReference
|
||||
| ExecutorSkillScenario::RestrictedVisible
|
||||
);
|
||||
if restricted {
|
||||
skip_if_target_windows!(
|
||||
@@ -162,9 +170,13 @@ stream_max_retries = 0
|
||||
ExecutorSkillScenario::VisibleWithBudgetWarning => 600 * 1024,
|
||||
ExecutorSkillScenario::ExplicitOnly
|
||||
| ExecutorSkillScenario::RestrictedPermittedReference
|
||||
| ExecutorSkillScenario::RestrictedDeniedReference => 40 * 1024,
|
||||
| ExecutorSkillScenario::RestrictedDeniedReference
|
||||
| ExecutorSkillScenario::RestrictedVisible => 40 * 1024,
|
||||
};
|
||||
let allow_implicit_invocation = scenario == ExecutorSkillScenario::VisibleWithBudgetWarning;
|
||||
let allow_implicit_invocation = matches!(
|
||||
scenario,
|
||||
ExecutorSkillScenario::VisibleWithBudgetWarning | ExecutorSkillScenario::RestrictedVisible
|
||||
);
|
||||
let reference_contents = format!("{REFERENCE_MARKER}\n{}", "x".repeat(reference_size));
|
||||
tokio::try_join!(
|
||||
file_system.write_file(
|
||||
@@ -207,6 +219,19 @@ stream_max_retries = 0
|
||||
std::fs::remove_file(reference_native_path.as_path())?;
|
||||
std::os::unix::fs::symlink(external_reference, reference_native_path.as_path())?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
if scenario == ExecutorSkillScenario::RestrictedVisible && !auto_env.environment().is_remote() {
|
||||
let denied_skill_dir = codex_home.path().join("denied-skill");
|
||||
std::fs::create_dir_all(&denied_skill_dir)?;
|
||||
std::fs::write(
|
||||
denied_skill_dir.join("SKILL.md"),
|
||||
"---\nname: denied\ndescription: Skill outside the permitted workspace.\n---\n",
|
||||
)?;
|
||||
std::os::unix::fs::symlink(
|
||||
denied_skill_dir,
|
||||
plugin_dir.to_abs_path()?.join("skills/denied"),
|
||||
)?;
|
||||
}
|
||||
if scenario == ExecutorSkillScenario::VisibleWithBudgetWarning {
|
||||
futures::stream::iter(0..200)
|
||||
.map(|index| {
|
||||
@@ -431,7 +456,8 @@ stream_max_retries = 0
|
||||
assert!(skill_fragment.contains(SKILL_MARKER));
|
||||
assert!(!skill_fragment.contains(LOCAL_SKILL_MARKER));
|
||||
match scenario {
|
||||
ExecutorSkillScenario::VisibleWithBudgetWarning => {
|
||||
ExecutorSkillScenario::VisibleWithBudgetWarning
|
||||
| ExecutorSkillScenario::RestrictedVisible => {
|
||||
assert!(!skill_fragment.contains("<resource_access>"));
|
||||
}
|
||||
ExecutorSkillScenario::ExplicitOnly
|
||||
@@ -459,7 +485,8 @@ stream_max_retries = 0
|
||||
.expect("skills.list output"),
|
||||
)?;
|
||||
match scenario {
|
||||
ExecutorSkillScenario::VisibleWithBudgetWarning => {
|
||||
ExecutorSkillScenario::VisibleWithBudgetWarning
|
||||
| ExecutorSkillScenario::RestrictedVisible => {
|
||||
let deploy_skill = list_output["skills"]
|
||||
.as_array()
|
||||
.and_then(|skills| skills.iter().find(|skill| skill["name"] == SKILL_NAME))
|
||||
@@ -474,7 +501,16 @@ stream_max_retries = 0
|
||||
"main_resource": main_resource,
|
||||
})
|
||||
);
|
||||
assert!(list_output["next_cursor"].is_string());
|
||||
assert!(list_output["skills"].as_array().is_none_or(|skills| {
|
||||
skills
|
||||
.iter()
|
||||
.all(|skill| skill["name"] != DENIED_SKILL_NAME)
|
||||
}));
|
||||
if scenario == ExecutorSkillScenario::VisibleWithBudgetWarning {
|
||||
assert!(list_output["next_cursor"].is_string());
|
||||
} else {
|
||||
assert!(list_output["next_cursor"].is_null());
|
||||
}
|
||||
}
|
||||
ExecutorSkillScenario::ExplicitOnly
|
||||
| ExecutorSkillScenario::RestrictedPermittedReference
|
||||
@@ -512,7 +548,8 @@ stream_max_retries = 0
|
||||
}
|
||||
ExecutorSkillScenario::ExplicitOnly
|
||||
| ExecutorSkillScenario::RestrictedPermittedReference
|
||||
| ExecutorSkillScenario::RestrictedDeniedReference => {
|
||||
| ExecutorSkillScenario::RestrictedDeniedReference
|
||||
| ExecutorSkillScenario::RestrictedVisible => {
|
||||
assert!(reference_output["next_cursor"].is_null());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,6 +589,7 @@ async fn executor_bundle_parser_matches_the_existing_environment_loader() {
|
||||
roots: vec![CapabilityRootDiscoverRequest {
|
||||
id: "demo@1".to_string(),
|
||||
path: root_uri,
|
||||
sandbox: None,
|
||||
}],
|
||||
},
|
||||
)
|
||||
@@ -656,6 +657,7 @@ async fn executor_bundle_preserves_parent_namespace_and_manifest_precedence() {
|
||||
roots: vec![CapabilityRootDiscoverRequest {
|
||||
id: "skills-only".to_string(),
|
||||
path: root_uri,
|
||||
sandbox: None,
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2,12 +2,14 @@ use super::mcp_refresh::McpRefreshInvalidationGuard;
|
||||
use super::*;
|
||||
use codex_exec_server::ExecutorCapabilityDiscoveryCache;
|
||||
use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
|
||||
use codex_exec_server::FileSystemSandboxContext;
|
||||
use codex_exec_server::MAX_SELECTED_CAPABILITY_ROOTS;
|
||||
use codex_exec_server::ResolvedSelectedCapabilityRoot;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::ElicitationReviewRequest;
|
||||
use codex_mcp::ElicitationReviewer;
|
||||
use codex_mcp::ElicitationReviewerHandle;
|
||||
use codex_protocol::capabilities::CapabilityRootLocation;
|
||||
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as MCP_ELICITATION_APPROVAL_KIND_KEY;
|
||||
@@ -90,6 +92,12 @@ impl Session {
|
||||
config: &Config,
|
||||
) -> (McpConfig, McpRuntimeContext) {
|
||||
let originator = self.originator().await;
|
||||
let windows_sandbox_level = self
|
||||
.state
|
||||
.lock()
|
||||
.await
|
||||
.session_configuration
|
||||
.windows_sandbox_level;
|
||||
let environments = self.services.turn_environments.snapshot().await;
|
||||
let selected_capability_roots = self
|
||||
.resolve_selected_capability_roots_for_step(&environments)
|
||||
@@ -97,7 +105,12 @@ impl Session {
|
||||
let ready_selected_capability_roots =
|
||||
Self::ready_selected_capability_roots(&selected_capability_roots);
|
||||
let executor_capability_discovery = self
|
||||
.executor_capability_discovery_for_step(config, &ready_selected_capability_roots)
|
||||
.executor_capability_discovery_for_step(
|
||||
config,
|
||||
&ready_selected_capability_roots,
|
||||
&environments,
|
||||
windows_sandbox_level,
|
||||
)
|
||||
.await;
|
||||
let mcp_config = self
|
||||
.services
|
||||
@@ -172,6 +185,8 @@ impl Session {
|
||||
.executor_capability_discovery_for_step(
|
||||
&desired.config,
|
||||
&ready_selected_capability_roots,
|
||||
&desired.environments,
|
||||
desired.windows_sandbox_level,
|
||||
)
|
||||
.await;
|
||||
let mcp_projection = self
|
||||
@@ -224,6 +239,8 @@ impl Session {
|
||||
.executor_capability_discovery_for_step(
|
||||
&desired.config,
|
||||
&ready_selected_capability_roots,
|
||||
&desired.environments,
|
||||
desired.windows_sandbox_level,
|
||||
)
|
||||
.await;
|
||||
let mcp_projection = self
|
||||
@@ -294,20 +311,67 @@ impl Session {
|
||||
&self,
|
||||
config: &Config,
|
||||
ready_selected_capability_roots: &[SelectedCapabilityRoot],
|
||||
environments: &TurnEnvironmentSnapshot,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
) -> Option<Arc<ExecutorCapabilityDiscoverySnapshot>> {
|
||||
if !config
|
||||
.features
|
||||
.enabled(Feature::ExecutorCapabilityDiscovery)
|
||||
let restricted_file_system = !config
|
||||
.permissions
|
||||
.file_system_sandbox_policy()
|
||||
.has_full_disk_read_access();
|
||||
if !restricted_file_system
|
||||
&& !config
|
||||
.features
|
||||
.enabled(Feature::ExecutorCapabilityDiscovery)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let sandbox_contexts = if restricted_file_system {
|
||||
environments
|
||||
.turn_environments()
|
||||
.map(|environment| {
|
||||
let mut sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd(
|
||||
config.permissions.permission_profile().clone(),
|
||||
environment.cwd().clone(),
|
||||
);
|
||||
sandbox.workspace_roots = environment.workspace_roots().to_vec();
|
||||
sandbox.windows_sandbox_level = windows_sandbox_level;
|
||||
sandbox.windows_sandbox_private_desktop =
|
||||
config.permissions.windows_sandbox_private_desktop;
|
||||
sandbox.use_legacy_landlock = config.features.use_legacy_landlock();
|
||||
(environment.environment_id.clone(), sandbox)
|
||||
})
|
||||
.collect::<HashMap<_, _>>()
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
let environment_manager = self.services.turn_environments.environment_manager();
|
||||
let cache = self
|
||||
.services
|
||||
.thread_extension_data
|
||||
.get_or_init(|| ExecutorCapabilityDiscoveryCache::new(environment_manager));
|
||||
let selected_capability_roots = ready_selected_capability_roots
|
||||
.iter()
|
||||
.filter(|selected_root| {
|
||||
if !restricted_file_system {
|
||||
return true;
|
||||
}
|
||||
let CapabilityRootLocation::Environment { environment_id, .. } =
|
||||
&selected_root.location;
|
||||
if sandbox_contexts.contains_key(environment_id) {
|
||||
return true;
|
||||
}
|
||||
warn!(
|
||||
selected_root = selected_root.id,
|
||||
environment_id, "skipping capability root without a filesystem sandbox context"
|
||||
);
|
||||
false
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
Some(Arc::new(
|
||||
cache.snapshot(ready_selected_capability_roots).await,
|
||||
cache
|
||||
.snapshot(&selected_capability_roots, &sandbox_contexts)
|
||||
.await,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -535,10 +599,13 @@ impl Session {
|
||||
.services
|
||||
.mcp_runtime
|
||||
.current_ready_selected_capability_roots();
|
||||
let environments = self.services.turn_environments.snapshot().await;
|
||||
let executor_capability_discovery = self
|
||||
.executor_capability_discovery_for_step(
|
||||
refresh_config,
|
||||
&ready_selected_capability_roots,
|
||||
&environments,
|
||||
turn_context.windows_sandbox_level,
|
||||
)
|
||||
.await;
|
||||
let mcp_projection = self
|
||||
|
||||
@@ -16,6 +16,7 @@ pub(super) struct McpDesiredState {
|
||||
pub(super) submit_id: String,
|
||||
pub(super) originator: String,
|
||||
pub(super) environments: TurnEnvironmentSnapshot,
|
||||
pub(super) windows_sandbox_level: WindowsSandboxLevel,
|
||||
}
|
||||
|
||||
impl McpDesiredState {
|
||||
@@ -51,6 +52,7 @@ impl Session {
|
||||
submit_id: self.next_internal_sub_id(),
|
||||
originator: session_configuration.originator.clone(),
|
||||
environments,
|
||||
windows_sandbox_level: session_configuration.windows_sandbox_level,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +74,7 @@ impl Session {
|
||||
submit_id: INITIAL_SUBMIT_ID.to_owned(),
|
||||
originator: session_configuration.originator.clone(),
|
||||
environments: resolved_environments.clone(),
|
||||
windows_sandbox_level: session_configuration.windows_sandbox_level,
|
||||
};
|
||||
self.publish_mcp_runtime(
|
||||
&desired,
|
||||
|
||||
@@ -3064,11 +3064,18 @@ impl Session {
|
||||
.executor_capability_discovery_for_step(
|
||||
&turn_context.config,
|
||||
&ready_selected_capability_roots,
|
||||
&environments,
|
||||
turn_context.windows_sandbox_level,
|
||||
)
|
||||
.await;
|
||||
let extension_data = codex_extension_api::ExtensionData::new(turn_context.sub_id.clone());
|
||||
extension_data.insert(selected_capability_roots.clone());
|
||||
if !turn_context
|
||||
if let Some(discovery) = &executor_capability_discovery {
|
||||
extension_data.insert(discovery.as_ref().clone());
|
||||
if !discovery.sandbox_contexts().is_empty() {
|
||||
extension_data.insert(discovery.sandbox_contexts().clone());
|
||||
}
|
||||
} else if !turn_context
|
||||
.config
|
||||
.permissions
|
||||
.file_system_sandbox_policy()
|
||||
|
||||
@@ -105,6 +105,9 @@ pub struct EnvironmentCapabilities {
|
||||
/// Whether `exec` accepts instructions for launching an executor-local network proxy.
|
||||
#[serde(default)]
|
||||
pub network_proxy_launch: bool,
|
||||
/// Whether capability discovery applies the filesystem sandbox sent with each root.
|
||||
#[serde(default)]
|
||||
pub capability_discovery_sandbox: bool,
|
||||
}
|
||||
|
||||
/// Status returned by an initialized exec-server connection.
|
||||
@@ -136,6 +139,7 @@ impl EnvironmentInfo {
|
||||
.and_then(|cwd| PathUri::from_host_native_path(cwd).ok()),
|
||||
capabilities: EnvironmentCapabilities {
|
||||
network_proxy_launch: true,
|
||||
capability_discovery_sandbox: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -475,6 +479,9 @@ pub struct CapabilityRootDiscoverRequest {
|
||||
pub id: String,
|
||||
/// Absolute root URI interpreted using the exec-server host's path rules.
|
||||
pub path: PathUri,
|
||||
/// Filesystem permissions for this root and its symlink targets.
|
||||
#[serde(default)]
|
||||
pub sandbox: Option<FileSystemSandboxContext>,
|
||||
}
|
||||
|
||||
/// Executor-local discovery results in request order.
|
||||
@@ -539,6 +546,7 @@ pub struct CapabilityRootDiscovery {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExecutorCapabilityDiscoverySnapshot {
|
||||
roots: Arc<[ExecutorCapabilityDiscoverySnapshotEntry]>,
|
||||
sandbox_contexts: Arc<HashMap<String, FileSystemSandboxContext>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -551,6 +559,7 @@ impl ExecutorCapabilityDiscoverySnapshot {
|
||||
pub fn new(
|
||||
selected_roots: &[SelectedCapabilityRoot],
|
||||
discoveries: Vec<Result<Arc<CapabilityRootDiscovery>, String>>,
|
||||
sandbox_contexts: HashMap<String, FileSystemSandboxContext>,
|
||||
) -> Self {
|
||||
debug_assert_eq!(selected_roots.len(), discoveries.len());
|
||||
Self {
|
||||
@@ -565,12 +574,17 @@ impl ExecutorCapabilityDiscoverySnapshot {
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
sandbox_contexts: Arc::new(sandbox_contexts),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn roots(&self) -> &[ExecutorCapabilityDiscoverySnapshotEntry] {
|
||||
&self.roots
|
||||
}
|
||||
|
||||
pub fn sandbox_contexts(&self) -> &HashMap<String, FileSystemSandboxContext> {
|
||||
self.sandbox_contexts.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP header represented in the executor protocol.
|
||||
|
||||
@@ -10,6 +10,7 @@ use codex_exec_server_protocol::DISCOVERABLE_PLUGIN_MANIFEST_PATHS;
|
||||
use codex_exec_server_protocol::DiscoveredPluginFiles;
|
||||
use codex_exec_server_protocol::DiscoveredSkillFiles;
|
||||
use codex_file_system::ExecutorFileSystem;
|
||||
use codex_file_system::FileSystemSandboxContext;
|
||||
use codex_file_system::WalkEntryKind;
|
||||
use codex_file_system::WalkOptions;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
@@ -17,7 +18,7 @@ use futures::StreamExt;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
const MAX_ROOTS_PER_REQUEST: usize = 128;
|
||||
pub(crate) const MAX_ROOTS_PER_REQUEST: usize = 128;
|
||||
const MAX_SCAN_DEPTH: usize = 6;
|
||||
const MAX_DIRECTORIES_PER_ROOT: usize = 2_000;
|
||||
const MAX_ENTRIES_PER_ROOT: usize = 20_000;
|
||||
@@ -63,7 +64,8 @@ async fn discover_root(
|
||||
file_system: &dyn ExecutorFileSystem,
|
||||
request: CapabilityRootDiscoverRequest,
|
||||
) -> CapabilityRootDiscovery {
|
||||
let CapabilityRootDiscoverRequest { id, path } = request;
|
||||
let CapabilityRootDiscoverRequest { id, path, sandbox } = request;
|
||||
let sandbox = sandbox.as_ref();
|
||||
let mut discovery = CapabilityRootDiscovery {
|
||||
id,
|
||||
path: path.clone(),
|
||||
@@ -74,7 +76,17 @@ async fn discover_root(
|
||||
error: None,
|
||||
};
|
||||
|
||||
match file_system.get_metadata(&path, /*sandbox*/ None).await {
|
||||
#[cfg(target_os = "windows")]
|
||||
if sandbox.is_some_and(|context| {
|
||||
context.should_run_in_sandbox()
|
||||
&& context.windows_sandbox_level
|
||||
== codex_protocol::config_types::WindowsSandboxLevel::Disabled
|
||||
}) {
|
||||
discovery.error = Some("filesystem sandbox is unavailable on this executor".to_string());
|
||||
return discovery;
|
||||
}
|
||||
|
||||
match file_system.get_metadata(&path, sandbox).await {
|
||||
Ok(metadata) if metadata.is_directory => {}
|
||||
Ok(_) => {
|
||||
discovery.error = Some(format!("capability root {path} is not a directory"));
|
||||
@@ -96,7 +108,7 @@ async fn discover_root(
|
||||
follow_directory_symlinks: true,
|
||||
prune_hidden_directories: false,
|
||||
},
|
||||
/*sandbox*/ None,
|
||||
sandbox,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -143,14 +155,26 @@ async fn discover_root(
|
||||
});
|
||||
|
||||
let mut budget = BundleBudget::default();
|
||||
let root_manifest =
|
||||
read_first_plugin_manifest(file_system, &path, &mut budget, &mut discovery.warnings).await;
|
||||
let root_manifest = read_first_plugin_manifest(
|
||||
file_system,
|
||||
&path,
|
||||
sandbox,
|
||||
&mut budget,
|
||||
&mut discovery.warnings,
|
||||
)
|
||||
.await;
|
||||
|
||||
let inherited_manifest = match root_manifest.as_ref() {
|
||||
Some(manifest) => Some(manifest.clone()),
|
||||
None => {
|
||||
read_nearest_ancestor_manifest(file_system, &path, &mut budget, &mut discovery.warnings)
|
||||
.await
|
||||
read_nearest_ancestor_manifest(
|
||||
file_system,
|
||||
&path,
|
||||
sandbox,
|
||||
&mut budget,
|
||||
&mut discovery.warnings,
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
let mut seen_namespace_roots = HashSet::new();
|
||||
@@ -170,6 +194,7 @@ async fn discover_root(
|
||||
if let Some(manifest) = read_optional_text_file(
|
||||
file_system,
|
||||
manifest_path,
|
||||
sandbox,
|
||||
&mut budget,
|
||||
&mut discovery.warnings,
|
||||
)
|
||||
@@ -190,15 +215,27 @@ async fn discover_root(
|
||||
};
|
||||
let mcp_config = match mcp_path {
|
||||
Some(path) => {
|
||||
read_optional_text_file(file_system, path, &mut budget, &mut discovery.warnings)
|
||||
.await
|
||||
read_optional_text_file(
|
||||
file_system,
|
||||
path,
|
||||
sandbox,
|
||||
&mut budget,
|
||||
&mut discovery.warnings,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let apps_config = match declarations.apps_config {
|
||||
Some(path) => {
|
||||
read_optional_text_file(file_system, path, &mut budget, &mut discovery.warnings)
|
||||
.await
|
||||
read_optional_text_file(
|
||||
file_system,
|
||||
path,
|
||||
sandbox,
|
||||
&mut budget,
|
||||
&mut discovery.warnings,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
@@ -213,6 +250,7 @@ async fn discover_root(
|
||||
let Some(instructions) = read_optional_text_file(
|
||||
file_system,
|
||||
skill_path.clone(),
|
||||
sandbox,
|
||||
&mut budget,
|
||||
&mut discovery.warnings,
|
||||
)
|
||||
@@ -228,6 +266,7 @@ async fn discover_root(
|
||||
read_optional_text_file(
|
||||
file_system,
|
||||
metadata_path,
|
||||
sandbox,
|
||||
&mut budget,
|
||||
&mut discovery.warnings,
|
||||
)
|
||||
@@ -247,6 +286,7 @@ async fn discover_root(
|
||||
async fn read_first_plugin_manifest(
|
||||
file_system: &dyn ExecutorFileSystem,
|
||||
root: &PathUri,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
budget: &mut BundleBudget,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Option<CapabilityTextFile> {
|
||||
@@ -254,7 +294,9 @@ async fn read_first_plugin_manifest(
|
||||
let Ok(path) = root.join(relative_path) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(manifest) = read_optional_text_file(file_system, path, budget, warnings).await {
|
||||
if let Some(manifest) =
|
||||
read_optional_text_file(file_system, path, sandbox, budget, warnings).await
|
||||
{
|
||||
return Some(manifest);
|
||||
}
|
||||
}
|
||||
@@ -264,13 +306,14 @@ async fn read_first_plugin_manifest(
|
||||
async fn read_nearest_ancestor_manifest(
|
||||
file_system: &dyn ExecutorFileSystem,
|
||||
root: &PathUri,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
budget: &mut BundleBudget,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Option<CapabilityTextFile> {
|
||||
let mut ancestor = root.parent();
|
||||
while let Some(path) = ancestor {
|
||||
if let Some(manifest) =
|
||||
read_first_plugin_manifest(file_system, &path, budget, warnings).await
|
||||
read_first_plugin_manifest(file_system, &path, sandbox, budget, warnings).await
|
||||
{
|
||||
return Some(manifest);
|
||||
}
|
||||
@@ -282,10 +325,11 @@ async fn read_nearest_ancestor_manifest(
|
||||
async fn read_optional_text_file(
|
||||
file_system: &dyn ExecutorFileSystem,
|
||||
path: PathUri,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
budget: &mut BundleBudget,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Option<CapabilityTextFile> {
|
||||
let metadata = match file_system.get_metadata(&path, /*sandbox*/ None).await {
|
||||
let metadata = match file_system.get_metadata(&path, sandbox).await {
|
||||
Ok(metadata) if metadata.is_file => metadata,
|
||||
Ok(_) => return None,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return None,
|
||||
@@ -310,32 +354,49 @@ async fn read_optional_text_file(
|
||||
));
|
||||
return None;
|
||||
}
|
||||
let mut stream = match file_system.read_file_stream(&path, /*sandbox*/ None).await {
|
||||
Ok(stream) => stream,
|
||||
Err(error) => {
|
||||
warnings.push(format!("failed to read capability file {path}: {error}"));
|
||||
return None;
|
||||
let contents = if sandbox.is_some_and(FileSystemSandboxContext::should_run_in_sandbox) {
|
||||
match file_system.read_file(&path, sandbox).await {
|
||||
Ok(contents) if contents.len() <= MAX_FILE_BYTES && budget.can_add(contents.len()) => {
|
||||
contents
|
||||
}
|
||||
Ok(_) => {
|
||||
warnings.push(format!("capability file {path} exceeded its read limit"));
|
||||
return None;
|
||||
}
|
||||
Err(error) => {
|
||||
warnings.push(format!("failed to read capability file {path}: {error}"));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut contents = Vec::with_capacity(size);
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = match chunk {
|
||||
Ok(chunk) => chunk,
|
||||
} else {
|
||||
let mut stream = match file_system.read_file_stream(&path, sandbox).await {
|
||||
Ok(stream) => stream,
|
||||
Err(error) => {
|
||||
warnings.push(format!("failed to read capability file {path}: {error}"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let Some(new_len) = contents.len().checked_add(chunk.len()) else {
|
||||
warnings.push(format!("capability file {path} exceeded its read limit"));
|
||||
return None;
|
||||
};
|
||||
if new_len > MAX_FILE_BYTES || !budget.can_add(new_len) {
|
||||
warnings.push(format!("capability file {path} exceeded its read limit"));
|
||||
return None;
|
||||
let mut contents = Vec::with_capacity(size);
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = match chunk {
|
||||
Ok(chunk) => chunk,
|
||||
Err(error) => {
|
||||
warnings.push(format!("failed to read capability file {path}: {error}"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let Some(new_len) = contents.len().checked_add(chunk.len()) else {
|
||||
warnings.push(format!("capability file {path} exceeded its read limit"));
|
||||
return None;
|
||||
};
|
||||
if new_len > MAX_FILE_BYTES || !budget.can_add(new_len) {
|
||||
warnings.push(format!("capability file {path} exceeded its read limit"));
|
||||
return None;
|
||||
}
|
||||
contents.extend_from_slice(&chunk);
|
||||
}
|
||||
contents.extend_from_slice(&chunk);
|
||||
}
|
||||
contents
|
||||
};
|
||||
let contents = match String::from_utf8(contents) {
|
||||
Ok(contents) => contents,
|
||||
Err(error) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_protocol::capabilities::CapabilityRootLocation;
|
||||
@@ -10,6 +11,7 @@ use crate::CapabilityRootDiscovery;
|
||||
use crate::CapabilityRootsDiscoverParams;
|
||||
use crate::EnvironmentManager;
|
||||
use crate::ExecutorCapabilityDiscoverySnapshot;
|
||||
use crate::FileSystemSandboxContext;
|
||||
|
||||
/// Thread-scoped cache shared by capability consumers using the high-level executor API.
|
||||
///
|
||||
@@ -31,6 +33,7 @@ impl std::fmt::Debug for ExecutorCapabilityDiscoveryCache {
|
||||
|
||||
struct CachedRoot {
|
||||
selected_root: SelectedCapabilityRoot,
|
||||
sandbox: Option<FileSystemSandboxContext>,
|
||||
// Both successes and failures are memoized for the thread. Retrying a transient failure for
|
||||
// the same stable selected root requires explicit invalidation or a new thread.
|
||||
result: Result<Arc<CapabilityRootDiscovery>, String>,
|
||||
@@ -53,36 +56,47 @@ impl ExecutorCapabilityDiscoveryCache {
|
||||
pub async fn discover(
|
||||
&self,
|
||||
selected_roots: &[SelectedCapabilityRoot],
|
||||
sandbox_contexts: &HashMap<String, FileSystemSandboxContext>,
|
||||
) -> Vec<Result<Arc<CapabilityRootDiscovery>, String>> {
|
||||
let missing = {
|
||||
let entries = self.entries.lock().await;
|
||||
selected_roots
|
||||
.iter()
|
||||
.filter(|selected_root| {
|
||||
!entries
|
||||
.iter()
|
||||
.any(|cached| cached.selected_root == **selected_root)
|
||||
let CapabilityRootLocation::Environment { environment_id, .. } =
|
||||
&selected_root.location;
|
||||
let sandbox = sandbox_contexts.get(environment_id);
|
||||
!entries.iter().any(|cached| {
|
||||
cached.selected_root == **selected_root
|
||||
&& cached.sandbox.as_ref() == sandbox
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let discovered = self.discover_missing(missing).await;
|
||||
let discovered = self.discover_missing(missing, sandbox_contexts).await;
|
||||
let mut entries = self.entries.lock().await;
|
||||
for discovered_root in discovered {
|
||||
if !entries
|
||||
.iter()
|
||||
.any(|cached| cached.selected_root == discovered_root.selected_root)
|
||||
if let Some(cached) = entries
|
||||
.iter_mut()
|
||||
.find(|cached| cached.selected_root == discovered_root.selected_root)
|
||||
{
|
||||
if cached.sandbox != discovered_root.sandbox {
|
||||
*cached = discovered_root;
|
||||
}
|
||||
} else {
|
||||
entries.push(discovered_root);
|
||||
}
|
||||
}
|
||||
selected_roots
|
||||
.iter()
|
||||
.map(|selected_root| {
|
||||
match entries
|
||||
.iter()
|
||||
.find(|cached| cached.selected_root == *selected_root)
|
||||
{
|
||||
let CapabilityRootLocation::Environment { environment_id, .. } =
|
||||
&selected_root.location;
|
||||
let sandbox = sandbox_contexts.get(environment_id);
|
||||
match entries.iter().find(|cached| {
|
||||
cached.selected_root == *selected_root && cached.sandbox.as_ref() == sandbox
|
||||
}) {
|
||||
Some(cached) => cached.result.clone(),
|
||||
None => Err(format!(
|
||||
"selected capability root `{}` was not discovered",
|
||||
@@ -97,14 +111,20 @@ impl ExecutorCapabilityDiscoveryCache {
|
||||
pub async fn snapshot(
|
||||
&self,
|
||||
selected_roots: &[SelectedCapabilityRoot],
|
||||
sandbox_contexts: &HashMap<String, FileSystemSandboxContext>,
|
||||
) -> ExecutorCapabilityDiscoverySnapshot {
|
||||
ExecutorCapabilityDiscoverySnapshot::new(
|
||||
selected_roots,
|
||||
self.discover(selected_roots).await,
|
||||
self.discover(selected_roots, sandbox_contexts).await,
|
||||
sandbox_contexts.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn discover_missing(&self, missing: Vec<SelectedCapabilityRoot>) -> Vec<CachedRoot> {
|
||||
async fn discover_missing(
|
||||
&self,
|
||||
missing: Vec<SelectedCapabilityRoot>,
|
||||
sandbox_contexts: &HashMap<String, FileSystemSandboxContext>,
|
||||
) -> Vec<CachedRoot> {
|
||||
let mut grouped = BTreeMap::<String, Vec<SelectedCapabilityRoot>>::new();
|
||||
for selected_root in missing {
|
||||
let CapabilityRootLocation::Environment { environment_id, .. } =
|
||||
@@ -115,8 +135,15 @@ impl ExecutorCapabilityDiscoveryCache {
|
||||
.push(selected_root);
|
||||
}
|
||||
|
||||
let discoveries = futures::future::join_all(grouped.into_iter().map(
|
||||
|(environment_id, selected_roots)| async move {
|
||||
let batches = grouped.into_iter().flat_map(|(environment_id, roots)| {
|
||||
roots
|
||||
.chunks(crate::capability_discovery::MAX_ROOTS_PER_REQUEST)
|
||||
.map(|batch| (environment_id.clone(), batch.to_vec()))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let discoveries =
|
||||
futures::future::join_all(batches.map(|(environment_id, selected_roots)| async move {
|
||||
let sandbox = sandbox_contexts.get(&environment_id).cloned();
|
||||
let Some(environment) = self.environment_manager.get_environment(&environment_id)
|
||||
else {
|
||||
let error = format!("environment `{environment_id}` is unavailable");
|
||||
@@ -124,6 +151,7 @@ impl ExecutorCapabilityDiscoveryCache {
|
||||
.into_iter()
|
||||
.map(|selected_root| CachedRoot {
|
||||
selected_root,
|
||||
sandbox: sandbox.clone(),
|
||||
result: Err(error.clone()),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -137,6 +165,7 @@ impl ExecutorCapabilityDiscoveryCache {
|
||||
CapabilityRootDiscoverRequest {
|
||||
id: selected_root.id.clone(),
|
||||
path: path.clone(),
|
||||
sandbox: sandbox.clone(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
@@ -149,6 +178,7 @@ impl ExecutorCapabilityDiscoveryCache {
|
||||
.into_iter()
|
||||
.map(|selected_root| CachedRoot {
|
||||
selected_root,
|
||||
sandbox: sandbox.clone(),
|
||||
result: Err(error.clone()),
|
||||
})
|
||||
.collect();
|
||||
@@ -164,6 +194,7 @@ impl ExecutorCapabilityDiscoveryCache {
|
||||
.into_iter()
|
||||
.map(|selected_root| CachedRoot {
|
||||
selected_root,
|
||||
sandbox: sandbox.clone(),
|
||||
result: Err(error.clone()),
|
||||
})
|
||||
.collect();
|
||||
@@ -185,13 +216,13 @@ impl ExecutorCapabilityDiscoveryCache {
|
||||
};
|
||||
CachedRoot {
|
||||
selected_root,
|
||||
sandbox: sandbox.clone(),
|
||||
result,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
},
|
||||
))
|
||||
.await;
|
||||
}))
|
||||
.await;
|
||||
discoveries.into_iter().flatten().collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,7 +814,24 @@ impl Environment {
|
||||
params: CapabilityRootsDiscoverParams,
|
||||
) -> Result<CapabilityRootsDiscoverResponse, ExecServerError> {
|
||||
match &self.remote_client {
|
||||
Some(client) => client.get().await?.discover_capability_roots(params).await,
|
||||
Some(client) => {
|
||||
let client = client.get().await?;
|
||||
if params.roots.iter().any(|root| {
|
||||
root.sandbox
|
||||
.as_ref()
|
||||
.is_some_and(crate::FileSystemSandboxContext::should_run_in_sandbox)
|
||||
}) && !client
|
||||
.environment_info()
|
||||
.await?
|
||||
.capabilities
|
||||
.capability_discovery_sandbox
|
||||
{
|
||||
return Err(ExecServerError::Protocol(
|
||||
"exec-server does not support sandboxed capability discovery".to_string(),
|
||||
));
|
||||
}
|
||||
client.discover_capability_roots(params).await
|
||||
}
|
||||
None => crate::discover_capability_roots(self.filesystem.as_ref(), params)
|
||||
.await
|
||||
.map_err(|error| ExecServerError::Protocol(error.to_string())),
|
||||
|
||||
@@ -4,11 +4,26 @@ use codex_exec_server::CAPABILITY_ROOTS_DISCOVER_METHOD;
|
||||
use codex_exec_server::CapabilityRootDiscovery;
|
||||
use codex_exec_server::CapabilityRootsDiscoverParams;
|
||||
use codex_exec_server::CapabilityRootsDiscoverResponse;
|
||||
use codex_exec_server::FileSystemSandboxContext;
|
||||
use codex_exec_server::InitializeParams;
|
||||
use codex_exec_server::InitializeResponse;
|
||||
use codex_exec_server_protocol::CapabilityRootDiscoverRequest;
|
||||
use codex_exec_server_protocol::JSONRPCMessage;
|
||||
use codex_exec_server_protocol::JSONRPCResponse;
|
||||
#[cfg(unix)]
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
#[cfg(unix)]
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
#[cfg(unix)]
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
#[cfg(unix)]
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
#[cfg(unix)]
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
#[cfg(unix)]
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
#[cfg(unix)]
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use common::exec_server::exec_server;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -166,10 +181,103 @@ async fn discovers_cursor_plugin_without_reading_default_mcp_for_inline_servers(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn sandboxed_discovery_follows_only_permitted_external_symlinks() -> anyhow::Result<()> {
|
||||
let root = tempfile::tempdir()?;
|
||||
let external = tempfile::tempdir()?;
|
||||
write_file(
|
||||
&root.path().join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"linked-plugin","mcpServers":"./external-mcp.json"}"#,
|
||||
)?;
|
||||
write_file(
|
||||
&external.path().join("mcp.json"),
|
||||
r#"{"mcpServers":{"linked":{"command":"linked-server"}}}"#,
|
||||
)?;
|
||||
write_file(
|
||||
&external.path().join("skill/SKILL.md"),
|
||||
"---\nname: linked\ndescription: Linked external skill.\n---\n",
|
||||
)?;
|
||||
std::fs::create_dir_all(root.path().join("skills"))?;
|
||||
std::os::unix::fs::symlink(
|
||||
external.path().join("skill"),
|
||||
root.path().join("skills/linked"),
|
||||
)?;
|
||||
std::os::unix::fs::symlink(
|
||||
external.path().join("mcp.json"),
|
||||
root.path().join("external-mcp.json"),
|
||||
)?;
|
||||
|
||||
let mut server = exec_server().await?;
|
||||
initialize(&mut server).await?;
|
||||
let root_uri = PathUri::from_host_native_path(root.path())?;
|
||||
let root_path = AbsolutePathBuf::from_absolute_path(root.path())?;
|
||||
let external_root = AbsolutePathBuf::from_absolute_path(external.path())?;
|
||||
let path_entry =
|
||||
|path, access| FileSystemSandboxEntry::new(FileSystemPath::Path { path }, access);
|
||||
let read_root = path_entry(root_path, FileSystemAccessMode::Read);
|
||||
let read_external = path_entry(external_root.clone(), FileSystemAccessMode::Read);
|
||||
let deny_external_skill = path_entry(external_root.join("skill"), FileSystemAccessMode::Deny);
|
||||
let cases = [
|
||||
(
|
||||
"permitted symlinks",
|
||||
vec![read_root.clone(), read_external.clone()],
|
||||
true,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"denied external root",
|
||||
vec![read_root.clone()],
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"denied external skill",
|
||||
vec![read_root, read_external, deny_external_skill],
|
||||
false,
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
for (scenario, entries, has_skill, has_mcp) in cases {
|
||||
let policy = FileSystemSandboxPolicy::restricted(entries);
|
||||
let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd(
|
||||
PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted),
|
||||
root_uri.clone(),
|
||||
);
|
||||
let discovery =
|
||||
discover_root_with_sandbox(&mut server, "linked", root_uri.clone(), Some(sandbox))
|
||||
.await?;
|
||||
|
||||
assert_eq!(discovery.error, None, "{scenario}");
|
||||
assert_eq!(discovery.skills.len(), usize::from(has_skill), "{scenario}");
|
||||
assert_eq!(
|
||||
discovery
|
||||
.plugin
|
||||
.and_then(|plugin| plugin.mcp_config)
|
||||
.is_some_and(|config| config.contents.contains("linked-server")),
|
||||
has_mcp,
|
||||
"{scenario}"
|
||||
);
|
||||
}
|
||||
|
||||
server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn discover_root(
|
||||
server: &mut common::exec_server::ExecServerHarness,
|
||||
id: &str,
|
||||
path: PathUri,
|
||||
) -> anyhow::Result<CapabilityRootDiscovery> {
|
||||
discover_root_with_sandbox(server, id, path, /*sandbox*/ None).await
|
||||
}
|
||||
|
||||
async fn discover_root_with_sandbox(
|
||||
server: &mut common::exec_server::ExecServerHarness,
|
||||
id: &str,
|
||||
path: PathUri,
|
||||
sandbox: Option<FileSystemSandboxContext>,
|
||||
) -> anyhow::Result<CapabilityRootDiscovery> {
|
||||
let request_id = server
|
||||
.send_request(
|
||||
@@ -178,6 +286,7 @@ async fn discover_root(
|
||||
roots: vec![CapabilityRootDiscoverRequest {
|
||||
id: id.to_string(),
|
||||
path,
|
||||
sandbox,
|
||||
}],
|
||||
})?,
|
||||
)
|
||||
|
||||
@@ -230,7 +230,7 @@ async fn raw_selected_plugin_contributions(
|
||||
{
|
||||
Some(
|
||||
ExecutorCapabilityDiscoveryCache::new(environment_manager)
|
||||
.snapshot(&selected_capability_roots)
|
||||
.snapshot(&selected_capability_roots, &Default::default())
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
use codex_core_skills::HostSkillsSnapshot;
|
||||
use codex_core_skills::injection::HostSkillsCatalogInWorldState;
|
||||
use codex_core_skills::injection::InjectedHostSkillPrompts;
|
||||
use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
|
||||
use codex_exec_server::FileSystemSandboxContext;
|
||||
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
|
||||
use codex_exec_server::ResolvedSelectedCapabilityRoot;
|
||||
@@ -457,7 +458,9 @@ where
|
||||
include_bundled_skills: false,
|
||||
include_orchestrator_skills: false,
|
||||
mcp_resources: None,
|
||||
executor_capability_discovery: None,
|
||||
executor_capability_discovery: step_store
|
||||
.get::<ExecutorCapabilityDiscoverySnapshot>()
|
||||
.map(|discovery| discovery.as_ref().clone()),
|
||||
});
|
||||
self.build_skill_tools(
|
||||
session_store,
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use codex_exec_server::FileSystemSandboxContext;
|
||||
use codex_extension_api::ExtensionMetrics;
|
||||
use codex_mcp::McpResourceClient;
|
||||
use codex_mcp::McpResourceClientCacheKey;
|
||||
@@ -138,12 +139,19 @@ impl SkillsThreadState {
|
||||
providers: &SkillProviders,
|
||||
query: SkillListQuery,
|
||||
) -> SkillCatalog {
|
||||
let sandbox_contexts = query
|
||||
.executor_capability_discovery
|
||||
.as_ref()
|
||||
.map(|discovery| discovery.sandbox_contexts().clone())
|
||||
.unwrap_or_default();
|
||||
if let Some(cached) = self
|
||||
.executor_discovery_cache
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.as_ref()
|
||||
.filter(|cached| cached.roots == query.executor_roots)
|
||||
.filter(|cached| {
|
||||
cached.roots == query.executor_roots && cached.sandbox_contexts == sandbox_contexts
|
||||
})
|
||||
{
|
||||
return cached.catalog.clone();
|
||||
}
|
||||
@@ -153,11 +161,15 @@ impl SkillsThreadState {
|
||||
.executor_discovery_cache
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(cached) = cache.as_ref().filter(|cached| cached.roots == roots) {
|
||||
if let Some(cached) = cache
|
||||
.as_ref()
|
||||
.filter(|cached| cached.roots == roots && cached.sandbox_contexts == sandbox_contexts)
|
||||
{
|
||||
return cached.catalog.clone();
|
||||
}
|
||||
*cache = Some(CachedExecutorDiscoveryCatalog {
|
||||
roots,
|
||||
sandbox_contexts,
|
||||
catalog: discovered.clone(),
|
||||
});
|
||||
discovered
|
||||
@@ -282,6 +294,7 @@ struct CachedExecutorCatalog {
|
||||
|
||||
struct CachedExecutorDiscoveryCatalog {
|
||||
roots: Vec<SelectedCapabilityRoot>,
|
||||
sandbox_contexts: HashMap<String, FileSystemSandboxContext>,
|
||||
catalog: SkillCatalog,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
@@ -365,7 +366,7 @@ async fn high_level_discovery_reuses_materialized_skill_contents_for_reads() {
|
||||
},
|
||||
}];
|
||||
let executor_capability_discovery = ExecutorCapabilityDiscoveryCache::new(manager)
|
||||
.snapshot(&executor_roots)
|
||||
.snapshot(&executor_roots, &Default::default())
|
||||
.await;
|
||||
let catalog = provider
|
||||
.list(SkillListQuery {
|
||||
@@ -403,6 +404,81 @@ async fn high_level_discovery_reuses_materialized_skill_contents_for_reads() {
|
||||
assert_eq!(read.contents, SKILL_CONTENTS);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn high_level_discovery_cache_separates_filesystem_permission_contexts() {
|
||||
let test_root = create_local_skill_root("permission-context").expect("create local skill root");
|
||||
let manager = Arc::new(EnvironmentManager::default_for_tests());
|
||||
let cache = ExecutorCapabilityDiscoveryCache::new(manager);
|
||||
let executor_roots = vec![SelectedCapabilityRoot {
|
||||
id: "permission-root".to_string(),
|
||||
location: CapabilityRootLocation::Environment {
|
||||
environment_id: "local".to_string(),
|
||||
path: PathUri::from_host_native_path(&test_root).expect("skill root URI"),
|
||||
},
|
||||
}];
|
||||
cache.snapshot(&executor_roots, &HashMap::new()).await;
|
||||
|
||||
let updated_contents =
|
||||
"---\nname: synthetic\ndescription: Updated executor skill.\n---\n\nUPDATED_BODY\n";
|
||||
std::fs::write(test_root.join("skill/SKILL.md"), updated_contents)
|
||||
.expect("update executor skill");
|
||||
let sandbox_contexts = HashMap::from([(
|
||||
"local".to_string(),
|
||||
FileSystemSandboxContext::from_permission_profile(PermissionProfile::Disabled),
|
||||
)]);
|
||||
let second_snapshot = cache.snapshot(&executor_roots, &sandbox_contexts).await;
|
||||
let second_discovery = second_snapshot.roots()[0]
|
||||
.result
|
||||
.as_ref()
|
||||
.expect("second discovery");
|
||||
|
||||
assert_eq!(
|
||||
second_discovery.skills[0].instructions.contents,
|
||||
updated_contents
|
||||
);
|
||||
assert_eq!(second_snapshot.sandbox_contexts(), &sandbox_contexts);
|
||||
|
||||
let restored_contents =
|
||||
"---\nname: synthetic\ndescription: Restored executor skill.\n---\n\nRESTORED_BODY\n";
|
||||
std::fs::write(test_root.join("skill/SKILL.md"), restored_contents)
|
||||
.expect("restore executor skill");
|
||||
let restored_snapshot = cache.snapshot(&executor_roots, &HashMap::new()).await;
|
||||
let restored_discovery = restored_snapshot.roots()[0]
|
||||
.result
|
||||
.as_ref()
|
||||
.expect("restored discovery");
|
||||
|
||||
assert_eq!(
|
||||
restored_discovery.skills[0].instructions.contents,
|
||||
restored_contents
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(test_root).expect("remove skill directory");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn high_level_discovery_batches_more_than_128_roots() {
|
||||
let test_root = create_local_skill_root("many-roots").expect("create local skill root");
|
||||
let manager = Arc::new(EnvironmentManager::default_for_tests());
|
||||
let cache = ExecutorCapabilityDiscoveryCache::new(manager);
|
||||
let executor_roots = (0..129)
|
||||
.map(|index| SelectedCapabilityRoot {
|
||||
id: format!("root-{index}"),
|
||||
location: CapabilityRootLocation::Environment {
|
||||
environment_id: "local".to_string(),
|
||||
path: PathUri::from_host_native_path(&test_root).expect("skill root URI"),
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let snapshot = cache.snapshot(&executor_roots, &HashMap::new()).await;
|
||||
|
||||
assert_eq!(snapshot.roots().len(), executor_roots.len());
|
||||
assert!(snapshot.roots().iter().all(|root| root.result.is_ok()));
|
||||
|
||||
std::fs::remove_dir_all(test_root).expect("remove skill directory");
|
||||
}
|
||||
|
||||
fn create_local_skill_root(label: &str) -> io::Result<std::path::PathBuf> {
|
||||
let id = NEXT_TEST_ROOT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let test_root = std::env::temp_dir().join(format!(
|
||||
|
||||
Reference in New Issue
Block a user