Skip to content

Commit 58049ba

Browse files
committed
refactor: prepare unified exec for zsh-fork backend
1 parent 8f828f8 commit 58049ba

File tree

15 files changed

+802
-61
lines changed

15 files changed

+802
-61
lines changed

codex-rs/core/src/exec.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,15 @@ pub(crate) async fn execute_exec_env(
235235
env: ExecRequest,
236236
sandbox_policy: &SandboxPolicy,
237237
stdout_stream: Option<StdoutStream>,
238+
) -> Result<ExecToolCallOutput> {
239+
execute_exec_env_after_spawn(env, sandbox_policy, stdout_stream, Box::new(|| {})).await
240+
}
241+
242+
pub(crate) async fn execute_exec_env_after_spawn(
243+
env: ExecRequest,
244+
sandbox_policy: &SandboxPolicy,
245+
stdout_stream: Option<StdoutStream>,
246+
after_spawn: Box<dyn FnOnce() + Send>,
238247
) -> Result<ExecToolCallOutput> {
239248
let ExecRequest {
240249
command,
@@ -263,7 +272,7 @@ pub(crate) async fn execute_exec_env(
263272
};
264273

265274
let start = Instant::now();
266-
let raw_output_result = exec(params, sandbox, sandbox_policy, stdout_stream).await;
275+
let raw_output_result = exec(params, sandbox, sandbox_policy, stdout_stream, after_spawn).await;
267276
let duration = start.elapsed();
268277
finalize_exec_result(raw_output_result, sandbox, duration)
269278
}
@@ -693,6 +702,7 @@ async fn exec(
693702
sandbox: SandboxType,
694703
sandbox_policy: &SandboxPolicy,
695704
stdout_stream: Option<StdoutStream>,
705+
after_spawn: Box<dyn FnOnce() + Send>,
696706
) -> Result<RawExecToolCallOutput> {
697707
#[cfg(target_os = "windows")]
698708
if sandbox == SandboxType::WindowsRestrictedToken
@@ -738,6 +748,7 @@ async fn exec(
738748
env,
739749
})
740750
.await?;
751+
after_spawn();
741752
consume_truncated_output(child, expiration, stdout_stream).await
742753
}
743754

@@ -1136,6 +1147,7 @@ mod tests {
11361147
SandboxType::None,
11371148
&SandboxPolicy::new_read_only_policy(),
11381149
None,
1150+
Box::new(|| {}),
11391151
)
11401152
.await?;
11411153
assert!(output.timed_out);

codex-rs/core/src/sandboxing/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use crate::exec::ExecToolCallOutput;
1111
use crate::exec::SandboxType;
1212
use crate::exec::StdoutStream;
1313
use crate::exec::execute_exec_env;
14+
use crate::exec::execute_exec_env_after_spawn;
1415
use crate::landlock::allow_network_for_proxy;
1516
use crate::landlock::create_linux_sandbox_command_args;
1617
use crate::protocol::SandboxPolicy;
@@ -425,6 +426,15 @@ pub async fn execute_env(
425426
execute_exec_env(env, &effective_policy, stdout_stream).await
426427
}
427428

