Support multi-environment apply_patch selection (#21617)

## Summary
- add multi-environment apply_patch routing for both freeform and
function-call tool flows
- parse and reconcile the optional environment selector in the main
apply_patch parser, then verify against the selected environment in the
handler
- carry environment_id through runtime and approval surfaces so
remote-targeted patches stay explicit end to end

## Testing
- just fmt
- remote exec-server e2e: `cargo test -p codex-core --test all
apply_patch_multi_environment_uses_remote_executor -- --nocapture` on
dev via `scripts/test-remote-env.sh`

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
starr-openai
2026-05-11 16:33:44 -07:00
committed by GitHub
Unverified
parent bb6134c028
commit 22e84c49d0
18 changed files with 991 additions and 123 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ pub(crate) async fn apply_patch(
turn_context.approval_policy.value(),
&turn_context.permission_profile(),
file_system_sandbox_policy,
&turn_context.cwd,
&action.cwd,
turn_context.windows_sandbox_level,
) {
SafetyCheck::AutoApprove {
+58 -28
View File
@@ -12,6 +12,7 @@ use crate::apply_patch::convert_apply_patch_to_protocol;
use crate::function_tool::FunctionCallError;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::session::turn_context::TurnEnvironment;
use crate::tools::context::ApplyPatchToolOutput;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::SharedTurnDiffTracker;
@@ -22,6 +23,7 @@ use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
use crate::tools::handlers::apply_granted_turn_permissions;
use crate::tools::handlers::apply_patch_spec::create_apply_patch_freeform_tool;
use crate::tools::handlers::resolve_tool_environment;
use crate::tools::hook_names::HookToolName;
use crate::tools::orchestrator::ToolOrchestrator;
use crate::tools::registry::PostToolUsePayload;
@@ -51,8 +53,18 @@ use codex_tools::ToolSpec;
use codex_utils_absolute_path::AbsolutePathBuf;
const APPLY_PATCH_ARGUMENT_DIFF_BUFFER_INTERVAL: Duration = Duration::from_millis(500);
/// Handles freeform `apply_patch` requests and routes verified patches to the
/// selected environment filesystem.
#[derive(Default)]
pub struct ApplyPatchHandler {
multi_environment: bool,
}
pub struct ApplyPatchHandler;
impl ApplyPatchHandler {
pub(crate) fn new(multi_environment: bool) -> Self {
Self { multi_environment }
}
}
#[derive(Default)]
struct ApplyPatchArgumentDiffConsumer {
@@ -253,6 +265,7 @@ async fn effective_patch_permissions(
session: &Session,
turn: &TurnContext,
action: &ApplyPatchAction,
cwd: &AbsolutePathBuf,
) -> (
Vec<AbsolutePathBuf>,
crate::tools::handlers::EffectiveAdditionalPermissions,
@@ -270,9 +283,9 @@ async fn effective_patch_permissions(
);
let effective_additional_permissions = apply_granted_turn_permissions(
session,
turn.cwd.as_path(),
cwd.as_path(),
crate::sandboxing::SandboxPermissions::UseDefault,
write_permissions_for_paths(&file_paths, &file_system_sandbox_policy, &turn.cwd),
write_permissions_for_paths(&file_paths, &file_system_sandbox_policy, cwd),
)
.await;
@@ -291,7 +304,7 @@ impl ToolHandler for ApplyPatchHandler {
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_apply_patch_freeform_tool())
Some(create_apply_patch_freeform_tool(self.multi_environment))
}
fn kind(&self) -> ToolKind {
@@ -350,32 +363,36 @@ impl ToolHandler for ApplyPatchHandler {
"apply_patch handler received unsupported payload".to_string(),
));
};
let args = match codex_apply_patch::parse_patch(&patch_input) {
Ok(args) => args,
Err(parse_error) => {
return Err(FunctionCallError::RespondToModel(format!(
"apply_patch verification failed: {parse_error}"
)));
}
};
let selected_environment_id =
require_environment_id(args.environment_id.as_deref(), self.multi_environment)?;
// Re-parse and verify the patch so we can compute changes and approval.
// Avoid building temporary ExecParams/command vectors; derive directly from inputs.
let cwd = turn.cwd.clone();
let command = vec!["apply_patch".to_string(), patch_input.clone()];
let Some(turn_environment) = turn.environments.primary() else {
// Verify the parsed patch against the selected environment filesystem.
let Some(turn_environment) =
resolve_tool_environment(turn.as_ref(), selected_environment_id.as_deref())?
else {
return Err(FunctionCallError::RespondToModel(
"apply_patch is unavailable in this session".to_string(),
));
};
let cwd = turn_environment.cwd.clone();
let fs = turn_environment.environment.get_filesystem();
let sandbox = turn_environment
.environment
.is_remote()
.then(|| turn.file_system_sandbox_context(/*additional_permissions*/ None));
match codex_apply_patch::maybe_parse_apply_patch_verified(
&command,
&cwd,
fs.as_ref(),
sandbox.as_ref(),
)
.await
let mut sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None);
sandbox.cwd = Some(cwd.clone());
match codex_apply_patch::verify_apply_patch_args(args, &cwd, fs.as_ref(), Some(&sandbox))
.await
{
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
let (file_paths, effective_additional_permissions, file_system_sandbox_policy) =
effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes).await;
effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes, &cwd)
.await;
match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes)
.await
{
@@ -396,6 +413,7 @@ impl ToolHandler for ApplyPatchHandler {
emitter.begin(event_ctx).await;
let req = ApplyPatchRequest {
turn_environment: turn_environment.clone(),
action: apply.action,
file_paths,
changes,
@@ -464,18 +482,16 @@ pub(crate) async fn intercept_apply_patch(
command: &[String],
cwd: &AbsolutePathBuf,
fs: &dyn ExecutorFileSystem,
turn_environment: TurnEnvironment,
session: Arc<Session>,
turn: Arc<TurnContext>,
tracker: Option<&SharedTurnDiffTracker>,
call_id: &str,
tool_name: &str,
) -> Result<Option<FunctionToolOutput>, FunctionCallError> {
let sandbox = turn
.environments
.primary()
.filter(|env| env.environment.is_remote())
.map(|_| turn.file_system_sandbox_context(/*additional_permissions*/ None));
match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, sandbox.as_ref())
let mut sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None);
sandbox.cwd = Some(cwd.clone());
match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, Some(&sandbox))
.await
{
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
@@ -488,7 +504,7 @@ pub(crate) async fn intercept_apply_patch(
)
.await;
let (approval_keys, effective_additional_permissions, file_system_sandbox_policy) =
effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes).await;
effective_patch_permissions(session.as_ref(), turn.as_ref(), &changes, cwd).await;
match apply_patch::apply_patch(turn.as_ref(), &file_system_sandbox_policy, changes)
.await
{
@@ -508,6 +524,7 @@ pub(crate) async fn intercept_apply_patch(
emitter.begin(event_ctx).await;
let req = ApplyPatchRequest {
turn_environment,
action: apply.action,
file_paths: approval_keys,
changes,
@@ -564,6 +581,19 @@ pub(crate) async fn intercept_apply_patch(
}
}
fn require_environment_id(
parsed_environment_id: Option<&str>,
allow_environment_id: bool,
) -> Result<Option<String>, FunctionCallError> {
match parsed_environment_id {
Some(_) if !allow_environment_id => Err(FunctionCallError::RespondToModel(
"apply_patch environment selection is unavailable for this turn".to_string(),
)),
Some(environment_id) => Ok(Some(environment_id.to_string())),
None => Ok(None),
}
}
#[cfg(test)]
#[path = "apply_patch_tests.rs"]
mod tests;
@@ -6,14 +6,22 @@ const APPLY_PATCH_LARK_GRAMMAR: &str = include_str!("apply_patch.lark");
/// Returns a custom tool that can be used to edit files. Well-suited for GPT-5 models
/// https://platform.openai.com/docs/guides/function-calling#custom-tools
pub fn create_apply_patch_freeform_tool() -> ToolSpec {
pub fn create_apply_patch_freeform_tool(include_environment_id: bool) -> ToolSpec {
let definition = if include_environment_id {
APPLY_PATCH_LARK_GRAMMAR.replace(
"start: begin_patch hunk+ end_patch",
"start: begin_patch environment_id? hunk+ end_patch\nenvironment_id: \"*** Environment ID: \" filename LF",
)
} else {
APPLY_PATCH_LARK_GRAMMAR.to_string()
};
ToolSpec::Freeform(FreeformTool {
name: "apply_patch".to_string(),
description: "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.".to_string(),
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: APPLY_PATCH_LARK_GRAMMAR.to_string(),
definition,
},
})
}
@@ -4,7 +4,7 @@ use pretty_assertions::assert_eq;
#[test]
fn create_apply_patch_freeform_tool_matches_expected_spec() {
assert_eq!(
create_apply_patch_freeform_tool(),
create_apply_patch_freeform_tool(/*include_environment_id*/ false),
ToolSpec::Freeform(FreeformTool {
name: "apply_patch".to_string(),
description:
@@ -18,3 +18,19 @@ fn create_apply_patch_freeform_tool_matches_expected_spec() {
})
);
}
#[test]
fn create_apply_patch_freeform_tool_includes_environment_id_when_requested() {
let ToolSpec::Freeform(tool) =
create_apply_patch_freeform_tool(/*include_environment_id*/ true)
else {
panic!("expected freeform tool");
};
assert!(tool.format.definition.contains("environment_id?"));
assert!(
tool.format
.definition
.contains("\"*** Environment ID: \" filename LF")
);
}
@@ -49,7 +49,7 @@ async fn pre_tool_use_payload_uses_freeform_patch_input() {
input: patch.to_string(),
};
let invocation = invocation_for_payload(payload).await;
let handler = ApplyPatchHandler;
let handler = ApplyPatchHandler::default();
assert_eq!(
handler.pre_tool_use_payload(&invocation),
@@ -68,7 +68,7 @@ async fn post_tool_use_payload_uses_patch_input_and_tool_output() {
};
let invocation = invocation_for_payload(payload).await;
let output = ApplyPatchToolOutput::from_text("Success. Updated files.".to_string());
let handler = ApplyPatchHandler;
let handler = ApplyPatchHandler::default();
assert_eq!(
handler.post_tool_use_payload(&invocation, &output),
@@ -135,6 +135,32 @@ fn diff_consumer_streams_apply_patch_changes() {
);
}
#[test]
fn diff_consumer_streams_apply_patch_changes_with_environment_header() {
let mut consumer = ApplyPatchArgumentDiffConsumer::default();
assert!(
consumer
.push_delta(
"call-1".to_string(),
"*** Begin Patch\n*** Environment ID: remote\n",
)
.is_none()
);
let event = consumer
.push_delta("call-1".to_string(), "*** Add File: hello.txt\n+hello")
.expect("progress event");
assert_eq!(
event.changes,
HashMap::from([(
PathBuf::from("hello.txt"),
FileChange::Add {
content: String::new(),
},
)])
);
}
#[test]
fn diff_consumer_sends_next_update_after_buffer_interval() {
let mut consumer = ApplyPatchArgumentDiffConsumer::default();
@@ -168,6 +194,22 @@ fn diff_consumer_sends_next_update_after_buffer_interval() {
);
}
#[test]
fn reconcile_environment_id_requires_selection_when_enabled() {
assert_eq!(
require_environment_id(Some("remote"), /*allow_environment_id*/ false),
Err(FunctionCallError::RespondToModel(
"apply_patch environment selection is unavailable for this turn".to_string(),
))
);
assert_eq!(
require_environment_id(
/*parsed_environment_id*/ None, /*allow_environment_id*/ true
),
Ok(None)
);
}
#[tokio::test]
async fn approval_keys_include_move_destination() {
let tmp = TempDir::new().expect("tmp");
@@ -198,6 +198,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, Func
&exec_params.command,
&exec_params.cwd,
fs.as_ref(),
turn_environment.clone(),
session.clone(),
turn.clone(),
Some(&tracker),
@@ -273,6 +273,7 @@ impl ToolHandler for ExecCommandHandler {
&command,
&cwd,
fs.as_ref(),
turn_environment.clone(),
context.session.clone(),
context.turn.clone(),
Some(&tracker),
@@ -6,6 +6,7 @@
use crate::exec::is_likely_sandbox_denied;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::review_approval_request;
use crate::session::turn_context::TurnEnvironment;
use crate::tools::hook_names::HookToolName;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
@@ -36,8 +37,15 @@ use futures::future::BoxFuture;
use std::path::PathBuf;
use std::time::Instant;
#[derive(Clone, Debug, Eq, PartialEq, Hash, serde::Serialize)]
pub(crate) struct ApplyPatchApprovalKey {
environment_id: String,
path: AbsolutePathBuf,
}
#[derive(Debug)]
pub struct ApplyPatchRequest {
pub turn_environment: TurnEnvironment,
pub action: ApplyPatchAction,
pub file_paths: Vec<AbsolutePathBuf>,
pub changes: std::collections::HashMap<PathBuf, FileChange>,
@@ -108,10 +116,17 @@ impl Sandboxable for ApplyPatchRuntime {
}
impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
type ApprovalKey = AbsolutePathBuf;
type ApprovalKey = ApplyPatchApprovalKey;
fn approval_keys(&self, req: &ApplyPatchRequest) -> Vec<Self::ApprovalKey> {
req.file_paths.clone()
req.file_paths
.iter()
.cloned()
.map(|path| ApplyPatchApprovalKey {
environment_id: req.turn_environment.environment_id.clone(),
path,
})
.collect()
}
fn start_approval_async<'a>(
@@ -198,17 +213,18 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
}
impl ToolRuntime<ApplyPatchRequest, ApplyPatchRuntimeOutput> for ApplyPatchRuntime {
fn sandbox_cwd<'a>(&self, req: &'a ApplyPatchRequest) -> Option<&'a AbsolutePathBuf> {
Some(&req.action.cwd)
}
async fn run(
&mut self,
req: &ApplyPatchRequest,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
_ctx: &ToolCtx,
) -> Result<ApplyPatchRuntimeOutput, ToolError> {
let turn_environment = ctx.turn.environments.primary().ok_or_else(|| {
ToolError::Rejected("apply_patch is unavailable in this session".to_string())
})?;
let started_at = Instant::now();
let fs = turn_environment.environment.get_filesystem();
let fs = req.turn_environment.environment.get_filesystem();
let sandbox = Self::file_system_sandbox_context_for_attempt(req, attempt);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
@@ -15,6 +15,14 @@ use codex_sandboxing::policy_transforms::effective_network_sandbox_policy;
use core_test_support::PathBufExt;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
fn test_turn_environment(environment_id: &str) -> crate::session::turn_context::TurnEnvironment {
crate::session::turn_context::TurnEnvironment {
environment_id: environment_id.to_string(),
environment: std::sync::Arc::new(codex_exec_server::Environment::default_for_tests()),
cwd: std::env::temp_dir().abs(),
shell: None,
}
}
#[test]
fn wants_no_sandbox_approval_granular_respects_sandbox_flag() {
@@ -40,8 +48,8 @@ fn wants_no_sandbox_approval_granular_respects_sandbox_flag() {
);
}
#[test]
fn guardian_review_request_includes_patch_context() {
#[tokio::test]
async fn guardian_review_request_includes_patch_context() {
let path = std::env::temp_dir()
.join("guardian-apply-patch-test.txt")
.abs();
@@ -49,6 +57,7 @@ fn guardian_review_request_includes_patch_context() {
let expected_cwd = action.cwd.clone();
let expected_patch = action.patch.clone();
let request = ApplyPatchRequest {
turn_environment: test_turn_environment(codex_exec_server::LOCAL_ENVIRONMENT_ID),
action,
file_paths: vec![path.clone()],
changes: HashMap::from([(
@@ -78,8 +87,8 @@ fn guardian_review_request_includes_patch_context() {
);
}
#[test]
fn permission_request_payload_uses_apply_patch_hook_name_and_aliases() {
#[tokio::test]
async fn permission_request_payload_uses_apply_patch_hook_name_and_aliases() {
let runtime = ApplyPatchRuntime::new();
let path = std::env::temp_dir()
.join("apply-patch-permission-request-payload.txt")
@@ -87,6 +96,7 @@ fn permission_request_payload_uses_apply_patch_hook_name_and_aliases() {
let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string());
let expected_patch = action.patch.clone();
let req = ApplyPatchRequest {
turn_environment: test_turn_environment(codex_exec_server::LOCAL_ENVIRONMENT_ID),
action,
file_paths: vec![path],
changes: HashMap::new(),
@@ -113,8 +123,62 @@ fn permission_request_payload_uses_apply_patch_hook_name_and_aliases() {
);
}
#[test]
fn file_system_sandbox_context_uses_active_attempt() {
#[tokio::test]
async fn approval_keys_include_environment_id() {
let runtime = ApplyPatchRuntime::new();
let path = std::env::temp_dir()
.join("apply-patch-approval-key.txt")
.abs();
let req = ApplyPatchRequest {
turn_environment: test_turn_environment("remote"),
action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()),
file_paths: vec![path.clone()],
changes: HashMap::new(),
exec_approval_requirement: ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: None,
},
additional_permissions: None,
permissions_preapproved: false,
};
let keys = runtime.approval_keys(&req);
assert_eq!(
serde_json::to_value(&keys).expect("serialize approval keys"),
serde_json::json!([
{
"environment_id": "remote",
"path": path,
}
])
);
}
#[tokio::test]
async fn sandbox_cwd_uses_patch_action_cwd() {
let runtime = ApplyPatchRuntime::new();
let path = std::env::temp_dir()
.join("apply-patch-runtime-sandbox-cwd.txt")
.abs();
let req = ApplyPatchRequest {
turn_environment: test_turn_environment(codex_exec_server::LOCAL_ENVIRONMENT_ID),
action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()),
file_paths: vec![path.clone()],
changes: HashMap::new(),
exec_approval_requirement: ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: None,
},
additional_permissions: None,
permissions_preapproved: false,
};
assert_eq!(runtime.sandbox_cwd(&req), Some(&req.action.cwd));
}
#[tokio::test]
async fn file_system_sandbox_context_uses_active_attempt() {
let path = std::env::temp_dir()
.join("apply-patch-runtime-attempt.txt")
.abs();
@@ -126,6 +190,7 @@ fn file_system_sandbox_context_uses_active_attempt() {
)),
};
let req = ApplyPatchRequest {
turn_environment: test_turn_environment(codex_exec_server::LOCAL_ENVIRONMENT_ID),
action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()),
file_paths: vec![path.clone()],
changes: HashMap::new(),
@@ -177,12 +242,13 @@ fn file_system_sandbox_context_uses_active_attempt() {
assert_eq!(sandbox.use_legacy_landlock, true);
}
#[test]
fn no_sandbox_attempt_has_no_file_system_context() {
#[tokio::test]
async fn no_sandbox_attempt_has_no_file_system_context() {
let path = std::env::temp_dir()
.join("apply-patch-runtime-none.txt")
.abs();
let req = ApplyPatchRequest {
turn_environment: test_turn_environment(codex_exec_server::LOCAL_ENVIRONMENT_ID),
action: ApplyPatchAction::new_add_for_test(&path, "hello".to_string()),
file_paths: vec![path.clone()],
changes: HashMap::new(),
+3 -1
View File
@@ -258,7 +258,9 @@ pub fn build_tool_registry_builder(
}
if config.environment_mode.has_environment() && config.apply_patch_tool_type.is_some() {
builder.register_handler(Arc::new(ApplyPatchHandler));
let include_environment_id =
matches!(config.environment_mode, ToolEnvironmentMode::Multiple);
builder.register_handler(Arc::new(ApplyPatchHandler::new(include_environment_id)));
}
if config
+47 -1
View File
@@ -190,7 +190,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
create_write_stdin_tool(),
create_update_plan_tool(),
request_user_input_tool_spec(&request_user_input_available_modes(&features)),
create_apply_patch_freeform_tool(),
create_apply_patch_freeform_tool(/*include_environment_id*/ false),
ToolSpec::WebSearch {
external_web_access: Some(true),
filters: None,
@@ -301,6 +301,40 @@ fn exec_command_spec_includes_environment_id_only_for_multiple_selected_environm
);
}
#[test]
fn apply_patch_spec_includes_environment_id_only_for_multiple_selected_environments() {
let model_info = model_info();
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &Features::with_defaults(),
image_generation_tool_auth_allowed: true,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
permission_profile: &PermissionProfile::Disabled,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
let (single_environment_tools, _) = build_specs(
&tools_config,
/*mcp_tools*/ None,
/*deferred_mcp_tools*/ None,
&[],
);
assert_apply_patch_environment_id(&single_environment_tools, /*expected_present*/ false);
let multi_environment_config =
tools_config.with_environment_mode(ToolEnvironmentMode::Multiple);
let (multi_environment_tools, _) = build_specs(
&multi_environment_config,
/*mcp_tools*/ None,
/*deferred_mcp_tools*/ None,
&[],
);
assert_apply_patch_environment_id(&multi_environment_tools, /*expected_present*/ true);
}
#[test]
fn test_build_specs_collab_tools_enabled() {
let model_info = model_info();
@@ -2649,6 +2683,18 @@ fn assert_process_tool_environment_id(
);
}
fn assert_apply_patch_environment_id(tools: &[ConfiguredToolSpec], expected_present: bool) {
let tool = find_tool(tools, "apply_patch");
let ToolSpec::Freeform(FreeformTool { format, .. }) = &tool.spec else {
panic!("expected freeform apply_patch tool");
};
assert_eq!(
format.definition.contains("environment_id?"),
expected_present,
"apply_patch environment_id grammar presence"
);
}
fn find_namespace_function_tool<'a>(
tools: &'a [ConfiguredToolSpec],
expected_namespace: &str,