unified-exec: launch remote commands without host sandbox

This commit is contained in:
Adam Perry
2026-06-13 02:41:45 +00:00
parent 35c88706ff
commit f89f19659a
8 changed files with 553 additions and 98 deletions

View File

@@ -142,26 +142,48 @@ impl ExecCommandHandler {
turn_environment.path_convention(),
)
.map_err(FunctionCallError::RespondToModel)?;
let base_cwd = turn_environment.compatible_cwd().ok_or_else(|| {
FunctionCallError::RespondToModel(
"cross-platform unified exec requires native remote routing".to_string(),
let is_remote = environment.is_remote();
let (cwd, sandbox_cwd, args) = if is_remote {
let raw_arguments: serde_json::Value = parse_arguments(&arguments)?;
if raw_arguments
.get("additional_permissions")
.is_some_and(|permissions| !permissions.is_null())
{
return Err(FunctionCallError::RespondToModel(
"remote exec does not support per-command permission elevation until it can be enforced by the exec-server".to_string(),
));
}
#[allow(deprecated)]
let compatibility_cwd = turn.cwd.clone();
(
compatibility_cwd,
None,
parse_arguments::<ExecCommandArgs>(&arguments)?,
)
})?;
let cwd = cwd_uri.to_abs_path().map_err(|error| {
FunctionCallError::RespondToModel(format!(
"working directory `{cwd_uri}` cannot be used on the app-server host: {error}"
))
})?;
let fs = environment.get_filesystem();
let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?;
} else {
let sandbox_cwd = turn_environment.compatible_cwd().ok_or_else(|| {
FunctionCallError::RespondToModel(
"selected local environment has an incompatible cwd".to_string(),
)
})?;
let cwd = cwd_uri.to_abs_path().map_err(|error| {
FunctionCallError::RespondToModel(format!(
"working directory `{cwd_uri}` cannot be used on the app-server host: {error}"
))
})?;
let args = parse_arguments_with_base_path(&arguments, &cwd)?;
(cwd, Some(sandbox_cwd), args)
};
let hook_command = args.cmd.clone();
maybe_emit_implicit_skill_invocation(
session.as_ref(),
context.turn.as_ref(),
&hook_command,
&cwd,
)
.await;
if !is_remote {
maybe_emit_implicit_skill_invocation(
session.as_ref(),
context.turn.as_ref(),
&hook_command,
&cwd,
)
.await;
}
let shell_mode =
shell_mode_for_environment(&turn.unified_exec_shell_mode, environment.as_ref());
let (default_shell, shell_resolution) = if environment.is_remote() {
@@ -183,7 +205,6 @@ impl ExecCommandHandler {
let command = resolved_command.command;
let shell_type = resolved_command.shell_type;
let command_for_display = codex_shell_command::parse_command::shlex_join(&command);
let process_id = manager.allocate_process_id().await;
let ExecCommandArgs {
tty,
@@ -196,6 +217,43 @@ impl ExecCommandHandler {
..
} = args;
if is_remote {
if sandbox_permissions.requests_sandbox_override() || additional_permissions.is_some() {
return Err(FunctionCallError::RespondToModel(
"remote exec does not support per-command permission elevation until it can be enforced by the exec-server".to_string(),
));
}
let process_id = manager.allocate_process_id().await;
emit_unified_exec_tty_metric(&turn.session_telemetry, tty);
return execute_unified_exec(
manager,
ExecCommandRequest {
command,
shell_type,
hook_command,
process_id,
environment_id: turn_environment.environment_id.clone(),
yield_time_ms,
max_output_tokens,
cwd,
cwd_uri,
sandbox_cwd,
environment,
shell_mode,
network: context.turn.network.clone(),
tty,
sandbox_permissions,
additional_permissions: None,
additional_permissions_preapproved: false,
justification,
prefix_rule,
},
&context,
&command_for_display,
)
.await;
}
let exec_permission_approvals_enabled =
session.features().enabled(Feature::ExecPermissionApprovals);
let requested_additional_permissions = additional_permissions.clone();
@@ -223,7 +281,6 @@ impl ExecCommandHandler {
)
{
let approval_policy = context.turn.approval_policy.value();
manager.release_process_id(process_id).await;
return Err(FunctionCallError::RespondToModel(format!(
"approval policy is {approval_policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {approval_policy:?}"
)));
@@ -248,12 +305,10 @@ impl ExecCommandHandler {
|permissions| Ok(Some(permissions)),
) {
Ok(normalized) => normalized,
Err(err) => {
manager.release_process_id(process_id).await;
return Err(FunctionCallError::RespondToModel(err));
}
Err(err) => return Err(FunctionCallError::RespondToModel(err)),
};
let fs = environment.get_filesystem();
if let Some(output) = intercept_apply_patch(
&command,
&cwd,
@@ -267,7 +322,6 @@ impl ExecCommandHandler {
)
.await?
{
manager.release_process_id(process_id).await;
return Ok(boxed_tool_output(ExecCommandToolOutput {
event_call_id: String::new(),
chunk_id: String::new(),
@@ -282,56 +336,70 @@ impl ExecCommandHandler {
}));
}
let process_id = manager.allocate_process_id().await;
emit_unified_exec_tty_metric(&turn.session_telemetry, tty);
match manager
.exec_command(
ExecCommandRequest {
command,
shell_type,
hook_command: hook_command.clone(),
process_id,
yield_time_ms,
max_output_tokens,
cwd,
sandbox_cwd: base_cwd,
environment,
shell_mode,
network: context.turn.network.clone(),
tty,
sandbox_permissions: effective_additional_permissions.sandbox_permissions,
additional_permissions: normalized_additional_permissions,
additional_permissions_preapproved: effective_additional_permissions
.permissions_preapproved,
justification,
prefix_rule,
},
&context,
)
.await
{
Ok(response) => Ok(boxed_tool_output(response)),
Err(UnifiedExecError::SandboxDenied { output, .. }) => {
let output_text = output.aggregated_output.text;
let original_token_count = approx_token_count(&output_text);
Ok(boxed_tool_output(ExecCommandToolOutput {
event_call_id: context.call_id.clone(),
chunk_id: generate_chunk_id(),
wall_time: output.duration,
raw_output: output_text.into_bytes(),
truncation_policy: turn.truncation_policy,
max_output_tokens,
// Sandbox denial is terminal, so there is no live
// process for write_stdin to resume.
process_id: None,
exit_code: Some(output.exit_code),
original_token_count: Some(original_token_count),
hook_command: Some(hook_command),
}))
}
Err(err) => Err(FunctionCallError::RespondToModel(format!(
"exec_command failed for `{command_for_display}`: {err:?}"
))),
execute_unified_exec(
manager,
ExecCommandRequest {
command,
shell_type,
hook_command,
process_id,
environment_id: turn_environment.environment_id.clone(),
yield_time_ms,
max_output_tokens,
cwd,
cwd_uri,
sandbox_cwd,
environment,
shell_mode,
network: context.turn.network.clone(),
tty,
sandbox_permissions: effective_additional_permissions.sandbox_permissions,
additional_permissions: normalized_additional_permissions,
additional_permissions_preapproved: effective_additional_permissions
.permissions_preapproved,
justification,
prefix_rule,
},
&context,
&command_for_display,
)
.await
}
}
async fn execute_unified_exec(
manager: &UnifiedExecProcessManager,
request: ExecCommandRequest,
context: &UnifiedExecContext,
command_for_display: &str,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
let max_output_tokens = request.max_output_tokens;
let hook_command = request.hook_command.clone();
match manager.exec_command(request, context).await {
Ok(response) => Ok(boxed_tool_output(response)),
Err(UnifiedExecError::SandboxDenied { output, .. }) => {
let output_text = output.aggregated_output.text;
let original_token_count = approx_token_count(&output_text);
Ok(boxed_tool_output(ExecCommandToolOutput {
event_call_id: context.call_id.clone(),
chunk_id: generate_chunk_id(),
wall_time: output.duration,
raw_output: output_text.into_bytes(),
truncation_policy: context.turn.truncation_policy,
max_output_tokens,
// Sandbox denial is terminal, so there is no live
// process for write_stdin to resume.
process_id: None,
exit_code: Some(output.exit_code),
original_token_count: Some(original_token_count),
hook_command: Some(hook_command),
}))
}
Err(err) => Err(FunctionCallError::RespondToModel(format!(
"exec_command failed for `{command_for_display}`: {err:?}"
))),
}
}

View File

@@ -19,6 +19,7 @@ use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::begin_network_approval;
use crate::tools::network_approval::finish_deferred_network_approval;
use crate::tools::network_approval::finish_immediate_network_approval;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::SandboxAttempt;
@@ -51,6 +52,13 @@ pub(crate) struct OrchestratorRunResult<Out> {
pub deferred_network_approval: Option<DeferredNetworkApproval>,
}
struct InitialApproval {
requirement: ExecApprovalRequirement,
already_approved: bool,
strict_auto_review: bool,
use_guardian: bool,
}
impl ToolOrchestrator {
pub fn new() -> Self {
Self {
@@ -129,28 +137,23 @@ impl ToolOrchestrator {
}
}
pub async fn run<Rq, Out, T>(
&mut self,
async fn resolve_initial_approval<Rq, T>(
tool: &mut T,
req: &Rq,
tool_ctx: &ToolCtx,
turn_ctx: &crate::session::turn_context::TurnContext,
approval_policy: AskForApproval,
) -> Result<OrchestratorRunResult<Out>, ToolError>
) -> Result<InitialApproval, ToolError>
where
T: ToolRuntime<Rq, Out>,
T: Approvable<Rq>,
{
let otel = turn_ctx.session_telemetry.clone();
let otel_tn = flat_tool_name(&tool_ctx.tool_name).into_owned();
let otel_ci = &tool_ctx.call_id;
let strict_auto_review = tool_ctx.session.strict_auto_review_enabled_for_turn().await;
let use_guardian = routes_approval_to_guardian(turn_ctx) || strict_auto_review;
// 1) Approval
let mut already_approved = false;
let file_system_sandbox_policy = turn_ctx.file_system_sandbox_policy();
let network_sandbox_policy = turn_ctx.network_sandbox_policy();
let requirement = tool.exec_approval_requirement(req).unwrap_or_else(|| {
default_exec_approval_requirement(approval_policy, &file_system_sandbox_policy)
});
@@ -218,6 +221,57 @@ impl ToolOrchestrator {
}
}
Ok(InitialApproval {
requirement,
already_approved,
strict_auto_review,
use_guardian,
})
}
/// Runs the normal command-approval phase without constructing a sandbox attempt.
///
/// Callers must validate that enforcement is disabled or owned by the target
/// environment before using this path.
pub(crate) async fn approve_direct_execution<Rq, T>(
&mut self,
tool: &mut T,
req: &Rq,
tool_ctx: &ToolCtx,
turn_ctx: &crate::session::turn_context::TurnContext,
approval_policy: AskForApproval,
) -> Result<(), ToolError>
where
T: Approvable<Rq>,
{
Self::resolve_initial_approval(tool, req, tool_ctx, turn_ctx, approval_policy)
.await
.map(|_| ())
}
pub async fn run<Rq, Out, T>(
&mut self,
tool: &mut T,
req: &Rq,
tool_ctx: &ToolCtx,
turn_ctx: &crate::session::turn_context::TurnContext,
approval_policy: AskForApproval,
) -> Result<OrchestratorRunResult<Out>, ToolError>
where
T: ToolRuntime<Rq, Out>,
{
let otel = turn_ctx.session_telemetry.clone();
let InitialApproval {
requirement,
already_approved,
strict_auto_review,
use_guardian,
} = Self::resolve_initial_approval(tool, req, tool_ctx, turn_ctx, approval_policy).await?;
let otel_tn = flat_tool_name(&tool_ctx.tool_name).into_owned();
let otel_ci = &tool_ctx.call_id;
let file_system_sandbox_policy = turn_ctx.file_system_sandbox_policy();
let network_sandbox_policy = turn_ctx.network_sandbox_policy();
// 2) First attempt under the selected sandbox.
let sandbox_override = sandbox_override_for_first_attempt(
tool.sandbox_permissions(req),
@@ -484,7 +538,7 @@ impl ToolOrchestrator {
// PermissionRequest hooks take top precedence for answering approval
// prompts. If no matching hook returns a decision, fall back to the
// normal guardian or user approval path.
async fn request_approval<Rq, Out, T>(
async fn request_approval<Rq, T>(
tool: &mut T,
req: &Rq,
permission_request_run_id: &str,
@@ -494,7 +548,7 @@ impl ToolOrchestrator {
otel: &codex_otel::SessionTelemetry,
) -> Result<ReviewDecision, ToolError>
where
T: ToolRuntime<Rq, Out>,
T: Approvable<Rq>,
{
if evaluate_permission_request_hooks
&& let Some(permission_request) = tool.permission_request_payload(req)

View File

@@ -51,6 +51,7 @@ use codex_sandboxing::SandboxablePreference;
use codex_shell_command::powershell::prefix_powershell_script_with_utf8;
use codex_tools::UnifiedExecShellMode;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use futures::future::BoxFuture;
use std::collections::HashMap;
use std::sync::Arc;
@@ -64,8 +65,10 @@ pub struct UnifiedExecRequest {
pub shell_type: ShellType,
pub hook_command: String,
pub process_id: i32,
pub environment_id: String,
pub cwd: AbsolutePathBuf,
pub sandbox_cwd: AbsolutePathBuf,
pub cwd_uri: PathUri,
pub sandbox_cwd: Option<AbsolutePathBuf>,
pub environment: Arc<Environment>,
pub env: HashMap<String, String>,
pub exec_server_env_config: Option<ExecServerEnvConfig>,
@@ -85,7 +88,8 @@ pub struct UnifiedExecRequest {
#[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)]
pub struct UnifiedExecApprovalKey {
pub command: Vec<String>,
pub cwd: AbsolutePathBuf,
pub environment_id: String,
pub cwd: PathUri,
pub tty: bool,
pub sandbox_permissions: SandboxPermissions,
pub additional_permissions: Option<AdditionalPermissionProfile>,
@@ -137,7 +141,8 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
fn approval_keys(&self, req: &UnifiedExecRequest) -> Vec<Self::ApprovalKey> {
vec![UnifiedExecApprovalKey {
command: canonicalize_command_for_approval(&req.command),
cwd: req.cwd.clone(),
environment_id: req.environment_id.clone(),
cwd: req.cwd_uri.clone(),
tty: req.tty,
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
@@ -224,7 +229,7 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRuntime<'a> {
fn sandbox_cwd<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b AbsolutePathBuf> {
Some(&req.sandbox_cwd)
req.sandbox_cwd.as_ref()
}
fn network_approval_spec(
@@ -461,8 +466,10 @@ mod tests {
shell_type: ShellType::Sh,
hook_command: "pwd".to_string(),
process_id: 1000,
cwd,
sandbox_cwd: sandbox_cwd.clone(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
cwd: cwd.clone(),
cwd_uri: PathUri::from_abs_path(&cwd),
sandbox_cwd: Some(sandbox_cwd.clone()),
environment: Arc::new(Environment::default_for_tests()),
env: HashMap::new(),
exec_server_env_config: None,
@@ -483,6 +490,33 @@ mod tests {
assert_eq!(runtime.sandbox_cwd(&request), Some(&sandbox_cwd));
}
#[tokio::test]
async fn unified_exec_approval_keys_include_environment_and_canonical_cwd() {
let manager = UnifiedExecProcessManager::default();
let runtime = UnifiedExecRuntime::new(&manager, UnifiedExecShellMode::Direct);
let mut request = test_request(
SandboxPermissions::UseDefault,
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: None,
},
);
request.environment_id = "windows".to_string();
request.cwd_uri = PathUri::parse("file:///C:/workspace").expect("Windows cwd URI");
assert_eq!(
runtime.approval_keys(&request),
vec![UnifiedExecApprovalKey {
command: canonicalize_command_for_approval(&request.command),
environment_id: "windows".to_string(),
cwd: request.cwd_uri,
tty: request.tty,
sandbox_permissions: request.sandbox_permissions,
additional_permissions: request.additional_permissions,
}]
);
}
#[tokio::test]
async fn zsh_fork_first_attempt_preserves_parent_sandbox_override() {
let manager = UnifiedExecProcessManager::default();
@@ -560,8 +594,10 @@ mod tests {
shell_type: ShellType::Zsh,
hook_command: "echo hi".to_string(),
process_id: 1000,
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
cwd: cwd.clone(),
sandbox_cwd: cwd,
cwd_uri: PathUri::from_abs_path(&cwd),
sandbox_cwd: Some(cwd),
environment: Arc::new(Environment::default_for_tests()),
env: HashMap::new(),
exec_server_env_config: None,

View File

@@ -33,6 +33,7 @@ use codex_protocol::models::AdditionalPermissionProfile;
use codex_tools::UnifiedExecShellMode;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_path_uri::PathUri;
use rand::Rng;
use rand::rng;
use tokio::sync::Mutex;
@@ -93,10 +94,13 @@ pub(crate) struct ExecCommandRequest {
pub shell_type: ShellType,
pub hook_command: String,
pub process_id: i32,
pub environment_id: String,
pub yield_time_ms: u64,
pub max_output_tokens: Option<usize>,
/// App-host-compatible cwd retained for approval and event compatibility.
pub cwd: AbsolutePathBuf,
pub sandbox_cwd: AbsolutePathBuf,
pub cwd_uri: PathUri,
pub sandbox_cwd: Option<AbsolutePathBuf>,
pub environment: Arc<Environment>,
pub shell_mode: UnifiedExecShellMode,
pub network: Option<NetworkProxy>,

View File

@@ -53,6 +53,7 @@ use crate::unified_exec::process::UnifiedExecProcess;
use codex_protocol::config_types::ShellEnvironmentPolicy;
use codex_protocol::error::CodexErr;
use codex_protocol::error::SandboxErr;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::ExecCommandSource;
use codex_tools::ToolName;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -74,6 +75,9 @@ const NETWORK_ACCESS_DENIED_MESSAGE: &str =
"Network access was denied by the Codex sandbox network proxy.";
const LATE_NETWORK_DENIAL_GRACE_PERIOD: Duration = Duration::from_millis(100);
const INTERRUPT: &str = "\u{3}";
const MANAGED_REMOTE_EXEC_UNSUPPORTED: &str = "remote exec with a managed permission profile is not supported until sandbox construction runs on the exec-server; select an external or disabled permission profile";
const MANAGED_REMOTE_NETWORK_UNSUPPORTED: &str = "remote exec with managed network enforcement is not supported until network policy runs on the exec-server";
const REMOTE_PERMISSION_ELEVATION_UNSUPPORTED: &str = "remote exec does not support per-command permission elevation until it can be enforced by the exec-server";
/// Test-only override for deterministic unified exec process IDs.
///
@@ -131,19 +135,20 @@ fn env_overlay_for_exec_server(
.collect()
}
fn exec_server_env_for_request(
request: &ExecRequest,
fn exec_server_env(
exec_server_env_config: Option<&ExecServerEnvConfig>,
request_env: &HashMap<String, String>,
) -> (
Option<codex_exec_server::ExecEnvPolicy>,
HashMap<String, String>,
) {
if let Some(exec_server_env_config) = &request.exec_server_env_config {
if let Some(exec_server_env_config) = exec_server_env_config {
(
Some(exec_server_env_config.policy.clone()),
env_overlay_for_exec_server(&request.env, &exec_server_env_config.local_policy_env),
env_overlay_for_exec_server(request_env, &exec_server_env_config.local_policy_env),
)
} else {
(None, request.env.clone())
(None, request_env.clone())
}
}
@@ -152,7 +157,7 @@ fn exec_server_params_for_request(
request: &ExecRequest,
tty: bool,
) -> codex_exec_server::ExecParams {
let (env_policy, env) = exec_server_env_for_request(request);
let (env_policy, env) = exec_server_env(request.exec_server_env_config.as_ref(), &request.env);
codex_exec_server::ExecParams {
process_id: exec_server_process_id(process_id).into(),
argv: request.command.clone(),
@@ -165,6 +170,45 @@ fn exec_server_params_for_request(
}
}
fn exec_server_params_for_remote_request(
request: &UnifiedExecToolRequest,
) -> codex_exec_server::ExecParams {
let (env_policy, env) = exec_server_env(request.exec_server_env_config.as_ref(), &request.env);
codex_exec_server::ExecParams {
process_id: exec_server_process_id(request.process_id).into(),
argv: if matches!(request.shell_type, crate::shell::ShellType::PowerShell) {
codex_shell_command::powershell::prefix_powershell_script_with_utf8(&request.command)
} else {
request.command.clone()
},
cwd: request.cwd_uri.clone(),
env_policy,
env,
tty: request.tty,
pipe_stdin: false,
// App-host sandbox helpers are never valid on the remote executor.
arg0: None,
}
}
fn validate_remote_direct_request(
permission_profile: &PermissionProfile,
managed_network: bool,
sandbox_permissions: crate::sandboxing::SandboxPermissions,
additional_permissions: Option<&codex_protocol::models::AdditionalPermissionProfile>,
) -> Result<(), &'static str> {
if matches!(permission_profile, PermissionProfile::Managed { .. }) {
return Err(MANAGED_REMOTE_EXEC_UNSUPPORTED);
}
if managed_network {
return Err(MANAGED_REMOTE_NETWORK_UNSUPPORTED);
}
if sandbox_permissions.requests_sandbox_override() || additional_permissions.is_some() {
return Err(REMOTE_PERMISSION_ELEVATION_UNSUPPORTED);
}
Ok(())
}
/// Borrowed process state prepared for a `write_stdin` or poll operation.
struct PreparedProcessHandles {
process: Arc<UnifiedExecProcess>,
@@ -1027,6 +1071,13 @@ impl UnifiedExecProcessManager {
cwd: AbsolutePathBuf,
context: &UnifiedExecContext,
) -> Result<(UnifiedExecProcess, Option<DeferredNetworkApproval>), UnifiedExecError> {
if request.environment.is_remote() {
let process = self
.open_remote_session_with_approval(request, context)
.await?;
return Ok((process, None));
}
let local_policy_env = create_env(
&context.turn.shell_environment_policy,
/*thread_id*/ None,
@@ -1065,7 +1116,9 @@ impl UnifiedExecProcessManager {
shell_type: request.shell_type,
hook_command: request.hook_command.clone(),
process_id: request.process_id,
environment_id: request.environment_id.clone(),
cwd,
cwd_uri: request.cwd_uri.clone(),
sandbox_cwd: request.sandbox_cwd.clone(),
environment: Arc::clone(&request.environment),
env,
@@ -1111,6 +1164,94 @@ impl UnifiedExecProcessManager {
})
}
async fn open_remote_session_with_approval(
&self,
request: &ExecCommandRequest,
context: &UnifiedExecContext,
) -> Result<UnifiedExecProcess, UnifiedExecError> {
validate_remote_direct_request(
&context.turn.permission_profile(),
request.network.is_some(),
request.sandbox_permissions,
request.additional_permissions.as_ref(),
)
.map_err(|message| UnifiedExecError::create_process(message.to_string()))?;
let exec_approval_requirement = context
.session
.services
.exec_policy
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &request.command,
approval_policy: context.turn.approval_policy.value(),
permission_profile: context.turn.permission_profile(),
windows_sandbox_level: context.turn.windows_sandbox_level,
sandbox_permissions: request.sandbox_permissions,
prefix_rule: request.prefix_rule.clone(),
})
.await;
let mut env = HashMap::from([(
CODEX_THREAD_ID_ENV_VAR.to_string(),
context.session.thread_id.to_string(),
)]);
env = apply_unified_exec_env(env);
let req = UnifiedExecToolRequest {
command: request.command.clone(),
shell_type: request.shell_type,
hook_command: request.hook_command.clone(),
process_id: request.process_id,
environment_id: request.environment_id.clone(),
cwd: request.cwd.clone(),
cwd_uri: request.cwd_uri.clone(),
sandbox_cwd: None,
environment: Arc::clone(&request.environment),
env,
exec_server_env_config: Some(ExecServerEnvConfig {
policy: exec_env_policy_from_shell_policy(&context.turn.shell_environment_policy),
local_policy_env: HashMap::new(),
}),
explicit_env_overrides: context.turn.shell_environment_policy.r#set.clone(),
network: None,
tty: request.tty,
sandbox_permissions: request.sandbox_permissions,
additional_permissions: None,
#[cfg(unix)]
additional_permissions_preapproved: false,
justification: request.justification.clone(),
exec_approval_requirement,
};
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = UnifiedExecRuntime::new(self, request.shell_mode.clone());
let tool_ctx = ToolCtx {
session: context.session.clone(),
turn: context.turn.clone(),
call_id: context.call_id.clone(),
tool_name: ToolName::plain("exec_command"),
};
orchestrator
.approve_direct_execution(
&mut runtime,
&req,
&tool_ctx,
&context.turn,
context.turn.approval_policy.value(),
)
.await
.map_err(|error| match error {
ToolError::Rejected(message) => UnifiedExecError::create_process(message),
ToolError::Codex(error) => UnifiedExecError::create_process(error.to_string()),
})?;
let started = request
.environment
.get_exec_backend()
.start(exec_server_params_for_remote_request(&req))
.await
.map_err(|error| UnifiedExecError::create_process(error.to_string()))?;
UnifiedExecProcess::from_exec_server_started(started, codex_sandboxing::SandboxType::None)
.await
}
pub(super) async fn collect_output_until_deadline(
output_buffer: &OutputBuffer,
output_notify: &Arc<Notify>,

View File

@@ -129,11 +129,127 @@ fn exec_server_params_use_env_policy_overlay_contract() {
);
}
#[tokio::test]
async fn remote_exec_params_preserve_target_cwd_and_clear_host_arg0() {
let compatibility_cwd: codex_utils_absolute_path::AbsolutePathBuf = std::env::current_dir()
.expect("current dir")
.try_into()
.expect("absolute path");
let cwd_uri = codex_utils_path_uri::PathUri::parse("file:///C:/workspace/build")
.expect("Windows cwd URI");
let policy = codex_exec_server::ExecEnvPolicy {
inherit: codex_protocol::config_types::ShellEnvironmentPolicyInherit::Core,
ignore_default_excludes: false,
exclude: vec!["SECRET".to_string()],
r#set: HashMap::new(),
include_only: Vec::new(),
};
let request = UnifiedExecToolRequest {
command: vec![
r"C:\Program Files\PowerShell\7\pwsh.exe".to_string(),
"-Command".to_string(),
"Get-Location".to_string(),
],
shell_type: crate::shell::ShellType::PowerShell,
hook_command: "Get-Location".to_string(),
process_id: 4321,
environment_id: "windows".to_string(),
cwd: compatibility_cwd,
cwd_uri: cwd_uri.clone(),
sandbox_cwd: None,
environment: Arc::new(codex_exec_server::Environment::default_for_tests()),
env: HashMap::from([("CODEX_CI".to_string(), "1".to_string())]),
exec_server_env_config: Some(ExecServerEnvConfig {
policy: policy.clone(),
local_policy_env: HashMap::new(),
}),
explicit_env_overrides: HashMap::new(),
network: None,
tty: false,
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
#[cfg(unix)]
additional_permissions_preapproved: false,
justification: None,
exec_approval_requirement: crate::tools::sandboxing::ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: None,
},
};
let expected_argv =
codex_shell_command::powershell::prefix_powershell_script_with_utf8(&request.command);
assert_eq!(
exec_server_params_for_remote_request(&request),
codex_exec_server::ExecParams {
process_id: codex_exec_server::ProcessId::from("4321"),
argv: expected_argv,
cwd: cwd_uri,
env_policy: Some(policy),
env: request.env,
tty: false,
pipe_stdin: false,
arg0: None,
}
);
}
#[test]
fn exec_server_process_id_matches_unified_exec_process_id() {
assert_eq!(exec_server_process_id(/*process_id*/ 4321), "4321");
}
#[test]
fn remote_direct_execution_requires_target_owned_or_disabled_enforcement() {
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::NetworkSandboxPolicy;
assert_eq!(
[
validate_remote_direct_request(
&PermissionProfile::Disabled,
/*managed_network*/ false,
crate::sandboxing::SandboxPermissions::UseDefault,
/*additional_permissions*/ None,
),
validate_remote_direct_request(
&PermissionProfile::External {
network: NetworkSandboxPolicy::Restricted,
},
/*managed_network*/ false,
crate::sandboxing::SandboxPermissions::UseDefault,
/*additional_permissions*/ None,
),
validate_remote_direct_request(
&PermissionProfile::read_only(),
/*managed_network*/ false,
crate::sandboxing::SandboxPermissions::UseDefault,
/*additional_permissions*/ None,
),
validate_remote_direct_request(
&PermissionProfile::Disabled,
/*managed_network*/ true,
crate::sandboxing::SandboxPermissions::UseDefault,
/*additional_permissions*/ None,
),
validate_remote_direct_request(
&PermissionProfile::Disabled,
/*managed_network*/ false,
crate::sandboxing::SandboxPermissions::RequireEscalated,
Some(&AdditionalPermissionProfile::default()),
),
],
[
Ok(()),
Ok(()),
Err(MANAGED_REMOTE_EXEC_UNSUPPORTED),
Err(MANAGED_REMOTE_NETWORK_UNSUPPORTED),
Err(REMOTE_PERMISSION_ELEVATION_UNSUPPORTED),
]
);
}
#[tokio::test]
async fn network_denial_fallback_message_names_sandbox_network_proxy() {
let message = network_denial_message_for_session(/*session*/ None, /*deferred*/ None).await;
@@ -164,6 +280,7 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() {
Arc::clone(&turn),
"call-unified-denied".to_string(),
);
let turn_environment = turn.environments.primary().expect("primary environment");
let request = ExecCommandRequest {
command: vec![
"sh".to_string(),
@@ -173,12 +290,14 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() {
shell_type: crate::shell::ShellType::Sh,
hook_command: "echo before".to_string(),
process_id: 123,
environment_id: turn_environment.environment_id.clone(),
yield_time_ms: 1000,
max_output_tokens: None,
#[allow(deprecated)]
cwd: turn.cwd.clone(),
cwd_uri: turn_environment.cwd_uri().clone(),
#[allow(deprecated)]
sandbox_cwd: turn.cwd.clone(),
sandbox_cwd: Some(turn.cwd.clone()),
environment: turn
.environments
.primary_environment()

View File

@@ -225,6 +225,27 @@ mod tests {
);
}
#[test]
fn prefixes_windows_powershell_path_on_a_foreign_host() {
let shell = r"C:\Program Files\PowerShell\7\pwsh.exe";
let cmd = vec![
shell.to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
"Write-Host hi".to_string(),
];
assert_eq!(
prefix_powershell_script_with_utf8(&cmd),
vec![
shell.to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
format!("{UTF8_OUTPUT_PREFIX}Write-Host hi"),
]
);
}
#[test]
fn does_not_duplicate_utf8_prefix() {
let cmd = vec![

View File

@@ -38,6 +38,12 @@ impl DetectedShell {
pub fn detect_shell_type(shell_path: impl AsRef<std::path::Path>) -> Option<ShellType> {
let shell_path = shell_path.as_ref();
if let Some(path) = shell_path.as_os_str().to_str()
&& let Some(file_name) = path.rsplit(['/', '\\']).next()
&& file_name != path
{
return detect_shell_type(PathBuf::from(file_name));
}
match shell_path.as_os_str().to_str() {
Some("zsh") => Some(ShellType::Zsh),
Some("sh") => Some(ShellType::Sh),
@@ -347,6 +353,12 @@ mod tests {
detect_shell_type(PathBuf::from("pwsh.exe")),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(PathBuf::from(
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"
)),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(PathBuf::from("/usr/local/bin/pwsh")),
Some(ShellType::PowerShell)