Gate stable environment tool exposure behind a feature flag (#50962)

## What changed

Add the default-off `stable_environment_tools` feature flag. When enabled, advertise environment-backed tools before an executor is ready, keep environment selectors stable as readiness changes, and include the `shell` and `login` parameters. Execution still requires a usable environment and its policy.

When disabled, expose environment-backed tools only when a usable environment exists, derive selectors from usable environments, and condition shell parameters on the execution configuration. Restore tool-specific unavailable-environment errors and skip the environment readiness check for `write_stdin`.

## Testing

Parameterize the empty-environment tool availability test for both flag states, check that commands require a ready transport by default, and explicitly opt tests that rely on stable tool exposure into the feature.

GitOrigin-RevId: 68c6240fb3adf85d7f9b407804d4823c515abfdc
This commit is contained in:
Ahmed Ibrahim
2026-10-04 21:08:31 +00:00
committed by copyberry
parent de3721a7be
commit 335c7f8eca
16 changed files with 180 additions and 51 deletions
+6
View File
@@ -1151,6 +1151,9 @@
"sqlite": {
"type": "boolean"
},
"stable_environment_tools": {
"type": "boolean"
},
"standalone_web_search": {
"type": "boolean"
},
@@ -7318,6 +7321,9 @@
"sqlite": {
"type": "boolean"
},
"stable_environment_tools": {
"type": "boolean"
},
"standalone_web_search": {
"type": "boolean"
},
@@ -346,8 +346,9 @@ impl ApplyPatchHandler {
// Verify the parsed patch against the selected environment filesystem.
let turn_environment = resolve_tool_environment(
&step_context.environments,
&step_context,
selected_environment_id.as_deref(),
"apply_patch is unavailable in this session",
)?;
let fs = turn_environment.environment.get_filesystem();
let sandbox = turn_environment.sandbox_context(/*additional_permissions*/ None);
+20 -10
View File
@@ -36,6 +36,7 @@ mod view_image;
pub(crate) mod view_image_spec;
mod wait_for_environment;
use codex_features::Feature;
use codex_file_system::FileSystemSandboxContext;
use codex_sandboxing::policy_transforms::materialize_additional_permissions_with_context;
use codex_sandboxing::policy_transforms::merge_permission_profiles;
@@ -47,7 +48,6 @@ use serde::Deserialize;
use serde_json::Map;
use serde_json::Value;
use crate::environment_selection::TurnEnvironmentSnapshot;
use crate::function_tool::FunctionCallError;
use crate::sandboxing::SandboxPermissions;
use crate::session::step_context::StepContext;
@@ -158,9 +158,17 @@ where
}
fn resolve_tool_environment<'a>(
environments: &'a TurnEnvironmentSnapshot,
step_context: &'a StepContext,
environment_id: Option<&str>,
legacy_unavailable_message: &'static str,
) -> Result<&'a TurnEnvironment, FunctionCallError> {
let environments = &step_context.environments;
let stable_environment_tools = step_context
.turn
.config
.features
.get()
.enabled(Feature::StableEnvironmentTools);
environment_id.map_or_else(
|| environments.primary(),
|environment_id| {
@@ -170,19 +178,21 @@ fn resolve_tool_environment<'a>(
},
).ok_or_else(|| {
if let Some(environment_id) = environment_id
&& !environments
.all_selections()
.iter()
.any(|selection| selection.environment_id == environment_id)
&& (!stable_environment_tools
|| !environments
.all_selections()
.iter()
.any(|selection| selection.environment_id == environment_id))
{
return FunctionCallError::RespondToModel(format!(
"unknown turn environment id `{environment_id}`"
));
}
FunctionCallError::RespondToModel(
"No usable execution environment is available. Wait for an environment to become available before using this tool."
.to_string(),
)
FunctionCallError::RespondToModel(if stable_environment_tools {
"No usable execution environment is available. Wait for an environment to become available before using this tool.".to_string()
} else {
legacy_unavailable_message.to_string()
})
})
}
@@ -69,8 +69,9 @@ impl RequestPermissionsHandler {
let environment_args: RequestPermissionsEnvironmentArgs = parse_arguments(&arguments)?;
let turn_environment = resolve_tool_environment(
&step_context.environments,
&step_context,
environment_args.environment_id.as_deref(),
"request_permissions requires a primary environment",
)?;
let sandbox_context =
turn_environment.sandbox_context(/*additional_permissions*/ None);
+20 -13
View File
@@ -7,6 +7,7 @@ use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CommandToolOptions {
pub include_login_parameter: bool,
pub exec_permission_approvals_enabled: bool,
}
@@ -15,6 +16,7 @@ pub fn create_exec_command_tool(options: CommandToolOptions) -> ToolSpec {
create_exec_command_tool_with_environment_id(
options,
/*include_environment_id*/ false,
/*include_shell_parameter*/ true,
/*include_windows_shell_guidance*/ cfg!(windows),
)
}
@@ -22,6 +24,7 @@ pub fn create_exec_command_tool(options: CommandToolOptions) -> ToolSpec {
pub(crate) fn create_exec_command_tool_with_environment_id(
options: CommandToolOptions,
include_environment_id: bool,
include_shell_parameter: bool,
include_windows_shell_guidance: bool,
) -> ToolSpec {
let yield_time_ms_description = if cfg!(windows) {
@@ -59,19 +62,23 @@ pub(crate) fn create_exec_command_tool_with_environment_id(
)),
),
]);
properties.insert(
"shell".to_string(),
JsonSchema::string(Some(
"Shell binary to launch. Defaults to the user's default shell.".to_string(),
)),
);
properties.insert(
"login".to_string(),
JsonSchema::boolean(Some(
"True runs the shell with -l/-i semantics; false disables them. Defaults to true."
.to_string(),
)),
);
if include_shell_parameter {
properties.insert(
"shell".to_string(),
JsonSchema::string(Some(
"Shell binary to launch. Defaults to the user's default shell.".to_string(),
)),
);
}
if options.include_login_parameter {
properties.insert(
"login".to_string(),
JsonSchema::boolean(Some(
"True runs the shell with -l/-i semantics; false disables them. Defaults to true."
.to_string(),
)),
);
}
if include_environment_id {
properties.insert(
"environment_id".to_string(),
@@ -9,6 +9,7 @@ fn windows_shell_guidance_description() -> String {
#[test]
fn exec_command_tool_matches_expected_spec() {
let tool = create_exec_command_tool(CommandToolOptions {
include_login_parameter: true,
exec_permission_approvals_enabled: false,
});
@@ -58,6 +58,8 @@ const EXEC_COMMAND_REJECTION_MAX_BYTES: usize = 900;
#[derive(Clone, Copy)]
pub(crate) struct ExecCommandHandlerOptions {
pub(crate) include_login_parameter: bool,
pub(crate) include_shell_parameter: bool,
pub(crate) allow_tty: bool,
pub(crate) exec_permission_approvals_enabled: bool,
pub(crate) include_environment_id: bool,
@@ -80,6 +82,8 @@ impl Default for ExecCommandHandler {
Self {
lifetime: ExecCommandLifetime::Interactive,
options: ExecCommandHandlerOptions {
include_login_parameter: false,
include_shell_parameter: true,
allow_tty: true,
exec_permission_approvals_enabled: false,
include_environment_id: false,
@@ -113,9 +117,11 @@ impl ToolExecutor<ToolInvocation> for ExecCommandHandler {
fn spec(&self) -> ToolSpec {
let spec = create_exec_command_tool_with_environment_id(
CommandToolOptions {
include_login_parameter: self.options.include_login_parameter,
exec_permission_approvals_enabled: self.options.exec_permission_approvals_enabled,
},
self.options.include_environment_id,
self.options.include_shell_parameter,
self.options.include_windows_shell_guidance,
);
let mut spec = match self.lifetime {
@@ -179,8 +185,9 @@ impl ExecCommandHandler {
);
let environment_args: ExecCommandEnvironmentArgs = parse_arguments(&arguments)?;
let turn_environment = resolve_tool_environment(
&step_context.environments,
&step_context,
environment_args.environment_id.as_deref(),
"unified exec is unavailable in this session",
)?;
let native_environment_cwd = turn_environment.cwd().clone();
let cwd = environment_args
@@ -1,4 +1,6 @@
use crate::function_tool::FunctionCallError;
use codex_features::Feature;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
@@ -80,7 +82,18 @@ impl WriteStdinHandler {
};
let args: WriteStdinArgs = parse_arguments(&arguments)?;
resolve_tool_environment(&step_context.environments, /*environment_id*/ None)?;
if turn
.config
.features
.get()
.enabled(Feature::StableEnvironmentTools)
{
resolve_tool_environment(
&step_context,
/*environment_id*/ None,
"unified exec is unavailable in this session",
)?;
}
let context =
UnifiedExecContext::new(session.clone(), step_context, cancellation_token, call_id);
let response = session
@@ -142,8 +142,11 @@ impl ViewImageHandler {
}
};
let turn_environment =
resolve_tool_environment(&step_context.environments, environment_id.as_deref())?;
let turn_environment = resolve_tool_environment(
&step_context,
environment_id.as_deref(),
"view_image is unavailable in this session",
)?;
let path_uri = turn_environment.cwd().join(&path).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"unable to resolve image path `{path}` against environment cwd `{}`: {err}",
+42 -9
View File
@@ -89,6 +89,7 @@ use codex_tools::ToolExecutor;
use codex_tools::ToolExposures;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use codex_tools::UnifiedExecShellMode;
use codex_tools::can_request_original_image_detail;
use codex_tools::collect_code_mode_exec_prompt_tool_definitions;
use codex_tools::collect_request_plugin_install_entries;
@@ -1079,9 +1080,20 @@ fn standalone_web_search_enabled(turn_context: &TurnContext, model_info: &ModelI
.enabled(Feature::StandaloneWebSearch))
}
fn tool_environment_mode(environments: &TurnEnvironmentSnapshot) -> ToolEnvironmentMode {
// Keep environment selectors stable as selected attachments change readiness.
ToolEnvironmentMode::from_count(environments.environments.len())
fn tool_environment_mode(context: &CoreToolPlanContext<'_>) -> ToolEnvironmentMode {
let count = if context
.turn_context
.config
.features
.get()
.enabled(Feature::StableEnvironmentTools)
{
// Keep selectors stable as selected attachments change readiness.
context.environments.environments.len()
} else {
context.environments.turn_environments().count()
};
ToolEnvironmentMode::from_count(count)
}
fn should_include_windows_shell_guidance(environments: &TurnEnvironmentSnapshot) -> bool {
@@ -1107,8 +1119,12 @@ fn should_include_windows_shell_guidance(environments: &TurnEnvironmentSnapshot)
fn add_shell_tools(context: &CoreToolPlanContext<'_>, registry: &mut ToolRegistry) {
let turn_context = context.turn_context;
let features = turn_context.config.features.get();
let environment_mode = tool_environment_mode(context.environments);
if !features.enabled(Feature::ShellTool)
let environment_mode = tool_environment_mode(context);
let stable_environment_tools = features.enabled(Feature::StableEnvironmentTools);
let advertise_environment_tools =
stable_environment_tools || environment_mode.has_environment();
if !advertise_environment_tools
|| !features.enabled(Feature::ShellTool)
|| matches!(context.model_info.shell_type, ConfigShellToolType::Disabled)
{
return;
@@ -1121,6 +1137,20 @@ fn add_shell_tools(context: &CoreToolPlanContext<'_>, registry: &mut ToolRegistr
&& context.tool_policy.expose_additional_permissions;
let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple);
let options = ExecCommandHandlerOptions {
include_login_parameter: stable_environment_tools
|| context
.environments
.turn_environments()
.any(|environment| environment.config().allow_login_shell),
include_shell_parameter: stable_environment_tools
|| !matches!(
&turn_context.unified_exec_shell_mode,
UnifiedExecShellMode::ZshFork(_)
)
|| context
.environments
.turn_environments()
.any(|environment| environment.environment.is_remote()),
allow_tty: features.enabled(Feature::UnifiedExecTty),
exec_permission_approvals_enabled,
include_environment_id,
@@ -1159,7 +1189,10 @@ fn add_mcp_resource_tools(context: &CoreToolPlanContext<'_>, registry: &mut Tool
fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, registry: &mut ToolRegistry) {
let turn_context = context.turn_context;
let features = turn_context.config.features.get();
let environment_mode = tool_environment_mode(context.environments);
let environment_mode = tool_environment_mode(context);
let stable_environment_tools = features.enabled(Feature::StableEnvironmentTools);
let advertise_environment_tools =
stable_environment_tools || environment_mode.has_environment();
if turn_context.config.update_plan_enabled {
registry.add(PlanHandler);
@@ -1224,7 +1257,7 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, registry: &mut Tool
registry.add_with_exposure(SendMessageToUserAsyncHandler, ToolExposure::DirectModelOnly);
}
if features.enabled(Feature::RequestPermissionsTool) {
if advertise_environment_tools && features.enabled(Feature::RequestPermissionsTool) {
registry.add(RequestPermissionsHandler);
}
@@ -1277,7 +1310,7 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, registry: &mut Tool
));
}
if context.model_info.apply_patch_tool_type.is_some() {
if advertise_environment_tools && context.model_info.apply_patch_tool_type.is_some() {
let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple);
registry.add(ApplyPatchHandler::new(include_environment_id));
}
@@ -1291,7 +1324,7 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, registry: &mut Tool
registry.add(TestSyncHandler);
}
if features.enabled(Feature::ViewImage) {
if advertise_environment_tools && features.enabled(Feature::ViewImage) {
let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple);
registry.add(ViewImageHandler::new(ViewImageToolOptions {
can_request_original_image_detail: can_request_original_image_detail(
+11 -3
View File
@@ -1907,6 +1907,10 @@ async fn pending_attachment_installs_configuration_before_waiting_turn_resumes()
let mut builder = test_codex()
.with_extensions(Arc::new(extensions.build()))
.with_config(|config| {
config
.features
.enable(Feature::StableEnvironmentTools)
.expect("enable stable environment tools");
assert!(config.features.enable(Feature::DeferredExecutor).is_ok());
if cfg!(unix) {
assert!(config.features.enable(Feature::ShellZshFork).is_ok());
@@ -2830,7 +2834,6 @@ async fn ready_before_selection_resolves_resumed_thread_capability_root_after_wa
// The first request may legally see either Starting or Ready; the wait makes step two ready.
let first_tools = tool_names(&requests[0].body_json());
assert!(first_tools.contains(&"wait_for_environment".to_string()));
assert!(first_tools.contains(&"exec_command".to_string()));
let first_user_context = requests[0].message_input_texts("user");
let first_environment_context = first_user_context
.iter()
@@ -2839,8 +2842,13 @@ async fn ready_before_selection_resolves_resumed_thread_capability_root_after_wa
let first_has_ready_root = first_user_context
.iter()
.any(|text| text.contains("<ready_capability_roots>ready-first-root"));
// Tool availability no longer distinguishes Starting from Ready.
if first_environment_context.contains("<status>starting</status>") {
let first_is_starting = first_environment_context.contains("<status>starting</status>");
assert_eq!(
first_tools.contains(&"exec_command".to_string()),
!first_is_starting,
"without stable_environment_tools, commands require a ready transport"
);
if first_is_starting {
assert!(!first_has_ready_root);
} else {
assert!(first_environment_context.contains("<shell>"));
+8 -3
View File
@@ -1710,9 +1710,14 @@ async fn subagent_waits_for_its_inherited_environment_configuration() -> Result<
use super::remote_env::spawn_tests::PendingSpawnCase;
use super::remote_env::spawn_tests::pending_subagent_scenario;
let requests =
pending_subagent_scenario(PendingSpawnCase::TurnSettings, configure_scenario_catalog)
.await?;
let requests = pending_subagent_scenario(PendingSpawnCase::TurnSettings, |config| {
configure_scenario_catalog(config);
config
.features
.enable(Feature::StableEnvironmentTools)
.expect("enable stable environment tools");
})
.await?;
let labels = [
"parent delegates while the environment is starting",
"parent waits for the worker",
@@ -3062,6 +3062,10 @@ async fn production_turn_keeps_rebalanced_catalogs_stable_after_compaction_and_r
model_info.max_context_window = None;
})
.with_config(|config| {
config
.features
.enable(Feature::StableEnvironmentTools)
.expect("enable stable environment tools");
configure_catalog_test(config);
config.cloud_skill_enabled = true;
config.model_provider.name = "Skills compaction test".to_string();
@@ -16,8 +16,8 @@ Settings:
text: {"verbosity":"low"}
tool_choice: "auto"
-- request 1 (turn) --
00:additional_tools/developer (3; hash=91d402a1b9d55c34):
- namespace/functions; hash=7d26e086c1dbba28
00:additional_tools/developer (3; hash=b6ebf543280ec271):
- namespace/functions; hash=bd9e182b4235cdc1
- custom/exec
- function/wait
- function/request_user_input
+26 -5
View File
@@ -244,8 +244,12 @@ async fn strict_tool_collisions_do_not_duplicate_unrelated_compaction_errors() -
Ok(())
}
#[test_case::test_case(false; "default_off")]
#[test_case::test_case(true; "opted_in")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn empty_turn_environments_keeps_environment_backed_tools() -> Result<()> {
async fn empty_turn_environments_gate_environment_backed_tools(
stable_environment_tools: bool,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
@@ -259,7 +263,17 @@ async fn empty_turn_environments_keeps_environment_backed_tools() -> Result<()>
)
.await;
let mut builder = test_codex().with_config(|config| {
let mut builder = test_codex().with_config(move |config| {
if stable_environment_tools {
config
.features
.enable(Feature::StableEnvironmentTools)
.expect("enable stable environment tools");
}
config
.features
.enable(Feature::RequestPermissionsTool)
.expect("enable permissions tool");
config.update_plan_enabled = true;
config
.features
@@ -276,10 +290,17 @@ async fn empty_turn_environments_keeps_environment_backed_tools() -> Result<()>
tools.contains(&"update_plan".to_string()),
"non-environment tool should remain available; got {tools:?}"
);
for environment_tool in ["exec_command", "write_stdin", "apply_patch", "view_image"] {
assert!(
for environment_tool in [
"exec_command",
"write_stdin",
"apply_patch",
"view_image",
"request_permissions",
] {
assert_eq!(
tools.contains(&environment_tool.to_string()),
"{environment_tool} should remain available for explicit empty turn environments; got {tools:?}"
stable_environment_tools,
"unexpected {environment_tool} availability for empty environments; got {tools:?}"
);
}
+9
View File
@@ -116,6 +116,9 @@ pub enum Feature {
DaemonAutoStart,
// Experimental
/// Advertise enabled environment-backed tools and their shell parameters before the
/// executor is ready. Actual execution still requires a usable environment and its policy.
StableEnvironmentTools,
/// Send per-content-entry classifications in internal Responses metadata.
ContentItemKinds,
/// Record model-attempted tool calls in internal Responses metadata.
@@ -1095,6 +1098,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::StableEnvironmentTools,
key: "stable_environment_tools",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::CodeMode,
key: "code_mode",