diff --git a/codex-rs/core-skills/src/injection.rs b/codex-rs/core-skills/src/injection.rs index 45979fa5c499..a358ddc6376b 100644 --- a/codex-rs/core-skills/src/injection.rs +++ b/codex-rs/core-skills/src/injection.rs @@ -28,6 +28,32 @@ pub struct SkillInjection { pub contents: String, } +/// Host skill prompts that have already been injected by an extension for this +/// turn. +/// +/// Core uses this to keep the legacy skill-injection path from sending the same +/// host `SKILL.md` body again while the skills extension is being wired in. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct InjectedHostSkillPrompts { + paths: HashSet, +} + +impl InjectedHostSkillPrompts { + pub fn insert_path(&mut self, path: impl Into) { + let path = path.into(); + self.paths.insert(normalize_host_skill_path(&path)); + self.paths.insert(path); + } + + pub fn is_empty(&self) -> bool { + self.paths.is_empty() + } + + pub fn contains_path(&self, path: &str) -> bool { + self.paths.contains(path) || self.paths.contains(&normalize_host_skill_path(path)) + } +} + pub async fn build_skill_injections( mentioned_skills: &[SkillMetadata], loaded_skills: Option<&SkillLoadOutcome>, @@ -85,6 +111,10 @@ pub async fn build_skill_injections( result } +fn normalize_host_skill_path(path: &str) -> String { + normalize_skill_path(path).replace('\\', "/") +} + fn emit_skill_injected_metric( otel: Option<&SessionTelemetry>, skill: &SkillMetadata, diff --git a/codex-rs/core-skills/src/lib.rs b/codex-rs/core-skills/src/lib.rs index e4a61697d143..0390302afdfe 100644 --- a/codex-rs/core-skills/src/lib.rs +++ b/codex-rs/core-skills/src/lib.rs @@ -15,6 +15,7 @@ pub use invocation_utils::detect_implicit_skill_invocation_for_command; pub use manager::SkillsLoadInput; pub use manager::SkillsManager; pub use mention_counts::build_skill_name_counts; +pub use model::HostLoadedSkills; pub use model::SkillError; pub use model::SkillLoadOutcome; pub use model::SkillMetadata; diff --git a/codex-rs/core-skills/src/model.rs b/codex-rs/core-skills/src/model.rs index fc8e9f5917d0..a9cc24306aae 100644 --- a/codex-rs/core-skills/src/model.rs +++ b/codex-rs/core-skills/src/model.rs @@ -1,9 +1,11 @@ use std::collections::HashMap; use std::collections::HashSet; use std::fmt; +use std::io; use std::sync::Arc; use codex_exec_server::ExecutorFileSystem; +use codex_exec_server::LOCAL_FS; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; @@ -23,7 +25,7 @@ pub struct SkillMetadata { } impl SkillMetadata { - fn allow_implicit_invocation(&self) -> bool { + pub fn allows_implicit_invocation(&self) -> bool { self.policy .as_ref() .and_then(|policy| policy.allow_implicit_invocation) @@ -103,7 +105,7 @@ impl SkillLoadOutcome { } pub fn is_skill_allowed_for_implicit_invocation(&self, skill: &SkillMetadata) -> bool { - self.is_skill_enabled(skill) && skill.allow_implicit_invocation() + self.is_skill_enabled(skill) && skill.allows_implicit_invocation() } pub fn allowed_skills_for_implicit_invocation(&self) -> Vec { @@ -129,6 +131,32 @@ impl SkillLoadOutcome { } } +/// Host-loaded skills for one turn, including the filesystem mapping needed to +/// read skill bodies through the environment that loaded them. +#[derive(Debug, Clone)] +pub struct HostLoadedSkills { + outcome: Arc, +} + +impl HostLoadedSkills { + pub fn new(outcome: Arc) -> Self { + Self { outcome } + } + + pub fn outcome(&self) -> &SkillLoadOutcome { + self.outcome.as_ref() + } + + pub async fn read_skill_text(&self, skill: &SkillMetadata) -> io::Result { + let fs = self + .outcome + .file_system_for_skill(skill) + .unwrap_or_else(|| Arc::clone(&LOCAL_FS)); + fs.read_file_text(&skill.path_to_skills_md, /*sandbox*/ None) + .await + } +} + #[derive(Clone, Default)] pub(crate) struct SkillFileSystemsByPath { values: Arc>>, diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index fe78e033ec3a..4df5cbbf2783 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -1,4 +1,5 @@ use super::*; +use codex_core_skills::HostLoadedSkills; use codex_protocol::openai_models::ToolMode; use std::sync::atomic::AtomicBool; @@ -100,6 +101,13 @@ pub(super) async fn spawn_review_thread( parent_turn_context.network.is_some(), )); + let extension_data = Arc::new(codex_extension_api::ExtensionData::new( + review_turn_id.clone(), + )); + extension_data.insert(HostLoadedSkills::new( + parent_turn_context.turn_skills.outcome.clone(), + )); + let review_turn_context = TurnContext { sub_id: review_turn_id.clone(), trace_id: current_span_trace_id(), @@ -143,7 +151,7 @@ pub(super) async fn spawn_review_thread( dynamic_tools: parent_turn_context.dynamic_tools.clone(), truncation_policy: model_info.truncation_policy.into(), turn_metadata_state, - extension_data: Arc::new(codex_extension_api::ExtensionData::new(review_turn_id)), + extension_data, turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.outcome.clone()), turn_timing_state: Arc::new(TurnTimingState::default()), server_model_warning_emitted: AtomicBool::new(false), diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 42d609561e71..2765de76fb23 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -69,6 +69,7 @@ use codex_analytics::InvocationType; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_async_utils::OrCancelExt; +use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_extension_api::TurnInputContext; use codex_extension_api::TurnInputEnvironment; use codex_features::Feature; @@ -535,6 +536,9 @@ async fn build_skills_and_plugins( ) .await; + let injected_host_skill_prompts = turn_context + .extension_data + .get::(); let SkillInjections { items: skill_injections, warnings: skill_warnings, @@ -591,7 +595,16 @@ async fn build_skills_and_plugins( .track_plugin_used(tracking.clone(), plugin); } - let mut injection_items = skill_items; + let mut injection_items: Vec = match injected_host_skill_prompts { + Some(injected_host_skill_prompts) => skill_injections + .iter() + .filter(|skill| !injected_host_skill_prompts.contains_path(&skill.path)) + .map(|skill| { + ContextualUserFragment::into(crate::context::SkillInstructions::from(skill)) + }) + .collect(), + None => skill_items, + }; injection_items.extend(plugin_items); injection_items.extend(extension_injection_items); Some((injection_items, explicitly_enabled_connectors)) diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index d60036813773..0a3cbf6472b5 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -2,6 +2,7 @@ use super::*; use crate::SkillLoadOutcome; use crate::config::GhostSnapshotConfig; use crate::environment_selection::ResolvedTurnEnvironments; +use codex_core_skills::HostLoadedSkills; use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; use codex_protocol::SessionId; @@ -530,6 +531,7 @@ impl Session { )); let (current_date, timezone) = local_time_context(); let extension_data = Arc::new(codex_extension_api::ExtensionData::new(sub_id.clone())); + extension_data.insert(HostLoadedSkills::new(Arc::clone(&skills_outcome))); TurnContext { sub_id, trace_id: current_span_trace_id(), diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index 14ccb6890972..8706e8ee7a38 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -89,7 +89,6 @@ pub trait TurnLifecycleContributor: Send + Sync { async fn on_turn_error(&self, _input: TurnErrorInput<'_>) {} } -/// WARNING: DO NOT USE YET /// Extension contribution that can add turn-local model input. /// /// Implementations should resolve only the model-visible input they own and diff --git a/codex-rs/ext/skills/src/extension.rs b/codex-rs/ext/skills/src/extension.rs index 85ff513acfc2..3961d7d85dcc 100644 --- a/codex-rs/ext/skills/src/extension.rs +++ b/codex-rs/ext/skills/src/extension.rs @@ -1,7 +1,9 @@ use std::sync::Arc; use codex_core::config::Config; +use codex_core_skills::HostLoadedSkills; use codex_core_skills::SkillInstructions; +use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_core_skills::injection::SkillInjection; use codex_extension_api::ConfigContributor; use codex_extension_api::ContextualUserFragment; @@ -20,6 +22,7 @@ use crate::catalog::SkillAuthority; use crate::catalog::SkillCatalogEntry; use crate::catalog::SkillReadResult; use crate::catalog::SkillSourceKind; +use crate::provider::HostSkillProvider; use crate::provider::SkillListQuery; use crate::provider::SkillReadRequest; use crate::render::available_skills_fragment; @@ -78,6 +81,7 @@ impl TurnInputContributor for SkillsExtension { }; let config = thread_state.config(); + let host_loaded_skills = turn_store.get::(); let query = SkillListQuery { turn_id: input.turn_id.clone(), executor_authorities: input @@ -90,6 +94,7 @@ impl TurnInputContributor for SkillsExtension { ) }) .collect(), + host: host_loaded_skills.clone(), include_host_skills: true, include_bundled_skills: config.bundled_skills_enabled, include_remote_skills: true, @@ -109,8 +114,12 @@ impl TurnInputContributor for SkillsExtension { let mut warnings = catalog.warnings.clone(); let mut main_prompts_injected = false; + let mut injected_host_skill_prompts = InjectedHostSkillPrompts::default(); for entry in &selected_entries { - match self.read_main_prompt(entry).await { + match self + .read_main_prompt(entry, host_loaded_skills.clone()) + .await + { Ok(read_result) => { let (contents, truncated) = truncate_main_prompt_contents(read_result.contents.as_str()); @@ -129,6 +138,9 @@ impl TurnInputContributor for SkillsExtension { }; fragments.push(Box::new(SkillInstructions::from(&injection))); main_prompts_injected = true; + if entry.authority.kind == SkillSourceKind::Host { + injected_host_skill_prompts.insert_path(entry.main_prompt.0.clone()); + } } Err(message) => { let warning = format!("Failed to load skill `{}`: {message}", entry.name); @@ -144,18 +156,26 @@ impl TurnInputContributor for SkillsExtension { warnings, main_prompts_injected, }); + if !injected_host_skill_prompts.is_empty() { + turn_store.insert(injected_host_skill_prompts); + } fragments } } impl SkillsExtension { - async fn read_main_prompt(&self, entry: &SkillCatalogEntry) -> Result { + async fn read_main_prompt( + &self, + entry: &SkillCatalogEntry, + host_loaded_skills: Option>, + ) -> Result { self.providers .read(SkillReadRequest { authority: entry.authority.clone(), package: entry.id.clone(), resource: entry.main_prompt.clone(), + host: host_loaded_skills, }) .await .map_err(|err| err.message) @@ -170,7 +190,10 @@ impl SkillsExtension { } pub fn install(registry: &mut ExtensionRegistryBuilder) { - install_with_providers(registry, SkillProviders::default()); + install_with_providers( + registry, + SkillProviders::new().with_host_provider(Arc::new(HostSkillProvider::new())), + ); } pub fn install_with_providers( diff --git a/codex-rs/ext/skills/src/lib.rs b/codex-rs/ext/skills/src/lib.rs index b5e224d49b22..3a28c5fbdf9d 100644 --- a/codex-rs/ext/skills/src/lib.rs +++ b/codex-rs/ext/skills/src/lib.rs @@ -8,5 +8,6 @@ mod state; pub use extension::install; pub use extension::install_with_providers; +pub use provider::HostSkillProvider; pub use sources::SkillProviderSource; pub use sources::SkillProviders; diff --git a/codex-rs/ext/skills/src/provider.rs b/codex-rs/ext/skills/src/provider.rs index f9c1b41feaf0..a968774afd46 100644 --- a/codex-rs/ext/skills/src/provider.rs +++ b/codex-rs/ext/skills/src/provider.rs @@ -1,5 +1,10 @@ use std::future::Future; use std::pin::Pin; +use std::sync::Arc; + +mod host; + +use codex_core_skills::HostLoadedSkills; use crate::catalog::SkillAuthority; use crate::catalog::SkillCatalog; @@ -9,20 +14,24 @@ use crate::catalog::SkillReadResult; use crate::catalog::SkillResourceId; use crate::catalog::SkillSearchResult; -#[derive(Clone, Debug, PartialEq, Eq)] +pub use host::HostSkillProvider; + +#[derive(Clone, Debug)] pub struct SkillListQuery { pub turn_id: String, pub executor_authorities: Vec, + pub host: Option>, pub include_host_skills: bool, pub include_bundled_skills: bool, pub include_remote_skills: bool, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug)] pub struct SkillReadRequest { pub authority: SkillAuthority, pub package: SkillPackageId, pub resource: SkillResourceId, + pub host: Option>, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/codex-rs/ext/skills/src/provider/host.rs b/codex-rs/ext/skills/src/provider/host.rs new file mode 100644 index 000000000000..27293aa1d50f --- /dev/null +++ b/codex-rs/ext/skills/src/provider/host.rs @@ -0,0 +1,134 @@ +use codex_core_skills::SkillLoadOutcome; +use codex_core_skills::SkillMetadata; + +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; + +const HOST_AUTHORITY_ID: &str = "host"; + +/// Host-owned skill provider backed by the already-loaded turn skills. +/// +/// The provider intentionally does not reload or cache host skills. Core owns +/// skill loading, including plugin roots, runtime extra roots, and the primary +/// environment filesystem. This adapter only maps that loaded outcome into the +/// skills-extension catalog/read contract. +#[derive(Clone, Default)] +pub struct HostSkillProvider; + +impl HostSkillProvider { + pub fn new() -> Self { + Self + } +} + +impl SkillProvider for HostSkillProvider { + fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> { + Box::pin(async move { + let Some(host_loaded_skills) = query.host else { + return Err(SkillProviderError::new( + "host skill provider requires loaded host skills", + )); + }; + + Ok(catalog_from_outcome(host_loaded_skills.outcome())) + }) + } + + fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult> { + Box::pin(async move { + let Some(host_loaded_skills) = request.host else { + return Err(SkillProviderError::new( + "host skill provider requires loaded host skills", + )); + }; + 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 + }) else { + return Err(SkillProviderError::new(format!( + "host skill resource is not loaded: {}", + request.resource.0 + ))); + }; + + let contents = host_loaded_skills + .read_skill_text(skill) + .await + .map_err(|err| { + SkillProviderError::new(format!( + "failed to read host skill resource {}: {err}", + request.resource.0 + )) + })?; + + Ok(SkillReadResult { + resource: request.resource, + contents, + }) + }) + } + + fn search(&self, _request: SkillSearchRequest) -> SkillProviderFuture<'_, SkillSearchResult> { + Box::pin(async { Ok(SkillSearchResult::default()) }) + } +} + +fn catalog_from_outcome(outcome: &SkillLoadOutcome) -> SkillCatalog { + let mut catalog = SkillCatalog { + entries: Vec::new(), + warnings: outcome + .errors + .iter() + .map(|err| { + format!( + "Failed to load skill at {}: {}", + err.path.display(), + err.message + ) + }) + .collect(), + }; + + for (skill, enabled) in outcome.skills_with_enabled() { + catalog.push_entry(catalog_entry_from_skill(skill, enabled)); + } + + catalog +} + +fn catalog_entry_from_skill(skill: &SkillMetadata, enabled: bool) -> SkillCatalogEntry { + let skill_path = skill.path_to_skills_md.to_string_lossy().into_owned(); + let display_path = skill_path.replace('\\', "/"); + let mut entry = SkillCatalogEntry::new( + SkillPackageId(skill_path.clone()), + SkillAuthority::new(SkillSourceKind::Host, HOST_AUTHORITY_ID), + skill.name.clone(), + skill.description.clone(), + SkillResourceId(skill_path), + ) + .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 +} diff --git a/codex-rs/ext/skills/tests/skills_extension.rs b/codex-rs/ext/skills/tests/skills_extension.rs index acdb5587be99..1e2a2f0b60c3 100644 --- a/codex-rs/ext/skills/tests/skills_extension.rs +++ b/codex-rs/ext/skills/tests/skills_extension.rs @@ -1,9 +1,15 @@ +use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; use codex_core::config::Config; +use codex_core::config::ConfigBuilder; +use codex_core_skills::HostLoadedSkills; +use codex_core_skills::SkillsLoadInput; +use codex_core_skills::SkillsManager; +use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::ThreadStartInput; @@ -21,6 +27,7 @@ use codex_skills_extension::catalog::SkillReadResult; use codex_skills_extension::catalog::SkillResourceId; use codex_skills_extension::catalog::SkillSearchResult; use codex_skills_extension::catalog::SkillSourceKind; +use codex_skills_extension::install; use codex_skills_extension::install_with_providers; use codex_skills_extension::provider::SkillListQuery; use codex_skills_extension::provider::SkillProvider; @@ -33,6 +40,93 @@ type TestResult = Result<(), Box>; static NEXT_CODEX_HOME_ID: AtomicUsize = AtomicUsize::new(0); +#[tokio::test] +async fn installed_extension_loads_host_skills_from_legacy_roots() -> TestResult { + let codex_home = test_codex_home(); + let skill_path = codex_home.join("skills").join("demo").join("SKILL.md"); + std::fs::create_dir_all( + skill_path + .parent() + .ok_or("skill path should have a parent")?, + )?; + std::fs::write( + &skill_path, + "---\nname: demo\ndescription: Demo skill.\n---\n# Demo\n\nUse the demo skill.\n", + )?; + let config = ConfigBuilder::default() + .codex_home(codex_home.clone()) + .fallback_cwd(Some(codex_home.clone())) + .build() + .await?; + + let mut builder = ExtensionRegistryBuilder::new(); + install(&mut builder); + let registry = builder.build(); + let session_store = ExtensionData::new("session"); + let thread_store = ExtensionData::new("thread"); + let session_source = SessionSource::Cli; + registry.thread_lifecycle_contributors()[0] + .on_thread_start(ThreadStartInput { + config: &config, + session_source: &session_source, + persistent_thread_state_available: true, + session_store: &session_store, + thread_store: &thread_store, + }) + .await; + + let manager = SkillsManager::new(config.codex_home.clone(), config.bundled_skills_enabled()); + let input = SkillsLoadInput::new( + config.cwd.clone(), + Vec::new(), + config.config_layer_stack.clone(), + config.bundled_skills_enabled(), + ); + let loaded_skills = Arc::new(manager.skills_for_config(&input, /*fs*/ None).await); + let skill_path_string = loaded_skills + .skills + .iter() + .find(|skill| skill.name == "demo") + .ok_or("demo skill should load")? + .path_to_skills_md + .to_string_lossy() + .into_owned(); + let skill_prompt_path = skill_path_string.replace('\\', "/"); + let turn_store = ExtensionData::new("turn-1"); + turn_store.insert(HostLoadedSkills::new(Arc::clone(&loaded_skills))); + + let fragments = registry.turn_input_contributors()[0] + .contribute( + TurnInputContext { + turn_id: "turn-1".to_string(), + user_input: vec![UserInput::Text { + text: "$demo".to_string(), + text_elements: Vec::new(), + }], + environments: Vec::new(), + }, + &session_store, + &thread_store, + &turn_store, + ) + .await; + + assert_eq!(2, fragments.len()); + assert!(fragments[0].render().contains("demo")); + assert!(fragments[0].render().contains(&skill_prompt_path)); + assert_eq!("user", fragments[1].role()); + assert!(fragments[1].render().contains("demo")); + assert!(fragments[1].render().contains("# Demo")); + assert!(fragments[1].render().contains(&skill_prompt_path)); + let injected_host_skill_prompts = turn_store + .get::() + .ok_or("host skill prompt marker should be set")?; + assert!(injected_host_skill_prompts.contains_path(&skill_path_string)); + + std::fs::remove_dir_all(codex_home)?; + Ok(()) +} + #[tokio::test] async fn installed_extension_injects_available_catalog_and_selected_entrypoint() -> TestResult { let host_read_requests = Arc::new(Mutex::new(Vec::new())); @@ -115,15 +209,12 @@ async fn installed_extension_injects_available_catalog_and_selected_entrypoint() assert!(fragments[1].render().contains("lint-fix")); assert!(fragments[1].render().contains("# Lint Fix")); assert_eq!( - vec![SkillReadRequest { - authority: SkillAuthority::new(SkillSourceKind::Host, "host"), - package: SkillPackageId("host/lint-fix".to_string()), - resource: SkillResourceId("lint-fix/SKILL.md".to_string()), - }], - host_read_requests - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() + vec![( + SkillAuthority::new(SkillSourceKind::Host, "host"), + SkillPackageId("host/lint-fix".to_string()), + SkillResourceId("lint-fix/SKILL.md".to_string()), + )], + read_request_keys(&host_read_requests) ); assert!( remote_read_requests @@ -220,15 +311,12 @@ async fn prompt_hidden_skill_can_still_be_invoked() -> TestResult { assert!(!catalog_fragment.contains("hidden-skill")); assert!(fragments[1].render().contains("hidden-skill")); assert_eq!( - vec![SkillReadRequest { - authority: SkillAuthority::new(SkillSourceKind::Host, "host"), - package: SkillPackageId("host/hidden-skill".to_string()), - resource: SkillResourceId("hidden-skill/SKILL.md".to_string()), - }], - read_requests - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() + vec![( + SkillAuthority::new(SkillSourceKind::Host, "host"), + SkillPackageId("host/hidden-skill".to_string()), + SkillResourceId("hidden-skill/SKILL.md".to_string()), + )], + read_request_keys(&read_requests) ); Ok(()) @@ -287,14 +375,35 @@ fn test_entry( } async fn default_config() -> std::io::Result { - let id = NEXT_CODEX_HOME_ID.fetch_add(1, Ordering::Relaxed); - let codex_home = std::env::temp_dir().join(format!( - "codex-skills-extension-test-{}-{id}", - std::process::id(), - )); + let codex_home = test_codex_home(); std::fs::create_dir_all(&codex_home)?; let config = Config::load_default_with_cli_overrides_for_codex_home(codex_home.clone(), vec![]).await?; std::fs::remove_dir_all(codex_home)?; Ok(config) } + +fn test_codex_home() -> PathBuf { + let id = NEXT_CODEX_HOME_ID.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "codex-skills-extension-test-{}-{id}", + std::process::id(), + )) +} + +fn read_request_keys( + requests: &Arc>>, +) -> Vec<(SkillAuthority, SkillPackageId, SkillResourceId)> { + requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .map(|request| { + ( + request.authority.clone(), + request.package.clone(), + request.resource.clone(), + ) + }) + .collect() +}