429+
pub async fn execute_env_after_spawn(
430+
env: ExecRequest,
431+
stdout_stream: Option<StdoutStream>,
432+
after_spawn: Box<dyn FnOnce() + Send>,
433+
) -> crate::error::Result<ExecToolCallOutput> {
434+
let effective_policy = env.sandbox_policy.clone();
435+
execute_exec_env_after_spawn(env, &effective_policy, stdout_stream, after_spawn).await
436+
}
437+
428438
#[cfg(test)]
429439
mod tests {
430440
use super::SandboxManager;

codex-rs/core/src/tools/runtimes/shell.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ Executes shell requests under the orchestrator: asks for approval when needed,
55
builds a CommandSpec, and runs it under the current SandboxAttempt.
66
*/
77
#[cfg(unix)]
8-
mod unix_escalation;
8+
pub(crate) mod unix_escalation;
9+
pub(crate) mod zsh_fork_backend;
910

1011
use crate::command_canonicalization::canonicalize_command_for_approval;
1112
use crate::exec::ExecToolCallOutput;
@@ -80,7 +81,6 @@ pub(crate) enum ShellRuntimeBackend {
8081

8182
#[derive(Default)]
8283
pub struct ShellRuntime {
83-
#[cfg_attr(not(unix), allow(dead_code))]
8484
backend: ShellRuntimeBackend,
8585
}
8686

@@ -215,9 +215,8 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
215215
command
216216
};
217217

218-
#[cfg(unix)]
219218
if self.backend == ShellRuntimeBackend::ShellCommandZshFork {
220-
match unix_escalation::try_run_zsh_fork(req, attempt, ctx, &command).await? {
219+
match zsh_fork_backend::maybe_run_shell_command(req, attempt, ctx, &command).await? {
221220
Some(out) => return Ok(out),
222221
None => {
223222
tracing::warn!(

codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs

Lines changed: 125 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use crate::exec::ExecToolCallOutput;
66
use crate::exec::SandboxType;
77
use crate::exec::is_likely_sandbox_denied;
88
use crate::features::Feature;
9+
use crate::sandboxing::ExecRequest;
910
use crate::sandboxing::SandboxPermissions;
1011
use crate::shell::ShellType;
1112
use crate::skills::SkillMetadata;
@@ -36,6 +37,7 @@ use codex_shell_escalation::EscalationDecision;
3637
use codex_shell_escalation::EscalationExecution;
3738
use codex_shell_escalation::EscalationPermissions;
3839
use codex_shell_escalation::EscalationPolicy;
40+
use codex_shell_escalation::EscalationSession;
3941
use codex_shell_escalation::ExecParams;
4042
use codex_shell_escalation::ExecResult;
4143
use codex_shell_escalation::Permissions as EscalatedPermissions;
@@ -51,6 +53,11 @@ use tokio::sync::RwLock;
5153
use tokio_util::sync::CancellationToken;
5254
use uuid::Uuid;
5355

56+
pub(crate) struct PreparedUnifiedExecZshFork {
57+
pub(crate) exec_request: ExecRequest,
58+
pub(crate) escalation_session: EscalationSession,
59+
}
60+
5461
pub(super) async fn try_run_zsh_fork(
5562
req: &ShellRequest,
5663
attempt: &SandboxAttempt<'_>,
@@ -95,7 +102,7 @@ pub(super) async fn try_run_zsh_fork(
95102
justification,
96103
arg0,
97104
} = sandbox_exec_request;
98-
let ParsedShellCommand { script, login } = extract_shell_script(&command)?;
105+
let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?;
99106
let effective_timeout = Duration::from_millis(
100107
req.timeout_ms
101108
.unwrap_or(crate::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS),
@@ -172,6 +179,103 @@ pub(super) async fn try_run_zsh_fork(
172179
map_exec_result(attempt.sandbox, exec_result).map(Some)
173180
}
174181

182+
pub(crate) async fn prepare_unified_exec_zsh_fork(
183+
req: &crate::tools::runtimes::unified_exec::UnifiedExecRequest,
184+
attempt: &SandboxAttempt<'_>,
185+
ctx: &ToolCtx,
186+
exec_request: ExecRequest,
187+
) -> Result<Option<PreparedUnifiedExecZshFork>, ToolError> {
188+
let Some(shell_zsh_path) = ctx.session.services.shell_zsh_path.as_ref() else {
189+
tracing::warn!("ZshFork backend specified, but shell_zsh_path is not configured.");
190+
return Ok(None);
191+
};
192+
if !ctx.session.features().enabled(Feature::ShellZshFork) {
193+
tracing::warn!("ZshFork backend specified, but ShellZshFork feature is not enabled.");
194+
return Ok(None);
195+
}
196+
if !matches!(ctx.session.user_shell().shell_type, ShellType::Zsh) {
197+
tracing::warn!("ZshFork backend specified, but user shell is not Zsh.");
198+
return Ok(None);
199+
}
200+
201+
let parsed = match extract_shell_script(&exec_request.command) {
202+
Ok(parsed) => parsed,
203+
Err(err) => {
204+
tracing::warn!("ZshFork unified exec fallback: {err:?}");
205+
return Ok(None);
206+
}
207+
};
208+
if parsed.program != shell_zsh_path.to_string_lossy() {
209+
tracing::warn!(
210+
"ZshFork backend specified, but unified exec command targets `{}` instead of `{}`.",
211+
parsed.program,
212+
shell_zsh_path.display(),
213+
);
214+
return Ok(None);
215+
}
216+
217+
let exec_policy = Arc::new(RwLock::new(
218+
ctx.session.services.exec_policy.current().as_ref().clone(),
219+
));
220+
let command_executor = CoreShellCommandExecutor {
221+
command: exec_request.command.clone(),
222+
cwd: exec_request.cwd.clone(),
223+
sandbox_policy: exec_request.sandbox_policy.clone(),
224+
sandbox: exec_request.sandbox,
225+
env: exec_request.env.clone(),
226+
network: exec_request.network.clone(),
227+
windows_sandbox_level: exec_request.windows_sandbox_level,
228+
sandbox_permissions: exec_request.sandbox_permissions,
229+
justification: exec_request.justification.clone(),
230+
arg0: exec_request.arg0.clone(),
231+
sandbox_policy_cwd: ctx.turn.cwd.clone(),
232+
macos_seatbelt_profile_extensions: ctx
233+
.turn
234+
.config
235+
.permissions
236+
.macos_seatbelt_profile_extensions
237+
.clone(),
238+
codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(),
239+
use_linux_sandbox_bwrap: ctx.turn.features.enabled(Feature::UseLinuxSandboxBwrap),
240+
};
241+
let main_execve_wrapper_exe = ctx
242+
.session
243+
.services
244+
.main_execve_wrapper_exe
245+
.clone()
246+
.ok_or_else(|| {
247+
ToolError::Rejected(
248+
"zsh fork feature enabled, but execve wrapper is not configured".to_string(),
249+
)
250+
})?;
251+
let escalation_policy = CoreShellActionProvider {
252+
policy: Arc::clone(&exec_policy),
253+
session: Arc::clone(&ctx.session),
254+
turn: Arc::clone(&ctx.turn),
255+
call_id: ctx.call_id.clone(),
256+
approval_policy: ctx.turn.approval_policy.value(),
257+
sandbox_policy: attempt.policy.clone(),
258+
sandbox_permissions: req.sandbox_permissions,
259+
prompt_permissions: req.additional_permissions.clone(),
260+
stopwatch: Stopwatch::unlimited(),
261+
};
262+
263+
let escalate_server = EscalateServer::new(
264+
shell_zsh_path.clone(),
265+
main_execve_wrapper_exe,
266+
escalation_policy,
267+
);
268+
let escalation_session = escalate_server
269+
.start_session(CancellationToken::new(), Arc::new(command_executor))
270+
.map_err(|err| ToolError::Rejected(err.to_string()))?;
271+
let mut exec_request = exec_request;
272+
exec_request.env.extend(escalation_session.env().clone());
273+
Ok(Some(PreparedUnifiedExecZshFork {
274+
exec_request,
275+
escalation_session,
276+
}))
277+
}
278+
175279
struct CoreShellActionProvider {
176280
policy: Arc<RwLock<Policy>>,
177281
session: Arc<crate::codex::Session>,
@@ -648,17 +752,20 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
648752
&self,
649753
_command: Vec<String>,
650754
_cwd: PathBuf,
651-
env: HashMap<String, String>,
755+
env_overlay: HashMap<String, String>,
652756
cancel_rx: CancellationToken,
757+
after_spawn: Option<Box<dyn FnOnce() + Send>>,
653758
) -> anyhow::Result<ExecResult> {
654759
let mut exec_env = self.env.clone();
760+
// `env_overlay` comes from `EscalationSession::env()`, so merge only the
761+
// wrapper/socket variables into the base shell environment.
655762
for var in ["CODEX_ESCALATE_SOCKET", "EXEC_WRAPPER", "BASH_EXEC_WRAPPER"] {
656-
if let Some(value) = env.get(var) {
763+
if let Some(value) = env_overlay.get(var) {
657764
exec_env.insert(var.to_string(), value.clone());
658765
}
659766
}
660767

661-
let result = crate::sandboxing::execute_env(
768+
let result = crate::sandboxing::execute_env_after_spawn(
662769
crate::sandboxing::ExecRequest {
663770
command: self.command.clone(),
664771
cwd: self.cwd.clone(),
@@ -673,6 +780,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
673780
arg0: self.arg0.clone(),
674781
},
675782
None,
783+
after_spawn.unwrap_or_else(|| Box::new(|| {})),
676784
)
677785
.await?;
678786

@@ -809,6 +917,7 @@ impl CoreShellCommandExecutor {
809917

810918
#[derive(Debug, Eq, PartialEq)]
811919
struct ParsedShellCommand {
920+
program: String,
812921
script: String,
813922
login: bool,
814923
}
@@ -817,12 +926,20 @@ fn extract_shell_script(command: &[String]) -> Result<ParsedShellCommand, ToolEr
817926
// Commands reaching zsh-fork can be wrapped by environment/sandbox helpers, so
818927
// we search for the first `-c`/`-lc` triple anywhere in the argv rather
819928
// than assuming it is the first positional form.
820-
if let Some((script, login)) = command.windows(3).find_map(|parts| match parts {
821-
[_, flag, script] if flag == "-c" => Some((script.to_owned(), false)),
822-
[_, flag, script] if flag == "-lc" => Some((script.to_owned(), true)),
929+
if let Some((program, script, login)) = command.windows(3).find_map(|parts| match parts {
930+
[program, flag, script] if flag == "-c" => {
931+
Some((program.to_owned(), script.to_owned(), false))
932+
}
933+
[program, flag, script] if flag == "-lc" => {
934+
Some((program.to_owned(), script.to_owned(), true))
935+
}
823936
_ => None,
824937
}) {
825-
return Ok(ParsedShellCommand { script, login });
938+
return Ok(ParsedShellCommand {
939+
program,
940+
script,
941+
login,
942+
});
826943
}
827944

828945
Err(ToolError::Rejected(

codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,15 @@ fn extract_shell_script_preserves_login_flag() {
6464
assert_eq!(
6565
extract_shell_script(&["/bin/zsh".into(), "-lc".into(), "echo hi".into()]).unwrap(),
6666
ParsedShellCommand {
67+
program: "/bin/zsh".to_string(),
6768
script: "echo hi".to_string(),
6869
login: true,
6970
}
7071
);
7172
assert_eq!(
7273
extract_shell_script(&["/bin/zsh".into(), "-c".into(), "echo hi".into()]).unwrap(),
7374
ParsedShellCommand {
75+
program: "/bin/zsh".to_string(),
7476
script: "echo hi".to_string(),
7577
login: false,
7678
}
@@ -89,6 +91,7 @@ fn extract_shell_script_supports_wrapped_command_prefixes() {
8991
])
9092
.unwrap(),
9193
ParsedShellCommand {
94+
program: "/bin/zsh".to_string(),
9295
script: "echo hello".to_string(),
9396
login: true,
9497
}
@@ -105,6 +108,7 @@ fn extract_shell_script_supports_wrapped_command_prefixes() {
105108
])
106109
.unwrap(),
107110
ParsedShellCommand {
111+
program: "/bin/zsh".to_string(),
108112
script: "pwd".to_string(),
109113
login: false,
110114
}

0 commit comments

Comments
 (0)