Compare commits

...

7 Commits

Author SHA1 Message Date
jif-oai
d4fe08299e Merge branch 'main' into codex/remote-exec-env-overlay 2026-04-09 18:44:10 +01:00
jif-oai
a8e105c80a Merge remote-tracking branch 'origin/codex/remote-exec-env-overlay' into codex/remote-exec-env-overlay 2026-04-09 17:45:22 +01:00
jif-oai
d1addcb1c6 clean up 2026-04-09 17:43:21 +01:00
jif-oai
a545d42ade Fix exec process tests for env policy 2026-04-09 13:13:02 +01:00
jif-oai
9747e855c6 Fix remote exec env CI failures
Co-authored-by: Codex <noreply@openai.com>
2026-04-09 12:56:07 +01:00
jif-oai
170be59f3f fixes 2026-04-09 12:53:19 +01:00
jif-oai
3040717ae2 Derive remote exec env on the exec-server
Add an exec-server env policy contract and send only the env overlay needed for runtime/sandbox transforms when Core starts remote unified-exec processes. Keep local process startup on the existing exact-env path, and share the shell-environment-policy builder from codex-config so the executor can apply the same inherit/filter/set/include rules against its own process environment.

Co-authored-by: Codex <noreply@openai.com>
2026-04-09 12:29:15 +01:00
21 changed files with 462 additions and 135 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -2097,6 +2097,7 @@ dependencies = [
"base64 0.22.1",
"clap",
"codex-app-server-protocol",
"codex-config",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",

View File

@@ -13,6 +13,7 @@ pub mod profile_toml;
mod project_root_markers;
mod requirements_exec_policy;
pub mod schema;
pub mod shell_environment;
mod skills_config;
mod state;
pub mod types;

View File

@@ -0,0 +1,123 @@
use crate::types::EnvironmentVariablePattern;
use crate::types::ShellEnvironmentPolicy;
use crate::types::ShellEnvironmentPolicyInherit;
use std::collections::HashMap;
use std::collections::HashSet;
pub const CODEX_THREAD_ID_ENV_VAR: &str = "CODEX_THREAD_ID";
/// Construct a shell environment from the supplied process environment and
/// shell-environment policy.
pub fn create_env(
policy: &ShellEnvironmentPolicy,
thread_id: Option<&str>,
) -> HashMap<String, String> {
create_env_from_vars(std::env::vars(), policy, thread_id)
}
pub fn create_env_from_vars<I>(
vars: I,
policy: &ShellEnvironmentPolicy,
thread_id: Option<&str>,
) -> HashMap<String, String>
where
I: IntoIterator<Item = (String, String)>,
{
let mut env_map = populate_env(vars, policy, thread_id);
if cfg!(target_os = "windows") {
// This is a workaround to address the failures we are seeing in the
// following tests when run via Bazel on Windows:
//
// ```
// suite::shell_command::unicode_output::with_login
// suite::shell_command::unicode_output::without_login
// ```
//
// Currently, we can only reproduce these failures in CI, which makes
// iteration times long, so we include this quick fix for now to unblock
// getting the Windows Bazel build running.
if !env_map.keys().any(|k| k.eq_ignore_ascii_case("PATHEXT")) {
env_map.insert("PATHEXT".to_string(), ".COM;.EXE;.BAT;.CMD".to_string());
}
}
env_map
}
pub fn populate_env<I>(
vars: I,
policy: &ShellEnvironmentPolicy,
thread_id: Option<&str>,
) -> HashMap<String, String>
where
I: IntoIterator<Item = (String, String)>,
{
// Step 1 - determine the starting set of variables based on the
// `inherit` strategy.
let mut env_map: HashMap<String, String> = match policy.inherit {
ShellEnvironmentPolicyInherit::All => vars.into_iter().collect(),
ShellEnvironmentPolicyInherit::None => HashMap::new(),
ShellEnvironmentPolicyInherit::Core => {
let core_vars: HashSet<&str> = COMMON_CORE_VARS
.iter()
.copied()
.chain(PLATFORM_CORE_VARS.iter().copied())
.collect();
let is_core_var = |name: &str| {
if cfg!(target_os = "windows") {
core_vars
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(name))
} else {
core_vars.contains(name)
}
};
vars.into_iter().filter(|(k, _)| is_core_var(k)).collect()
}
};
// Internal helper - does `name` match any pattern in `patterns`?
let matches_any = |name: &str, patterns: &[EnvironmentVariablePattern]| -> bool {
patterns.iter().any(|pattern| pattern.matches(name))
};
// Step 2 - Apply the default exclude if not disabled.
if !policy.ignore_default_excludes {
let default_excludes = vec![
EnvironmentVariablePattern::new_case_insensitive("*KEY*"),
EnvironmentVariablePattern::new_case_insensitive("*SECRET*"),
EnvironmentVariablePattern::new_case_insensitive("*TOKEN*"),
];
env_map.retain(|k, _| !matches_any(k, &default_excludes));
}
// Step 3 - Apply custom excludes.
if !policy.exclude.is_empty() {
env_map.retain(|k, _| !matches_any(k, &policy.exclude));
}
// Step 4 - Apply user-provided overrides.
for (key, val) in &policy.r#set {
env_map.insert(key.clone(), val.clone());
}
// Step 5 - If include_only is non-empty, keep only the matching vars.
if !policy.include_only.is_empty() {
env_map.retain(|k, _| matches_any(k, &policy.include_only));
}
// Step 6 - Populate the thread ID environment variable when provided.
if let Some(thread_id) = thread_id {
env_map.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string());
}
env_map
}
const COMMON_CORE_VARS: &[&str] = &["PATH", "SHELL", "TMPDIR", "TEMP", "TMP"];
#[cfg(target_os = "windows")]
const PLATFORM_CORE_VARS: &[&str] = &["PATHEXT", "USERNAME", "USERPROFILE"];
#[cfg(unix)]
const PLATFORM_CORE_VARS: &[&str] = &["HOME", "LANG", "LC_ALL", "LC_CTYPE", "LOGNAME", "USER"];

