Add configurable default plugin activation policy (#52220)

## What changed

Support `plugins._default.enabled` with explicit per-plugin `enabled` overrides. Omission preserves existing source and account enablement; configuration cannot activate a plugin disabled at its source. Plugin settings such as remembered tool approvals inherit the default unless `enabled` is explicitly set.

Apply the policy to plugin skills, MCP servers, Apps, and hooks, including selected executor roots and runtime configuration refreshes. Preserve the latest plugin policy when adding MCP dependencies and rebuild hooks when concurrent configuration updates replace their inputs.

Report effective enablement in `plugin/list`, `plugin/installed`, `plugin/read`, and `plugin/share/list`. Remote installation still installs disabled packages, but skips MCP and Apps authentication setup without writing an enablement override.

## Testing

Add regression coverage for default inheritance and explicit overrides, source-disabled plugins, policy refreshes during active turns, disabled or malformed selected plugin roots, and remote installation authentication behavior.

GitOrigin-RevId: 83b70c5b47fc95474fec292f248683f1f887221e
This commit is contained in:
stevenlee-oai
2026-10-08 18:42:58 +00:00
committed by copyberry
parent 79773e8826
commit 89d68bc8d2
49 changed files with 2134 additions and 240 deletions
@@ -23,6 +23,8 @@ use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TurnEnvironmentRequest;
use codex_protocol::protocol::TurnEnvironmentRequests;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::user_input::UserInput;
use codex_utils_path_uri::PathUri;
use core_test_support::apps_test_server::AppsTestServer;
@@ -31,6 +33,7 @@ use core_test_support::apps_test_server::recorded_apps_tool_calls;
use core_test_support::responses::ResponseMock;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_response_sequence;
use core_test_support::responses::sse;
@@ -43,10 +46,12 @@ use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::TestCodexBuilder;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use core_test_support::wait_for_mcp_server;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use test_case::test_case;
@@ -114,6 +119,81 @@ async fn thread_plugin_selection_disables_executor_hooks_without_disabling_their
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn executor_stop_hook_uses_plugin_policy_refreshed_during_turn() -> Result<()> {
skip_if_no_network!(Ok(()));
let fixture = executor_plugin_hook_fixture(
test_codex().with_config(|config| {
config
.features
.enable(Feature::DefaultModeRequestUserInput)
.expect("enable request_user_input");
}),
&[("computer-use@openai-bundled", computer_use_hook_manifest())],
vec![
sse_response(sse(vec![
ev_function_call(
"pause",
"request_user_input",
r#"{"questions":[{"id":"continue","header":"Continue","question":"Continue?","options":[{"label":"Yes","description":"Finish."},{"label":"No","description":"Stop."}]}]}"#,
),
ev_completed("paused-step"),
])),
completed_turn_response("finished-turn"),
],
)
.await?;
fixture.attach().await?;
fixture
.test
.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "Ask before finishing this turn".to_string(),
text_elements: Vec::new(),
}]))
.await?;
let request = wait_for_event_match(&fixture.test.codex, |event| match event {
EventMsg::RequestUserInput(request) => Some(request.clone()),
_ => None,
})
.await;
let current_config = fixture.test.codex.config().await;
let mut config = (*current_config).clone();
config.config_layer_stack = config.config_layer_stack.with_user_config(
&config.codex_home.join("config.toml"),
toml::from_str("[plugins._default]\nenabled = false")?,
)?;
let _ = fixture
.test
.codex
.refresh_runtime_config(current_config, config)
.await;
fixture
.test
.codex
.submit(Op::UserInputAnswer {
id: request.turn_id,
response: RequestUserInputResponse {
answers: HashMap::from([(
"continue".to_string(),
RequestUserInputAnswer {
answers: vec!["Yes".to_string()],
},
)]),
},
})
.await?;
wait_for_event(&fixture.test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
assert_eq!(fixture.responses.requests().len(), 2);
assert_eq!(fixture.calls().await?, Vec::<Value>::new());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn executor_stop_hook_runs_after_attachment() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -0,0 +1,202 @@
//! Native plugin hook admission must follow policy changes within an active turn.
use std::collections::HashMap;
use std::fs;
use std::sync::Arc;
use anyhow::Result;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerSource;
use codex_config::ConfigLayerStack;
use codex_core::ConfigRefreshOutcome;
use codex_core::TurnInputRequest;
use codex_features::Feature;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::user_input::UserInput;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::apps_test_server::recorded_apps_tool_calls;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use core_test_support::wait_for_mcp_server;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::Request;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_partial_json;
use wiremock::matchers::method;
use wiremock::matchers::path;
use super::rmcp_client::remote_aware_environment_id;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn native_stop_hook_uses_plugin_policy_refreshed_during_turn() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let observer = AppsTestServer::mount(&server).await?;
Mock::given(method("POST"))
.and(path("/api/codex/ps/mcp"))
.and(body_partial_json(json!({ "method": "tools/call" })))
.respond_with(|request: &Request| {
let request: serde_json::Value =
serde_json::from_slice(&request.body).expect("observer tool call");
ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
"jsonrpc": "2.0",
"id": request["id"],
"result": { "content": [{ "type": "text", "text": "{}" }] },
}))
})
.with_priority(/*p*/ 1)
.mount(&server)
.await;
let home = Arc::new(TempDir::new()?);
let plugin_root = home.path().join("plugins/cache/test/example/local");
fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
serde_json::to_vec(&json!({
"name": "example",
"hooks": { "hooks": { "Stop": [{ "hooks": [{
"type": "mcp_tool",
"server": "hook_observer",
"tool": "calendar_list_events",
}] }] } },
}))?,
)?;
fs::write(
home.path().join("config.toml"),
"[features]\nplugins = true\nhooks = true\n\n[plugins.\"example@test\"]\n",
)?;
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_assistant_message("baseline", "done"),
ev_completed("baseline"),
]),
sse(vec![
ev_function_call(
"pause",
"request_user_input",
r#"{"questions":[{"id":"continue","header":"Continue","question":"Continue?","options":[{"label":"Yes","description":"Finish."},{"label":"No","description":"Stop."}]}]}"#,
),
ev_completed("paused-step"),
]),
sse(vec![
ev_assistant_message("finished-turn", "done"),
ev_completed("finished-turn"),
]),
],
)
.await;
let test = test_codex()
.with_home(home)
.with_config(move |config| {
config.bypass_hook_trust = true;
config
.features
.enable(Feature::DefaultModeRequestUserInput)
.expect("enable request_user_input");
config
.mcp_servers
.set(HashMap::from([(
"hook_observer".to_string(),
serde_json::from_value(json!({
"url": format!("{}/api/codex/ps/mcp", observer.chatgpt_base_url),
"environment_id": remote_aware_environment_id(),
}))
.expect("observer MCP configuration"),
)]))
.expect("configure independent hook observer");
})
.build_with_auto_env(&server)
.await?;
wait_for_mcp_server(&test.codex, "hook_observer").await?;
test.submit_text_turn("finish the enabled baseline turn")
.await?;
assert_eq!(recorded_apps_tool_calls(&server).await.len(), 1);
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "Ask before finishing this turn".to_string(),
text_elements: Vec::new(),
}]))
.await?;
let request = wait_for_event_match(&test.codex, |event| match event {
EventMsg::RequestUserInput(request) => Some(request.clone()),
_ => None,
})
.await;
let current_config = test.codex.config().await;
let mut next_config = (*current_config).clone();
let mut layers = current_config
.config_layer_stack
.all_layers_low_to_high()
.cloned()
.collect::<Vec<_>>();
layers.push(ConfigLayerEntry::new(
ConfigLayerSource::System {
file: current_config.codex_home.join("system-config.toml"),
},
toml::from_str("[plugins._default]\nenabled = false")?,
));
layers.sort_by_key(|layer| layer.name.precedence());
next_config.config_layer_stack = ConfigLayerStack::new(
layers,
current_config.config_layer_stack.requirements().clone(),
current_config
.config_layer_stack
.requirements_toml()
.clone(),
)
.expect("updated system layer");
assert_eq!(
test.codex
.refresh_mcp_config(current_config, next_config)
.await,
ConfigRefreshOutcome::Published
);
test.codex
.submit(Op::UserInputAnswer {
id: request.turn_id,
response: RequestUserInputResponse {
answers: HashMap::from([(
"continue".to_string(),
RequestUserInputAnswer {
answers: vec!["Yes".to_string()],
},
)]),
},
})
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
assert_eq!(responses.requests().len(), 3);
assert_eq!(recorded_apps_tool_calls(&server).await.len(), 1);
test.codex
.call_mcp_tool(
"hook_observer",
"calendar_list_events",
/*arguments*/ None,
/*meta*/ None,
)
.await?;
assert_eq!(recorded_apps_tool_calls(&server).await.len(), 2);
Ok(())
}
@@ -38,6 +38,7 @@ impl McpServerContributor<Config> for ExecutorSelectionRecorder {
fn selected_plugins<'a>(
&'a self,
context: McpServerContributionContext<'a, Config>,
_plugins_config: &'a codex_config::types::PluginsConfigToml,
) -> ExtensionFuture<'a, Vec<SelectedPlugin<'a>>> {
Box::pin(async move {
self.plugin_contexts
+1
View File
@@ -118,6 +118,7 @@ mod hooks;
mod hooks_executor;
#[cfg(not(target_os = "windows"))]
mod hooks_mcp;
mod hooks_plugin_policy_refresh;
mod image_rollout;
mod injected_models_cache;
#[cfg(not(target_os = "windows"))]
+149 -9
View File
@@ -716,7 +716,101 @@ async fn astra_kickoff_with_skills_plugins_and_remote_compaction() -> Result<()>
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn astra_omits_disabled_executor_and_plugin_skills_from_model_context() -> Result<()> {
async fn astra_plugin_policy_limits_visible_skills() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let home = Arc::new(TempDir::new()?);
write_scenario_capabilities(&home)?;
fs::rename(
home.path().join("plugins/cache/test"),
home.path().join("plugins/cache/openai-curated-remote"),
)?;
fs::write(
home.path().join("config.toml"),
r#"[features]
plugins = true
remote_plugin = true
[skills.bundled]
enabled = false
[plugins._default]
enabled = false
[plugins."calendar@openai-curated-remote"]
enabled = true
"#,
)?;
let installed_plugins = ["calendar", "notes"].map(|name| json!({
"id": format!("plugins~Plugin_{name}"),
"name": name,
"scope": "GLOBAL",
"status": "ENABLED",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": { "version": "local", "display_name": name, "description": name, "interface": {} },
"enabled": true,
}));
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/ps/plugins/installed"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({
"plugins": installed_plugins,
"pagination": { "next_page_token": null },
})))
.mount(&server)
.await;
let mock = mount_sse_sequence(
&server,
vec![sse(vec![
ev_response_created("skills-response"),
ev_assistant_message("skills", "The calendar agenda skill is available."),
ev_completed("skills-response"),
])],
)
.await;
let chatgpt_base_url = server.uri();
let test = test_codex()
.with_model("gpt-6-astra")
.with_home(home)
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_extensions(skills_extensions())
.with_config(move |config| {
configure_scenario_catalog(config);
config.chatgpt_base_url = chatgpt_base_url;
config.workspace_roots = vec![config.cwd.clone()];
})
.build_with_auto_env(&server)
.await?;
let auth = test.thread_manager.auth_manager().auth().await;
test.thread_manager
.plugins_manager()
.reconcile_remote_installed_plugins(&test.config.plugins_config_input(), auth.as_ref())
.await?;
test.submit_turn("Plan a team kickoff. Which planning skills are available?")
.await?;
let requests = mock.requests();
insta::assert_snapshot!(
"astra_plugin_policy_limits_visible_skills",
context_snapshot::format_request_history_snapshot(
"Astra sees the permitted calendar plugin and standalone skills, but not the unlisted notes plugin.",
&requests,
&ContextSnapshotOptions::default().include_request_settings(),
)
);
Ok(())
}
#[derive(Clone, Copy)]
enum ExecutorPluginDisablement {
ThreadSelection,
ConfigRefresh,
}
#[test_case::test_case(ExecutorPluginDisablement::ThreadSelection; "thread_selection")]
#[test_case::test_case(ExecutorPluginDisablement::ConfigRefresh; "config_refresh")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn astra_omits_disabled_executor_and_plugin_skills_from_model_context(
disablement: ExecutorPluginDisablement,
) -> Result<()> {
use codex_extension_api::ContextContributor;
use codex_extension_api::ExtensionFuture;
use codex_extension_api::SelectedPluginSnapshot;
@@ -736,9 +830,11 @@ async fn astra_omits_disabled_executor_and_plugin_skills_from_model_context() ->
input: WorldStateContributionInput<'a>,
) -> ExtensionFuture<'a, Vec<WorldStateSectionContribution>> {
Box::pin(async move {
let first = self.first.lock().unwrap().take();
let first = self.first.lock().expect("world state pause lock").take();
if let Some(first) = first {
first.send(input.turn_id.to_string()).unwrap();
first
.send(input.turn_id.to_string())
.expect("world state pause receiver");
self.resume.notified().await;
}
Vec::new()
@@ -851,7 +947,10 @@ async fn astra_omits_disabled_executor_and_plugin_skills_from_model_context() ->
let mut thread_extension_init = ExtensionDataInit::new();
thread_extension_init.insert(vec![selected_root(
"plugin-skills",
PathUri::from_host_native_path(plugin_root)?,
PathUri::from_host_native_path(match disablement {
ExecutorPluginDisablement::ThreadSelection => plugin_root,
ExecutorPluginDisablement::ConfigRefresh => plugin_root.join("skills"),
})?,
)]);
let mut first_environment = test.executor_environment().selection().clone();
let mut environment_config = environment_config_for_selection(&test.config, &first_environment);
@@ -867,7 +966,12 @@ async fn astra_omits_disabled_executor_and_plugin_skills_from_model_context() ->
.start_thread(StartThreadOptions {
environments: Some(vec![first_environment.into_request()]),
thread_extension_init,
disabled_plugin_ids: Some(vec!["plugin-skills".to_string()]),
disabled_plugin_ids: match disablement {
ExecutorPluginDisablement::ThreadSelection => {
Some(vec!["plugin-skills".to_string()])
}
ExecutorPluginDisablement::ConfigRefresh => None,
},
..StartThreadOptions::new(test.config.clone())
})
.await?
@@ -890,6 +994,20 @@ async fn astra_omits_disabled_executor_and_plugin_skills_from_model_context() ->
})
.await?;
assert_eq!(outcome.await?, TurnSettingsUpdateOutcome::Applied);
if matches!(disablement, ExecutorPluginDisablement::ConfigRefresh) {
let current_config = thread.config().await;
let mut next_config = (*current_config).clone();
next_config.config_layer_stack = next_config.config_layer_stack.with_user_config(
&next_config.codex_home.join("config.toml"),
toml::from_str("[plugins._default]\nenabled = false")?,
)?;
assert_eq!(
thread
.refresh_runtime_config(current_config, next_config)
.await,
codex_core::ConfigRefreshOutcome::Published
);
}
// Refresh shared MCP for the new roots (there is no Apps server in this test), then
// replace its plugin list before the next step captures it or needs another refresh.
let _ = thread.refresh_codex_apps_tools().await;
@@ -911,6 +1029,15 @@ async fn astra_omits_disabled_executor_and_plugin_skills_from_model_context() ->
.expect("first step's skills.list output");
assert!(first_step_tool.contains("active-helper"));
assert!(!first_step_tool.contains("next-helper"));
let plugin_was_enabled = matches!(disablement, ExecutorPluginDisablement::ConfigRefresh);
assert_eq!(
first_step_tool.contains("disabled-plugin-helper"),
plugin_was_enabled
);
assert_eq!(
requests[0].body_contains_text("disabled-plugin-helper"),
plugin_was_enabled
);
let developer_texts = requests[1].message_input_texts("developer");
let latest_skills = developer_texts
.iter()
@@ -919,6 +1046,7 @@ async fn astra_omits_disabled_executor_and_plugin_skills_from_model_context() ->
.expect("second step's skills section");
assert!(latest_skills.contains("next-helper"));
assert!(!latest_skills.contains("active-helper"));
assert!(!latest_skills.contains("disabled-plugin-helper"));
let mut bodies = requests
.iter()
.map(core_test_support::responses::ResponsesRequest::body_json)
@@ -926,7 +1054,9 @@ async fn astra_omits_disabled_executor_and_plugin_skills_from_model_context() ->
for body in &mut bodies {
let input = body["input"].to_string();
assert!(!input.contains("retired-helper"));
assert!(!input.contains("disabled-plugin-helper"));
if !plugin_was_enabled {
assert!(!input.contains("disabled-plugin-helper"));
}
// Normalize opaque skill locators before snapshot truncation and hashing.
body["input"] = serde_json::from_str(&input.replace(&skill_files, "<SKILLS_ROOT>/"))?;
// Cargo and Bazel can serialize the JSON inside the tool output in different key orders.
@@ -940,10 +1070,20 @@ async fn astra_omits_disabled_executor_and_plugin_skills_from_model_context() ->
}
}
let entries = bodies.iter().map(SnapshotEntry::body).collect::<Vec<_>>();
insta::assert_snapshot!(
"astra_disabled_executor_skills",
context_snapshot::format_context_snapshot(
let (snapshot_name, description) = match disablement {
ExecutorPluginDisablement::ThreadSelection => (
"astra_disabled_executor_skills",
"Astra keeps disabled skills hidden and uses each step's selected skills after the environment changes during preparation.",
),
ExecutorPluginDisablement::ConfigRefresh => (
"astra_executor_plugin_policy_refresh",
"Astra applies refreshed plugin policy to the next step while preserving earlier authorized skill context and tool output.",
),
};
insta::assert_snapshot!(
snapshot_name,
context_snapshot::format_context_snapshot(
description,
&entries,
&ContextSnapshotOptions::default().include_request_settings(),
)
@@ -0,0 +1,139 @@
---
source: core/tests/suite/scenarios.rs
expression: "context_snapshot::format_context_snapshot(description, &entries,\n&ContextSnapshotOptions::default().include_request_settings(),)"
---
Scenario: Astra applies refreshed plugin policy to the next step while preserving earlier authorized skill context and tool output.
## 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=5e8370540461db70):
- namespace/functions; hash=7d26e086c1dbba28
- 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 `read-only`: The san... permits reading files. Network access is restricted. [hash=be7da4425f89053c]
# Escalation Requests
Commands are run outside the sandbox if they are approved by the user, or match an existing rule that al...hell control operators, including but not limited to: [hash=d24ebf5fa4e4b9bb]
- Pipes: |
- Logical operators: &&, ||
<OMITTED 44 LINES; ~790 TOKENS; hash=3a8900343e968d61>
NEVER provide a prefix_rule if your command uses a heredoc or herestring.
### Examples
Good examples of prefixes:
- ["npm", "run", "dev"]
- ["gh", "pr", "check"]
- ["cargo", "test"]
</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
- `e0` = `skill://plugin-skills/<SKILLS_ROOT>/plugin/skills`
- `e1` = `skill://workspace-skills/<SKILLS_ROOT>/skills`
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
- disabled:disabled-plugin-helper: Use a disabled plugin workflow (executor package: e0/disabled-plugin-helper)
- active-helper: Use the current workflow (executor package: e1/active-helper)
</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="managed"><f...ntry></file_system></permission_profile></filesystem> [hash=e8c1de60307d0a3d]
</environment_context>
06:message/user:
Which workflow skills are available?
-- request 2 (request) --
07:function_call/skills.list:{"authority":{"kind":"executor"}}
08:function_call_output:{"next_cursor":null,"skills":[{"authority":{"id":"plugin-skills","kind":"executor"},"description":"Use a.../<SKILLS_ROOT>/skills/active-helper"}],"warnings":[]} [hash=a640b563d0b76db9]
09:message/developer:
<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
- `e0` = `skill://other-skills/<SKILLS_ROOT>/other-skills`
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
- next-helper: Use the next workflow (executor package: e0/next-helper)
</skills_instructions>
@@ -0,0 +1,113 @@
---
source: core/tests/suite/scenarios.rs
expression: "context_snapshot::format_request_history_snapshot(\"Astra sees the permitted calendar plugin and standalone skills, but not the unlisted notes plugin.\",\n&requests, &ContextSnapshotOptions::default().include_request_settings(),)"
---
Scenario: Astra sees the permitted calendar plugin and standalone skills, but not the unlisted notes plugin.
## 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 (turn) --
00:additional_tools/developer (3; hash=bf2e30605ff5dc17):
- namespace/functions; hash=fada4a476a3754dd
- 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] <skills_instructions>
## Skills
A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list ...ed into an absolute path using the skill roots table. [hash=ca27c3957f42502b]
### Skill roots
- `r0` = `<SKILLS_ROOT>`
- `r1` = `<PLUGINS_CACHE>/openai-curated-remote`
### Available skills
- calendar:agenda: Plan a team agenda (file: r1/calendar/local/skills/agenda/SKILL.md)
- final-check: Review a final brief (file: r0/final-check/SKILL.md)
- outline: Draft a project outline (file: r0/outline/SKILL.md)
</skills_instructions>
[02] <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>
[03] <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: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:
Plan a team kickoff. Which planning skills are available?