mirror of
https://github.com/openai/codex.git
synced 2026-05-21 19:45:26 +00:00
Compare commits
15 Commits
starr/full
...
starr/shel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aac7a6ee04 | ||
|
|
331b9d58e4 | ||
|
|
51b9a48f38 | ||
|
|
34f3fe9e4f | ||
|
|
13e887a52b | ||
|
|
3b0575d02b | ||
|
|
66862fda28 | ||
|
|
7c9d130d50 | ||
|
|
907f39f7bd | ||
|
|
2bc8b42997 | ||
|
|
1c0d1b25dc | ||
|
|
bf7937b4a7 | ||
|
|
c7b4daee61 | ||
|
|
1841ed3f01 | ||
|
|
8cbd0edff0 |
@@ -41,6 +41,19 @@ impl ResolvedTurnEnvironments {
|
||||
self.turn_environments.first()
|
||||
}
|
||||
|
||||
pub(crate) fn get_by_id(&self, environment_id: &str) -> Option<&TurnEnvironment> {
|
||||
self.turn_environments
|
||||
.iter()
|
||||
.find(|environment| environment.environment_id == environment_id)
|
||||
}
|
||||
|
||||
pub(crate) fn get_or_primary(&self, environment_id: Option<&str>) -> Option<&TurnEnvironment> {
|
||||
environment_id.map_or_else(
|
||||
|| self.primary(),
|
||||
|environment_id| self.get_by_id(environment_id),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn primary_environment(&self) -> Option<Arc<codex_exec_server::Environment>> {
|
||||
self.primary()
|
||||
.map(|environment| Arc::clone(&environment.environment))
|
||||
@@ -177,4 +190,35 @@ mod tests {
|
||||
"local"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolved_environment_selections_gets_by_id_or_primary() {
|
||||
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
|
||||
let manager = EnvironmentManager::default_for_tests();
|
||||
|
||||
let resolved = resolve_environment_selections(
|
||||
&manager,
|
||||
&[TurnEnvironmentSelection {
|
||||
environment_id: "local".to_string(),
|
||||
cwd,
|
||||
}],
|
||||
)
|
||||
.expect("environment selections should resolve");
|
||||
|
||||
assert_eq!(
|
||||
resolved
|
||||
.get_or_primary(/*environment_id*/ None)
|
||||
.expect("primary environment")
|
||||
.environment_id,
|
||||
"local"
|
||||
);
|
||||
assert_eq!(
|
||||
resolved
|
||||
.get_or_primary(Some("local"))
|
||||
.expect("selected environment")
|
||||
.environment_id,
|
||||
"local"
|
||||
);
|
||||
assert!(resolved.get_or_primary(Some("unknown")).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::exec::execute_exec_request;
|
||||
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
|
||||
use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::config_types::ShellEnvironmentPolicy;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::exec_output::ExecToolCallOutput;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
@@ -41,6 +42,43 @@ pub(crate) struct ExecServerEnvConfig {
|
||||
pub(crate) local_policy_env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ExecServerEnvConfig {
|
||||
pub(crate) fn from_shell_policy(
|
||||
policy: &ShellEnvironmentPolicy,
|
||||
local_policy_env: HashMap<String, String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
policy: 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(),
|
||||
},
|
||||
local_policy_env,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn env_overlay(
|
||||
&self,
|
||||
request_env: &HashMap<String, String>,
|
||||
) -> HashMap<String, String> {
|
||||
request_env
|
||||
.iter()
|
||||
.filter(|(key, value)| self.local_policy_env.get(*key) != Some(*value))
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ExecRequest {
|
||||
pub command: Vec<String>,
|
||||
@@ -175,3 +213,68 @@ pub async fn execute_exec_request_with_after_spawn(
|
||||
) -> codex_protocol::error::Result<ExecToolCallOutput> {
|
||||
execute_exec_request(exec_request, stdout_stream, after_spawn).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::config_types::EnvironmentVariablePattern;
|
||||
use codex_protocol::config_types::ShellEnvironmentPolicyInherit;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn exec_server_env_config_from_shell_policy_preserves_policy_fields() {
|
||||
let policy = ShellEnvironmentPolicy {
|
||||
inherit: ShellEnvironmentPolicyInherit::Core,
|
||||
ignore_default_excludes: false,
|
||||
exclude: vec![EnvironmentVariablePattern::new_case_insensitive("SECRET_*")],
|
||||
r#set: HashMap::from([("FROM_POLICY".to_string(), "set-value".to_string())]),
|
||||
include_only: vec![EnvironmentVariablePattern::new_case_insensitive("*PATH")],
|
||||
use_profile: true,
|
||||
};
|
||||
let local_policy_env = HashMap::from([
|
||||
("PATH".to_string(), "/usr/bin".to_string()),
|
||||
("FROM_POLICY".to_string(), "set-value".to_string()),
|
||||
]);
|
||||
|
||||
let config = ExecServerEnvConfig::from_shell_policy(&policy, local_policy_env.clone());
|
||||
|
||||
assert_eq!(config.policy.inherit, ShellEnvironmentPolicyInherit::Core);
|
||||
assert!(!config.policy.ignore_default_excludes);
|
||||
assert_eq!(config.policy.exclude, vec!["SECRET_*".to_string()]);
|
||||
assert_eq!(config.policy.r#set, policy.r#set);
|
||||
assert_eq!(config.policy.include_only, vec!["*PATH".to_string()]);
|
||||
assert_eq!(config.local_policy_env, local_policy_env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_server_env_config_env_overlay_keeps_only_runtime_changes() {
|
||||
let config = ExecServerEnvConfig {
|
||||
policy: codex_exec_server::ExecEnvPolicy {
|
||||
inherit: 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()),
|
||||
("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()),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
config.env_overlay(&request_env),
|
||||
HashMap::from([
|
||||
("PATH".to_string(), "/sandbox-path".to_string()),
|
||||
("CODEX_THREAD_ID".to_string(), "thread-1".to_string()),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,13 +24,12 @@ use codex_sandboxing::policy_transforms::normalize_additional_permissions;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::environment_selection::ResolvedTurnEnvironments;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::session::turn_context::TurnEnvironment;
|
||||
pub(crate) use crate::tools::code_mode::CodeModeExecuteHandler;
|
||||
pub(crate) use crate::tools::code_mode::CodeModeWaitHandler;
|
||||
@@ -83,35 +82,58 @@ where
|
||||
|
||||
fn resolve_workdir_base_path(
|
||||
arguments: &str,
|
||||
default_cwd: &AbsolutePathBuf,
|
||||
cwd: &AbsolutePathBuf,
|
||||
) -> Result<AbsolutePathBuf, FunctionCallError> {
|
||||
let arguments: Value = parse_arguments(arguments)?;
|
||||
Ok(arguments
|
||||
.get("workdir")
|
||||
.and_then(Value::as_str)
|
||||
let target_args: EnvironmentTargetArgs = parse_arguments(arguments)?;
|
||||
Ok(target_args
|
||||
.workdir
|
||||
.as_deref()
|
||||
.filter(|workdir| !workdir.is_empty())
|
||||
.map_or_else(|| default_cwd.clone(), |workdir| default_cwd.join(workdir)))
|
||||
.map_or_else(|| cwd.clone(), |workdir| cwd.join(workdir)))
|
||||
}
|
||||
|
||||
fn resolve_tool_environment<'a>(
|
||||
turn: &'a TurnContext,
|
||||
environment_id: Option<&str>,
|
||||
) -> Result<Option<&'a TurnEnvironment>, FunctionCallError> {
|
||||
environment_id.map_or_else(
|
||||
|| Ok(turn.environments.primary()),
|
||||
|environment_id| {
|
||||
turn.environments
|
||||
.turn_environments
|
||||
.iter()
|
||||
.find(|environment| environment.environment_id == environment_id)
|
||||
.map(Some)
|
||||
.ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"unknown turn environment id `{environment_id}`"
|
||||
))
|
||||
})
|
||||
},
|
||||
)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EnvironmentTargetArgs {
|
||||
#[serde(default)]
|
||||
environment_id: Option<String>,
|
||||
// Keep this raw until after environment selection; relative paths must be
|
||||
// resolved against the selected environment cwd, not the process cwd.
|
||||
#[serde(default)]
|
||||
workdir: Option<String>,
|
||||
}
|
||||
|
||||
/// Parses optional `environment_id` and `workdir` fields from tool arguments.
|
||||
///
|
||||
/// Returns the selected turn environment plus the effective execution cwd. The
|
||||
/// returned cwd is `turn_environment.cwd.join(workdir)` when `workdir` is
|
||||
/// provided and non-empty, otherwise it is the selected `turn_environment.cwd`.
|
||||
fn resolve_environment_target(
|
||||
arguments: &str,
|
||||
environments: &ResolvedTurnEnvironments,
|
||||
) -> Result<Option<(TurnEnvironment, AbsolutePathBuf)>, FunctionCallError> {
|
||||
let target_args: EnvironmentTargetArgs = parse_arguments(arguments)?;
|
||||
let requested_environment_id = target_args.environment_id.as_deref();
|
||||
let turn_environment = match environments.get_or_primary(requested_environment_id) {
|
||||
Some(turn_environment) => turn_environment,
|
||||
None => {
|
||||
let Some(environment_id) = requested_environment_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"unknown turn environment id `{environment_id}`"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let cwd = target_args
|
||||
.workdir
|
||||
.as_deref()
|
||||
.filter(|workdir| !workdir.is_empty())
|
||||
.map_or_else(
|
||||
|| turn_environment.cwd.clone(),
|
||||
|workdir| turn_environment.cwd.join(workdir),
|
||||
);
|
||||
|
||||
Ok(Some((turn_environment.clone(), cwd)))
|
||||
}
|
||||
|
||||
/// Validates feature/policy constraints for `with_additional_permissions` and
|
||||
|
||||
@@ -2,6 +2,7 @@ use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ShellCommandToolCallParams;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::exec::ExecCapturePolicy;
|
||||
@@ -12,6 +13,8 @@ use crate::function_tool::FunctionCallError;
|
||||
use crate::maybe_emit_implicit_skill_invocation;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::shell::Shell;
|
||||
use crate::shell::ShellType;
|
||||
use crate::shell::empty_shell_snapshot_receiver;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
@@ -24,6 +27,7 @@ use crate::tools::handlers::implicit_granted_permissions;
|
||||
use crate::tools::handlers::normalize_and_validate_additional_permissions;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::handlers::parse_arguments_with_base_path;
|
||||
use crate::tools::handlers::resolve_environment_target;
|
||||
use crate::tools::handlers::resolve_workdir_base_path;
|
||||
use crate::tools::hook_names::HookToolName;
|
||||
use crate::tools::orchestrator::ToolOrchestrator;
|
||||
@@ -90,6 +94,7 @@ struct RunExecLikeArgs {
|
||||
tool_name: String,
|
||||
exec_params: ExecParams,
|
||||
hook_command: String,
|
||||
environment: Arc<codex_exec_server::Environment>,
|
||||
additional_permissions: Option<AdditionalPermissionProfile>,
|
||||
prefix_rule: Option<Vec<String>>,
|
||||
session: Arc<crate::session::session::Session>,
|
||||
@@ -105,10 +110,11 @@ impl ShellHandler {
|
||||
params: &ShellToolCallParams,
|
||||
turn_context: &TurnContext,
|
||||
thread_id: ThreadId,
|
||||
cwd: codex_utils_absolute_path::AbsolutePathBuf,
|
||||
) -> ExecParams {
|
||||
ExecParams {
|
||||
command: params.command.clone(),
|
||||
cwd: turn_context.resolve_path(params.workdir.clone()),
|
||||
cwd,
|
||||
expiration: params.timeout_ms.into(),
|
||||
capture_policy: ExecCapturePolicy::ShellTool,
|
||||
env: create_env(&turn_context.shell_environment_policy, Some(thread_id)),
|
||||
@@ -152,18 +158,18 @@ impl ShellCommandHandler {
|
||||
|
||||
fn to_exec_params(
|
||||
params: &ShellCommandToolCallParams,
|
||||
session: &crate::session::session::Session,
|
||||
shell: &Shell,
|
||||
turn_context: &TurnContext,
|
||||
thread_id: ThreadId,
|
||||
cwd: codex_utils_absolute_path::AbsolutePathBuf,
|
||||
allow_login_shell: bool,
|
||||
) -> Result<ExecParams, FunctionCallError> {
|
||||
let shell = session.user_shell();
|
||||
let use_login_shell = Self::resolve_use_login_shell(params.login, allow_login_shell)?;
|
||||
let command = Self::base_command(shell.as_ref(), ¶ms.command, use_login_shell);
|
||||
let command = Self::base_command(shell, ¶ms.command, use_login_shell);
|
||||
|
||||
Ok(ExecParams {
|
||||
command,
|
||||
cwd: turn_context.resolve_path(params.workdir.clone()),
|
||||
cwd,
|
||||
expiration: params.timeout_ms.into(),
|
||||
capture_policy: ExecCapturePolicy::ShellTool,
|
||||
env: create_env(&turn_context.shell_environment_policy, Some(thread_id)),
|
||||
@@ -250,11 +256,20 @@ impl ToolHandler for ShellHandler {
|
||||
let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?;
|
||||
let prefix_rule = params.prefix_rule.clone();
|
||||
let exec_params =
|
||||
ShellHandler::to_exec_params(¶ms, turn.as_ref(), session.conversation_id);
|
||||
ShellHandler::to_exec_params(¶ms, turn.as_ref(), session.conversation_id, cwd);
|
||||
ShellHandler::run_exec_like(RunExecLikeArgs {
|
||||
tool_name: "shell".to_string(),
|
||||
exec_params,
|
||||
hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command),
|
||||
environment: turn
|
||||
.environments
|
||||
.primary()
|
||||
.map(|environment| Arc::clone(&environment.environment))
|
||||
.ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(
|
||||
"shell is unavailable in this session".to_string(),
|
||||
)
|
||||
})?,
|
||||
additional_permissions: params.additional_permissions.clone(),
|
||||
prefix_rule,
|
||||
session,
|
||||
@@ -328,11 +343,20 @@ impl ToolHandler for ContainerExecHandler {
|
||||
let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?;
|
||||
let prefix_rule = params.prefix_rule.clone();
|
||||
let exec_params =
|
||||
ShellHandler::to_exec_params(¶ms, turn.as_ref(), session.conversation_id);
|
||||
ShellHandler::to_exec_params(¶ms, turn.as_ref(), session.conversation_id, cwd);
|
||||
ShellHandler::run_exec_like(RunExecLikeArgs {
|
||||
tool_name: "container.exec".to_string(),
|
||||
exec_params,
|
||||
hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command),
|
||||
environment: turn
|
||||
.environments
|
||||
.primary()
|
||||
.map(|environment| Arc::clone(&environment.environment))
|
||||
.ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(
|
||||
"shell is unavailable in this session".to_string(),
|
||||
)
|
||||
})?,
|
||||
additional_permissions: params.additional_permissions.clone(),
|
||||
prefix_rule,
|
||||
session,
|
||||
@@ -408,12 +432,29 @@ impl ToolHandler for LocalShellHandler {
|
||||
));
|
||||
};
|
||||
|
||||
let exec_params =
|
||||
ShellHandler::to_exec_params(¶ms, turn.as_ref(), session.conversation_id);
|
||||
// `LocalShell` is an internal payload with already-parsed params, not a
|
||||
// model-facing JSON tool call. It does not carry raw `environment_id` /
|
||||
// `workdir` arguments, so there is nothing to resolve through
|
||||
// `resolve_environment_target(...)` here.
|
||||
let exec_params = ShellHandler::to_exec_params(
|
||||
¶ms,
|
||||
turn.as_ref(),
|
||||
session.conversation_id,
|
||||
turn.resolve_path(params.workdir.clone()),
|
||||
);
|
||||
ShellHandler::run_exec_like(RunExecLikeArgs {
|
||||
tool_name: "local_shell".to_string(),
|
||||
exec_params,
|
||||
hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command),
|
||||
environment: turn
|
||||
.environments
|
||||
.primary()
|
||||
.map(|environment| Arc::clone(&environment.environment))
|
||||
.ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(
|
||||
"shell is unavailable in this session".to_string(),
|
||||
)
|
||||
})?,
|
||||
additional_permissions: None,
|
||||
prefix_rule: None,
|
||||
session,
|
||||
@@ -524,28 +565,49 @@ impl ToolHandler for ShellCommandHandler {
|
||||
)));
|
||||
};
|
||||
|
||||
let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?;
|
||||
let Some((turn_environment, cwd)) =
|
||||
resolve_environment_target(&arguments, &turn.environments)?
|
||||
else {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"shell is unavailable in this session".to_string(),
|
||||
));
|
||||
};
|
||||
let params: ShellCommandToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?;
|
||||
let workdir = turn.resolve_path(params.workdir.clone());
|
||||
maybe_emit_implicit_skill_invocation(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
¶ms.command,
|
||||
&workdir,
|
||||
&cwd,
|
||||
)
|
||||
.await;
|
||||
let prefix_rule = params.prefix_rule.clone();
|
||||
let session_shell = session.user_shell();
|
||||
let environment_shell = turn_environment.environment.is_remote().then(|| {
|
||||
let shell_path = PathBuf::from(&turn_environment.shell);
|
||||
Shell {
|
||||
// Remote environment metadata currently reports a bash path.
|
||||
// Do not probe or infer from the local host here.
|
||||
shell_type: ShellType::Bash,
|
||||
shell_path,
|
||||
shell_snapshot: empty_shell_snapshot_receiver(),
|
||||
}
|
||||
});
|
||||
let shell = environment_shell
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| session_shell.as_ref());
|
||||
let exec_params = Self::to_exec_params(
|
||||
¶ms,
|
||||
session.as_ref(),
|
||||
shell,
|
||||
turn.as_ref(),
|
||||
session.conversation_id,
|
||||
cwd,
|
||||
turn.tools_config.allow_login_shell,
|
||||
)?;
|
||||
ShellHandler::run_exec_like(RunExecLikeArgs {
|
||||
tool_name: self.tool_name().display(),
|
||||
exec_params,
|
||||
hook_command: params.command,
|
||||
environment: Arc::clone(&turn_environment.environment),
|
||||
additional_permissions: params.additional_permissions.clone(),
|
||||
prefix_rule,
|
||||
session,
|
||||
@@ -565,6 +627,7 @@ impl ShellHandler {
|
||||
tool_name,
|
||||
exec_params,
|
||||
hook_command,
|
||||
environment,
|
||||
additional_permissions,
|
||||
prefix_rule,
|
||||
session,
|
||||
@@ -576,12 +639,8 @@ impl ShellHandler {
|
||||
} = args;
|
||||
|
||||
let mut exec_params = exec_params;
|
||||
let Some(turn_environment) = turn.environments.primary() else {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"shell is unavailable in this session".to_string(),
|
||||
));
|
||||
};
|
||||
let fs = turn_environment.environment.get_filesystem();
|
||||
let fs = environment.get_filesystem();
|
||||
let local_policy_env = exec_params.env.clone();
|
||||
|
||||
let dependency_env = session.dependency_env().await;
|
||||
if !dependency_env.is_empty() {
|
||||
@@ -600,7 +659,7 @@ impl ShellHandler {
|
||||
let requested_additional_permissions = additional_permissions.clone();
|
||||
let effective_additional_permissions = apply_granted_turn_permissions(
|
||||
session.as_ref(),
|
||||
turn.cwd.as_path(),
|
||||
exec_params.cwd.as_path(),
|
||||
exec_params.sandbox_permissions,
|
||||
additional_permissions,
|
||||
)
|
||||
@@ -686,7 +745,7 @@ impl ShellHandler {
|
||||
approval_policy: turn.approval_policy.value(),
|
||||
permission_profile: turn.permission_profile(),
|
||||
file_system_sandbox_policy: &file_system_sandbox_policy,
|
||||
sandbox_cwd: turn.cwd.as_path(),
|
||||
sandbox_cwd: exec_params.cwd.as_path(),
|
||||
sandbox_permissions: if effective_additional_permissions.permissions_preapproved {
|
||||
codex_protocol::models::SandboxPermissions::UseDefault
|
||||
} else {
|
||||
@@ -700,6 +759,13 @@ impl ShellHandler {
|
||||
command: exec_params.command.clone(),
|
||||
hook_command,
|
||||
cwd: exec_params.cwd.clone(),
|
||||
exec_server_env_config: environment.is_remote().then(|| {
|
||||
crate::sandboxing::ExecServerEnvConfig::from_shell_policy(
|
||||
&turn.shell_environment_policy,
|
||||
local_policy_env,
|
||||
)
|
||||
}),
|
||||
environment,
|
||||
timeout_ms: exec_params.expiration.timeout_ms(),
|
||||
env: exec_params.env.clone(),
|
||||
explicit_env_overrides,
|
||||
|
||||
@@ -76,7 +76,7 @@ fn assert_safe(shell: &Shell, command: &str) {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_command_handler_to_exec_params_uses_session_shell_and_turn_context() {
|
||||
async fn shell_command_handler_to_exec_params_uses_selected_shell_and_turn_context() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
|
||||
let command = "echo hello".to_string();
|
||||
@@ -86,9 +86,8 @@ async fn shell_command_handler_to_exec_params_uses_session_shell_and_turn_contex
|
||||
let sandbox_permissions = SandboxPermissions::RequireEscalated;
|
||||
let justification = Some("because tests".to_string());
|
||||
|
||||
let expected_command = session
|
||||
.user_shell()
|
||||
.derive_exec_args(&command, /*use_login_shell*/ true);
|
||||
let shell = session.user_shell();
|
||||
let expected_command = shell.derive_exec_args(&command, /*use_login_shell*/ true);
|
||||
let expected_cwd = turn_context.resolve_path(workdir.clone());
|
||||
let expected_env = create_env(
|
||||
&turn_context.shell_environment_policy,
|
||||
@@ -108,9 +107,10 @@ async fn shell_command_handler_to_exec_params_uses_session_shell_and_turn_contex
|
||||
|
||||
let exec_params = ShellCommandHandler::to_exec_params(
|
||||
¶ms,
|
||||
&session,
|
||||
&shell,
|
||||
&turn_context,
|
||||
session.conversation_id,
|
||||
expected_cwd.clone(),
|
||||
/*allow_login_shell*/ true,
|
||||
)
|
||||
.expect("login shells should be allowed");
|
||||
@@ -162,6 +162,7 @@ fn shell_command_handler_respects_explicit_login_flag() {
|
||||
#[tokio::test]
|
||||
async fn shell_command_handler_defaults_to_non_login_when_disallowed() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let shell = session.user_shell();
|
||||
let params = ShellCommandToolCallParams {
|
||||
command: "echo hello".to_string(),
|
||||
workdir: None,
|
||||
@@ -175,9 +176,10 @@ async fn shell_command_handler_defaults_to_non_login_when_disallowed() {
|
||||
|
||||
let exec_params = ShellCommandHandler::to_exec_params(
|
||||
¶ms,
|
||||
&session,
|
||||
&shell,
|
||||
&turn_context,
|
||||
session.conversation_id,
|
||||
turn_context.resolve_path(params.workdir.clone()),
|
||||
/*allow_login_shell*/ false,
|
||||
)
|
||||
.expect("non-login shells should still be allowed");
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::tools::handlers::implicit_granted_permissions;
|
||||
use crate::tools::handlers::normalize_and_validate_additional_permissions;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::handlers::parse_arguments_with_base_path;
|
||||
use crate::tools::handlers::resolve_tool_environment;
|
||||
use crate::tools::handlers::resolve_environment_target;
|
||||
use crate::tools::hook_names::HookToolName;
|
||||
use crate::tools::registry::PostToolUsePayload;
|
||||
use crate::tools::registry::PreToolUsePayload;
|
||||
@@ -69,16 +69,6 @@ pub(crate) struct ExecCommandArgs {
|
||||
prefix_rule: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ExecCommandEnvironmentArgs {
|
||||
#[serde(default)]
|
||||
environment_id: Option<String>,
|
||||
// Keep this raw until after environment selection; relative paths must be
|
||||
// resolved against the selected environment cwd, not the process cwd.
|
||||
#[serde(default)]
|
||||
workdir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WriteStdinArgs {
|
||||
// The model is trained on `session_id`.
|
||||
@@ -191,22 +181,13 @@ impl ToolHandler for ExecCommandHandler {
|
||||
|
||||
let manager: &UnifiedExecProcessManager = &session.services.unified_exec_manager;
|
||||
let context = UnifiedExecContext::new(session.clone(), turn.clone(), call_id.clone());
|
||||
let environment_args: ExecCommandEnvironmentArgs = parse_arguments(&arguments)?;
|
||||
let Some(turn_environment) =
|
||||
resolve_tool_environment(turn.as_ref(), environment_args.environment_id.as_deref())?
|
||||
let Some((turn_environment, cwd)) =
|
||||
resolve_environment_target(&arguments, &turn.environments)?
|
||||
else {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"unified exec is unavailable in this session".to_string(),
|
||||
));
|
||||
};
|
||||
let cwd = environment_args
|
||||
.workdir
|
||||
.as_deref()
|
||||
.filter(|workdir| !workdir.is_empty())
|
||||
.map_or_else(
|
||||
|| turn_environment.cwd.clone(),
|
||||
|workdir| turn_environment.cwd.join(workdir),
|
||||
);
|
||||
let environment = Arc::clone(&turn_environment.environment);
|
||||
let fs = environment.get_filesystem();
|
||||
let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?;
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
use super::*;
|
||||
use crate::shell::default_user_shell;
|
||||
use crate::tools::handlers::parse_arguments_with_base_path;
|
||||
use codex_protocol::models::AdditionalPermissionProfile as PermissionProfile;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_tools::UnifiedExecShellMode;
|
||||
use codex_tools::ZshForkConfig;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::PathExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
|
||||
use crate::session::tests::make_session_and_context;
|
||||
use crate::tools::context::ExecCommandToolOutput;
|
||||
@@ -178,6 +184,39 @@ fn test_get_command_ignores_explicit_shell_in_zsh_fork_mode() -> anyhow::Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_command_args_resolve_relative_additional_permissions_against_selected_workdir()
|
||||
-> anyhow::Result<()> {
|
||||
let selected_cwd = tempdir()?;
|
||||
let workdir = selected_cwd.path().join("nested");
|
||||
fs::create_dir_all(&workdir)?;
|
||||
let expected_write = workdir.join("relative-write.txt");
|
||||
let json = r#"{
|
||||
"cmd": "echo hello",
|
||||
"workdir": "nested",
|
||||
"additional_permissions": {
|
||||
"file_system": {
|
||||
"write": ["./relative-write.txt"]
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let target_cwd = selected_cwd.path().abs().join("nested");
|
||||
let args: ExecCommandArgs = parse_arguments_with_base_path(json, &target_cwd)?;
|
||||
|
||||
assert_eq!(
|
||||
args.additional_permissions,
|
||||
Some(PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions::from_read_write_roots(
|
||||
/*read*/ None,
|
||||
Some(vec![expected_write.abs()]),
|
||||
)),
|
||||
..Default::default()
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_command_pre_tool_use_payload_uses_raw_command() {
|
||||
let payload = ToolPayload::Function {
|
||||
|
||||
@@ -4,12 +4,14 @@ Runtime: shell
|
||||
Executes shell requests under the orchestrator: asks for approval when needed,
|
||||
builds sandbox transform inputs, and runs them under the current SandboxAttempt.
|
||||
*/
|
||||
pub(crate) mod remote_exec;
|
||||
#[cfg(unix)]
|
||||
pub(crate) mod unix_escalation;
|
||||
pub(crate) mod zsh_fork_backend;
|
||||
|
||||
use crate::command_canonicalization::canonicalize_command_for_approval;
|
||||
use crate::exec::ExecCapturePolicy;
|
||||
use crate::exec::ExecExpiration;
|
||||
use crate::guardian::GuardianApprovalRequest;
|
||||
use crate::guardian::GuardianNetworkAccessTrigger;
|
||||
use crate::guardian::review_approval_request;
|
||||
@@ -35,6 +37,7 @@ use crate::tools::sandboxing::ToolRuntime;
|
||||
use crate::tools::sandboxing::managed_network_for_sandbox_permissions;
|
||||
use crate::tools::sandboxing::sandbox_override_for_first_attempt;
|
||||
use crate::tools::sandboxing::with_cached_approval;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::exec_output::ExecToolCallOutput;
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
@@ -44,14 +47,17 @@ use codex_shell_command::powershell::prefix_powershell_script_with_utf8;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use futures::future::BoxFuture;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ShellRequest {
|
||||
pub command: Vec<String>,
|
||||
pub hook_command: String,
|
||||
pub cwd: AbsolutePathBuf,
|
||||
pub environment: Arc<Environment>,
|
||||
pub timeout_ms: Option<u64>,
|
||||
pub env: HashMap<String, String>,
|
||||
pub exec_server_env_config: Option<crate::sandboxing::ExecServerEnvConfig>,
|
||||
pub explicit_env_overrides: HashMap<String, String>,
|
||||
pub network: Option<NetworkProxy>,
|
||||
pub sandbox_permissions: SandboxPermissions,
|
||||
@@ -215,6 +221,10 @@ impl Approvable<ShellRequest> for ShellRuntime {
|
||||
}
|
||||
|
||||
impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
|
||||
fn sandbox_cwd<'a>(&self, req: &'a ShellRequest) -> Option<&'a AbsolutePathBuf> {
|
||||
Some(&req.cwd)
|
||||
}
|
||||
|
||||
fn network_approval_spec(
|
||||
&self,
|
||||
req: &ShellRequest,
|
||||
@@ -249,20 +259,25 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
|
||||
let managed_network =
|
||||
managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions);
|
||||
let env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions);
|
||||
let command = maybe_wrap_shell_lc_with_snapshot(
|
||||
&req.command,
|
||||
session_shell.as_ref(),
|
||||
&req.cwd,
|
||||
&req.explicit_env_overrides,
|
||||
&env,
|
||||
);
|
||||
let command = if req.environment.is_remote() {
|
||||
req.command.clone()
|
||||
} else {
|
||||
maybe_wrap_shell_lc_with_snapshot(
|
||||
&req.command,
|
||||
session_shell.as_ref(),
|
||||
&req.cwd,
|
||||
&req.explicit_env_overrides,
|
||||
&env,
|
||||
)
|
||||
};
|
||||
let command = if matches!(session_shell.shell_type, ShellType::PowerShell) {
|
||||
prefix_powershell_script_with_utf8(&command)
|
||||
} else {
|
||||
command
|
||||
};
|
||||
|
||||
if self.backend == ShellRuntimeBackend::ShellCommandZshFork {
|
||||
if self.backend == ShellRuntimeBackend::ShellCommandZshFork && !req.environment.is_remote()
|
||||
{
|
||||
match zsh_fork_backend::maybe_run_shell_command(req, attempt, ctx, &command).await? {
|
||||
Some(out) => return Ok(out),
|
||||
None => {
|
||||
@@ -275,7 +290,7 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
|
||||
|
||||
let command =
|
||||
build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone())?;
|
||||
let mut expiration: crate::exec::ExecExpiration = req.timeout_ms.into();
|
||||
let mut expiration: ExecExpiration = req.timeout_ms.into();
|
||||
if let Some(cancellation) = attempt.network_denial_cancellation_token.clone() {
|
||||
expiration = expiration.with_cancellation(cancellation);
|
||||
}
|
||||
@@ -283,12 +298,22 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
|
||||
expiration,
|
||||
capture_policy: ExecCapturePolicy::ShellTool,
|
||||
};
|
||||
let env = attempt
|
||||
let mut exec_env = attempt
|
||||
.env_for(command, options, managed_network)
|
||||
.map_err(|err| ToolError::Codex(err.into()))?;
|
||||
let out = execute_env(env, Self::stdout_stream(ctx))
|
||||
.await
|
||||
.map_err(ToolError::Codex)?;
|
||||
exec_env.exec_server_env_config = req.exec_server_env_config.clone();
|
||||
let out = if req.environment.is_remote() {
|
||||
// TODO: unify this remote path with unified-exec's exec-backend flow
|
||||
// so shell and unified-exec do not maintain separate local-vs-remote
|
||||
// execution splits here.
|
||||
remote_exec::RemoteShellExecutor::new(req, &exec_env, attempt, ctx)
|
||||
.run(exec_env)
|
||||
.await?
|
||||
} else {
|
||||
execute_env(exec_env, Self::stdout_stream(ctx))
|
||||
.await
|
||||
.map_err(ToolError::Codex)?
|
||||
};
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
594
codex-rs/core/src/tools/runtimes/shell/remote_exec.rs
Normal file
594
codex-rs/core/src/tools/runtimes/shell/remote_exec.rs
Normal file
@@ -0,0 +1,594 @@
|
||||
use crate::exec::ExecExpiration;
|
||||
use crate::exec::StdoutStream;
|
||||
use crate::exec::is_likely_sandbox_denied;
|
||||
use crate::sandboxing::ExecRequest;
|
||||
use crate::tools::runtimes::shell::ShellRequest;
|
||||
use crate::tools::sandboxing::SandboxAttempt;
|
||||
use crate::tools::sandboxing::ToolCtx;
|
||||
use crate::tools::sandboxing::ToolError;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_exec_server::ExecOutputStream;
|
||||
use codex_exec_server::ExecParams as ExecServerParams;
|
||||
use codex_exec_server::ExecProcess;
|
||||
use codex_exec_server::ProcessId;
|
||||
use codex_exec_server::ReadResponse;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::SandboxErr;
|
||||
use codex_protocol::exec_output::ExecToolCallOutput;
|
||||
use codex_protocol::exec_output::StreamOutput;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ExecCommandOutputDeltaEvent;
|
||||
use codex_sandboxing::SandboxType;
|
||||
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::time::Instant;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// Executes the remote branch of `ShellRuntime`.
|
||||
///
|
||||
/// Local shell requests still go through `execute_env(...)`. Remote
|
||||
/// environments have a different contract: start a process on the exec
|
||||
/// backend, page output until the session closes, stream deltas to the client,
|
||||
/// and then synthesize `ExecToolCallOutput`.
|
||||
pub(super) struct RemoteShellExecutor {
|
||||
environment: Arc<Environment>,
|
||||
process_id: String,
|
||||
timeout_ms: Option<u64>,
|
||||
sandbox: SandboxType,
|
||||
stdout_stream: Option<StdoutStream>,
|
||||
network_denial_cancellation_token: Option<CancellationToken>,
|
||||
}
|
||||
|
||||
/// Minimal remote-process contract needed by shell's remote exec path.
|
||||
///
|
||||
/// The shell runtime only needs retained-output reads plus termination. Keeping
|
||||
/// this narrow makes the remote-only loop unit-testable without a live
|
||||
/// exec-server backend.
|
||||
pub(super) trait RemoteShellProcess: Send + Sync {
|
||||
fn read(
|
||||
&self,
|
||||
after_seq: Option<u64>,
|
||||
wait_ms: u64,
|
||||
) -> impl std::future::Future<Output = Result<ReadResponse, String>> + Send;
|
||||
|
||||
fn terminate(&self) -> impl std::future::Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
struct ExecServerRemoteShellProcess {
|
||||
inner: Arc<dyn ExecProcess>,
|
||||
}
|
||||
|
||||
impl RemoteShellExecutor {
|
||||
pub(super) fn new(
|
||||
req: &ShellRequest,
|
||||
exec_env: &ExecRequest,
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
ctx: &ToolCtx,
|
||||
) -> Self {
|
||||
Self {
|
||||
environment: Arc::clone(&req.environment),
|
||||
process_id: remote_shell_process_id(&ctx.call_id, attempt.sandbox),
|
||||
timeout_ms: req.timeout_ms,
|
||||
sandbox: exec_env.sandbox,
|
||||
stdout_stream: crate::tools::runtimes::shell::ShellRuntime::stdout_stream(ctx),
|
||||
network_denial_cancellation_token: attempt.network_denial_cancellation_token.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn run(self, exec_env: ExecRequest) -> Result<ExecToolCallOutput, ToolError> {
|
||||
let started = self
|
||||
.environment
|
||||
.get_exec_backend()
|
||||
.start(exec_server_params_for_request(&exec_env, &self.process_id))
|
||||
.await
|
||||
.map_err(|err| ToolError::Rejected(err.to_string()))?;
|
||||
let process = ExecServerRemoteShellProcess {
|
||||
inner: started.process,
|
||||
};
|
||||
self.run_with_process(&process).await
|
||||
}
|
||||
|
||||
async fn run_with_process<P: RemoteShellProcess>(
|
||||
self,
|
||||
process: &P,
|
||||
) -> Result<ExecToolCallOutput, ToolError> {
|
||||
let start = Instant::now();
|
||||
let expiration: ExecExpiration = self.timeout_ms.into();
|
||||
let timeout = expiration.timeout_ms().map(Duration::from_millis);
|
||||
let deadline = timeout.map(|timeout| start + timeout);
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
let mut aggregated_output = Vec::new();
|
||||
let mut after_seq = None;
|
||||
let mut exit_code = None;
|
||||
|
||||
loop {
|
||||
if let Some(cancellation) = self.network_denial_cancellation_token.as_ref()
|
||||
&& cancellation.is_cancelled()
|
||||
{
|
||||
process.terminate().await;
|
||||
return Err(ToolError::Rejected(
|
||||
"Network access was denied by the Codex sandbox network proxy.".to_string(),
|
||||
));
|
||||
}
|
||||
let wait_ms = deadline
|
||||
.map(|deadline| deadline.saturating_duration_since(Instant::now()))
|
||||
.map(|remaining| remaining.min(Duration::from_millis(100)).as_millis() as u64)
|
||||
.unwrap_or(100);
|
||||
if wait_ms == 0 {
|
||||
process.terminate().await;
|
||||
let output = shell_output(
|
||||
stdout,
|
||||
stderr,
|
||||
aggregated_output,
|
||||
/*exit_code*/ 124,
|
||||
start.elapsed(),
|
||||
/*timed_out*/ true,
|
||||
);
|
||||
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout {
|
||||
output: Box::new(output),
|
||||
})));
|
||||
}
|
||||
|
||||
let response = process
|
||||
.read(after_seq, wait_ms)
|
||||
.await
|
||||
.map_err(ToolError::Rejected)?;
|
||||
for chunk in response.chunks {
|
||||
let stream = chunk.stream;
|
||||
let bytes = chunk.chunk.into_inner();
|
||||
if let Some(stdout_stream) = self.stdout_stream.as_ref() {
|
||||
emit_output_delta(stdout_stream, stream, bytes.clone()).await;
|
||||
}
|
||||
match stream {
|
||||
ExecOutputStream::Stdout | ExecOutputStream::Pty => {
|
||||
append_capped(&mut stdout, &bytes);
|
||||
append_capped(&mut aggregated_output, &bytes);
|
||||
}
|
||||
ExecOutputStream::Stderr => {
|
||||
append_capped(&mut stderr, &bytes);
|
||||
append_capped(&mut aggregated_output, &bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(failure) = response.failure {
|
||||
return Err(ToolError::Rejected(failure));
|
||||
}
|
||||
if response.exited {
|
||||
exit_code = response.exit_code;
|
||||
}
|
||||
if response.closed {
|
||||
break;
|
||||
}
|
||||
after_seq = response.next_seq.checked_sub(1);
|
||||
}
|
||||
|
||||
let output = shell_output(
|
||||
stdout,
|
||||
stderr,
|
||||
aggregated_output,
|
||||
exit_code.unwrap_or(-1),
|
||||
start.elapsed(),
|
||||
/*timed_out*/ false,
|
||||
);
|
||||
if is_likely_sandbox_denied(self.sandbox, &output) {
|
||||
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
|
||||
output: Box::new(output),
|
||||
network_policy_decision: None,
|
||||
})));
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteShellProcess for ExecServerRemoteShellProcess {
|
||||
async fn read(&self, after_seq: Option<u64>, wait_ms: u64) -> Result<ReadResponse, String> {
|
||||
self.inner
|
||||
.read(after_seq, /*max_bytes*/ None, Some(wait_ms))
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
async fn terminate(&self) {
|
||||
let _ = self.inner.terminate().await;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
let env = exec_server_env_config.env_overlay(&request.env);
|
||||
(Some(exec_server_env_config.policy.clone()), env)
|
||||
} else {
|
||||
(None, request.env.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn exec_server_params_for_request(request: &ExecRequest, call_id: &str) -> ExecServerParams {
|
||||
let (env_policy, env) = exec_server_env_for_request(request);
|
||||
ExecServerParams {
|
||||
process_id: ProcessId::from(call_id.to_string()),
|
||||
argv: request.command.clone(),
|
||||
cwd: request.cwd.to_path_buf(),
|
||||
env_policy,
|
||||
env,
|
||||
tty: false,
|
||||
pipe_stdin: false,
|
||||
arg0: request.arg0.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_shell_process_id(call_id: &str, sandbox: SandboxType) -> String {
|
||||
match sandbox {
|
||||
SandboxType::None => format!("{call_id}:unsandboxed"),
|
||||
SandboxType::MacosSeatbelt => format!("{call_id}:seatbelt"),
|
||||
SandboxType::LinuxSeccomp => format!("{call_id}:seccomp"),
|
||||
SandboxType::WindowsRestrictedToken => format!("{call_id}:windows"),
|
||||
}
|
||||
}
|
||||
|
||||
fn append_capped(dst: &mut Vec<u8>, src: &[u8]) {
|
||||
if dst.len() >= DEFAULT_OUTPUT_BYTES_CAP {
|
||||
return;
|
||||
}
|
||||
let remaining = DEFAULT_OUTPUT_BYTES_CAP.saturating_sub(dst.len());
|
||||
dst.extend_from_slice(&src[..src.len().min(remaining)]);
|
||||
}
|
||||
|
||||
async fn emit_output_delta(
|
||||
stdout_stream: &StdoutStream,
|
||||
call_stream: ExecOutputStream,
|
||||
chunk: Vec<u8>,
|
||||
) {
|
||||
let stream = match call_stream {
|
||||
ExecOutputStream::Stdout | ExecOutputStream::Pty => {
|
||||
codex_protocol::protocol::ExecOutputStream::Stdout
|
||||
}
|
||||
ExecOutputStream::Stderr => codex_protocol::protocol::ExecOutputStream::Stderr,
|
||||
};
|
||||
let msg = EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent {
|
||||
call_id: stdout_stream.call_id.clone(),
|
||||
stream,
|
||||
chunk,
|
||||
});
|
||||
let event = Event {
|
||||
id: stdout_stream.sub_id.clone(),
|
||||
msg,
|
||||
};
|
||||
let _ = stdout_stream.tx_event.send(event).await;
|
||||
}
|
||||
|
||||
fn shell_output(
|
||||
stdout: Vec<u8>,
|
||||
stderr: Vec<u8>,
|
||||
aggregated_output: Vec<u8>,
|
||||
exit_code: i32,
|
||||
duration: Duration,
|
||||
timed_out: bool,
|
||||
) -> ExecToolCallOutput {
|
||||
let stdout = StreamOutput {
|
||||
text: stdout,
|
||||
truncated_after_lines: None,
|
||||
};
|
||||
let stderr = StreamOutput {
|
||||
text: stderr,
|
||||
truncated_after_lines: None,
|
||||
};
|
||||
ExecToolCallOutput {
|
||||
exit_code,
|
||||
stdout: stdout.from_utf8_lossy(),
|
||||
stderr: stderr.from_utf8_lossy(),
|
||||
aggregated_output: StreamOutput {
|
||||
text: codex_protocol::exec_output::bytes_to_string_smart(&aggregated_output),
|
||||
truncated_after_lines: None,
|
||||
},
|
||||
duration,
|
||||
timed_out,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_exec_server::ExecEnvPolicy;
|
||||
use codex_exec_server::ExecOutputStream;
|
||||
use codex_exec_server::ProcessOutputChunk;
|
||||
use codex_protocol::config_types::ShellEnvironmentPolicyInherit;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct FakeRemoteShellProcess {
|
||||
responses: Mutex<VecDeque<ReadResponse>>,
|
||||
terminated: Mutex<bool>,
|
||||
}
|
||||
|
||||
impl FakeRemoteShellProcess {
|
||||
fn new(responses: Vec<ReadResponse>) -> Self {
|
||||
Self {
|
||||
responses: Mutex::new(VecDeque::from(responses)),
|
||||
terminated: Mutex::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn terminated(&self) -> bool {
|
||||
*self
|
||||
.terminated
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteShellProcess for FakeRemoteShellProcess {
|
||||
async fn read(
|
||||
&self,
|
||||
_after_seq: Option<u64>,
|
||||
_wait_ms: u64,
|
||||
) -> Result<ReadResponse, String> {
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.pop_front()
|
||||
.ok_or_else(|| "no more responses".to_string())
|
||||
}
|
||||
|
||||
async fn terminate(&self) {
|
||||
*self
|
||||
.terminated
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn chunk(seq: u64, stream: ExecOutputStream, bytes: &[u8]) -> ProcessOutputChunk {
|
||||
ProcessOutputChunk {
|
||||
seq,
|
||||
stream,
|
||||
chunk: bytes.to_vec().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_executor(timeout_ms: Option<u64>) -> RemoteShellExecutor {
|
||||
RemoteShellExecutor {
|
||||
environment: Arc::new(Environment::default_for_tests()),
|
||||
process_id: "call-1".to_string(),
|
||||
timeout_ms,
|
||||
sandbox: SandboxType::None,
|
||||
stdout_stream: None,
|
||||
network_denial_cancellation_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_response(
|
||||
chunks: Vec<ProcessOutputChunk>,
|
||||
next_seq: u64,
|
||||
exited: bool,
|
||||
exit_code: Option<i32>,
|
||||
closed: bool,
|
||||
failure: Option<String>,
|
||||
) -> ReadResponse {
|
||||
ReadResponse {
|
||||
chunks,
|
||||
next_seq,
|
||||
exited,
|
||||
exit_code,
|
||||
closed,
|
||||
failure,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_shell_executor_collects_output_until_closed() {
|
||||
let executor = test_executor(/*timeout_ms*/ None);
|
||||
let process = FakeRemoteShellProcess::new(vec![
|
||||
read_response(
|
||||
vec![
|
||||
chunk(/*seq*/ 1, ExecOutputStream::Stdout, b"hello "),
|
||||
chunk(/*seq*/ 2, ExecOutputStream::Stderr, b"warn"),
|
||||
],
|
||||
/*next_seq*/ 3,
|
||||
/*exited*/ false,
|
||||
/*exit_code*/ None,
|
||||
/*closed*/ false,
|
||||
/*failure*/ None,
|
||||
),
|
||||
read_response(
|
||||
vec![chunk(/*seq*/ 3, ExecOutputStream::Stdout, b"world")],
|
||||
/*next_seq*/ 4,
|
||||
/*exited*/ true,
|
||||
/*exit_code*/ Some(0),
|
||||
/*closed*/ true,
|
||||
/*failure*/ None,
|
||||
),
|
||||
]);
|
||||
|
||||
let output = executor
|
||||
.run_with_process(&process)
|
||||
.await
|
||||
.expect("remote shell output should succeed");
|
||||
|
||||
assert_eq!(output.exit_code, 0);
|
||||
assert_eq!(output.stdout.text, "hello world");
|
||||
assert_eq!(output.stderr.text, "warn");
|
||||
assert_eq!(output.aggregated_output.text, "hello warnworld");
|
||||
assert!(!output.timed_out);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_shell_executor_times_out_and_terminates_process() {
|
||||
let executor = test_executor(/*timeout_ms*/ Some(0));
|
||||
let process = FakeRemoteShellProcess::new(Vec::new());
|
||||
|
||||
let err = executor
|
||||
.run_with_process(&process)
|
||||
.await
|
||||
.expect_err("timeout should fail");
|
||||
|
||||
assert!(process.terminated());
|
||||
match err {
|
||||
ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { .. })) => {}
|
||||
other => panic!("expected timeout error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_shell_executor_rejects_exec_backend_failure() {
|
||||
let executor = test_executor(/*timeout_ms*/ None);
|
||||
let process = FakeRemoteShellProcess::new(vec![read_response(
|
||||
Vec::new(),
|
||||
/*next_seq*/ 1,
|
||||
/*exited*/ false,
|
||||
/*exit_code*/ None,
|
||||
/*closed*/ false,
|
||||
/*failure*/ Some("backend disconnected".to_string()),
|
||||
)]);
|
||||
|
||||
let err = executor
|
||||
.run_with_process(&process)
|
||||
.await
|
||||
.expect_err("remote exec failure should fail");
|
||||
|
||||
match err {
|
||||
ToolError::Rejected(message) => assert_eq!(message, "backend disconnected"),
|
||||
other => panic!("expected rejected error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_shell_executor_terminates_on_network_denial_cancellation() {
|
||||
let cancellation = CancellationToken::new();
|
||||
cancellation.cancel();
|
||||
let executor = RemoteShellExecutor {
|
||||
environment: Arc::new(Environment::default_for_tests()),
|
||||
process_id: "call-1".to_string(),
|
||||
timeout_ms: None,
|
||||
sandbox: SandboxType::None,
|
||||
stdout_stream: None,
|
||||
network_denial_cancellation_token: Some(cancellation),
|
||||
};
|
||||
let process = FakeRemoteShellProcess::new(Vec::new());
|
||||
|
||||
let err = executor
|
||||
.run_with_process(&process)
|
||||
.await
|
||||
.expect_err("network denial should fail");
|
||||
|
||||
assert!(process.terminated());
|
||||
match err {
|
||||
ToolError::Rejected(message) => assert_eq!(
|
||||
message,
|
||||
"Network access was denied by the Codex sandbox network proxy."
|
||||
),
|
||||
other => panic!("expected rejected error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_shell_executor_maps_likely_sandbox_denials() {
|
||||
let executor = RemoteShellExecutor {
|
||||
environment: Arc::new(Environment::default_for_tests()),
|
||||
process_id: "call-1".to_string(),
|
||||
timeout_ms: None,
|
||||
sandbox: SandboxType::LinuxSeccomp,
|
||||
stdout_stream: None,
|
||||
network_denial_cancellation_token: None,
|
||||
};
|
||||
let process = FakeRemoteShellProcess::new(vec![read_response(
|
||||
vec![chunk(
|
||||
/*seq*/ 1,
|
||||
ExecOutputStream::Stderr,
|
||||
b"operation not permitted: sandbox denied",
|
||||
)],
|
||||
/*next_seq*/ 2,
|
||||
/*exited*/ true,
|
||||
/*exit_code*/ Some(1),
|
||||
/*closed*/ true,
|
||||
/*failure*/ None,
|
||||
)]);
|
||||
|
||||
let err = executor
|
||||
.run_with_process(&process)
|
||||
.await
|
||||
.expect_err("sandbox denial should fail");
|
||||
|
||||
match err {
|
||||
ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { .. })) => {}
|
||||
other => panic!("expected sandbox denied error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_server_params_use_env_policy_overlay_contract() {
|
||||
let cwd: codex_utils_absolute_path::AbsolutePathBuf = std::env::current_dir()
|
||||
.expect("current dir")
|
||||
.try_into()
|
||||
.expect("absolute path");
|
||||
let request = ExecRequest {
|
||||
command: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()],
|
||||
cwd: cwd.clone(),
|
||||
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(crate::sandboxing::ExecServerEnvConfig {
|
||||
policy: ExecEnvPolicy {
|
||||
inherit: 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: SandboxType::None,
|
||||
windows_sandbox_policy_cwd: cwd,
|
||||
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
|
||||
windows_sandbox_private_desktop: false,
|
||||
permission_profile: codex_protocol::models::PermissionProfile::Disabled,
|
||||
file_system_sandbox_policy:
|
||||
codex_protocol::permissions::FileSystemSandboxPolicy::unrestricted(),
|
||||
network_sandbox_policy: codex_protocol::permissions::NetworkSandboxPolicy::Restricted,
|
||||
windows_sandbox_filesystem_overrides: None,
|
||||
arg0: None,
|
||||
};
|
||||
|
||||
let params = exec_server_params_for_request(&request, "call-123");
|
||||
|
||||
assert_eq!(params.process_id.as_str(), "call-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 remote_shell_process_id_distinguishes_retry_attempts() {
|
||||
assert_eq!(
|
||||
remote_shell_process_id("call-123", SandboxType::MacosSeatbelt),
|
||||
"call-123:seatbelt"
|
||||
);
|
||||
assert_eq!(
|
||||
remote_shell_process_id("call-123", SandboxType::None),
|
||||
"call-123:unsandboxed"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,6 @@ 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_protocol::config_types::ShellEnvironmentPolicy;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::SandboxErr;
|
||||
use codex_protocol::protocol::ExecCommandSource;
|
||||
@@ -98,37 +97,6 @@ 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,
|
||||
) -> (
|
||||
@@ -138,7 +106,7 @@ fn exec_server_env_for_request(
|
||||
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),
|
||||
exec_server_env_config.env_overlay(&request.env),
|
||||
)
|
||||
} else {
|
||||
(None, request.env.clone())
|
||||
@@ -990,10 +958,10 @@ impl UnifiedExecProcessManager {
|
||||
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),
|
||||
let exec_server_env_config = ExecServerEnvConfig::from_shell_policy(
|
||||
&context.turn.shell_environment_policy,
|
||||
local_policy_env,
|
||||
};
|
||||
);
|
||||
let mut orchestrator = ToolOrchestrator::new();
|
||||
let mut runtime = UnifiedExecRuntime::new(
|
||||
self,
|
||||
|
||||
@@ -52,8 +52,19 @@ fn env_overlay_for_exec_server_keeps_runtime_changes_only() {
|
||||
),
|
||||
]);
|
||||
|
||||
let config = ExecServerEnvConfig {
|
||||
policy: codex_exec_server::ExecEnvPolicy {
|
||||
inherit: codex_protocol::config_types::ShellEnvironmentPolicyInherit::Core,
|
||||
ignore_default_excludes: false,
|
||||
exclude: Vec::new(),
|
||||
r#set: HashMap::new(),
|
||||
include_only: Vec::new(),
|
||||
},
|
||||
local_policy_env,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
env_overlay_for_exec_server(&request_env, &local_policy_env),
|
||||
config.env_overlay(&request_env),
|
||||
HashMap::from([
|
||||
("PATH".to_string(), "/sandbox-path".to_string()),
|
||||
("CODEX_THREAD_ID".to_string(), "thread-1".to_string()),
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
use std::time::Duration;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_exec_server::CreateDirectoryOptions;
|
||||
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
|
||||
use codex_exec_server::REMOTE_ENVIRONMENT_ID;
|
||||
use codex_exec_server::RemoveOptions;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use core_test_support::PathBufExt;
|
||||
use core_test_support::PathExt;
|
||||
use core_test_support::assert_regex_match;
|
||||
use core_test_support::get_remote_test_env;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::skip_if_windows;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::TestCodexBuilder;
|
||||
use core_test_support::test_codex::TestCodexHarness;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
use test_case::test_case;
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -67,6 +86,17 @@ async fn shell_command_harness_with(
|
||||
TestCodexHarness::with_builder(builder).await
|
||||
}
|
||||
|
||||
async fn remote_aware_shell_command_test(server: &wiremock::MockServer) -> Result<TestCodex> {
|
||||
test_codex()
|
||||
.with_model("test-gpt-5-codex")
|
||||
.with_config(|config| {
|
||||
config.use_experimental_unified_exec_tool = false;
|
||||
let _ = config.features.disable(Feature::UnifiedExec);
|
||||
})
|
||||
.build_remote_aware(server)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn mount_shell_responses(
|
||||
harness: &TestCodexHarness,
|
||||
call_id: &str,
|
||||
@@ -90,6 +120,76 @@ async fn mount_shell_responses_with_timeout(
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn shell_command_routing_output(
|
||||
test: &TestCodex,
|
||||
server: &wiremock::MockServer,
|
||||
call_id: &str,
|
||||
arguments: Value,
|
||||
environments: Option<Vec<TurnEnvironmentSelection>>,
|
||||
) -> Result<String> {
|
||||
let response_mock = mount_sse_sequence(
|
||||
server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
call_id,
|
||||
"shell_command",
|
||||
&serde_json::to_string(&arguments)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn_with_environments("route shell command", environments)
|
||||
.await?;
|
||||
|
||||
response_mock
|
||||
.function_call_output_text(call_id)
|
||||
.with_context(|| format!("missing function_call_output for {call_id}"))
|
||||
}
|
||||
|
||||
async fn shell_command_response_mock(
|
||||
test: &TestCodex,
|
||||
server: &wiremock::MockServer,
|
||||
call_id: &str,
|
||||
arguments: Value,
|
||||
environments: Option<Vec<TurnEnvironmentSelection>>,
|
||||
) -> Result<core_test_support::responses::ResponseMock> {
|
||||
let response_mock = mount_sse_sequence(
|
||||
server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
call_id,
|
||||
"shell_command",
|
||||
&serde_json::to_string(&arguments)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn_with_environments("route shell command", environments)
|
||||
.await?;
|
||||
|
||||
Ok(response_mock)
|
||||
}
|
||||
|
||||
fn assert_shell_command_output(output: &str, expected: &str) -> Result<()> {
|
||||
let normalized_output = output
|
||||
.replace("\r\n", "\n")
|
||||
@@ -256,6 +356,242 @@ async fn shell_command_times_out_with_timeout_ms() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shell_command_routes_to_selected_remote_environment() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
let Some(_remote_env) = get_remote_test_env() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let test = remote_aware_shell_command_test(&server).await?;
|
||||
let local_cwd = TempDir::new()?;
|
||||
fs::write(local_cwd.path().join("marker.txt"), "local-routing")?;
|
||||
let local_selection = TurnEnvironmentSelection {
|
||||
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
|
||||
cwd: local_cwd.path().abs(),
|
||||
};
|
||||
let remote_cwd = PathBuf::from(format!(
|
||||
"/tmp/codex-shell-remote-routing-{}",
|
||||
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
|
||||
))
|
||||
.abs();
|
||||
let remote_marker_name = "marker.txt";
|
||||
test.fs()
|
||||
.create_directory(
|
||||
&remote_cwd,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
test.fs()
|
||||
.write_file(
|
||||
&remote_cwd.join(remote_marker_name),
|
||||
b"remote-routing".to_vec(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
let remote_selection = TurnEnvironmentSelection {
|
||||
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
|
||||
cwd: remote_cwd.clone(),
|
||||
};
|
||||
let output = shell_command_routing_output(
|
||||
&test,
|
||||
&server,
|
||||
"call-shell-multi-env",
|
||||
json!({
|
||||
"command": format!("cat {remote_marker_name}"),
|
||||
"login": false,
|
||||
"timeout_ms": 1_000,
|
||||
"environment_id": REMOTE_ENVIRONMENT_ID,
|
||||
}),
|
||||
Some(vec![local_selection, remote_selection]),
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
output.contains("remote-routing"),
|
||||
"unexpected shell_command output: {output}",
|
||||
);
|
||||
assert!(
|
||||
!output.contains("local-routing"),
|
||||
"shell_command should not route to local: {output}",
|
||||
);
|
||||
|
||||
test.fs()
|
||||
.remove(
|
||||
&remote_cwd,
|
||||
RemoveOptions {
|
||||
recursive: true,
|
||||
force: true,
|
||||
},
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shell_command_resolves_relative_workdir_in_selected_remote_environment() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
let Some(_remote_env) = get_remote_test_env() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let test = remote_aware_shell_command_test(&server).await?;
|
||||
let local_cwd = TempDir::new()?;
|
||||
let local_nested = local_cwd.path().join("nested");
|
||||
fs::create_dir_all(&local_nested)?;
|
||||
fs::write(local_nested.join("marker.txt"), "local-routing")?;
|
||||
let local_selection = TurnEnvironmentSelection {
|
||||
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
|
||||
cwd: local_cwd.path().abs(),
|
||||
};
|
||||
let remote_cwd = PathBuf::from(format!(
|
||||
"/tmp/codex-shell-remote-workdir-{}",
|
||||
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
|
||||
))
|
||||
.abs();
|
||||
let remote_nested = remote_cwd.join("nested");
|
||||
test.fs()
|
||||
.create_directory(
|
||||
&remote_nested,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
test.fs()
|
||||
.write_file(
|
||||
&remote_nested.join("marker.txt"),
|
||||
b"remote-routing".to_vec(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
let remote_selection = TurnEnvironmentSelection {
|
||||
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
|
||||
cwd: remote_cwd.clone(),
|
||||
};
|
||||
|
||||
let output = shell_command_routing_output(
|
||||
&test,
|
||||
&server,
|
||||
"call-shell-remote-workdir",
|
||||
json!({
|
||||
"command": "cat marker.txt",
|
||||
"workdir": "nested",
|
||||
"login": false,
|
||||
"timeout_ms": 1_000,
|
||||
"environment_id": REMOTE_ENVIRONMENT_ID,
|
||||
}),
|
||||
Some(vec![local_selection, remote_selection]),
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
output.contains("remote-routing"),
|
||||
"unexpected shell_command output: {output}",
|
||||
);
|
||||
assert!(
|
||||
!output.contains("local-routing"),
|
||||
"shell_command should resolve workdir in the selected remote environment: {output}",
|
||||
);
|
||||
|
||||
test.fs()
|
||||
.remove(
|
||||
&remote_cwd,
|
||||
RemoveOptions {
|
||||
recursive: true,
|
||||
force: true,
|
||||
},
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shell_command_timeout_in_selected_remote_environment() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
let Some(_remote_env) = get_remote_test_env() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let test = remote_aware_shell_command_test(&server).await?;
|
||||
let local_cwd = TempDir::new()?;
|
||||
let local_selection = TurnEnvironmentSelection {
|
||||
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
|
||||
cwd: local_cwd.path().abs(),
|
||||
};
|
||||
let remote_cwd = PathBuf::from(format!(
|
||||
"/tmp/codex-shell-remote-timeout-{}",
|
||||
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
|
||||
))
|
||||
.abs();
|
||||
test.fs()
|
||||
.create_directory(
|
||||
&remote_cwd,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
let remote_selection = TurnEnvironmentSelection {
|
||||
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
|
||||
cwd: remote_cwd.clone(),
|
||||
};
|
||||
|
||||
let response_mock = shell_command_response_mock(
|
||||
&test,
|
||||
&server,
|
||||
"call-shell-remote-timeout",
|
||||
json!({
|
||||
"command": "sleep 1",
|
||||
"login": false,
|
||||
"timeout_ms": 50,
|
||||
"environment_id": REMOTE_ENVIRONMENT_ID,
|
||||
}),
|
||||
Some(vec![local_selection, remote_selection]),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let output_str = response_mock
|
||||
.function_call_output_text("call-shell-remote-timeout")
|
||||
.expect("timeout output string");
|
||||
|
||||
if let Ok(output_json) = serde_json::from_str::<Value>(&output_str) {
|
||||
assert_eq!(
|
||||
output_json["metadata"]["exit_code"].as_i64(),
|
||||
Some(124),
|
||||
"expected timeout exit code 124",
|
||||
);
|
||||
let stdout = output_json["output"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
stdout.contains("command timed out"),
|
||||
"timeout output missing `command timed out`: {stdout}"
|
||||
);
|
||||
} else {
|
||||
let lower = output_str.to_lowercase();
|
||||
assert!(
|
||||
lower.contains("timed out") || lower.contains("signal"),
|
||||
"unexpected timeout output: {output_str}",
|
||||
);
|
||||
}
|
||||
|
||||
test.fs()
|
||||
.remove(
|
||||
&remote_cwd,
|
||||
RemoveOptions {
|
||||
recursive: true,
|
||||
force: true,
|
||||
},
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// This test verifies that a shell, particularly PowerShell, can correctly
|
||||
/// handle unicode output when the UTF-8 BOM is used. See
|
||||
/// https://github.com/openai/codex/pull/7902 for more context.
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::collections::BTreeMap;
|
||||
pub struct CommandToolOptions {
|
||||
pub allow_login_shell: bool,
|
||||
pub exec_permission_approvals_enabled: bool,
|
||||
pub include_environment_id: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -17,13 +18,6 @@ pub struct ShellToolOptions {
|
||||
}
|
||||
|
||||
pub fn create_exec_command_tool(options: CommandToolOptions) -> ToolSpec {
|
||||
create_exec_command_tool_with_environment_id(options, /*include_environment_id*/ false)
|
||||
}
|
||||
|
||||
pub(crate) fn create_exec_command_tool_with_environment_id(
|
||||
options: CommandToolOptions,
|
||||
include_environment_id: bool,
|
||||
) -> ToolSpec {
|
||||
let mut properties = BTreeMap::from([
|
||||
(
|
||||
"cmd".to_string(),
|
||||
@@ -70,7 +64,7 @@ pub(crate) fn create_exec_command_tool_with_environment_id(
|
||||
)),
|
||||
);
|
||||
}
|
||||
if include_environment_id {
|
||||
if options.include_environment_id {
|
||||
properties.insert(
|
||||
"environment_id".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
@@ -241,6 +235,14 @@ pub fn create_shell_command_tool(options: CommandToolOptions) -> ToolSpec {
|
||||
)),
|
||||
);
|
||||
}
|
||||
if options.include_environment_id {
|
||||
properties.insert(
|
||||
"environment_id".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Optional environment id from the <environment_context> block. If omitted, uses the primary environment.".to_string(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
properties.extend(create_approval_parameters(
|
||||
options.exec_permission_approvals_enabled,
|
||||
));
|
||||
|
||||
@@ -96,6 +96,7 @@ fn exec_command_tool_matches_expected_spec() {
|
||||
let tool = create_exec_command_tool(CommandToolOptions {
|
||||
allow_login_shell: true,
|
||||
exec_permission_approvals_enabled: false,
|
||||
include_environment_id: false,
|
||||
});
|
||||
|
||||
let description = if cfg!(windows) {
|
||||
@@ -332,6 +333,7 @@ fn shell_command_tool_matches_expected_spec() {
|
||||
let tool = create_shell_command_tool(CommandToolOptions {
|
||||
allow_login_shell: true,
|
||||
exec_permission_approvals_enabled: false,
|
||||
include_environment_id: false,
|
||||
});
|
||||
|
||||
let description = if cfg!(windows) {
|
||||
|
||||
@@ -43,7 +43,6 @@ use crate::create_request_user_input_tool;
|
||||
use crate::create_resume_agent_tool;
|
||||
use crate::create_send_input_tool_v1;
|
||||
use crate::create_send_message_tool;
|
||||
use crate::create_shell_command_tool;
|
||||
use crate::create_shell_tool;
|
||||
use crate::create_spawn_agent_tool_v1;
|
||||
use crate::create_spawn_agent_tool_v2;
|
||||
@@ -60,7 +59,8 @@ use crate::create_web_search_tool;
|
||||
use crate::create_write_stdin_tool;
|
||||
use crate::default_namespace_description;
|
||||
use crate::dynamic_tool_to_loadable_tool_spec;
|
||||
use crate::local_tool::create_exec_command_tool_with_environment_id;
|
||||
use crate::local_tool::create_exec_command_tool;
|
||||
use crate::local_tool::create_shell_command_tool;
|
||||
use crate::mcp_tool_to_responses_api_tool;
|
||||
use crate::request_permissions_tool_description;
|
||||
use crate::request_user_input_tool_description;
|
||||
@@ -157,13 +157,11 @@ pub fn build_tool_registry_plan(
|
||||
}
|
||||
ConfigShellToolType::UnifiedExec => {
|
||||
plan.push_spec(
|
||||
create_exec_command_tool_with_environment_id(
|
||||
CommandToolOptions {
|
||||
allow_login_shell: config.allow_login_shell,
|
||||
exec_permission_approvals_enabled,
|
||||
},
|
||||
create_exec_command_tool(CommandToolOptions {
|
||||
allow_login_shell: config.allow_login_shell,
|
||||
exec_permission_approvals_enabled,
|
||||
include_environment_id,
|
||||
),
|
||||
}),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
@@ -181,6 +179,7 @@ pub fn build_tool_registry_plan(
|
||||
create_shell_command_tool(CommandToolOptions {
|
||||
allow_login_shell: config.allow_login_shell,
|
||||
exec_permission_approvals_enabled,
|
||||
include_environment_id,
|
||||
}),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
config.code_mode_enabled,
|
||||
|
||||
@@ -89,6 +89,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
|
||||
create_exec_command_tool(CommandToolOptions {
|
||||
allow_login_shell: true,
|
||||
exec_permission_approvals_enabled: false,
|
||||
include_environment_id: false,
|
||||
}),
|
||||
create_write_stdin_tool(),
|
||||
create_update_plan_tool(),
|
||||
@@ -203,6 +204,49 @@ fn exec_command_spec_includes_environment_id_only_for_multiple_selected_environm
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_command_spec_includes_environment_id_only_for_multiple_selected_environments() {
|
||||
let model_info = model_info();
|
||||
let available_models = Vec::new();
|
||||
let mut shell_command_features = Features::with_defaults();
|
||||
shell_command_features.disable(Feature::UnifiedExec);
|
||||
let shell_command_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &shell_command_features,
|
||||
image_generation_tool_auth_allowed: true,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
permission_profile: &PermissionProfile::Disabled,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
let (single_environment_shell_tools, _) = build_specs(
|
||||
&shell_command_config,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
);
|
||||
assert_process_tool_environment_id(
|
||||
&single_environment_shell_tools,
|
||||
"shell_command",
|
||||
/*expected_present*/ false,
|
||||
);
|
||||
|
||||
let multi_environment_shell_config =
|
||||
shell_command_config.with_environment_mode(ToolEnvironmentMode::Multiple);
|
||||
let (multi_environment_shell_tools, _) = build_specs(
|
||||
&multi_environment_shell_config,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
);
|
||||
assert_process_tool_environment_id(
|
||||
&multi_environment_shell_tools,
|
||||
"shell_command",
|
||||
/*expected_present*/ true,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_specs_collab_tools_enabled() {
|
||||
let model_info = model_info();
|
||||
|
||||
Reference in New Issue
Block a user