View File

@@ -632,7 +632,7 @@ impl From<SandboxWorkspaceWrite> for codex_app_server_protocol::SandboxSettings
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum ShellEnvironmentPolicyInherit {
/// "Core" environment variables for the platform. On UNIX, this would

View File

@@ -322,6 +322,7 @@ pub(crate) async fn execute_exec_request(
command,
cwd,
env,
exec_server_env_config: _,
network,
expiration,
capture_policy,

View File

@@ -1,11 +1,10 @@
#[cfg(test)]
use codex_config::types::EnvironmentVariablePattern;
use codex_config::types::ShellEnvironmentPolicy;
use codex_config::types::ShellEnvironmentPolicyInherit;
use codex_protocol::ThreadId;
use std::collections::HashMap;
use std::collections::HashSet;
pub const CODEX_THREAD_ID_ENV_VAR: &str = "CODEX_THREAD_ID";
pub use codex_config::shell_environment::CODEX_THREAD_ID_ENV_VAR;
/// Construct an environment map based on the rules in the specified policy. The
/// resulting map can be passed directly to `Command::envs()` after calling
@@ -21,9 +20,11 @@ pub fn create_env(
policy: &ShellEnvironmentPolicy,
thread_id: Option<ThreadId>,
) -> HashMap<String, String> {
create_env_from_vars(std::env::vars(), policy, thread_id)
let thread_id = thread_id.map(|thread_id| thread_id.to_string());
codex_config::shell_environment::create_env(policy, thread_id.as_deref())
}
#[cfg(all(test, target_os = "windows"))]
fn create_env_from_vars<I>(
vars: I,
policy: &ShellEnvironmentPolicy,
@@ -32,35 +33,11 @@ fn create_env_from_vars<I>(
where
I: IntoIterator<Item = (String, String)>,
{
let mut env_map = populate_env(vars, policy, thread_id);
if cfg!(target_os = "windows") {
// This is a workaround to address the failures we are seeing in the
// following tests when run via Bazel on Windows:
//
// ```
// suite::shell_command::unicode_output::with_login
// suite::shell_command::unicode_output::without_login
// ```
//
// Currently, we can only reproduce these failures in CI, which makes
// iteration times long, so we include this quick fix for now to unblock
// getting the Windows Bazel build running.
if !env_map.keys().any(|k| k.eq_ignore_ascii_case("PATHEXT")) {
env_map.insert("PATHEXT".to_string(), ".COM;.EXE;.BAT;.CMD".to_string());
}
}
env_map
let thread_id = thread_id.map(|thread_id| thread_id.to_string());
codex_config::shell_environment::create_env_from_vars(vars, policy, thread_id.as_deref())
}
const COMMON_CORE_VARS: &[&str] = &["PATH", "SHELL", "TMPDIR", "TEMP", "TMP"];
#[cfg(target_os = "windows")]
const PLATFORM_CORE_VARS: &[&str] = &["PATHEXT", "USERNAME", "USERPROFILE"];
#[cfg(unix)]
const PLATFORM_CORE_VARS: &[&str] = &["HOME", "LANG", "LC_ALL", "LC_CTYPE", "LOGNAME", "USER"];
#[cfg(test)]
fn populate_env<I>(
vars: I,
policy: &ShellEnvironmentPolicy,
@@ -69,66 +46,8 @@ fn populate_env<I>(
where
I: IntoIterator<Item = (String, String)>,
{
// Step 1 determine the starting set of variables based on the
// `inherit` strategy.
let mut env_map: HashMap<String, String> = match policy.inherit {
ShellEnvironmentPolicyInherit::All => vars.into_iter().collect(),
ShellEnvironmentPolicyInherit::None => HashMap::new(),
ShellEnvironmentPolicyInherit::Core => {
let core_vars: HashSet<&str> = COMMON_CORE_VARS
.iter()
.copied()
.chain(PLATFORM_CORE_VARS.iter().copied())
.collect();
let is_core_var = |name: &str| {
if cfg!(target_os = "windows") {
core_vars
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(name))
} else {
core_vars.contains(name)
}
};
vars.into_iter().filter(|(k, _)| is_core_var(k)).collect()
}
};
// Internal helper does `name` match **any** pattern in `patterns`?
let matches_any = |name: &str, patterns: &[EnvironmentVariablePattern]| -> bool {
patterns.iter().any(|pattern| pattern.matches(name))
};
// Step 2 Apply the default exclude if not disabled.
if !policy.ignore_default_excludes {
let default_excludes = vec![
EnvironmentVariablePattern::new_case_insensitive("*KEY*"),
EnvironmentVariablePattern::new_case_insensitive("*SECRET*"),
EnvironmentVariablePattern::new_case_insensitive("*TOKEN*"),
];
env_map.retain(|k, _| !matches_any(k, &default_excludes));
}
// Step 3 Apply custom excludes.
if !policy.exclude.is_empty() {
env_map.retain(|k, _| !matches_any(k, &policy.exclude));
}
// Step 4 Apply user-provided overrides.
for (key, val) in &policy.r#set {
env_map.insert(key.clone(), val.clone());
}
// Step 5 If include_only is non-empty, keep *only* the matching vars.
if !policy.include_only.is_empty() {
env_map.retain(|k, _| matches_any(k, &policy.include_only));
}
// Step 6 Populate the thread ID environment variable when provided.
if let Some(thread_id) = thread_id {
env_map.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string());
}
env_map
let thread_id = thread_id.map(|thread_id| thread_id.to_string());
codex_config::shell_environment::populate_env(vars, policy, thread_id.as_deref())
}
#[cfg(test)]

View File

@@ -33,11 +33,18 @@ pub(crate) struct ExecOptions {
pub(crate) capture_policy: ExecCapturePolicy,
}
#[derive(Clone, Debug)]
pub(crate) struct ExecServerEnvConfig {
pub(crate) policy: codex_exec_server::ExecEnvPolicy,
pub(crate) local_policy_env: HashMap<String, String>,
}
#[derive(Debug)]
pub struct ExecRequest {
pub command: Vec<String>,
pub cwd: AbsolutePathBuf,
pub env: HashMap<String, String>,
pub(crate) exec_server_env_config: Option<ExecServerEnvConfig>,
pub network: Option<NetworkProxy>,
pub expiration: ExecExpiration,
pub capture_policy: ExecCapturePolicy,
@@ -73,6 +80,7 @@ impl ExecRequest {
command,
cwd,
env,
exec_server_env_config: None,
network,
expiration,
capture_policy,
@@ -122,6 +130,7 @@ impl ExecRequest {
command,
cwd,
env,
exec_server_env_config: None,
network,
expiration,
capture_policy,

View File

@@ -162,6 +162,7 @@ pub(crate) async fn execute_user_shell_command(
command: exec_command.clone(),
cwd: cwd.clone(),
env: exec_env_map,
exec_server_env_config: None,
network: turn_context.network.clone(),
// TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we
// should use that instead of an "arbitrarily large" timeout here.

View File

@@ -124,6 +124,7 @@ pub(super) async fn try_run_zsh_fork(
command,
cwd: sandbox_cwd,
env: sandbox_env,
exec_server_env_config: _,
network: sandbox_network,
expiration: _sandbox_expiration,
capture_policy: _capture_policy,
@@ -713,6 +714,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
command: self.command.clone(),
cwd: self.cwd.clone(),
env: exec_env,
exec_server_env_config: None,
network: self.network.clone(),
expiration: ExecExpiration::Cancellation(cancel_rx),
capture_policy: ExecCapturePolicy::ShellTool,

View File

@@ -11,6 +11,7 @@ use crate::guardian::GuardianApprovalRequest;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::ExecServerEnvConfig;
use crate::sandboxing::SandboxPermissions;
use crate::shell::ShellType;
use crate::tools::network_approval::NetworkApprovalMode;
@@ -53,6 +54,7 @@ pub struct UnifiedExecRequest {
pub process_id: i32,
pub cwd: AbsolutePathBuf,
pub env: HashMap<String, String>,
pub exec_server_env_config: Option<ExecServerEnvConfig>,
pub explicit_env_overrides: HashMap<String, String>,
pub network: Option<NetworkProxy>,
pub tty: bool,
@@ -236,9 +238,10 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
expiration: ExecExpiration::DefaultTimeout,
capture_policy: ExecCapturePolicy::ShellTool,
};
let exec_env = attempt
let mut exec_env = attempt
.env_for(command, options, req.network.as_ref())
.map_err(|err| ToolError::Codex(err.into()))?;
exec_env.exec_server_env_config = req.exec_server_env_config.clone();
match zsh_fork_backend::maybe_prepare_unified_exec(
req,
attempt,
@@ -293,9 +296,10 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
expiration: ExecExpiration::DefaultTimeout,
capture_policy: ExecCapturePolicy::ShellTool,
};
let exec_env = attempt
let mut exec_env = attempt
.env_for(command, options, req.network.as_ref())
.map_err(|err| ToolError::Codex(err.into()))?;
exec_env.exec_server_env_config = req.exec_server_env_config.clone();
let Some(environment) = ctx.turn.environment.as_ref() else {
return Err(ToolError::Rejected(
"exec_command is unavailable in this session".to_string(),

View File

@@ -66,10 +66,11 @@ pub(crate) struct OutputHandles {
/// Transport-specific process handle used by unified exec.
enum ProcessHandle {
Local(Box<ExecCommandSession>),
Remote(Arc<dyn ExecProcess>),
ExecServer(Arc<dyn ExecProcess>),
}
/// Unified wrapper over local PTY sessions and exec-server-backed processes.
/// Unified wrapper over directly spawned PTY sessions and exec-server-backed
/// processes.
pub(crate) struct UnifiedExecProcess {
process_handle: ProcessHandle,
output_tx: broadcast::Sender<Vec<u8>>,
@@ -135,7 +136,7 @@ impl UnifiedExecProcess {
.send(data.to_vec())
.await
.map_err(|_| UnifiedExecError::WriteToStdin),
ProcessHandle::Remote(process_handle) => {
ProcessHandle::ExecServer(process_handle) => {
match process_handle.write(data.to_vec()).await {
Ok(response) => match response.status {
WriteStatus::Accepted => Ok(()),
@@ -179,7 +180,7 @@ impl UnifiedExecProcess {
let state = self.state_rx.borrow().clone();
match &self.process_handle {
ProcessHandle::Local(process_handle) => state.has_exited || process_handle.has_exited(),
ProcessHandle::Remote(_) => state.has_exited,
ProcessHandle::ExecServer(_) => state.has_exited,
}
}
@@ -189,7 +190,7 @@ impl UnifiedExecProcess {
ProcessHandle::Local(process_handle) => {
state.exit_code.or_else(|| process_handle.exit_code())
}
ProcessHandle::Remote(_) => state.exit_code,
ProcessHandle::ExecServer(_) => state.exit_code,
}
}
@@ -198,7 +199,7 @@ impl UnifiedExecProcess {
self.output_closed_notify.notify_waiters();
match &self.process_handle {
ProcessHandle::Local(process_handle) => process_handle.terminate(),
ProcessHandle::Remote(process_handle) => {
ProcessHandle::ExecServer(process_handle) => {
let process_handle = Arc::clone(process_handle);
tokio::spawn(async move {
let _ = process_handle.terminate().await;
@@ -331,14 +332,14 @@ impl UnifiedExecProcess {
Ok(managed)
}
pub(super) async fn from_remote_started(
pub(super) async fn from_exec_server_started(
started: StartedExecProcess,
sandbox_type: SandboxType,
) -> Result<Self, UnifiedExecError> {
let process_handle = ProcessHandle::Remote(Arc::clone(&started.process));
let process_handle = ProcessHandle::ExecServer(Arc::clone(&started.process));
let mut managed = Self::new(process_handle, sandbox_type, /*spawn_lifecycle*/ None);
let output_handles = managed.output_handles();
managed.output_task = Some(Self::spawn_remote_output_task(
managed.output_task = Some(Self::spawn_exec_server_output_task(
started,
output_handles,
managed.output_tx.clone(),
@@ -366,7 +367,7 @@ impl UnifiedExecProcess {
Ok(managed)
}
fn spawn_remote_output_task(
fn spawn_exec_server_output_task(
started: StartedExecProcess,
output_handles: OutputHandles,
output_tx: broadcast::Sender<Vec<u8>>,

View File

@@ -11,9 +11,11 @@ use tokio::time::Duration;
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use crate::exec_env::CODEX_THREAD_ID_ENV_VAR;
use crate::exec_env::create_env;
use crate::exec_policy::ExecApprovalRequest;
use crate::sandboxing::ExecRequest;
use crate::sandboxing::ExecServerEnvConfig;
use crate::tools::context::ExecCommandToolOutput;
use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
@@ -47,6 +49,7 @@ use crate::unified_exec::process::OutputBuffer;
use crate::unified_exec::process::OutputHandles;
use crate::unified_exec::process::SpawnLifecycleHandle;
use crate::unified_exec::process::UnifiedExecProcess;
use codex_config::types::ShellEnvironmentPolicy;
use codex_protocol::protocol::ExecCommandSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_output_truncation::approx_token_count;
@@ -89,6 +92,70 @@ fn apply_unified_exec_env(mut env: HashMap<String, String>) -> HashMap<String, S
env
}
fn exec_env_policy_from_shell_policy(
policy: &ShellEnvironmentPolicy,
) -> codex_exec_server::ExecEnvPolicy {
codex_exec_server::ExecEnvPolicy {
inherit: policy.inherit.clone(),
ignore_default_excludes: policy.ignore_default_excludes,
exclude: policy
.exclude
.iter()
.map(std::string::ToString::to_string)
.collect(),
r#set: policy.r#set.clone(),
include_only: policy
.include_only
.iter()
.map(std::string::ToString::to_string)
.collect(),
}
}
fn env_overlay_for_exec_server(
request_env: &HashMap<String, String>,
local_policy_env: &HashMap<String, String>,
) -> HashMap<String, String> {
request_env
.iter()
.filter(|(key, value)| local_policy_env.get(*key) != Some(*value))
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
}
fn exec_server_env_for_request(
request: &ExecRequest,
) -> (
Option<codex_exec_server::ExecEnvPolicy>,
HashMap<String, String>,
) {
if let Some(exec_server_env_config) = &request.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),
)
} else {
(None, request.env.clone())
}
}
fn exec_server_params_for_request(
process_id: i32,
request: &ExecRequest,
tty: bool,
) -> codex_exec_server::ExecParams {
let (env_policy, env) = exec_server_env_for_request(request);
codex_exec_server::ExecParams {
process_id: exec_server_process_id(process_id).into(),
argv: request.command.clone(),
cwd: request.cwd.to_path_buf(),
env_policy,
env,
tty,
arg0: request.arg0.clone(),
}
}
/// Borrowed process state prepared for a `write_stdin` or poll operation.
struct PreparedProcessHandles {
process: Arc<UnifiedExecProcess>,
@@ -587,34 +654,27 @@ impl UnifiedExecProcessManager {
mut spawn_lifecycle: SpawnLifecycleHandle,
environment: &codex_exec_server::Environment,
) -> Result<UnifiedExecProcess, UnifiedExecError> {
let inherited_fds = spawn_lifecycle.inherited_fds();
if inherited_fds.is_empty() {
let started = environment
.get_exec_backend()
.start(exec_server_params_for_request(process_id, request, tty))
.await
.map_err(|err| UnifiedExecError::create_process(err.to_string()))?;
spawn_lifecycle.after_spawn();
return UnifiedExecProcess::from_exec_server_started(started, request.sandbox).await;
}
if environment.is_remote() {
return Err(UnifiedExecError::create_process(
"remote exec-server does not support inherited file descriptors".to_string(),
));
}
let (program, args) = request
.command
.split_first()
.ok_or(UnifiedExecError::MissingCommandLine)?;
let inherited_fds = spawn_lifecycle.inherited_fds();
if environment.is_remote() {
if !inherited_fds.is_empty() {
return Err(UnifiedExecError::create_process(
"remote exec-server does not support inherited file descriptors".to_string(),
));
}
let started = environment
.get_exec_backend()
.start(codex_exec_server::ExecParams {
process_id: exec_server_process_id(process_id).into(),
argv: request.command.clone(),
cwd: request.cwd.to_path_buf(),
env: request.env.clone(),
tty,
arg0: request.arg0.clone(),
})
.await
.map_err(|err| UnifiedExecError::create_process(err.to_string()))?;
return UnifiedExecProcess::from_remote_started(started, request.sandbox).await;
}
let spawn_result = if tty {
codex_utils_pty::pty::spawn_process_with_inherited_fds(
program,
@@ -649,10 +709,20 @@ impl UnifiedExecProcessManager {
cwd: AbsolutePathBuf,
context: &UnifiedExecContext,
) -> Result<(UnifiedExecProcess, Option<DeferredNetworkApproval>), UnifiedExecError> {
let env = apply_unified_exec_env(create_env(
let local_policy_env = create_env(
&context.turn.shell_environment_policy,
Some(context.session.conversation_id),
));
/*thread_id*/ None,
);
let mut env = local_policy_env.clone();
env.insert(
CODEX_THREAD_ID_ENV_VAR.to_string(),
context.session.conversation_id.to_string(),
);
let env = apply_unified_exec_env(env);
let exec_server_env_config = ExecServerEnvConfig {
policy: exec_env_policy_from_shell_policy(&context.turn.shell_environment_policy),
local_policy_env,
};
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = UnifiedExecRuntime::new(
self,
@@ -680,6 +750,7 @@ impl UnifiedExecProcessManager {
process_id: request.process_id,
cwd,
env,
exec_server_env_config: Some(exec_server_env_config),
explicit_env_overrides: context.turn.shell_environment_policy.r#set.clone(),
network: request.network.clone(),
tty: request.tty,

View File

@@ -34,6 +34,92 @@ fn unified_exec_env_overrides_existing_values() {
assert_eq!(env.get("PATH"), Some(&"/usr/bin".to_string()));
}
#[test]
fn env_overlay_for_exec_server_keeps_runtime_changes_only() {
let local_policy_env = HashMap::from([
("HOME".to_string(), "/client-home".to_string()),
("PATH".to_string(), "/client-path".to_string()),
("SHELL_SET".to_string(), "policy".to_string()),
]);
let request_env = HashMap::from([
("HOME".to_string(), "/client-home".to_string()),
("PATH".to_string(), "/sandbox-path".to_string()),
("SHELL_SET".to_string(), "policy".to_string()),
("CODEX_THREAD_ID".to_string(), "thread-1".to_string()),
(
"CODEX_SANDBOX_NETWORK_DISABLED".to_string(),
"1".to_string(),
),
]);
assert_eq!(
env_overlay_for_exec_server(&request_env, &local_policy_env),
HashMap::from([
("PATH".to_string(), "/sandbox-path".to_string()),
("CODEX_THREAD_ID".to_string(), "thread-1".to_string()),
(
"CODEX_SANDBOX_NETWORK_DISABLED".to_string(),
"1".to_string()
),
])
);
}
#[test]
fn exec_server_params_use_env_policy_overlay_contract() {
let request = ExecRequest {
command: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()],
cwd: std::env::current_dir()
.expect("current dir")
.try_into()
.expect("absolute path"),
env: HashMap::from([
("HOME".to_string(), "/client-home".to_string()),
("PATH".to_string(), "/sandbox-path".to_string()),
("CODEX_THREAD_ID".to_string(), "thread-1".to_string()),
]),
exec_server_env_config: Some(ExecServerEnvConfig {
policy: codex_exec_server::ExecEnvPolicy {
inherit: codex_config::types::ShellEnvironmentPolicyInherit::Core,
ignore_default_excludes: false,
exclude: Vec::new(),
r#set: HashMap::new(),
include_only: Vec::new(),
},
local_policy_env: HashMap::from([
("HOME".to_string(), "/client-home".to_string()),
("PATH".to_string(), "/client-path".to_string()),
]),
}),
network: None,
expiration: crate::exec::ExecExpiration::DefaultTimeout,
capture_policy: crate::exec::ExecCapturePolicy::ShellTool,
sandbox: codex_sandboxing::SandboxType::None,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: codex_protocol::permissions::FileSystemSandboxPolicy::from(
&codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
),
network_sandbox_policy: codex_protocol::permissions::NetworkSandboxPolicy::Restricted,
windows_restricted_token_filesystem_overlay: None,
arg0: None,
};
let params =
exec_server_params_for_request(/*process_id*/ 123, &request, /*tty*/ true);
assert_eq!(params.process_id.as_str(), "123");
assert!(params.env_policy.is_some());
assert_eq!(
params.env,
HashMap::from([
("PATH".to_string(), "/sandbox-path".to_string()),
("CODEX_THREAD_ID".to_string(), "thread-1".to_string()),
])
);
}
#[test]
fn exec_server_process_id_matches_unified_exec_process_id() {
assert_eq!(exec_server_process_id(/*process_id*/ 4321), "4321");

View File

@@ -76,7 +76,7 @@ async fn remote_process(write_status: WriteStatus) -> UnifiedExecProcess {
}),
};
UnifiedExecProcess::from_remote_started(started, SandboxType::None)
UnifiedExecProcess::from_exec_server_started(started, SandboxType::None)
.await
.expect("remote process should start")
}
@@ -133,7 +133,7 @@ async fn remote_process_waits_for_early_exit_event() {
let _ = wake_tx.send(1);
});
let process = UnifiedExecProcess::from_remote_started(started, SandboxType::None)
let process = UnifiedExecProcess::from_exec_server_started(started, SandboxType::None)
.await
.expect("remote process should observe early exit");

View File

@@ -20,6 +20,7 @@ async-trait = { workspace = true }
base64 = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-app-server-protocol = { workspace = true }
codex-config = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-pty = { workspace = true }

View File

@@ -288,6 +288,7 @@ mod tests {
process_id: ProcessId::from("default-env-proc"),
argv: vec!["true".to_string()],
cwd: std::env::current_dir().expect("read current dir"),
env_policy: None,
env: Default::default(),
tty: false,
arg0: None,

View File

@@ -33,6 +33,7 @@ pub use process::ExecProcess;
pub use process::StartedExecProcess;
pub use process_id::ProcessId;
pub use protocol::ExecClosedNotification;
pub use protocol::ExecEnvPolicy;
pub use protocol::ExecExitedNotification;
pub use protocol::ExecOutputDeltaNotification;
pub use protocol::ExecOutputStream;

View File

@@ -7,6 +7,9 @@ use std::time::Duration;
use async_trait::async_trait;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_config::shell_environment;
use codex_config::types::EnvironmentVariablePattern;
use codex_config::types::ShellEnvironmentPolicy;
use codex_utils_pty::ExecCommandSession;
use codex_utils_pty::TerminalSize;
use tokio::sync::Mutex;
@@ -21,6 +24,7 @@ use crate::ProcessId;
use crate::StartedExecProcess;
use crate::protocol::EXEC_CLOSED_METHOD;
use crate::protocol::ExecClosedNotification;
use crate::protocol::ExecEnvPolicy;
use crate::protocol::ExecExitedNotification;
use crate::protocol::ExecOutputDeltaNotification;
use crate::protocol::ExecOutputStream;
@@ -183,12 +187,13 @@ impl LocalProcess {
process_map.insert(process_id.clone(), ProcessEntry::Starting);
}
let env = child_env(&params);
let spawned_result = if params.tty {
codex_utils_pty::spawn_pty_process(
program,
args,
params.cwd.as_path(),
&params.env,
&env,
&params.arg0,
TerminalSize::default(),
)
@@ -198,7 +203,7 @@ impl LocalProcess {
program,
args,
params.cwd.as_path(),
&params.env,
&env,
&params.arg0,
)
.await
@@ -411,6 +416,36 @@ impl LocalProcess {
}
}
fn child_env(params: &ExecParams) -> HashMap<String, String> {
let Some(env_policy) = &params.env_policy else {
return params.env.clone();
};
let policy = shell_environment_policy(env_policy);
let mut env = shell_environment::create_env(&policy, /*thread_id*/ None);
env.extend(params.env.clone());
env
}
fn shell_environment_policy(env_policy: &ExecEnvPolicy) -> ShellEnvironmentPolicy {
ShellEnvironmentPolicy {
inherit: env_policy.inherit.clone(),
ignore_default_excludes: env_policy.ignore_default_excludes,
exclude: env_policy
.exclude
.iter()
.map(|pattern| EnvironmentVariablePattern::new_case_insensitive(pattern))
.collect(),
r#set: env_policy.r#set.clone(),
include_only: env_policy
.include_only
.iter()
.map(|pattern| EnvironmentVariablePattern::new_case_insensitive(pattern))
.collect(),
use_profile: false,
}
}
#[async_trait]
impl ExecBackend for LocalProcess {
async fn start(&self, params: ExecParams) -> Result<StartedExecProcess, ExecServerError> {
@@ -652,3 +687,54 @@ async fn maybe_emit_closed(process_id: ProcessId, inner: Arc<Inner>) {
.is_err()
{}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_config::types::ShellEnvironmentPolicyInherit;
fn test_exec_params(env: HashMap<String, String>) -> ExecParams {
ExecParams {
process_id: ProcessId::from("env-test"),
argv: vec!["true".to_string()],
cwd: std::path::PathBuf::from("/tmp"),
env_policy: None,
env,
tty: false,
arg0: None,
}
}
#[test]
fn child_env_defaults_to_exact_env() {
let params = test_exec_params(HashMap::from([("ONLY_THIS".to_string(), "1".to_string())]));
assert_eq!(
child_env(&params),
HashMap::from([("ONLY_THIS".to_string(), "1".to_string())])
);
}
#[test]
fn child_env_applies_policy_then_overlay() {
let mut params = test_exec_params(HashMap::from([
("OVERLAY".to_string(), "overlay".to_string()),
("POLICY_SET".to_string(), "overlay-wins".to_string()),
]));
params.env_policy = Some(ExecEnvPolicy {
inherit: ShellEnvironmentPolicyInherit::None,
ignore_default_excludes: true,
exclude: Vec::new(),
r#set: HashMap::from([("POLICY_SET".to_string(), "policy".to_string())]),
include_only: Vec::new(),
});
assert_eq!(
child_env(&params),
HashMap::from([
("OVERLAY".to_string(), "overlay".to_string()),
("POLICY_SET".to_string(), "overlay-wins".to_string()),
])
);
}
}

View File

@@ -2,6 +2,7 @@ use std::collections::HashMap;
use std::path::PathBuf;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_config::types::ShellEnvironmentPolicyInherit;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
@@ -60,11 +61,23 @@ pub struct ExecParams {
pub process_id: ProcessId,
pub argv: Vec<String>,
pub cwd: PathBuf,
#[serde(default)]
pub env_policy: Option<ExecEnvPolicy>,
pub env: HashMap<String, String>,
pub tty: bool,
pub arg0: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecEnvPolicy {
pub inherit: ShellEnvironmentPolicyInherit,
pub ignore_default_excludes: bool,
pub exclude: Vec<String>,
pub r#set: HashMap<String, String>,
pub include_only: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecResponse {

View File

@@ -26,6 +26,7 @@ fn exec_params(process_id: &str) -> ExecParams {
"sleep 0.1".to_string(),
],
cwd: std::env::current_dir().expect("cwd"),
env_policy: None,
env,
tty: false,
arg0: None,

View File

@@ -51,6 +51,7 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> {
process_id: ProcessId::from("proc-1"),
argv: vec!["true".to_string()],
cwd: std::env::current_dir()?,
env_policy: /*env_policy*/ None,
env: Default::default(),
tty: false,
arg0: None,
@@ -127,6 +128,7 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> {
"sleep 0.05; printf 'session output\\n'".to_string(),
],
cwd: std::env::current_dir()?,
env_policy: /*env_policy*/ None,
env: Default::default(),
tty: false,
arg0: None,
@@ -156,6 +158,7 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> {
"import sys; line = sys.stdin.readline(); sys.stdout.write(f'from-stdin:{line}'); sys.stdout.flush()".to_string(),
],
cwd: std::env::current_dir()?,
env_policy: /*env_policy*/ None,
env: Default::default(),
tty: true,
arg0: None,
@@ -192,6 +195,7 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe(
"printf 'queued output\\n'".to_string(),
],
cwd: std::env::current_dir()?,
env_policy: /*env_policy*/ None,
env: Default::default(),
tty: false,
arg0: None,
@@ -222,6 +226,7 @@ async fn remote_exec_process_reports_transport_disconnect() -> Result<()> {
"sleep 10".to_string(),
],
cwd: std::env::current_dir()?,
env_policy: /*env_policy*/ None,
env: Default::default(),
tty: false,
arg0: None,