mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
Add frozen cloud skill snapshots to the app server (#55631)
## What changed - Add `--cloud-skills-snapshot PATH` to load a cloud skill catalog and resource contents from JSON once at app-server startup, without contacting a live skill service. - Expose snapshot skills through the native `skills.list` and `skills.read` interface, preserving aliases and prompt visibility. Reads remain available after the snapshot file is removed. - Validate package boundaries, required resources, duplicate packages, and size limits; fail startup for missing or invalid snapshots. - Keep frozen cloud catalog and read caches independent of live MCP resource changes. ## Testing Add provider, cache, app-server, and core scenario tests covering validation, exact resource contents, authority boundaries, startup failures, and listing and reading frozen skills alongside a named executor. GitOrigin-RevId: d438a5d678121db3e17515fc5edcd00d4e20b900
This commit is contained in:
committed by
copyberry
parent
48b6a9d8e0
commit
e776b27eb0
@@ -41,6 +41,7 @@ pub(crate) struct ThreadExtensionDependencies {
|
||||
pub(crate) goal_service: Arc<GoalService>,
|
||||
pub(crate) environment_manager: Arc<EnvironmentManager>,
|
||||
pub(crate) executor_skill_provider: Arc<dyn codex_skills_extension::SkillProvider>,
|
||||
pub(crate) cloud_skill_provider: Option<Arc<dyn codex_skills_extension::SkillProvider>>,
|
||||
pub(crate) git_attribution_base_url: String,
|
||||
pub(crate) http_client_factory: HttpClientFactory,
|
||||
/// Process-scoped queue shared by idle dispatch and app-server requests.
|
||||
@@ -62,6 +63,7 @@ pub(crate) fn thread_extensions(
|
||||
goal_service,
|
||||
environment_manager,
|
||||
executor_skill_provider,
|
||||
cloud_skill_provider,
|
||||
git_attribution_base_url,
|
||||
http_client_factory,
|
||||
queue_service,
|
||||
@@ -110,9 +112,12 @@ pub(crate) fn thread_extensions(
|
||||
codex_image_generation_extension::install(&mut builder, auth_manager, |config: &Config| {
|
||||
Some(config.codex_home.clone())
|
||||
});
|
||||
let skill_providers = codex_skills_extension::SkillProviders::new()
|
||||
let mut skill_providers = codex_skills_extension::SkillProviders::new()
|
||||
.with_executor_provider(executor_skill_provider)
|
||||
.with_host_provider(Arc::new(codex_skills_extension::HostSkillProvider::new()));
|
||||
if let Some(provider) = cloud_skill_provider {
|
||||
skill_providers = skill_providers.with_cloud_provider(provider);
|
||||
}
|
||||
codex_skills_extension::install_with_providers_and_metrics(
|
||||
&mut builder,
|
||||
skill_providers,
|
||||
|
||||
@@ -498,6 +498,7 @@ async fn start_uninitialized(mut args: InProcessStartArgs) -> IoResult<InProcess
|
||||
let (processor_tx, mut processor_rx) = mpsc::channel::<ProcessorCommand>(channel_capacity);
|
||||
let mut processor_handle = tokio::spawn(async move {
|
||||
let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs {
|
||||
cloud_skill_provider: None,
|
||||
outgoing: Arc::clone(&processor_outgoing),
|
||||
analytics_events_client,
|
||||
arg0_paths: args.arg0_paths,
|
||||
|
||||
@@ -480,6 +480,8 @@ pub enum PluginStartupTasks {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AppServerRuntimeOptions {
|
||||
/// Optional host-owned frozen cloud skill catalog and resources, loaded once at startup.
|
||||
pub cloud_skills_snapshot: Option<std::path::PathBuf>,
|
||||
pub code_mode_host_transport: CodeModeHostTransport,
|
||||
pub plugin_startup_tasks: PluginStartupTasks,
|
||||
pub remote_control_startup_mode: RemoteControlStartupMode,
|
||||
@@ -490,6 +492,7 @@ pub struct AppServerRuntimeOptions {
|
||||
impl Default for AppServerRuntimeOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cloud_skills_snapshot: None,
|
||||
code_mode_host_transport: CodeModeHostTransport::Local,
|
||||
plugin_startup_tasks: PluginStartupTasks::Start,
|
||||
remote_control_startup_mode: RemoteControlStartupMode::ResolvePersisted,
|
||||
@@ -511,6 +514,18 @@ pub async fn run_main_with_transport_options(
|
||||
auth: WebsocketAuthSettings,
|
||||
runtime_options: AppServerRuntimeOptions,
|
||||
) -> IoResult<AppServerExit> {
|
||||
let cloud_skill_provider = match runtime_options.cloud_skills_snapshot {
|
||||
Some(path) => {
|
||||
let provider = tokio::task::spawn_blocking(move || {
|
||||
codex_skills_extension::FrozenCloudSkillProvider::from_file(&path)
|
||||
})
|
||||
.await
|
||||
.map_err(std::io::Error::other)?
|
||||
.map_err(|err| std::io::Error::new(ErrorKind::InvalidInput, err))?;
|
||||
Some(Arc::new(provider) as Arc<dyn codex_skills_extension::SkillProvider>)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
let _registered_core = codex_windows_sandbox::registered_core_requested();
|
||||
let managed_daemon = matches!(&transport, AppServerTransport::UnixSocket { .. })
|
||||
@@ -992,6 +1007,7 @@ pub async fn run_main_with_transport_options(
|
||||
let initialize_notification_sender = outgoing_message_sender.clone();
|
||||
let outbound_control_tx = outbound_control_tx;
|
||||
let mut processor = MessageProcessor::new(MessageProcessorArgs {
|
||||
cloud_skill_provider,
|
||||
outgoing: outgoing_message_sender,
|
||||
analytics_events_client,
|
||||
arg0_paths,
|
||||
|
||||
@@ -30,6 +30,9 @@ const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_C
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(version)]
|
||||
struct AppServerArgs {
|
||||
/// Load frozen cloud skills from a JSON snapshot instead of a live skill service.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
cloud_skills_snapshot: Option<PathBuf>,
|
||||
#[command(flatten)]
|
||||
config_overrides: CliConfigOverrides,
|
||||
|
||||
@@ -80,6 +83,7 @@ fn main() -> anyhow::Result<()> {
|
||||
let remote_control_disabled = codex_app_server::take_remote_control_disabled_env();
|
||||
arg0_dispatch_or_else(move |arg0_paths: Arg0DispatchPaths| async move {
|
||||
let AppServerArgs {
|
||||
cloud_skills_snapshot,
|
||||
config_overrides,
|
||||
code_mode_host,
|
||||
listen,
|
||||
@@ -101,6 +105,7 @@ fn main() -> anyhow::Result<()> {
|
||||
let transport = listen;
|
||||
let auth = auth.try_into_settings()?;
|
||||
let mut runtime_options = AppServerRuntimeOptions {
|
||||
cloud_skills_snapshot,
|
||||
code_mode_host_transport: code_mode_host.into(),
|
||||
managed_daemon,
|
||||
..Default::default()
|
||||
|
||||
@@ -525,6 +525,7 @@ enabled = false
|
||||
sample_responses: Arc::new(
|
||||
crate::thread_sample::SampleResponseRegistry::default(),
|
||||
),
|
||||
cloud_skill_provider: None,
|
||||
event_sink: Arc::new(NoopExtensionEventSink),
|
||||
auth_manager: auth_manager.clone(),
|
||||
state_db: Some(state_db.clone()),
|
||||
|
||||
@@ -249,6 +249,7 @@ impl ConnectionSessionState {
|
||||
}
|
||||
|
||||
pub(crate) struct MessageProcessorArgs {
|
||||
pub(crate) cloud_skill_provider: Option<Arc<dyn codex_skills_extension::SkillProvider>>,
|
||||
pub(crate) outgoing: Arc<OutgoingMessageSender>,
|
||||
pub(crate) analytics_events_client: AnalyticsEventsClient,
|
||||
pub(crate) arg0_paths: Arg0DispatchPaths,
|
||||
@@ -277,6 +278,7 @@ impl MessageProcessor {
|
||||
/// `Sender` so handlers can enqueue messages to be written to stdout.
|
||||
pub(crate) fn new(args: MessageProcessorArgs) -> Self {
|
||||
let MessageProcessorArgs {
|
||||
cloud_skill_provider,
|
||||
outgoing,
|
||||
analytics_events_client,
|
||||
arg0_paths,
|
||||
@@ -358,6 +360,7 @@ impl MessageProcessor {
|
||||
goal_service: Arc::clone(&goal_service),
|
||||
environment_manager: Arc::clone(&environment_manager_for_extensions),
|
||||
executor_skill_provider: Arc::clone(&executor_skill_provider),
|
||||
cloud_skill_provider: cloud_skill_provider.clone(),
|
||||
git_attribution_base_url: config.chatgpt_base_url.clone(),
|
||||
http_client_factory: config.http_client_factory(),
|
||||
queue_service: queue_service.clone(),
|
||||
|
||||
@@ -261,6 +261,7 @@ pub(super) async fn build_test_processor(
|
||||
analytics_events_client.clone(),
|
||||
));
|
||||
let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs {
|
||||
cloud_skill_provider: None,
|
||||
outgoing,
|
||||
analytics_events_client,
|
||||
arg0_paths: Arg0DispatchPaths::default(),
|
||||
|
||||
@@ -64,6 +64,7 @@ async fn background_routing_call_sites_skip_config(api_key: Option<&str>) -> any
|
||||
analytics_events_client.clone(),
|
||||
));
|
||||
let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs {
|
||||
cloud_skill_provider: None,
|
||||
outgoing,
|
||||
analytics_events_client,
|
||||
arg0_paths: Arg0DispatchPaths::default(),
|
||||
|
||||
@@ -154,6 +154,7 @@ impl Harness {
|
||||
AnalyticsEventsClient::disabled(),
|
||||
));
|
||||
let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs {
|
||||
cloud_skill_provider: None,
|
||||
outgoing: Arc::clone(&outgoing),
|
||||
analytics_events_client: AnalyticsEventsClient::disabled(),
|
||||
arg0_paths: Arg0DispatchPaths::default(),
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Frozen cloud sources use the native catalog and read handler without an MCP server.
|
||||
|
||||
use anyhow::Result;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::test]
|
||||
async fn frozen_cloud_catalog_and_read_use_native_skills_interface() -> Result<()> {
|
||||
let server = responses::start_mock_server().await;
|
||||
let responses = responses::mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("read-skill"),
|
||||
responses::ev_function_call_with_namespace(
|
||||
"read",
|
||||
"skills",
|
||||
"read",
|
||||
&json!({"package": "c0/work"}).to_string(),
|
||||
),
|
||||
responses::ev_completed("read-skill"),
|
||||
]),
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("done"),
|
||||
responses::ev_assistant_message("answer", "Read it."),
|
||||
responses::ev_completed("done"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let home = tempfile::tempdir()?;
|
||||
let snapshot = home.path().join("cloud skills.json");
|
||||
std::fs::write(
|
||||
&snapshot,
|
||||
json!({"skills": [{
|
||||
"package": "skill://demo/work", "name": "demo:work",
|
||||
"description": "Do the work.", "alias_root": "skill://demo",
|
||||
"main_resource": "skill://demo/work/SKILL.md",
|
||||
"resources": {"skill://demo/work/SKILL.md": "# Work\nExact frozen contents.\n"}
|
||||
}]})
|
||||
.to_string(),
|
||||
)?;
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
format!(
|
||||
r#"
|
||||
model = "mock-model"
|
||||
model_provider = "mock_provider"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "read-only"
|
||||
[skills]
|
||||
include_instructions = true
|
||||
[skills.bundled]
|
||||
enabled = false
|
||||
[cloud.skills]
|
||||
enabled = true
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider"
|
||||
base_url = "{}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
"#,
|
||||
server.uri(),
|
||||
),
|
||||
)?;
|
||||
let mut app = TestAppServer::builder()
|
||||
.with_codex_home(home.path())
|
||||
// Cloud parents have no local executor; local threads intentionally hide cloud skills.
|
||||
.without_auto_env()
|
||||
.with_args(&[
|
||||
"--cloud-skills-snapshot",
|
||||
snapshot.to_str().expect("snapshot path must be UTF-8"),
|
||||
])
|
||||
.build()
|
||||
.await?;
|
||||
app.initialize().await?;
|
||||
// The process owns a frozen snapshot even if its source file later disappears.
|
||||
std::fs::remove_file(snapshot)?;
|
||||
let id = app
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
environments: Some(Vec::new()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let response = app
|
||||
.read_stream_until_response_message(RequestId::Integer(id))
|
||||
.await?;
|
||||
let ThreadStartResponse { thread, .. } = to_response(response)?;
|
||||
app.start_turn_and_wait_for_completion(TurnStartParams {
|
||||
thread_id: thread.id,
|
||||
input: vec![UserInput::Text {
|
||||
text: "Read the work skill.".into(),
|
||||
text_elements: vec![],
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let requests = responses.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let catalog = requests[0].message_input_texts("developer").join("\n");
|
||||
assert!(catalog.contains("- `c0` = `skill://demo`"));
|
||||
assert!(catalog.contains("(cloud package: c0/work)"));
|
||||
let result: serde_json::Value = serde_json::from_str(
|
||||
&requests[1]
|
||||
.function_call_output_text("read")
|
||||
.expect("native read output"),
|
||||
)?;
|
||||
assert_eq!(
|
||||
result,
|
||||
json!({
|
||||
"resource": "skill://demo/work/SKILL.md",
|
||||
"contents": "# Work\nExact frozen contents.\n",
|
||||
"next_cursor": null,
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case::test_case(None; "missing snapshot")]
|
||||
#[test_case::test_case(Some("not JSON"); "invalid snapshot")]
|
||||
#[tokio::test]
|
||||
async fn frozen_cloud_snapshot_errors_fail_startup(contents: Option<&str>) -> Result<()> {
|
||||
let home = tempfile::tempdir()?;
|
||||
let snapshot = home.path().join("cloud-skills.json");
|
||||
if let Some(contents) = contents {
|
||||
std::fs::write(&snapshot, contents)?;
|
||||
}
|
||||
let mut app = TestAppServer::builder()
|
||||
.with_codex_home(home.path())
|
||||
.without_auto_env()
|
||||
.with_args(&[
|
||||
"--cloud-skills-snapshot",
|
||||
snapshot.to_str().expect("snapshot path must be UTF-8"),
|
||||
])
|
||||
.build()
|
||||
.await?;
|
||||
assert!(app.initialize().await.is_err());
|
||||
Ok(())
|
||||
}
|
||||
@@ -62,6 +62,7 @@ mod experimental_feature_list;
|
||||
mod external_agent_config;
|
||||
mod external_agent_import_sync;
|
||||
mod feedback;
|
||||
mod frozen_cloud_skills;
|
||||
mod fs;
|
||||
#[path = "gateway_oauth_tests.rs"]
|
||||
mod gateway_oauth;
|
||||
|
||||
@@ -573,6 +573,9 @@ struct AppServerCommand {
|
||||
#[command(flatten)]
|
||||
startup_config: codex_app_server::StartupConfigArgs,
|
||||
|
||||
/// Load frozen cloud skills from a JSON snapshot instead of a live skill service.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
cloud_skills_snapshot: Option<PathBuf>,
|
||||
/// Omit to run the app server; specify a subcommand for tooling.
|
||||
#[command(subcommand)]
|
||||
subcommand: Option<AppServerSubcommand>,
|
||||
@@ -1241,6 +1244,7 @@ async fn cli_main(
|
||||
Some(Subcommand::AppServer(app_server_cli)) => {
|
||||
let AppServerCommand {
|
||||
startup_config,
|
||||
cloud_skills_snapshot,
|
||||
subcommand,
|
||||
code_mode_host,
|
||||
strict_config: app_server_strict_config,
|
||||
@@ -1282,6 +1286,7 @@ async fn cli_main(
|
||||
};
|
||||
let auth = auth.try_into_settings()?;
|
||||
let runtime_options = codex_app_server::AppServerRuntimeOptions {
|
||||
cloud_skills_snapshot,
|
||||
code_mode_host_transport: code_mode_host.into(),
|
||||
managed_daemon,
|
||||
remote_control_startup_mode: match (remote_control, remote_control_disabled)
|
||||
|
||||
@@ -184,6 +184,9 @@ mod tools_namespace_budget;
|
||||
#[path = "scenarios_skill_catalog_dedup.rs"]
|
||||
mod skill_catalog_dedup;
|
||||
|
||||
#[path = "scenarios_frozen_cloud_skills.rs"]
|
||||
mod frozen_cloud_skills;
|
||||
|
||||
#[path = "scenarios_compaction_tests.rs"]
|
||||
mod compaction;
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Frozen cloud skills remain available alongside a named executor and use native skill tools.
|
||||
|
||||
use super::*;
|
||||
use codex_skills_extension::FrozenCloudSkillProvider;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn frozen_cloud_skills_with_named_executor_request_history() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let workspace = tempfile::tempdir()?;
|
||||
let path = workspace.path().join("skills.json");
|
||||
fs::write(
|
||||
&path,
|
||||
json!({"skills": [{
|
||||
"package": "skill://demo/work", "name": "demo:work",
|
||||
"description": "Inspect and explain the requested work.",
|
||||
"alias_root": "skill://demo", "main_resource": "skill://demo/work/SKILL.md",
|
||||
"resources": {"skill://demo/work/SKILL.md": "# Work\nInspect the inputs before explaining the result.\n"}
|
||||
}]})
|
||||
.to_string(),
|
||||
)?;
|
||||
let provider = FrozenCloudSkillProvider::from_file(&path)?;
|
||||
fs::remove_file(path)?;
|
||||
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
|
||||
install_with_providers(
|
||||
&mut extensions,
|
||||
SkillProviders::new()
|
||||
.with_cloud_provider(Arc::new(provider))
|
||||
.with_executor_provider(Arc::new(
|
||||
ExecutorSkillProvider::new_with_restriction_product(
|
||||
Arc::new(EnvironmentManager::default_for_tests()),
|
||||
/*restriction_product*/ None,
|
||||
),
|
||||
)),
|
||||
|config: &Config| SkillsExtensionConfig {
|
||||
include_instructions: true,
|
||||
max_context_tokens: config.skill_max_context_tokens,
|
||||
bundled_skills_enabled: false,
|
||||
cloud_skill_enabled: true,
|
||||
shadow_selection_enabled: false,
|
||||
},
|
||||
);
|
||||
let server = start_mock_server().await;
|
||||
let mock = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_function_call_with_namespace(
|
||||
"list-skills",
|
||||
"skills",
|
||||
"list",
|
||||
r#"{"authority":{"kind":"cloud"}}"#,
|
||||
),
|
||||
ev_completed("listed"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_function_call_with_namespace(
|
||||
"read-work",
|
||||
"skills",
|
||||
"read",
|
||||
r#"{"package":"c0/work","source":{"kind":"cloud"}}"#,
|
||||
),
|
||||
ev_completed("read"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("answer", "I read the frozen work instructions."),
|
||||
ev_completed("done"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let test = test_codex()
|
||||
.with_model("gpt-6-astra")
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
// Cloud parents start without the reserved local executor attachment.
|
||||
.with_exec_server_url("none")
|
||||
.with_extensions(Arc::new(extensions.build()))
|
||||
.with_config(|config| {
|
||||
configure_scenario_catalog(config);
|
||||
config.cloud_skill_enabled = true;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::ExecutorCapabilityDiscoveryV2)
|
||||
.unwrap();
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
let executor = core_test_support::exec_server::ExecServerProcess::start().await?;
|
||||
test.thread_manager
|
||||
.environment_manager()
|
||||
.upsert_environment(
|
||||
"work-env".into(),
|
||||
executor.websocket_url.clone(),
|
||||
/*connect_timeout*/ None,
|
||||
)?;
|
||||
let cwd = PathUri::from_host_native_path(fs::canonicalize(workspace.path())?)?;
|
||||
test.submit_turn_with_environments(
|
||||
"List the cloud skills, then read the work skill.",
|
||||
Some(vec![TurnEnvironmentSelection {
|
||||
environment_id: "work-env".into(),
|
||||
cwd: cwd.clone(),
|
||||
workspace_roots: vec![cwd],
|
||||
selected_capability_roots: Default::default(),
|
||||
config: EnvironmentConfigState::FromThread,
|
||||
}]),
|
||||
)
|
||||
.await?;
|
||||
let requests = mock.requests();
|
||||
assert_eq!(requests.len(), 3);
|
||||
let output: serde_json::Value = serde_json::from_str(
|
||||
&mock
|
||||
.function_call_output_text("read-work")
|
||||
.expect("frozen skill read"),
|
||||
)?;
|
||||
assert_eq!(
|
||||
output,
|
||||
json!({
|
||||
"resource": "skill://demo/work/SKILL.md",
|
||||
"contents": "# Work\nInspect the inputs before explaining the result.\n",
|
||||
"installations": [{
|
||||
"package": "skill://demo/work", "resource": "skill://demo/work/SKILL.md",
|
||||
}],
|
||||
"next_cursor": null,
|
||||
})
|
||||
);
|
||||
let mut bodies = requests
|
||||
.iter()
|
||||
.map(core_test_support::responses::ResponsesRequest::body_json)
|
||||
.collect::<Vec<_>>();
|
||||
// Cargo and Bazel can serialize embedded JSON in different key orders.
|
||||
// Preserve every output value while normalizing before truncation and hashing.
|
||||
for body in &mut bodies {
|
||||
for item in body["input"].as_array_mut().expect("request input") {
|
||||
if item["type"] == "function_call_output"
|
||||
&& let Some(serde_json::Value::String(output)) = item.get_mut("output")
|
||||
{
|
||||
*output = context_snapshot::normalize_json_lines(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
insta::assert_snapshot!(
|
||||
"frozen_cloud_skills_with_named_executor_request_history",
|
||||
context_snapshot::format_context_snapshot(
|
||||
"A cloud parent lists and reads a frozen skill alongside a named executor after the snapshot file has been removed.",
|
||||
&bodies.iter().map(SnapshotEntry::body).collect::<Vec<_>>(),
|
||||
&ContextSnapshotOptions::default().include_request_settings(),
|
||||
)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
---
|
||||
source: core/tests/suite/scenarios_frozen_cloud_skills.rs
|
||||
assertion_line: 142
|
||||
expression: "context_snapshot::format_context_snapshot(\"A cloud parent lists and reads a frozen skill alongside a named executor after the snapshot file has been removed.\",\n&bodies.iter().map(SnapshotEntry::body).collect::<Vec<_>>(),\n&ContextSnapshotOptions::default().include_request_settings(),)"
|
||||
---
|
||||
Scenario: A cloud parent lists and reads a frozen skill alongside a named executor after the snapshot file has been removed.
|
||||
|
||||
## Window 1
|
||||
Settings:
|
||||
include: ["reasoning.encrypted_content"]
|
||||
model: "gpt-6-astra"
|
||||
parallel_tool_calls: false
|
||||
prompt_cache_key: "<PROMPT_CACHE_KEY 1>"
|
||||
reasoning: {"context":"all_turns","effort":"low"}
|
||||
store: false
|
||||
stream: true
|
||||
text: {"verbosity":"low"}
|
||||
tool_choice: "auto"
|
||||
-- request 1 (request) --
|
||||
00:additional_tools/developer (3; hash=e075a43448be3246):
|
||||
- namespace/functions; hash=bbcd688349ca7e12
|
||||
- custom/exec
|
||||
- function/wait
|
||||
- function/request_user_input
|
||||
- function/request_user_input_async
|
||||
- namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317
|
||||
- function/sleep
|
||||
- namespace/collaboration: Tools for spawning and managing sub-agents.; hash=2c8d6295978dbe63
|
||||
- function/followup_task
|
||||
- function/interrupt_agent
|
||||
- function/list_agents
|
||||
- function/send_message
|
||||
- function/spawn_agent
|
||||
- function/wait_agent
|
||||
01:message/developer:
|
||||
You are Codex, an agent based on GPT-6. You and the user share one workspace, and your job is to collabo...them until their intended goal is completely handled. [hash=efa50f074efa5803]
|
||||
|
||||
# When to ask the user for permission
|
||||
|
||||
Use your best judgement given task context for when you really need user permission, like a competent co...ork without ending the turn to clarify with the user. [hash=ca7367e658b51388]
|
||||
|
||||
User authorization and preferences persist across turns. Do not request permission again when the user h... any guidelines provided in skills or external files. [hash=911f91c92c72882c]
|
||||
|
||||
<OMITTED 155 LINES; ~4935 TOKENS; hash=5573869716192298>
|
||||
|
||||
- Skill naming: If a plugin contributes skills, those skill entries are prefixed with plugin_name: in the Skills list.
|
||||
- MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as mcp__server__tool; use tool provenance to tell which plugin they come from.
|
||||
- Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn.
|
||||
- Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task.
|
||||
- Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associa..., MCP tools, and apps exposed elsewhere in this turn. [hash=620459a4e7b590f0]
|
||||
- Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for t..., say so briefly and continue with the best fallback. [hash=746b0469a8f6cb3e]
|
||||
|
||||
02:message/developer[3]:
|
||||
[01] <permissions instructions>
|
||||
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`...ll commands are permitted. Network access is enabled. [hash=c134061cebb94a1b]
|
||||
Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected.
|
||||
</permissions instructions>
|
||||
[02] <collaboration_mode># Collaboration Mode: Default
|
||||
|
||||
You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.
|
||||
|
||||
Your active mode changes only when new developer instructions with a different `<collaboration_mode>...<...by themselves. Known mode names are Default and Plan. [hash=e75f6b12fc52d812]
|
||||
|
||||
## request_user_input availability
|
||||
|
||||
Use the `request_user_input` tool only when it is listed in the available tools for this turn.
|
||||
|
||||
Use the `request_user_input` tool only for optional questions where the answer would materially improve the quality of the work.
|
||||
|
||||
If `request_user_input` returns no answers, continue with best judgment instead of asking again or treating the turn as blocked.
|
||||
|
||||
Never use the `request_user_input` tool for permission requests or permission-related escalations.
|
||||
</collaboration_mode>
|
||||
[03] <skills_instructions>
|
||||
## Skills
|
||||
A skill is a set of instructions provided through a `SKILL.md` source. Below is the list of skills that ...locators can be expanded using the skill roots table. [hash=f00d6a4d3e192f06]
|
||||
### Skill roots
|
||||
For each alias listed below, this mapping replaces any earlier mapping for that alias. Use it for subsequent skill calls.
|
||||
- `c0` = `skill://demo`
|
||||
Read a skill package directly with `skills.read({"package":"<package>"})` to read its `SKILL.md`; root a...ackage is not provided, use `skills.list` to find it. [hash=25bf84ea2123d647]
|
||||
### Available skills
|
||||
- demo:work: Inspect and explain the requested work. (cloud package: c0/work)
|
||||
</skills_instructions>
|
||||
03:message/developer:
|
||||
<multi_agent_role>You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals.
|
||||
|
||||
At the start of your turn, you are the active agent.
|
||||
You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents.
|
||||
All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools.
|
||||
|
||||
You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task an...message to a running agent without triggering a turn. [hash=7d40ff8b58977202]
|
||||
`send_message` calls may be read by a human, so ensure they are legible. Always put proper spaces between words and/or numbers.
|
||||
<OMITTED 17 LINES; ~246 TOKENS; hash=780031e2df907944>
|
||||
- All agents use the same current working directory.
|
||||
- As a result, edits made by one agent are immediately visible to all other agents.
|
||||
|
||||
When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.
|
||||
|
||||
There are 4 available concurrency slots, meaning that up to 4 agents can be active at once, including you.
|
||||
|
||||
Full-history forks (`fork_turns` omitted or `"all"`) inherit the parent model and reasoning effort and d... so, set `fork_turns` to `"none"`.</multi_agent_role> [hash=d5928243dc643bcc]
|
||||
04:message/developer:
|
||||
<multi_agent_mode>Any earlier instruction enabling proactive multi-agent delegation no longer applies. D...elegation, or parallel agent work.</multi_agent_mode> [hash=2b6e037067b4390c]
|
||||
05:message/user:
|
||||
<environment_context>
|
||||
<cwd><CWD></cwd>
|
||||
<shell><HOST_SHELL></shell>
|
||||
<current_date><CURRENT_DATE></current_date>
|
||||
<timezone><HOST_TIMEZONE></timezone>
|
||||
<filesystem><workspace_roots><root><CWD></root></workspace_roots><permission_profile type="disabled"><...e="unrestricted" /></permission_profile></filesystem> [hash=d40b2f45cb028888]
|
||||
</environment_context>
|
||||
06:message/user:
|
||||
List the cloud skills, then read the work skill.
|
||||
-- request 2 (request) --
|
||||
07:function_call/skills.list:{"authority":{"kind":"cloud"}}
|
||||
08:function_call_output:{"next_cursor":null,"skills":[{"authority":{"kind":"cloud"},"description":"Inspect and explain the reque...:work","package":"skill://demo/work"}],"warnings":[]} [hash=0a735291ec9e0ec7]
|
||||
-- request 3 (request) --
|
||||
09:function_call/skills.read:{"package":"c0/work","source":{"kind":"cloud"}}
|
||||
10:function_call_output:{"contents":"# Work\\nInspect the inputs before explaining the result.\\n","installations":[{"package":"sk...cursor":null,"resource":"skill://demo/work/SKILL.md"} [hash=adc0897fa3bc41cf]
|
||||
@@ -116,6 +116,51 @@ fn request(client: Option<Arc<McpResourceClient>>) -> SkillReadRequest<'static>
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn frozen_cloud_catalog_and_reads_ignore_live_mcp_changes() {
|
||||
let state = state();
|
||||
let provider = crate::FrozenCloudSkillProvider::from_json(
|
||||
serde_json::json!({"skills": [{
|
||||
"package": "skill://demo/work", "name": "demo", "description": "Frozen skill",
|
||||
"alias_root": "skill://demo", "main_resource": "skill://demo/work/SKILL.md",
|
||||
"resources": {"skill://demo/work/SKILL.md": "Frozen contents"}
|
||||
}]})
|
||||
.to_string()
|
||||
.as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
let providers = SkillProviders::new().with_cloud_provider(Arc::new(provider));
|
||||
state
|
||||
.refresh_cloud_catalog(&providers, query(Some(client())))
|
||||
.await
|
||||
.unwrap();
|
||||
let catalog = state.available_cloud_catalog().unwrap();
|
||||
let generation = state.cloud_cache(/*mcp_resources*/ None);
|
||||
for mcp_resources in [Some(client()), None, Some(client())] {
|
||||
let request = SkillReadRequest {
|
||||
package: SkillPackageId("skill://demo/work".into()),
|
||||
resource: SkillResourceId::new("skill://demo/work/SKILL.md"),
|
||||
..request(mcp_resources.clone())
|
||||
};
|
||||
assert_eq!(
|
||||
state.read_skill(&providers, request).await.unwrap(),
|
||||
SkillReadResult {
|
||||
resource: SkillResourceId::new("skill://demo/work/SKILL.md"),
|
||||
contents: "Frozen contents".into(),
|
||||
}
|
||||
);
|
||||
state
|
||||
.refresh_cloud_catalog(&providers, query(mcp_resources))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(state.available_cloud_catalog(), Some(catalog.clone()));
|
||||
assert!(Arc::ptr_eq(
|
||||
&generation,
|
||||
&state.cloud_cache(/*mcp_resources*/ None)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn late_read_cannot_return_or_populate_a_replaced_cache() {
|
||||
for replacement in [Some(client()), None] {
|
||||
|
||||
@@ -44,6 +44,7 @@ pub use host_service::HostSkillsService;
|
||||
pub use host_snapshot::HostSkillsSnapshot;
|
||||
pub use invocation::detect_implicit_skill_invocation;
|
||||
pub use provider::ExecutorSkillProvider;
|
||||
pub use provider::FrozenCloudSkillProvider;
|
||||
pub use provider::HostSkillProvider;
|
||||
pub use provider::SkillProvider;
|
||||
pub use required::EnvironmentSkillRequirements;
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod executor;
|
||||
mod frozen_cloud;
|
||||
mod host;
|
||||
|
||||
use crate::HostSkillsSnapshot;
|
||||
@@ -23,6 +24,7 @@ use crate::catalog::SkillSearchResult;
|
||||
|
||||
pub use executor::ExecutorSkillProvider;
|
||||
pub(crate) use executor::attribute_executor_plugins;
|
||||
pub use frozen_cloud::FrozenCloudSkillProvider;
|
||||
pub use host::HostSkillProvider;
|
||||
|
||||
pub(crate) const MAX_SKILL_RESOURCE_CONTENT_BYTES: usize = 1024 * 1024;
|
||||
@@ -85,6 +87,12 @@ pub type SkillProviderFuture<'a, T> =
|
||||
/// provider must be read or searched through the same provider/authority rather
|
||||
/// than converted into an ambient local path.
|
||||
pub trait SkillProvider: Send + Sync {
|
||||
/// Whether Cloud catalog and read caches must follow the live MCP auth/resource scope.
|
||||
/// When all configured Cloud providers opt out, their caches are independent of MCP.
|
||||
fn uses_mcp_resources(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Providers set `is_partial` when listing omits entries; warnings are diagnostic only.
|
||||
fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog>;
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Immutable cloud skill snapshots for offline sources. Only declared package resources are
|
||||
//! readable; the normal skills extension owns aliases, tool schemas, rendering, and pagination.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::MAX_SKILL_RESOURCE_CONTENT_BYTES;
|
||||
use super::SkillListQuery;
|
||||
use super::SkillProvider;
|
||||
use super::SkillProviderFuture;
|
||||
use super::SkillReadRequest;
|
||||
use super::SkillSearchRequest;
|
||||
use crate::catalog::SkillAuthority;
|
||||
use crate::catalog::SkillCatalog;
|
||||
use crate::catalog::SkillCatalogEntry;
|
||||
use crate::catalog::SkillPackageId;
|
||||
use crate::catalog::SkillProviderError;
|
||||
use crate::catalog::SkillProviderResult;
|
||||
use crate::catalog::SkillReadResult;
|
||||
use crate::catalog::SkillResourceId;
|
||||
use crate::catalog::SkillSearchResult;
|
||||
use crate::catalog::SkillSourceKind;
|
||||
|
||||
const MAX_SNAPSHOT_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Snapshot {
|
||||
skills: Vec<SnapshotSkill>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SnapshotSkill {
|
||||
package: String,
|
||||
name: String,
|
||||
description: String,
|
||||
alias_root: String,
|
||||
main_resource: String,
|
||||
#[serde(default = "default_prompt_visible")]
|
||||
prompt_visible: bool,
|
||||
resources: HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn default_prompt_visible() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Supplies a fixed cloud catalog and resource contents without contacting a skill service.
|
||||
pub struct FrozenCloudSkillProvider {
|
||||
catalog: SkillCatalog,
|
||||
resources: HashMap<SkillPackageId, HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl FrozenCloudSkillProvider {
|
||||
/// Loads once from a host-owned file; subsequent reads never access the filesystem.
|
||||
pub fn from_file(path: &Path) -> SkillProviderResult<Self> {
|
||||
let mut bytes = Vec::new();
|
||||
std::fs::File::open(path)
|
||||
.and_then(|file| {
|
||||
file.take((MAX_SNAPSHOT_BYTES + 1) as u64)
|
||||
.read_to_end(&mut bytes)
|
||||
})
|
||||
.map_err(|err| SkillProviderError::new(format!("cannot read skill snapshot: {err}")))?;
|
||||
Self::from_json(&bytes)
|
||||
}
|
||||
|
||||
/// Rejects missing main resources, duplicate packages, and resources outside their package.
|
||||
pub fn from_json(bytes: &[u8]) -> SkillProviderResult<Self> {
|
||||
if bytes.len() > MAX_SNAPSHOT_BYTES {
|
||||
return Err(SkillProviderError::new("skill snapshot exceeds 64 MiB"));
|
||||
}
|
||||
let snapshot: Snapshot = serde_json::from_slice(bytes)
|
||||
.map_err(|err| SkillProviderError::new(format!("invalid skill snapshot: {err}")))?;
|
||||
let mut catalog = SkillCatalog::default();
|
||||
let mut resources = HashMap::new();
|
||||
for skill in snapshot.skills {
|
||||
let package = SkillPackageId(skill.package);
|
||||
let root = SkillPackageId(skill.alias_root.clone());
|
||||
if !skill.alias_root.starts_with("skill://")
|
||||
|| root.relative_resource_path(&package.0).is_none()
|
||||
|| skill.name.trim().is_empty()
|
||||
|| !skill.resources.contains_key(&skill.main_resource)
|
||||
|| skill.resources.iter().any(|(resource, contents)| {
|
||||
package.relative_resource_path(resource).is_none()
|
||||
|| contents.len() > MAX_SKILL_RESOURCE_CONTENT_BYTES
|
||||
})
|
||||
{
|
||||
return Err(SkillProviderError::new("invalid skill package or resource"));
|
||||
}
|
||||
let mut entry = SkillCatalogEntry::new(
|
||||
package.clone(),
|
||||
SkillAuthority::new(SkillSourceKind::Cloud, CODEX_APPS_MCP_SERVER_NAME),
|
||||
skill.name,
|
||||
skill.description,
|
||||
SkillResourceId::new(skill.main_resource),
|
||||
)
|
||||
.with_display_path(&package.0)
|
||||
.with_alias_root(skill.alias_root);
|
||||
if !skill.prompt_visible {
|
||||
entry = entry.hidden_from_prompt();
|
||||
}
|
||||
catalog.entries.push(entry);
|
||||
if resources.insert(package, skill.resources).is_some() {
|
||||
return Err(SkillProviderError::new("duplicate skill package"));
|
||||
}
|
||||
}
|
||||
Ok(Self { catalog, resources })
|
||||
}
|
||||
}
|
||||
|
||||
impl SkillProvider for FrozenCloudSkillProvider {
|
||||
fn uses_mcp_resources(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> {
|
||||
Box::pin(async move {
|
||||
Ok(if query.include_cloud_skills {
|
||||
self.catalog.clone()
|
||||
} else {
|
||||
SkillCatalog::default()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn read<'a>(
|
||||
&'a self,
|
||||
request: SkillReadRequest<'a>,
|
||||
) -> SkillProviderFuture<'a, SkillReadResult> {
|
||||
Box::pin(async move {
|
||||
if request.authority
|
||||
!= SkillAuthority::new(SkillSourceKind::Cloud, CODEX_APPS_MCP_SERVER_NAME)
|
||||
{
|
||||
return Err(SkillProviderError::new("invalid frozen skill authority"));
|
||||
}
|
||||
let contents = self
|
||||
.resources
|
||||
.get(&request.package)
|
||||
.and_then(|resources| resources.get(request.resource.as_str()))
|
||||
.ok_or_else(|| SkillProviderError::new("unknown frozen skill resource"))?;
|
||||
Ok(SkillReadResult {
|
||||
resource: request.resource,
|
||||
contents: contents.clone(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn search(&self, _request: SkillSearchRequest) -> SkillProviderFuture<'_, SkillSearchResult> {
|
||||
Box::pin(async {
|
||||
Err(SkillProviderError::new(
|
||||
"frozen skill snapshots do not support search",
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "frozen_cloud_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Snapshot validation and immutable resource reads across authority boundaries.
|
||||
|
||||
use super::*;
|
||||
use crate::provider::SkillReadContext;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
fn snapshot() -> serde_json::Value {
|
||||
json!({"skills": [{
|
||||
"package": "skill://demo/work",
|
||||
"name": "demo:work",
|
||||
"description": "Do the work.",
|
||||
"alias_root": "skill://demo",
|
||||
"main_resource": "skill://demo/work/SKILL.md",
|
||||
"resources": {"skill://demo/work/SKILL.md": "# Work\nKeep it readable.\n"}
|
||||
}]})
|
||||
}
|
||||
|
||||
fn request() -> SkillReadRequest<'static> {
|
||||
SkillReadRequest {
|
||||
authority: SkillAuthority::new(SkillSourceKind::Cloud, CODEX_APPS_MCP_SERVER_NAME),
|
||||
package: SkillPackageId("skill://demo/work".into()),
|
||||
resource: SkillResourceId::new("skill://demo/work/SKILL.md"),
|
||||
context: SkillReadContext::Cloud {
|
||||
mcp_resources: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_is_loaded_once_and_reads_preserve_the_exact_contents() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("skills.json");
|
||||
std::fs::write(&path, snapshot().to_string()).unwrap();
|
||||
let provider = FrozenCloudSkillProvider::from_file(&path).unwrap();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
assert_eq!(
|
||||
provider.read(request()).await.unwrap(),
|
||||
SkillReadResult {
|
||||
resource: request().resource,
|
||||
contents: "# Work\nKeep it readable.\n".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(provider.catalog.entries.len(), 1);
|
||||
assert!(provider.catalog.entries[0].prompt_visible);
|
||||
assert_eq!(
|
||||
provider.catalog.entries[0].rendered_path(),
|
||||
"skill://demo/work"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hidden_skills_remain_readable_without_implicit_prompt_exposure() {
|
||||
let mut value = snapshot();
|
||||
value["skills"][0]["prompt_visible"] = json!(false);
|
||||
let provider = FrozenCloudSkillProvider::from_json(value.to_string().as_bytes()).unwrap();
|
||||
assert!(!provider.catalog.entries[0].prompt_visible);
|
||||
assert_eq!(
|
||||
provider.read(request()).await.unwrap().contents,
|
||||
"# Work\nKeep it readable.\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_cannot_cross_authority_or_package_boundaries() {
|
||||
let provider = FrozenCloudSkillProvider::from_json(snapshot().to_string().as_bytes()).unwrap();
|
||||
let mut other_authority = request();
|
||||
other_authority.authority = SkillAuthority::new(SkillSourceKind::Executor, "demo");
|
||||
let mut other_package = request();
|
||||
other_package.package = SkillPackageId("skill://other/work".into());
|
||||
let mut other_resource = request();
|
||||
other_resource.resource = SkillResourceId::new("skill://demo/work/../SKILL.md");
|
||||
for request in [other_authority, other_package, other_resource] {
|
||||
assert!(provider.read(request).await.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_snapshots_are_rejected_before_inference() {
|
||||
let mut missing_main = snapshot();
|
||||
missing_main["skills"][0]["main_resource"] = json!("skill://demo/work/missing.md");
|
||||
let mut escaped = snapshot();
|
||||
escaped["skills"][0]["resources"]["skill://other/secret.md"] = json!("secret");
|
||||
let mut oversized = snapshot();
|
||||
oversized["skills"][0]["resources"]["skill://demo/work/SKILL.md"] =
|
||||
json!("x".repeat(MAX_SKILL_RESOURCE_CONTENT_BYTES + 1));
|
||||
let mut duplicate = snapshot();
|
||||
duplicate["skills"] = json!([snapshot()["skills"][0], snapshot()["skills"][0]]);
|
||||
for value in [missing_main, escaped, oversized, duplicate] {
|
||||
assert!(FrozenCloudSkillProvider::from_json(value.to_string().as_bytes()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,17 @@ impl SkillProviders {
|
||||
.any(|source| source.kind == SkillSourceKind::Cloud)
|
||||
}
|
||||
|
||||
pub(crate) fn cloud_mcp_resources<'a>(
|
||||
&self,
|
||||
resources: Option<&'a codex_mcp::McpResourceClient>,
|
||||
) -> Option<&'a codex_mcp::McpResourceClient> {
|
||||
resources.filter(|_| {
|
||||
self.sources.iter().any(|source| {
|
||||
source.kind == SkillSourceKind::Cloud && source.provider.uses_mcp_resources()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn has_host_provider(&self) -> bool {
|
||||
self.sources
|
||||
.iter()
|
||||
|
||||
@@ -292,7 +292,7 @@ impl SkillsThreadState {
|
||||
}
|
||||
// Switch generations before the fallible lookup; only same-auth-scope failures
|
||||
// may retain previously authorized metadata and contents.
|
||||
let cache = self.cloud_cache(query.mcp_resources.as_deref());
|
||||
let cache = self.cloud_cache(providers.cloud_mcp_resources(query.mcp_resources.as_deref()));
|
||||
let resource_cache_key = cache.current_resource_cache_key();
|
||||
if cache.is_current()
|
||||
&& cache
|
||||
@@ -360,7 +360,7 @@ impl SkillsThreadState {
|
||||
"cloud skill reads require a cloud context",
|
||||
));
|
||||
};
|
||||
let cache = self.cloud_cache(mcp_resources.as_deref());
|
||||
let cache = self.cloud_cache(providers.cloud_mcp_resources(mcp_resources.as_deref()));
|
||||
let cache_key = SkillReadCacheKey::from(&request);
|
||||
{
|
||||
let config = self
|
||||
|
||||
Reference in New Issue
Block a user