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:
Francis Chalissery
2026-10-20 17:01:52 +00:00
committed by copyberry
parent 48b6a9d8e0
commit e776b27eb0
22 changed files with 785 additions and 3 deletions
+6 -1
View File
@@ -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,
+1
View File
@@ -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,
+16
View File
@@ -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,
+5
View File
@@ -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()
+1
View File
@@ -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;
+5
View File
@@ -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)
+3
View File
@@ -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(())
}
@@ -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] {
+1
View File
@@ -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;
+8
View File
@@ -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());
}
}
+11
View File
@@ -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()
+2 -2
View File
@@ -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