diff --git a/codex-rs/app-server-protocol/schema/json/FileChangeRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/FileChangeRequestApprovalParams.json index f17388aa53..2f635bbd3c 100644 --- a/codex-rs/app-server-protocol/schema/json/FileChangeRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/FileChangeRequestApprovalParams.json @@ -1,6 +1,32 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, "properties": { + "cwd": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Selected working directory for this patch apply." + }, + "environmentId": { + "default": null, + "description": "Stable selected environment id for this patch apply.", + "type": [ + "string", + "null" + ] + }, "grantRoot": { "description": "[UNSTABLE] When set, the agent is asking the user to allow writes under this root for the remainder of the session (unclear if this is honored today).", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 9844eac0b8..3b6bb5cd79 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -587,6 +587,26 @@ }, "FileChangeRequestApprovalParams": { "properties": { + "cwd": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Selected working directory for this patch apply." + }, + "environmentId": { + "default": null, + "description": "Stable selected environment id for this patch apply.", + "type": [ + "string", + "null" + ] + }, "grantRoot": { "description": "[UNSTABLE] When set, the agent is asking the user to allow writes under this root for the remainder of the session (unclear if this is honored today).", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index d11d499845..9fe85669f0 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -2400,6 +2400,26 @@ "FileChangeRequestApprovalParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "cwd": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Selected working directory for this patch apply." + }, + "environmentId": { + "default": null, + "description": "Stable selected environment id for this patch apply.", + "type": [ + "string", + "null" + ] + }, "grantRoot": { "description": "[UNSTABLE] When set, the agent is asking the user to allow writes under this root for the remainder of the session (unclear if this is honored today).", "type": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FileChangeRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FileChangeRequestApprovalParams.ts index 2db7be9ec4..331c823e9d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FileChangeRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FileChangeRequestApprovalParams.ts @@ -1,12 +1,21 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; export type FileChangeRequestApprovalParams = { threadId: string, turnId: string, itemId: string, /** * Unix timestamp (in milliseconds) when this approval request started. */ startedAtMs: number, +/** + * Stable selected environment id for this patch apply. + */ +environmentId?: string | null, +/** + * Selected working directory for this patch apply. + */ +cwd?: AbsolutePathBuf | null, /** * Optional explanatory reason (e.g. request for extra write access). */ diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index b053cf7e76..250d240f94 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -2533,9 +2533,11 @@ mod tests { turn_id: turn_id.to_string(), started_at_ms: 0, environment_id: "local".into(), - cwd: std::env::temp_dir() - .try_into() - .expect("temp dir should be absolute"), + cwd: Some( + std::env::temp_dir() + .try_into() + .expect("temp dir should be absolute"), + ), changes: [( PathBuf::from("README.md"), codex_protocol::protocol::FileChange::Add { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index 0e22c48590..9239ba43ff 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -1336,6 +1336,14 @@ pub struct FileChangeRequestApprovalParams { /// Unix timestamp (in milliseconds) when this approval request started. #[ts(type = "number")] pub started_at_ms: i64, + /// Stable selected environment id for this patch apply. + #[serde(default)] + #[ts(optional = nullable)] + pub environment_id: Option, + /// Selected working directory for this patch apply. + #[serde(default)] + #[ts(optional = nullable)] + pub cwd: Option, /// Optional explanatory reason (e.g. request for extra write access). #[ts(optional = nullable)] pub reason: Option, diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index e67f6e02f3..a4575c3631 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -2022,6 +2022,8 @@ impl CodexClient { turn_id, item_id, started_at_ms: _, + environment_id, + cwd, reason, grant_root, } = params; @@ -2032,6 +2034,12 @@ impl CodexClient { if let Some(reason) = reason.as_deref() { println!("< reason: {reason}"); } + if let Some(environment_id) = environment_id.as_deref() { + println!("< environment: {environment_id}"); + } + if let Some(cwd) = cwd.as_ref() { + println!("< cwd: {}", cwd.display()); + } if let Some(grant_root) = grant_root.as_deref() { println!("< grant root: {}", grant_root.display()); } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 5dd75ee6f4..1712c68473 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1326,7 +1326,7 @@ Order of messages: Order of messages: 1. `item/started` — emits a `fileChange` item with `changes` (diff chunk summaries) and `status: "inProgress"`. Show the proposed edits and paths to the user. -2. `item/fileChange/requestApproval` (request) — includes `itemId`, `threadId`, `turnId`, an optional `reason`, and may include unstable `grantRoot` when the agent is asking for session-scoped write access under a specific root. +2. `item/fileChange/requestApproval` (request) — includes `itemId`, `threadId`, `turnId`, optional `environmentId` and `cwd` describing the selected patch target, an optional `reason`, and may include unstable `grantRoot` when the agent is asking for session-scoped write access under a specific root. 3. Client response — `{ "decision": "accept" }`, `{ "decision": "acceptForSession" }`, `{ "decision": "decline" }`, or `{ "decision": "cancel" }`. 4. `serverRequest/resolved` — `{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt. 5. `item/completed` — returns the same `fileChange` item with `status` updated to `completed`, `failed`, or `declined` after the patch attempt. Rely on this to show success/failure and finalize the diff state in your UI. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 1f2f289b05..40e844897e 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -512,6 +512,9 @@ pub(crate) async fn apply_bespoke_event_handling( turn_id: event.turn_id.clone(), item_id: item_id.clone(), started_at_ms: event.started_at_ms, + environment_id: (!event.environment_id.is_empty()) + .then_some(event.environment_id.clone()), + cwd: event.cwd.clone(), reason: event.reason.clone(), grant_root: event.grant_root.clone(), }; diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index cbe196cd98..76722523f5 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -1210,6 +1210,8 @@ mod tests { turn_id: "turn-1".to_string(), item_id: "call-2".to_string(), started_at_ms: 0, + environment_id: None, + cwd: None, reason: None, grant_root: None, }, diff --git a/codex-rs/apply-patch/src/parser.rs b/codex-rs/apply-patch/src/parser.rs index bfe73e972b..7c45520225 100644 --- a/codex-rs/apply-patch/src/parser.rs +++ b/codex-rs/apply-patch/src/parser.rs @@ -761,6 +761,54 @@ fn test_parse_patch() { ); } +#[test] +fn test_parse_patch_environment_id_header() { + let patch = "*** Begin Patch\n*** Environment ID: remote\n*** Add File: hello.txt\n+hello\n*** End Patch"; + + assert_eq!( + parse_patch_with_options( + patch, + ParseOptions { + allow_environment_id: true, + }, + ), + Ok(ApplyPatchArgs { + hunks: vec![AddFile { + path: PathBuf::from("hello.txt"), + contents: "hello\n".to_string(), + }], + patch: "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch".to_string(), + workdir: None, + environment_id: Some("remote".to_string()), + }) + ); +} + +#[test] +fn test_parse_patch_environment_id_requires_opt_in() { + let patch = "*** Begin Patch\n*** Environment ID: remote\n*** Add File: hello.txt\n+hello\n*** End Patch"; + + assert!(parse_patch(patch).is_err()); +} + +#[test] +fn test_parse_patch_environment_id_cannot_be_empty() { + let patch = + "*** Begin Patch\n*** Environment ID: \n*** Add File: hello.txt\n+hello\n*** End Patch"; + + assert_eq!( + parse_patch_with_options( + patch, + ParseOptions { + allow_environment_id: true, + }, + ), + Err(InvalidPatchError( + "Environment ID cannot be empty".to_string() + )) + ); +} + #[test] fn test_parse_patch_accepts_relative_and_absolute_hunk_paths() { let dir = tempfile::tempdir().unwrap(); diff --git a/codex-rs/apply-patch/src/streaming_parser.rs b/codex-rs/apply-patch/src/streaming_parser.rs index ae7efaac74..4a5b5328db 100644 --- a/codex-rs/apply-patch/src/streaming_parser.rs +++ b/codex-rs/apply-patch/src/streaming_parser.rs @@ -450,6 +450,24 @@ mod tests { ); } + #[test] + fn test_streaming_patch_parser_allows_fragmented_environment_id_header() { + let mut parser = StreamingPatchParser::default(); + parser.set_allow_environment_id(true); + + assert_eq!( + parser.push_delta("*** Begin Patch\n*** Environment ID: rem"), + Ok(Vec::new()) + ); + assert_eq!( + parser.push_delta("ote\n*** Add File: src/hello.txt\n+hello\n"), + Ok(vec![AddFile { + path: PathBuf::from("src/hello.txt"), + contents: "hello\n".to_string(), + }]) + ); + } + #[test] fn test_streaming_patch_parser_large_patch_split_by_character() { let patch = "\ diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index 2463d69c2b..38dc45ec1f 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -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 { diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index dbecd2b57f..ea4fd12cde 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -528,6 +528,7 @@ async fn handle_patch_approval( grant_root, .. } = event; + let cwd = cwd.unwrap_or_else(|| parent_ctx.cwd.clone()); let approval_id = call_id.clone(); let guardian_decision = if routes_approval_to_guardian(parent_ctx) { let files = changes diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 0ce6f7ee1c..ff66895923 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2019,7 +2019,7 @@ impl Session { turn_id: turn_context.sub_id.clone(), started_at_ms: now_unix_timestamp_ms(), environment_id, - cwd, + cwd: Some(cwd), changes, reason, grant_root, diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index f57dd015e4..5befe342f5 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -25,6 +25,7 @@ use crate::tools::handlers::apply_patch_spec::ApplyPatchToolArgs; use crate::tools::handlers::apply_patch_spec::create_apply_patch_freeform_tool; use crate::tools::handlers::apply_patch_spec::create_apply_patch_json_tool; use crate::tools::handlers::parse_arguments; +use crate::tools::handlers::resolve_tool_environment; use crate::tools::hook_names::HookToolName; use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::registry::PostToolUsePayload; @@ -278,14 +279,52 @@ fn write_permissions_for_paths( /// The apply_patch tool can arrive as the older JSON/function shape or as a /// freeform custom tool call. Both represent the same file edit operation, so /// hooks see the raw patch body in `tool_input.command` either way. -fn apply_patch_payload_command(payload: &ToolPayload) -> Option { - match payload { - ToolPayload::Function { arguments } => parse_arguments::(arguments) - .ok() - .map(|args| args.input), - ToolPayload::Custom { input } => Some(input.clone()), +fn apply_patch_hook_tool_input(invocation: &ToolInvocation) -> Option { + let (command, function_environment_id) = match &invocation.payload { + ToolPayload::Function { arguments } => { + let args = parse_arguments::(arguments).ok()?; + Some((args.input, args.environment_id)) + } + ToolPayload::Custom { input } => Some((input.clone(), None)), _ => None, + }?; + + let parsed_environment_id = codex_apply_patch::parse_patch_with_options( + &command, + ParseOptions { + allow_environment_id: true, + }, + ) + .ok() + .and_then(|args| args.environment_id); + let requested_environment_id = function_environment_id + .as_deref() + .or(parsed_environment_id.as_deref()) + .filter(|environment_id| !environment_id.trim().is_empty()); + let selected_environment = + resolve_tool_environment(invocation.turn.as_ref(), requested_environment_id) + .ok() + .flatten(); + + let mut tool_input = serde_json::json!({ "command": command }); + if let Some(object) = tool_input.as_object_mut() { + if let Some(environment) = selected_environment { + object.insert( + "environment_id".to_string(), + serde_json::Value::String(environment.environment_id.clone()), + ); + object.insert( + "cwd".to_string(), + serde_json::json!(environment.cwd.clone()), + ); + } else if let Some(environment_id) = requested_environment_id { + object.insert( + "environment_id".to_string(), + serde_json::Value::String(environment_id.to_string()), + ); + } } + Some(tool_input) } fn reconcile_environment_id( @@ -415,9 +454,9 @@ impl ToolHandler for ApplyPatchHandler { } fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - apply_patch_payload_command(&invocation.payload).map(|command| PreToolUsePayload { + apply_patch_hook_tool_input(invocation).map(|tool_input| PreToolUsePayload { tool_name: HookToolName::apply_patch(), - tool_input: serde_json::json!({ "command": command }), + tool_input, }) } @@ -431,9 +470,7 @@ impl ToolHandler for ApplyPatchHandler { Some(PostToolUsePayload { tool_name: HookToolName::apply_patch(), tool_use_id: invocation.call_id.clone(), - tool_input: serde_json::json!({ - "command": apply_patch_payload_command(&invocation.payload)?, - }), + tool_input: apply_patch_hook_tool_input(invocation)?, tool_response, }) } @@ -607,17 +644,16 @@ pub(crate) async fn intercept_apply_patch( command: &[String], cwd: &AbsolutePathBuf, fs: &dyn ExecutorFileSystem, + environment_id: &str, + environment_is_remote: bool, session: Arc, turn: Arc, tracker: Option<&SharedTurnDiffTracker>, call_id: &str, tool_name: &str, ) -> Result, FunctionCallError> { - let sandbox = turn - .environments - .primary() - .filter(|env| env.environment.is_remote()) - .map(|_| turn.file_system_sandbox_context(/*additional_permissions*/ None)); + let sandbox = environment_is_remote + .then(|| turn.file_system_sandbox_context(/*additional_permissions*/ None)); match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, sandbox.as_ref()) .await { @@ -658,11 +694,7 @@ pub(crate) async fn intercept_apply_patch( let req = ApplyPatchRequest { action: apply.action, - environment_id: turn - .environments - .primary() - .map(|environment| environment.environment_id.clone()) - .unwrap_or_default(), + environment_id: environment_id.to_string(), file_paths: approval_keys, changes, exec_approval_requirement: apply.exec_approval_requirement, diff --git a/codex-rs/core/src/tools/handlers/apply_patch_spec.rs b/codex-rs/core/src/tools/handlers/apply_patch_spec.rs index d021fbdff4..f2fe6b9239 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch_spec.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch_spec.rs @@ -85,6 +85,8 @@ const APPLY_PATCH_JSON_TOOL_MULTI_ENVIRONMENT_SUFFIX: &str = r#" When multiple environments are available, you may target a non-default environment by setting the `environment_id` function parameter. If the patch body also includes a `*** Environment ID: ...` header, it must match the `environment_id` parameter. "#; +const APPLY_PATCH_FREEFORM_TOOL_DESCRIPTION: &str = "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON."; +const APPLY_PATCH_FREEFORM_TOOL_MULTI_ENVIRONMENT_DESCRIPTION: &str = "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON. When multiple environments are available, you may target a non-default environment by adding `*** Environment ID: ` immediately after `*** Begin Patch`, using an id from the turn environment context."; /// TODO(dylan): deprecate once we get rid of json tool #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -98,7 +100,11 @@ pub struct ApplyPatchToolArgs { pub fn create_apply_patch_freeform_tool(multi_environment: bool) -> ToolSpec { 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(), + description: if multi_environment { + APPLY_PATCH_FREEFORM_TOOL_MULTI_ENVIRONMENT_DESCRIPTION.to_string() + } else { + APPLY_PATCH_FREEFORM_TOOL_DESCRIPTION.to_string() + }, format: FreeformToolFormat { r#type: "grammar".to_string(), syntax: "lark".to_string(), diff --git a/codex-rs/core/src/tools/handlers/apply_patch_spec_tests.rs b/codex-rs/core/src/tools/handlers/apply_patch_spec_tests.rs index 80ff086b73..ee9d43ecad 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch_spec_tests.rs @@ -9,9 +9,7 @@ fn create_apply_patch_freeform_tool_matches_expected_spec() { create_apply_patch_freeform_tool(/*multi_environment*/ true), 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(), + description: APPLY_PATCH_FREEFORM_TOOL_MULTI_ENVIRONMENT_DESCRIPTION.to_string(), format: FreeformToolFormat { r#type: "grammar".to_string(), syntax: "lark".to_string(), @@ -65,6 +63,7 @@ fn strict_apply_patch_tools_do_not_advertise_multi_environment() { .replace(APPLY_PATCH_WITH_ENV_START, APPLY_PATCH_STRICT_START) .replace(APPLY_PATCH_ENVIRONMENT_RULE, ""); assert_eq!(freeform.format.definition, expected_strict); + assert_eq!(freeform.description, APPLY_PATCH_FREEFORM_TOOL_DESCRIPTION); let json_tool = create_apply_patch_json_tool(/*multi_environment*/ false); let ToolSpec::Function(json_tool) = json_tool else { diff --git a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs index c0d4d17f32..9fe66ed7a1 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs @@ -49,13 +49,18 @@ async fn pre_tool_use_payload_uses_json_patch_input() { arguments: json!({ "input": patch }).to_string(), }; let invocation = invocation_for_payload(payload).await; + let environment = invocation.turn.environments.primary().expect("primary env"); let handler = ApplyPatchHandler::default(); assert_eq!( handler.pre_tool_use_payload(&invocation), Some(PreToolUsePayload { tool_name: HookToolName::apply_patch(), - tool_input: json!({ "command": patch }), + tool_input: json!({ + "command": patch, + "environment_id": environment.environment_id.clone(), + "cwd": environment.cwd.clone(), + }), }) ); } @@ -67,13 +72,18 @@ async fn pre_tool_use_payload_uses_freeform_patch_input() { input: patch.to_string(), }; let invocation = invocation_for_payload(payload).await; + let environment = invocation.turn.environments.primary().expect("primary env"); let handler = ApplyPatchHandler::default(); assert_eq!( handler.pre_tool_use_payload(&invocation), Some(PreToolUsePayload { tool_name: HookToolName::apply_patch(), - tool_input: json!({ "command": patch }), + tool_input: json!({ + "command": patch, + "environment_id": environment.environment_id.clone(), + "cwd": environment.cwd.clone(), + }), }) ); } @@ -85,6 +95,7 @@ async fn post_tool_use_payload_uses_patch_input_and_tool_output() { input: patch.to_string(), }; let invocation = invocation_for_payload(payload).await; + let environment = invocation.turn.environments.primary().expect("primary env"); let output = ApplyPatchToolOutput::from_text("Success. Updated files.".to_string()); let handler = ApplyPatchHandler::default(); @@ -93,7 +104,11 @@ async fn post_tool_use_payload_uses_patch_input_and_tool_output() { Some(PostToolUsePayload { tool_name: HookToolName::apply_patch(), tool_use_id: "call-apply-patch".to_string(), - tool_input: json!({ "command": patch }), + tool_input: json!({ + "command": patch, + "environment_id": environment.environment_id.clone(), + "cwd": environment.cwd.clone(), + }), tool_response: json!("Success. Updated files."), }) ); diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index f6960bca41..4713350dc3 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -197,6 +197,8 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result, #[serde(skip_serializing_if = "Option::is_none")] pub codex_grant_root: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub codex_environment_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub codex_cwd: Option, pub codex_changes: HashMap, } @@ -45,6 +49,8 @@ pub(crate) async fn handle_patch_approval_request( call_id: String, reason: Option, grant_root: Option, + environment_id: String, + cwd: Option, changes: HashMap, outgoing: Arc, codex: Arc, @@ -58,6 +64,14 @@ pub(crate) async fn handle_patch_approval_request( if let Some(r) = &reason { message_lines.push(r.clone()); } + let codex_environment_id = (!environment_id.is_empty()).then_some(environment_id); + let codex_cwd = cwd; + if let Some(environment_id) = codex_environment_id.as_deref() { + message_lines.push(format!("Environment: {environment_id}")); + } + if let Some(cwd) = codex_cwd.as_ref() { + message_lines.push(format!("Working directory: {}", cwd.display())); + } message_lines.push("Allow Codex to apply proposed code changes?".to_string()); let params = PatchApprovalElicitRequestParams { @@ -70,6 +84,8 @@ pub(crate) async fn handle_patch_approval_request( codex_call_id: call_id, codex_reason: reason, codex_grant_root: grant_root, + codex_environment_id, + codex_cwd, codex_changes: changes, }; let params_json = match serde_json::to_value(¶ms) { diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index dfb9579a73..b904916620 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -383,7 +383,9 @@ pub struct ApplyPatchApprovalRequestEvent { /// Stable selected environment id for this patch apply. #[serde(default)] pub environment_id: String, - pub cwd: AbsolutePathBuf, + /// Selected working directory for this patch apply. + #[serde(default)] + pub cwd: Option, pub changes: HashMap, /// Optional explanatory reason (e.g. request for extra write access). #[serde(skip_serializing_if = "Option::is_none")] @@ -420,6 +422,21 @@ mod tests { ); } + #[test] + fn apply_patch_approval_request_deserializes_legacy_shape_without_cwd() { + let event: ApplyPatchApprovalRequestEvent = serde_json::from_value(serde_json::json!({ + "call_id": "call-1", + "changes": {}, + })) + .expect("legacy apply patch approval request should deserialize"); + + assert_eq!(event.call_id, "call-1"); + assert_eq!(event.turn_id, ""); + assert_eq!(event.environment_id, ""); + assert_eq!(event.cwd, None); + assert_eq!(event.changes, HashMap::new()); + } + #[cfg(unix)] #[test] fn guardian_assessment_action_round_trips_execve_shape() { diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index dce87f367e..a044ba621b 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -689,6 +689,8 @@ mod tests { turn_id: "turn-1".to_string(), item_id: "patch-1".to_string(), started_at_ms: 0, + environment_id: None, + cwd: None, reason: None, grant_root: None, }, diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 1a21d4df50..6794328cd4 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -635,6 +635,8 @@ mod tests { turn_id: turn_id.to_string(), item_id: call_id.to_string(), started_at_ms: 0, + environment_id: None, + cwd: None, reason: None, grant_root: None, }, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index eacb6d5053..4a61f401d5 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2660,6 +2660,8 @@ async fn inactive_thread_file_change_approval_recovers_buffered_changes() { turn_id: "turn-approval".to_string(), item_id: "patch-approval".to_string(), started_at_ms: 0, + environment_id: None, + cwd: None, reason: Some("command failed; retry without sandbox?".to_string()), grant_root: None, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 94cd1d375a..32b92f457d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1626,8 +1626,8 @@ fn patch_approval_request_from_params( ApplyPatchApprovalRequestEvent { call_id: params.item_id, turn_id: params.turn_id, - environment_id: String::new(), - cwd: fallback_cwd.clone(), + environment_id: params.environment_id.unwrap_or_default(), + cwd: params.cwd.unwrap_or_else(|| fallback_cwd.clone()), changes: HashMap::new(), reason: params.reason, grant_root: params.grant_root,