mirror of
https://github.com/openai/codex.git
synced 2026-09-04 15:08:45 +00:00
unified-exec: launch remote commands without host sandbox
This commit is contained in:
@@ -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:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user