mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat(core): zsh exec bridge (#12052)
zsh fork PR stack: - https://github.com/openai/codex/pull/12051 - https://github.com/openai/codex/pull/12052 👈 ### Summary This PR introduces a feature-gated native shell runtime path that routes shell execution through a patched zsh exec bridge, removing MCP-specific behavior from the shell hot path while preserving existing CommandExecution lifecycle semantics. When shell_zsh_fork is enabled, shell commands run via patched zsh with per-`execve` interception through EXEC_WRAPPER. Core receives wrapper IPC requests over a Unix socket, applies existing approval policy, and returns allow/deny before the subcommand executes. ### What’s included **1) New zsh exec bridge runtime in core** - Wrapper-mode entrypoint (maybe_run_zsh_exec_wrapper_mode) for EXEC_WRAPPER invocations. - Per-execution Unix-socket IPC handling for wrapper requests/responses. - Approval callback integration using existing core approval orchestration. - Streaming stdout/stderr deltas to existing command output event pipeline. - Error handling for malformed IPC, denial/abort, and execution failures. **2) Session lifecycle integration** SessionServices now owns a `ZshExecBridge`. Session startup initializes bridge state; shutdown tears it down cleanly. **3) Shell runtime routing (feature-gated)** When `shell_zsh_fork` is enabled: - Build execution env/spec as usual. - Add wrapper socket env wiring. - Execute via `zsh_exec_bridge.execute_shell_request(...)` instead of the regular shell path. - Non-zsh-fork behavior remains unchanged. **4) Config + feature wiring** - Added `Feature::ShellZshFork` (under development). - Added config support for `zsh_path` (optional absolute path to patched zsh): - `Config`, `ConfigToml`, `ConfigProfile`, overrides, and schema. - Session startup validates that `zsh_path` exists/usable when zsh-fork is enabled. - Added startup test for missing `zsh_path` failure mode. **5) Seatbelt/sandbox updates for wrapper IPC** - Extended seatbelt policy generation to optionally allow outbound connection to explicitly permitted Unix sockets. - Wired sandboxing path to pass wrapper socket path through to seatbelt policy generation. - Added/updated seatbelt tests for explicit socket allow rule and argument emission. **6) Runtime entrypoint hooks** - This allows the same binary to act as the zsh wrapper subprocess when invoked via `EXEC_WRAPPER`. **7) Tool selection behavior** - ToolsConfig now prefers ShellCommand type when shell_zsh_fork is enabled. - Added test coverage for precedence with unified-exec enabled.
This commit is contained in:
committed by
GitHub
Unverified
parent
fc810ba045
commit
edacbf7b6e
+102
-1
@@ -241,6 +241,7 @@ use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use crate::unified_exec::UnifiedExecProcessManager;
|
||||
use crate::util::backoff;
|
||||
use crate::windows_sandbox::WindowsSandboxLevelExt;
|
||||
use crate::zsh_exec_bridge::ZshExecBridge;
|
||||
use codex_async_utils::OrCancelExt;
|
||||
use codex_otel::OtelManager;
|
||||
use codex_otel::TelemetryAuthMode;
|
||||
@@ -1191,7 +1192,22 @@ impl Session {
|
||||
config.active_profile.clone(),
|
||||
);
|
||||
|
||||
let mut default_shell = shell::default_user_shell();
|
||||
let use_zsh_fork_shell = config.features.enabled(Feature::ShellZshFork);
|
||||
let mut default_shell = if use_zsh_fork_shell {
|
||||
let zsh_path = config.zsh_path.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"zsh fork feature enabled, but `zsh_path` is not configured; set `zsh_path` in config.toml"
|
||||
)
|
||||
})?;
|
||||
shell::get_shell(shell::ShellType::Zsh, Some(zsh_path)).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"zsh fork feature enabled, but zsh_path `{}` is not usable; set `zsh_path` to a valid zsh executable",
|
||||
zsh_path.display()
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
shell::default_user_shell()
|
||||
};
|
||||
// Create the mutable state for the Session.
|
||||
let shell_snapshot_tx = if config.features.enabled(Feature::ShellSnapshot) {
|
||||
ShellSnapshot::start_snapshotting(
|
||||
@@ -1262,10 +1278,17 @@ impl Session {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let zsh_exec_bridge =
|
||||
ZshExecBridge::new(config.zsh_path.clone(), config.codex_home.clone());
|
||||
zsh_exec_bridge
|
||||
.initialize_for_session(&conversation_id.to_string())
|
||||
.await;
|
||||
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
|
||||
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
|
||||
unified_exec_manager: UnifiedExecProcessManager::default(),
|
||||
zsh_exec_bridge,
|
||||
analytics_events_client: AnalyticsEventsClient::new(
|
||||
Arc::clone(&config),
|
||||
Arc::clone(&auth_manager),
|
||||
@@ -3969,6 +3992,7 @@ mod handlers {
|
||||
.unified_exec_manager
|
||||
.terminate_all_processes()
|
||||
.await;
|
||||
sess.services.zsh_exec_bridge.shutdown().await;
|
||||
info!("Shutting down Codex instance");
|
||||
let history = sess.clone_history().await;
|
||||
let turn_count = history
|
||||
@@ -7201,6 +7225,81 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
|
||||
let codex_home = tempfile::tempdir().expect("create temp dir");
|
||||
let mut config = build_test_config(codex_home.path()).await;
|
||||
config.features.enable(Feature::ShellZshFork);
|
||||
config.zsh_path = None;
|
||||
let config = Arc::new(config);
|
||||
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
|
||||
let models_manager = Arc::new(ModelsManager::new(
|
||||
config.codex_home.clone(),
|
||||
auth_manager.clone(),
|
||||
));
|
||||
let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref());
|
||||
let model_info =
|
||||
ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config);
|
||||
let collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
settings: Settings {
|
||||
model,
|
||||
reasoning_effort: config.model_reasoning_effort,
|
||||
developer_instructions: None,
|
||||
},
|
||||
};
|
||||
let session_configuration = SessionConfiguration {
|
||||
provider: config.model_provider.clone(),
|
||||
collaboration_mode,
|
||||
model_reasoning_summary: config.model_reasoning_summary,
|
||||
developer_instructions: config.developer_instructions.clone(),
|
||||
user_instructions: config.user_instructions.clone(),
|
||||
personality: config.personality,
|
||||
base_instructions: config
|
||||
.base_instructions
|
||||
.clone()
|
||||
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
approval_policy: config.permissions.approval_policy.clone(),
|
||||
sandbox_policy: config.permissions.sandbox_policy.clone(),
|
||||
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
|
||||
cwd: config.cwd.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
};
|
||||
|
||||
let (tx_event, _rx_event) = async_channel::unbounded();
|
||||
let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit);
|
||||
let result = Session::new(
|
||||
session_configuration,
|
||||
Arc::clone(&config),
|
||||
auth_manager,
|
||||
models_manager,
|
||||
ExecPolicyManager::default(),
|
||||
tx_event,
|
||||
agent_status_tx,
|
||||
InitialHistory::New,
|
||||
SessionSource::Exec,
|
||||
Arc::new(SkillsManager::new(config.codex_home.clone())),
|
||||
Arc::new(FileWatcher::noop()),
|
||||
AgentControl::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let err = match result {
|
||||
Ok(_) => panic!("expected startup to fail"),
|
||||
Err(err) => err,
|
||||
};
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("zsh fork feature enabled, but `zsh_path` is not configured"));
|
||||
}
|
||||
|
||||
// todo: use online model info
|
||||
pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
let (tx_event, _rx_event) = async_channel::unbounded();
|
||||
@@ -7274,6 +7373,7 @@ mod tests {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
|
||||
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
|
||||
unified_exec_manager: UnifiedExecProcessManager::default(),
|
||||
zsh_exec_bridge: ZshExecBridge::default(),
|
||||
analytics_events_client: AnalyticsEventsClient::new(
|
||||
Arc::clone(&config),
|
||||
Arc::clone(&auth_manager),
|
||||
@@ -7422,6 +7522,7 @@ mod tests {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
|
||||
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
|
||||
unified_exec_manager: UnifiedExecProcessManager::default(),
|
||||
zsh_exec_bridge: ZshExecBridge::default(),
|
||||
analytics_events_client: AnalyticsEventsClient::new(
|
||||
Arc::clone(&config),
|
||||
Arc::clone(&auth_manager),
|
||||
|
||||
@@ -338,6 +338,8 @@ pub struct Config {
|
||||
|
||||
/// Optional absolute path to the Node runtime used by `js_repl`.
|
||||
pub js_repl_node_path: Option<PathBuf>,
|
||||
/// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution.
|
||||
pub zsh_path: Option<PathBuf>,
|
||||
|
||||
/// Value to use for `reasoning.effort` when making a request using the
|
||||
/// Responses API.
|
||||
@@ -977,6 +979,8 @@ pub struct ConfigToml {
|
||||
|
||||
/// Optional absolute path to the Node runtime used by `js_repl`.
|
||||
pub js_repl_node_path: Option<AbsolutePathBuf>,
|
||||
/// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution.
|
||||
pub zsh_path: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Profile to use from the `profiles` map.
|
||||
pub profile: Option<String>,
|
||||
@@ -1355,6 +1359,7 @@ pub struct ConfigOverrides {
|
||||
pub config_profile: Option<String>,
|
||||
pub codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
pub js_repl_node_path: Option<PathBuf>,
|
||||
pub zsh_path: Option<PathBuf>,
|
||||
pub base_instructions: Option<String>,
|
||||
pub developer_instructions: Option<String>,
|
||||
pub personality: Option<Personality>,
|
||||
@@ -1482,6 +1487,7 @@ impl Config {
|
||||
config_profile: config_profile_key,
|
||||
codex_linux_sandbox_exe,
|
||||
js_repl_node_path: js_repl_node_path_override,
|
||||
zsh_path: zsh_path_override,
|
||||
base_instructions,
|
||||
developer_instructions,
|
||||
personality,
|
||||
@@ -1742,6 +1748,9 @@ impl Config {
|
||||
let js_repl_node_path = js_repl_node_path_override
|
||||
.or(config_profile.js_repl_node_path.map(Into::into))
|
||||
.or(cfg.js_repl_node_path.map(Into::into));
|
||||
let zsh_path = zsh_path_override
|
||||
.or(config_profile.zsh_path.map(Into::into))
|
||||
.or(cfg.zsh_path.map(Into::into));
|
||||
|
||||
let review_model = override_review_model.or(cfg.review_model);
|
||||
|
||||
@@ -1866,6 +1875,7 @@ impl Config {
|
||||
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
|
||||
codex_linux_sandbox_exe,
|
||||
js_repl_node_path,
|
||||
zsh_path,
|
||||
|
||||
hide_agent_reasoning: cfg.hide_agent_reasoning.unwrap_or(false),
|
||||
show_raw_agent_reasoning: cfg
|
||||
@@ -4196,6 +4206,7 @@ model_verbosity = "high"
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
js_repl_node_path: None,
|
||||
zsh_path: None,
|
||||
hide_agent_reasoning: false,
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: Some(ReasoningEffort::High),
|
||||
@@ -4309,6 +4320,7 @@ model_verbosity = "high"
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
js_repl_node_path: None,
|
||||
zsh_path: None,
|
||||
hide_agent_reasoning: false,
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: None,
|
||||
@@ -4420,6 +4432,7 @@ model_verbosity = "high"
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
js_repl_node_path: None,
|
||||
zsh_path: None,
|
||||
hide_agent_reasoning: false,
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: None,
|
||||
@@ -4517,6 +4530,7 @@ model_verbosity = "high"
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
js_repl_node_path: None,
|
||||
zsh_path: None,
|
||||
hide_agent_reasoning: false,
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: Some(ReasoningEffort::High),
|
||||
|
||||
@@ -30,6 +30,8 @@ pub struct ConfigProfile {
|
||||
pub chatgpt_base_url: Option<String>,
|
||||
/// Optional path to a file containing model instructions.
|
||||
pub model_instructions_file: Option<AbsolutePathBuf>,
|
||||
/// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution.
|
||||
pub zsh_path: Option<AbsolutePathBuf>,
|
||||
pub js_repl_node_path: Option<AbsolutePathBuf>,
|
||||
/// Deprecated: ignored. Use `model_instructions_file`.
|
||||
#[schemars(skip)]
|
||||
|
||||
@@ -75,7 +75,7 @@ pub struct ExecParams {
|
||||
}
|
||||
|
||||
/// Mechanism to terminate an exec invocation before it finishes naturally.
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ExecExpiration {
|
||||
Timeout(Duration),
|
||||
DefaultTimeout,
|
||||
@@ -97,7 +97,7 @@ impl From<u64> for ExecExpiration {
|
||||
}
|
||||
|
||||
impl ExecExpiration {
|
||||
async fn wait(self) {
|
||||
pub(crate) async fn wait(self) {
|
||||
match self {
|
||||
ExecExpiration::Timeout(duration) => tokio::time::sleep(duration).await,
|
||||
ExecExpiration::DefaultTimeout => {
|
||||
|
||||
@@ -84,6 +84,8 @@ pub enum Feature {
|
||||
JsReplToolsOnly,
|
||||
/// Use the single unified PTY-backed exec tool.
|
||||
UnifiedExec,
|
||||
/// Route shell tool execution through the zsh exec bridge.
|
||||
ShellZshFork,
|
||||
/// Include the freeform apply_patch tool.
|
||||
ApplyPatchFreeform,
|
||||
/// Allow the model to request web searches that fetch live content.
|
||||
@@ -431,6 +433,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Stable,
|
||||
default_enabled: !cfg!(windows),
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ShellZshFork,
|
||||
key: "shell_zsh_fork",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ShellSnapshot,
|
||||
key: "shell_snapshot",
|
||||
|
||||
@@ -109,6 +109,7 @@ pub mod terminal;
|
||||
mod tools;
|
||||
pub mod turn_diff_tracker;
|
||||
mod turn_metadata;
|
||||
mod zsh_exec_bridge;
|
||||
pub use rollout::ARCHIVED_SESSIONS_SUBDIR;
|
||||
pub use rollout::INTERACTIVE_SESSION_SOURCES;
|
||||
pub use rollout::RolloutRecorder;
|
||||
@@ -152,6 +153,7 @@ pub use file_watcher::FileWatcherEvent;
|
||||
pub use safety::get_platform_sandbox;
|
||||
pub use tools::spec::parse_tool_input_schema;
|
||||
pub use turn_metadata::build_turn_metadata_header;
|
||||
pub use zsh_exec_bridge::maybe_run_zsh_exec_wrapper_mode;
|
||||
// Re-export the protocol types from the standalone `codex-protocol` crate so existing
|
||||
// `codex_core::protocol::...` references continue to work across the workspace.
|
||||
pub use codex_protocol::protocol;
|
||||
|
||||
@@ -164,12 +164,19 @@ impl SandboxManager {
|
||||
SandboxType::MacosSeatbelt => {
|
||||
let mut seatbelt_env = HashMap::new();
|
||||
seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
|
||||
let zsh_exec_bridge_wrapper_socket = env
|
||||
.get(crate::zsh_exec_bridge::ZSH_EXEC_BRIDGE_WRAPPER_SOCKET_ENV_VAR)
|
||||
.map(PathBuf::from);
|
||||
let zsh_exec_bridge_allowed_unix_sockets = zsh_exec_bridge_wrapper_socket
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |path| vec![path.clone()]);
|
||||
let mut args = create_seatbelt_command_args(
|
||||
command.clone(),
|
||||
policy,
|
||||
sandbox_policy_cwd,
|
||||
enforce_managed_network,
|
||||
network,
|
||||
&zsh_exec_bridge_allowed_unix_sockets,
|
||||
);
|
||||
let mut full_command = Vec::with_capacity(1 + args.len());
|
||||
full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string());
|
||||
|
||||
@@ -39,8 +39,14 @@ pub async fn spawn_command_under_seatbelt(
|
||||
network: Option<&NetworkProxy>,
|
||||
mut env: HashMap<String, String>,
|
||||
) -> std::io::Result<Child> {
|
||||
let args =
|
||||
create_seatbelt_command_args(command, sandbox_policy, sandbox_policy_cwd, false, network);
|
||||
let args = create_seatbelt_command_args(
|
||||
command,
|
||||
sandbox_policy,
|
||||
sandbox_policy_cwd,
|
||||
false,
|
||||
network,
|
||||
&[],
|
||||
);
|
||||
let arg0 = None;
|
||||
env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
|
||||
spawn_child_async(SpawnChildRequest {
|
||||
@@ -181,6 +187,7 @@ pub(crate) fn create_seatbelt_command_args(
|
||||
sandbox_policy_cwd: &Path,
|
||||
enforce_managed_network: bool,
|
||||
network: Option<&NetworkProxy>,
|
||||
allowed_unix_socket_paths: &[PathBuf],
|
||||
) -> Vec<String> {
|
||||
create_seatbelt_command_args_with_extensions(
|
||||
command,
|
||||
@@ -189,6 +196,7 @@ pub(crate) fn create_seatbelt_command_args(
|
||||
enforce_managed_network,
|
||||
network,
|
||||
None,
|
||||
allowed_unix_socket_paths,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -199,6 +207,7 @@ pub(crate) fn create_seatbelt_command_args_with_extensions(
|
||||
enforce_managed_network: bool,
|
||||
network: Option<&NetworkProxy>,
|
||||
extensions: Option<&MacOsSeatbeltProfileExtensions>,
|
||||
allowed_unix_socket_paths: &[PathBuf],
|
||||
) -> Vec<String> {
|
||||
let (file_write_policy, file_write_dir_params) = {
|
||||
if sandbox_policy.has_full_disk_write_access() {
|
||||
@@ -295,6 +304,8 @@ pub(crate) fn create_seatbelt_command_args_with_extensions(
|
||||
|
||||
let proxy = proxy_policy_inputs(network);
|
||||
let network_policy = dynamic_network_policy(sandbox_policy, enforce_managed_network, &proxy);
|
||||
let (unix_socket_policy, unix_socket_params) =
|
||||
unix_socket_policy_and_params(allowed_unix_socket_paths);
|
||||
let seatbelt_extensions = extensions.map_or_else(
|
||||
|| {
|
||||
// Backward-compatibility default when no extension profile is provided.
|
||||
@@ -303,20 +314,24 @@ pub(crate) fn create_seatbelt_command_args_with_extensions(
|
||||
build_seatbelt_extensions,
|
||||
);
|
||||
|
||||
let full_policy = if seatbelt_extensions.policy.is_empty() {
|
||||
format!(
|
||||
"{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}\n{}",
|
||||
seatbelt_extensions.policy
|
||||
)
|
||||
};
|
||||
let mut policy_sections = vec![
|
||||
MACOS_SEATBELT_BASE_POLICY.to_string(),
|
||||
file_read_policy,
|
||||
file_write_policy,
|
||||
network_policy,
|
||||
];
|
||||
if !unix_socket_policy.is_empty() {
|
||||
policy_sections.push(unix_socket_policy);
|
||||
}
|
||||
if !seatbelt_extensions.policy.is_empty() {
|
||||
policy_sections.push(seatbelt_extensions.policy.clone());
|
||||
}
|
||||
let full_policy = policy_sections.join("\n");
|
||||
|
||||
let dir_params = [
|
||||
file_read_dir_params,
|
||||
file_write_dir_params,
|
||||
unix_socket_params,
|
||||
macos_dir_params(),
|
||||
seatbelt_extensions.dir_params,
|
||||
]
|
||||
@@ -332,6 +347,27 @@ pub(crate) fn create_seatbelt_command_args_with_extensions(
|
||||
seatbelt_args
|
||||
}
|
||||
|
||||
fn unix_socket_policy_and_params(
|
||||
allowed_unix_socket_paths: &[PathBuf],
|
||||
) -> (String, Vec<(String, PathBuf)>) {
|
||||
if allowed_unix_socket_paths.is_empty() {
|
||||
return (String::new(), Vec::new());
|
||||
}
|
||||
|
||||
let mut policy = String::from("; allow outbound connect to explicitly-approved unix sockets\n");
|
||||
let mut params = Vec::with_capacity(allowed_unix_socket_paths.len());
|
||||
|
||||
for (index, path) in allowed_unix_socket_paths.iter().enumerate() {
|
||||
let key = format!("ALLOWED_UNIX_SOCKET_{index}");
|
||||
policy.push_str(&format!(
|
||||
"(allow network-outbound (remote unix-socket (path (param \"{key}\"))))\n"
|
||||
));
|
||||
params.push((key, path.clone()));
|
||||
}
|
||||
|
||||
(policy, params)
|
||||
}
|
||||
|
||||
/// Wraps libc::confstr to return a String.
|
||||
fn confstr(name: libc::c_int) -> Option<String> {
|
||||
let mut buf = vec![0_i8; (libc::PATH_MAX as usize) + 1];
|
||||
@@ -451,6 +487,7 @@ mod tests {
|
||||
macos_accessibility: true,
|
||||
macos_calendar: true,
|
||||
}),
|
||||
&[],
|
||||
);
|
||||
let policy = &args[1];
|
||||
|
||||
@@ -469,6 +506,7 @@ mod tests {
|
||||
cwd.as_path(),
|
||||
false,
|
||||
None,
|
||||
&[],
|
||||
);
|
||||
let policy = &args[1];
|
||||
assert!(policy.contains("(allow user-preference-read)"));
|
||||
@@ -485,6 +523,7 @@ mod tests {
|
||||
false,
|
||||
None,
|
||||
Some(&MacOsSeatbeltProfileExtensions::default()),
|
||||
&[],
|
||||
);
|
||||
let policy = &args[1];
|
||||
assert!(!policy.contains("appleevent-send"));
|
||||
@@ -494,6 +533,32 @@ mod tests {
|
||||
assert!(!policy.contains("user-preference-write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seatbelt_args_allow_explicit_wrapper_unix_socket_path() {
|
||||
let cwd = std::env::temp_dir();
|
||||
let socket_path = std::env::temp_dir().join("codex-zsh-wrapper-test.sock");
|
||||
let args = create_seatbelt_command_args(
|
||||
vec!["echo".to_string(), "ok".to_string()],
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
cwd.as_path(),
|
||||
false,
|
||||
None,
|
||||
std::slice::from_ref(&socket_path),
|
||||
);
|
||||
let policy = &args[1];
|
||||
let param_key = "ALLOWED_UNIX_SOCKET_0";
|
||||
|
||||
assert!(
|
||||
policy.contains("(allow network-outbound (remote unix-socket (path (param \"ALLOWED_UNIX_SOCKET_0\"))))"),
|
||||
"policy should contain explicit unix-socket allow rule:\n{policy}"
|
||||
);
|
||||
assert!(
|
||||
args.iter()
|
||||
.any(|arg| arg == &format!("-D{param_key}={}", socket_path.to_string_lossy())),
|
||||
"args should include unix-socket path param"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_seatbelt_args_allows_local_binding_when_explicitly_enabled() {
|
||||
let policy = dynamic_network_policy(
|
||||
@@ -649,7 +714,8 @@ mod tests {
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
let args = create_seatbelt_command_args(shell_command.clone(), &policy, &cwd, false, None);
|
||||
let args =
|
||||
create_seatbelt_command_args(shell_command.clone(), &policy, &cwd, false, None, &[]);
|
||||
|
||||
// Build the expected policy text using a raw string for readability.
|
||||
// Note that the policy includes:
|
||||
@@ -746,7 +812,7 @@ mod tests {
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
let write_hooks_file_args =
|
||||
create_seatbelt_command_args(shell_command_git, &policy, &cwd, false, None);
|
||||
create_seatbelt_command_args(shell_command_git, &policy, &cwd, false, None, &[]);
|
||||
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
|
||||
.args(&write_hooks_file_args)
|
||||
.current_dir(&cwd)
|
||||
@@ -777,7 +843,7 @@ mod tests {
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
let write_allowed_file_args =
|
||||
create_seatbelt_command_args(shell_command_allowed, &policy, &cwd, false, None);
|
||||
create_seatbelt_command_args(shell_command_allowed, &policy, &cwd, false, None, &[]);
|
||||
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
|
||||
.args(&write_allowed_file_args)
|
||||
.current_dir(&cwd)
|
||||
@@ -838,7 +904,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
let args = create_seatbelt_command_args(shell_command, &policy, &cwd, false, None);
|
||||
let args = create_seatbelt_command_args(shell_command, &policy, &cwd, false, None, &[]);
|
||||
|
||||
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
|
||||
.args(&args)
|
||||
@@ -869,7 +935,7 @@ mod tests {
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
let gitdir_args =
|
||||
create_seatbelt_command_args(shell_command_gitdir, &policy, &cwd, false, None);
|
||||
create_seatbelt_command_args(shell_command_gitdir, &policy, &cwd, false, None, &[]);
|
||||
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
|
||||
.args(&gitdir_args)
|
||||
.current_dir(&cwd)
|
||||
@@ -932,6 +998,7 @@ mod tests {
|
||||
vulnerable_root.as_path(),
|
||||
false,
|
||||
None,
|
||||
&[],
|
||||
);
|
||||
|
||||
let tmpdir_env_var = std::env::var("TMPDIR")
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::state_db::StateDbHandle;
|
||||
use crate::tools::network_approval::NetworkApprovalService;
|
||||
use crate::tools::sandboxing::ApprovalStore;
|
||||
use crate::unified_exec::UnifiedExecProcessManager;
|
||||
use crate::zsh_exec_bridge::ZshExecBridge;
|
||||
use codex_hooks::Hooks;
|
||||
use codex_otel::OtelManager;
|
||||
use tokio::sync::Mutex;
|
||||
@@ -26,6 +27,7 @@ pub(crate) struct SessionServices {
|
||||
pub(crate) mcp_connection_manager: Arc<RwLock<McpConnectionManager>>,
|
||||
pub(crate) mcp_startup_cancellation_token: Mutex<CancellationToken>,
|
||||
pub(crate) unified_exec_manager: UnifiedExecProcessManager,
|
||||
pub(crate) zsh_exec_bridge: ZshExecBridge,
|
||||
pub(crate) analytics_events_client: AnalyticsEventsClient,
|
||||
pub(crate) hooks: Hooks,
|
||||
pub(crate) rollout: Mutex<Option<RolloutRecorder>>,
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::tools::sandboxing::ToolCtx;
|
||||
use crate::tools::sandboxing::ToolError;
|
||||
use crate::tools::sandboxing::ToolRuntime;
|
||||
use crate::tools::sandboxing::with_cached_approval;
|
||||
use crate::zsh_exec_bridge::ZSH_EXEC_BRIDGE_WRAPPER_SOCKET_ENV_VAR;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use futures::future::BoxFuture;
|
||||
@@ -182,6 +183,36 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
|
||||
command
|
||||
};
|
||||
|
||||
if ctx.session.features().enabled(Feature::ShellZshFork) {
|
||||
let wrapper_socket_path = ctx
|
||||
.session
|
||||
.services
|
||||
.zsh_exec_bridge
|
||||
.next_wrapper_socket_path();
|
||||
let mut zsh_fork_env = req.env.clone();
|
||||
zsh_fork_env.insert(
|
||||
ZSH_EXEC_BRIDGE_WRAPPER_SOCKET_ENV_VAR.to_string(),
|
||||
wrapper_socket_path.to_string_lossy().to_string(),
|
||||
);
|
||||
let spec = build_command_spec(
|
||||
&command,
|
||||
&req.cwd,
|
||||
&zsh_fork_env,
|
||||
req.timeout_ms.into(),
|
||||
req.sandbox_permissions,
|
||||
req.justification.clone(),
|
||||
)?;
|
||||
let env = attempt
|
||||
.env_for(spec, req.network.as_ref())
|
||||
.map_err(|err| ToolError::Codex(err.into()))?;
|
||||
return ctx
|
||||
.session
|
||||
.services
|
||||
.zsh_exec_bridge
|
||||
.execute_shell_request(&env, ctx.session, ctx.turn, &ctx.call_id)
|
||||
.await;
|
||||
}
|
||||
|
||||
let spec = build_command_spec(
|
||||
&command,
|
||||
&req.cwd,
|
||||
|
||||
@@ -69,6 +69,8 @@ impl ToolsConfig {
|
||||
|
||||
let shell_type = if !features.enabled(Feature::ShellTool) {
|
||||
ConfigShellToolType::Disabled
|
||||
} else if features.enabled(Feature::ShellZshFork) {
|
||||
ConfigShellToolType::ShellCommand
|
||||
} else if features.enabled(Feature::UnifiedExec) {
|
||||
// If ConPTY not supported (for old Windows versions), fallback on ShellCommand.
|
||||
if codex_utils_pty::conpty_supported() {
|
||||
@@ -2346,6 +2348,23 @@ mod tests {
|
||||
assert_contains_tool_names(&tools, &subset);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_zsh_fork_prefers_shell_command_over_unified_exec() {
|
||||
let config = test_config();
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests("o3", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::UnifiedExec);
|
||||
features.enable(Feature::ShellZshFork);
|
||||
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Live),
|
||||
});
|
||||
|
||||
assert_eq!(tools_config.shell_type, ConfigShellToolType::ShellCommand);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_parallel_support_flags() {
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::tools::sandboxing::ToolError;
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[cfg(unix)]
|
||||
use crate::error::CodexErr;
|
||||
#[cfg(unix)]
|
||||
use crate::error::SandboxErr;
|
||||
#[cfg(unix)]
|
||||
use crate::protocol::EventMsg;
|
||||
#[cfg(unix)]
|
||||
use crate::protocol::ExecCommandOutputDeltaEvent;
|
||||
#[cfg(unix)]
|
||||
use crate::protocol::ExecOutputStream;
|
||||
#[cfg(unix)]
|
||||
use crate::protocol::ReviewDecision;
|
||||
#[cfg(unix)]
|
||||
use anyhow::Context as _;
|
||||
#[cfg(unix)]
|
||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
#[cfg(unix)]
|
||||
use codex_utils_pty::process_group::kill_child_process_group;
|
||||
#[cfg(unix)]
|
||||
use serde::Deserialize;
|
||||
#[cfg(unix)]
|
||||
use serde::Serialize;
|
||||
#[cfg(unix)]
|
||||
use std::io::Read;
|
||||
#[cfg(unix)]
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::time::Instant;
|
||||
#[cfg(unix)]
|
||||
use tokio::io::AsyncReadExt;
|
||||
#[cfg(unix)]
|
||||
use tokio::net::UnixListener;
|
||||
#[cfg(unix)]
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
pub(crate) const ZSH_EXEC_BRIDGE_WRAPPER_SOCKET_ENV_VAR: &str =
|
||||
"CODEX_ZSH_EXEC_BRIDGE_WRAPPER_SOCKET";
|
||||
pub(crate) const ZSH_EXEC_WRAPPER_MODE_ENV_VAR: &str = "CODEX_ZSH_EXEC_WRAPPER_MODE";
|
||||
#[cfg(unix)]
|
||||
pub(crate) const EXEC_WRAPPER_ENV_VAR: &str = "EXEC_WRAPPER";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub(crate) struct ZshExecBridgeSessionState {
|
||||
pub(crate) initialized_session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct ZshExecBridge {
|
||||
zsh_path: Option<PathBuf>,
|
||||
state: Mutex<ZshExecBridgeSessionState>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum WrapperIpcRequest {
|
||||
ExecRequest {
|
||||
request_id: String,
|
||||
file: String,
|
||||
argv: Vec<String>,
|
||||
cwd: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum WrapperIpcResponse {
|
||||
ExecResponse {
|
||||
request_id: String,
|
||||
action: WrapperExecAction,
|
||||
reason: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum WrapperExecAction {
|
||||
Run,
|
||||
Deny,
|
||||
}
|
||||
|
||||
impl ZshExecBridge {
|
||||
pub(crate) fn new(zsh_path: Option<PathBuf>, _codex_home: PathBuf) -> Self {
|
||||
Self {
|
||||
zsh_path,
|
||||
state: Mutex::new(ZshExecBridgeSessionState::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn initialize_for_session(&self, session_id: &str) {
|
||||
let mut state = self.state.lock().await;
|
||||
state.initialized_session_id = Some(session_id.to_string());
|
||||
}
|
||||
|
||||
pub(crate) async fn shutdown(&self) {
|
||||
let mut state = self.state.lock().await;
|
||||
state.initialized_session_id = None;
|
||||
}
|
||||
|
||||
pub(crate) fn next_wrapper_socket_path(&self) -> PathBuf {
|
||||
let socket_id = Uuid::new_v4().as_simple().to_string();
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let canonical_temp_dir = temp_dir.canonicalize().unwrap_or(temp_dir);
|
||||
canonical_temp_dir.join(format!("czs-{}.sock", &socket_id[..12]))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub(crate) async fn execute_shell_request(
|
||||
&self,
|
||||
_req: &crate::sandboxing::ExecRequest,
|
||||
_session: &crate::codex::Session,
|
||||
_turn: &crate::codex::TurnContext,
|
||||
_call_id: &str,
|
||||
) -> Result<ExecToolCallOutput, ToolError> {
|
||||
let _ = &self.zsh_path;
|
||||
Err(ToolError::Rejected(
|
||||
"shell_zsh_fork is only supported on unix".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) async fn execute_shell_request(
|
||||
&self,
|
||||
req: &crate::sandboxing::ExecRequest,
|
||||
session: &crate::codex::Session,
|
||||
turn: &crate::codex::TurnContext,
|
||||
call_id: &str,
|
||||
) -> Result<ExecToolCallOutput, ToolError> {
|
||||
let zsh_path = self.zsh_path.clone().ok_or_else(|| {
|
||||
ToolError::Rejected(
|
||||
"shell_zsh_fork enabled, but zsh_path is not configured".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let command = req.command.clone();
|
||||
if command.is_empty() {
|
||||
return Err(ToolError::Rejected("command args are empty".to_string()));
|
||||
}
|
||||
|
||||
let wrapper_socket_path = req
|
||||
.env
|
||||
.get(ZSH_EXEC_BRIDGE_WRAPPER_SOCKET_ENV_VAR)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| self.next_wrapper_socket_path());
|
||||
|
||||
let listener = {
|
||||
let _ = std::fs::remove_file(&wrapper_socket_path);
|
||||
UnixListener::bind(&wrapper_socket_path).map_err(|err| {
|
||||
ToolError::Rejected(format!(
|
||||
"bind wrapper socket at {}: {err}",
|
||||
wrapper_socket_path.display()
|
||||
))
|
||||
})?
|
||||
};
|
||||
|
||||
let wrapper_path = std::env::current_exe().map_err(|err| {
|
||||
ToolError::Rejected(format!("resolve current executable path: {err}"))
|
||||
})?;
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&command[0]);
|
||||
if command.len() > 1 {
|
||||
cmd.args(&command[1..]);
|
||||
}
|
||||
cmd.current_dir(&req.cwd);
|
||||
cmd.stdin(std::process::Stdio::null());
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
cmd.kill_on_drop(true);
|
||||
cmd.env_clear();
|
||||
cmd.envs(&req.env);
|
||||
cmd.env(
|
||||
ZSH_EXEC_BRIDGE_WRAPPER_SOCKET_ENV_VAR,
|
||||
wrapper_socket_path.to_string_lossy().to_string(),
|
||||
);
|
||||
cmd.env(EXEC_WRAPPER_ENV_VAR, &wrapper_path);
|
||||
cmd.env(ZSH_EXEC_WRAPPER_MODE_ENV_VAR, "1");
|
||||
|
||||
let mut child = cmd.spawn().map_err(|err| {
|
||||
ToolError::Rejected(format!(
|
||||
"failed to start zsh fork command {} with zsh_path {}: {err}",
|
||||
command[0],
|
||||
zsh_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
let (stream_tx, mut stream_rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<(ExecOutputStream, Vec<u8>)>();
|
||||
|
||||
if let Some(mut out) = child.stdout.take() {
|
||||
let tx = stream_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0_u8; 8192];
|
||||
loop {
|
||||
let read = match out.read(&mut buf).await {
|
||||
Ok(0) => break,
|
||||
Ok(n) => n,
|
||||
Err(err) => {
|
||||
tracing::warn!("zsh fork stdout read error: {err}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
let _ = tx.send((ExecOutputStream::Stdout, buf[..read].to_vec()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(mut err) = child.stderr.take() {
|
||||
let tx = stream_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0_u8; 8192];
|
||||
loop {
|
||||
let read = match err.read(&mut buf).await {
|
||||
Ok(0) => break,
|
||||
Ok(n) => n,
|
||||
Err(err) => {
|
||||
tracing::warn!("zsh fork stderr read error: {err}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
let _ = tx.send((ExecOutputStream::Stderr, buf[..read].to_vec()));
|
||||
}
|
||||
});
|
||||
}
|
||||
drop(stream_tx);
|
||||
|
||||
let mut stdout_bytes = Vec::new();
|
||||
let mut stderr_bytes = Vec::new();
|
||||
let mut child_exit = None;
|
||||
let mut timed_out = false;
|
||||
let mut stream_open = true;
|
||||
let mut user_rejected = false;
|
||||
let start = Instant::now();
|
||||
|
||||
let expiration = req.expiration.clone().wait();
|
||||
tokio::pin!(expiration);
|
||||
|
||||
while child_exit.is_none() || stream_open {
|
||||
tokio::select! {
|
||||
result = child.wait(), if child_exit.is_none() => {
|
||||
child_exit = Some(result.map_err(|err| ToolError::Rejected(format!("wait for zsh fork command exit: {err}")))?);
|
||||
}
|
||||
stream = stream_rx.recv(), if stream_open => {
|
||||
if let Some((output_stream, chunk)) = stream {
|
||||
match output_stream {
|
||||
ExecOutputStream::Stdout => stdout_bytes.extend_from_slice(&chunk),
|
||||
ExecOutputStream::Stderr => stderr_bytes.extend_from_slice(&chunk),
|
||||
}
|
||||
session
|
||||
.send_event(
|
||||
turn,
|
||||
EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent {
|
||||
call_id: call_id.to_string(),
|
||||
stream: output_stream,
|
||||
chunk,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
stream_open = false;
|
||||
}
|
||||
}
|
||||
accept_result = listener.accept(), if child_exit.is_none() => {
|
||||
let (stream, _) = accept_result.map_err(|err| {
|
||||
ToolError::Rejected(format!("failed to accept wrapper request: {err}"))
|
||||
})?;
|
||||
if self
|
||||
.handle_wrapper_request(stream, req.justification.clone(), session, turn, call_id)
|
||||
.await?
|
||||
{
|
||||
user_rejected = true;
|
||||
}
|
||||
}
|
||||
_ = &mut expiration, if child_exit.is_none() => {
|
||||
timed_out = true;
|
||||
kill_child_process_group(&mut child).map_err(|err| {
|
||||
ToolError::Rejected(format!("kill zsh fork command process group: {err}"))
|
||||
})?;
|
||||
child.start_kill().map_err(|err| {
|
||||
ToolError::Rejected(format!("kill zsh fork command process: {err}"))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&wrapper_socket_path);
|
||||
|
||||
let status = child_exit.ok_or_else(|| {
|
||||
ToolError::Rejected("zsh fork command did not return exit status".to_string())
|
||||
})?;
|
||||
|
||||
if user_rejected {
|
||||
return Err(ToolError::Rejected("rejected by user".to_string()));
|
||||
}
|
||||
|
||||
let stdout_text = crate::text_encoding::bytes_to_string_smart(&stdout_bytes);
|
||||
let stderr_text = crate::text_encoding::bytes_to_string_smart(&stderr_bytes);
|
||||
let output = ExecToolCallOutput {
|
||||
exit_code: status.code().unwrap_or(-1),
|
||||
stdout: crate::exec::StreamOutput::new(stdout_text.clone()),
|
||||
stderr: crate::exec::StreamOutput::new(stderr_text.clone()),
|
||||
aggregated_output: crate::exec::StreamOutput::new(format!(
|
||||
"{stdout_text}{stderr_text}"
|
||||
)),
|
||||
duration: start.elapsed(),
|
||||
timed_out,
|
||||
};
|
||||
|
||||
Self::map_exec_result(req.sandbox, output)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn handle_wrapper_request(
|
||||
&self,
|
||||
mut stream: UnixStream,
|
||||
approval_reason: Option<String>,
|
||||
session: &crate::codex::Session,
|
||||
turn: &crate::codex::TurnContext,
|
||||
call_id: &str,
|
||||
) -> Result<bool, ToolError> {
|
||||
let mut request_buf = Vec::new();
|
||||
stream.read_to_end(&mut request_buf).await.map_err(|err| {
|
||||
ToolError::Rejected(format!("read wrapper request from socket: {err}"))
|
||||
})?;
|
||||
let request_line = String::from_utf8(request_buf).map_err(|err| {
|
||||
ToolError::Rejected(format!("decode wrapper request as utf-8: {err}"))
|
||||
})?;
|
||||
let request = parse_wrapper_request_line(request_line.trim())?;
|
||||
|
||||
let (request_id, file, argv, cwd) = match request {
|
||||
WrapperIpcRequest::ExecRequest {
|
||||
request_id,
|
||||
file,
|
||||
argv,
|
||||
cwd,
|
||||
} => (request_id, file, argv, cwd),
|
||||
};
|
||||
|
||||
let command_for_approval = if argv.is_empty() {
|
||||
vec![file.clone()]
|
||||
} else {
|
||||
argv.clone()
|
||||
};
|
||||
|
||||
let approval_id = Uuid::new_v4().to_string();
|
||||
let decision = session
|
||||
.request_command_approval(
|
||||
turn,
|
||||
call_id.to_string(),
|
||||
Some(approval_id),
|
||||
command_for_approval,
|
||||
PathBuf::from(cwd),
|
||||
approval_reason,
|
||||
None,
|
||||
None::<ExecPolicyAmendment>,
|
||||
)
|
||||
.await;
|
||||
|
||||
let (action, reason, user_rejected) = match decision {
|
||||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedForSession
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
|
||||
(WrapperExecAction::Run, None, false)
|
||||
}
|
||||
ReviewDecision::Denied => (
|
||||
WrapperExecAction::Deny,
|
||||
Some("command denied by host approval policy".to_string()),
|
||||
true,
|
||||
),
|
||||
ReviewDecision::Abort => (
|
||||
WrapperExecAction::Deny,
|
||||
Some("command aborted by host approval policy".to_string()),
|
||||
true,
|
||||
),
|
||||
};
|
||||
|
||||
write_json_line(
|
||||
&mut stream,
|
||||
&WrapperIpcResponse::ExecResponse {
|
||||
request_id,
|
||||
action,
|
||||
reason,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(user_rejected)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn map_exec_result(
|
||||
sandbox: crate::exec::SandboxType,
|
||||
output: ExecToolCallOutput,
|
||||
) -> Result<ExecToolCallOutput, ToolError> {
|
||||
if output.timed_out {
|
||||
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout {
|
||||
output: Box::new(output),
|
||||
})));
|
||||
}
|
||||
|
||||
if crate::exec::is_likely_sandbox_denied(sandbox, &output) {
|
||||
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
|
||||
output: Box::new(output),
|
||||
network_policy_decision: None,
|
||||
})));
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn maybe_run_zsh_exec_wrapper_mode() -> anyhow::Result<bool> {
|
||||
if std::env::var_os(ZSH_EXEC_WRAPPER_MODE_ENV_VAR).is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
run_exec_wrapper_mode()?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn run_exec_wrapper_mode() -> anyhow::Result<()> {
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
anyhow::bail!("zsh exec wrapper mode is only supported on unix");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::net::UnixStream as StdUnixStream;
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() < 2 {
|
||||
anyhow::bail!("exec wrapper mode requires target executable path");
|
||||
}
|
||||
let file = args[1].clone();
|
||||
let argv = if args.len() > 2 {
|
||||
args[2..].to_vec()
|
||||
} else {
|
||||
vec![file.clone()]
|
||||
};
|
||||
let cwd = std::env::current_dir()
|
||||
.context("resolve wrapper cwd")?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let socket_path = std::env::var(ZSH_EXEC_BRIDGE_WRAPPER_SOCKET_ENV_VAR)
|
||||
.context("missing wrapper socket path env var")?;
|
||||
|
||||
let request_id = Uuid::new_v4().to_string();
|
||||
let request = WrapperIpcRequest::ExecRequest {
|
||||
request_id: request_id.clone(),
|
||||
file: file.clone(),
|
||||
argv: argv.clone(),
|
||||
cwd,
|
||||
};
|
||||
|
||||
let mut stream = StdUnixStream::connect(&socket_path)
|
||||
.with_context(|| format!("connect to wrapper socket at {socket_path}"))?;
|
||||
let encoded = serde_json::to_string(&request).context("serialize wrapper request")?;
|
||||
stream
|
||||
.write_all(encoded.as_bytes())
|
||||
.context("write wrapper request")?;
|
||||
stream
|
||||
.write_all(b"\n")
|
||||
.context("write wrapper request newline")?;
|
||||
stream
|
||||
.shutdown(std::net::Shutdown::Write)
|
||||
.context("shutdown wrapper write")?;
|
||||
|
||||
let mut response_buf = String::new();
|
||||
stream
|
||||
.read_to_string(&mut response_buf)
|
||||
.context("read wrapper response")?;
|
||||
let response: WrapperIpcResponse =
|
||||
serde_json::from_str(response_buf.trim()).context("parse wrapper response")?;
|
||||
|
||||
let (response_request_id, action, reason) = match response {
|
||||
WrapperIpcResponse::ExecResponse {
|
||||
request_id,
|
||||
action,
|
||||
reason,
|
||||
} => (request_id, action, reason),
|
||||
};
|
||||
if response_request_id != request_id {
|
||||
anyhow::bail!(
|
||||
"wrapper response request_id mismatch: expected {request_id}, got {response_request_id}"
|
||||
);
|
||||
}
|
||||
|
||||
if action == WrapperExecAction::Deny {
|
||||
if let Some(reason) = reason {
|
||||
tracing::warn!("execution denied: {reason}");
|
||||
} else {
|
||||
tracing::warn!("execution denied");
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let mut command = std::process::Command::new(&file);
|
||||
if argv.len() > 1 {
|
||||
command.args(&argv[1..]);
|
||||
}
|
||||
command.env_remove(ZSH_EXEC_WRAPPER_MODE_ENV_VAR);
|
||||
command.env_remove(ZSH_EXEC_BRIDGE_WRAPPER_SOCKET_ENV_VAR);
|
||||
command.env_remove(EXEC_WRAPPER_ENV_VAR);
|
||||
let status = command.status().context("spawn wrapped executable")?;
|
||||
std::process::exit(status.code().unwrap_or(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn parse_wrapper_request_line(request_line: &str) -> Result<WrapperIpcRequest, ToolError> {
|
||||
serde_json::from_str(request_line)
|
||||
.map_err(|err| ToolError::Rejected(format!("parse wrapper request payload: {err}")))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn write_json_line<W: tokio::io::AsyncWrite + Unpin, T: Serialize>(
|
||||
writer: &mut W,
|
||||
message: &T,
|
||||
) -> Result<(), ToolError> {
|
||||
let encoded = serde_json::to_string(message)
|
||||
.map_err(|err| ToolError::Rejected(format!("serialize wrapper message: {err}")))?;
|
||||
tokio::io::AsyncWriteExt::write_all(writer, encoded.as_bytes())
|
||||
.await
|
||||
.map_err(|err| ToolError::Rejected(format!("write wrapper message: {err}")))?;
|
||||
tokio::io::AsyncWriteExt::write_all(writer, b"\n")
|
||||
.await
|
||||
.map_err(|err| ToolError::Rejected(format!("write wrapper newline: {err}")))?;
|
||||
tokio::io::AsyncWriteExt::flush(writer)
|
||||
.await
|
||||
.map_err(|err| ToolError::Rejected(format!("flush wrapper message: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_wrapper_request_line_rejects_malformed_json() {
|
||||
let err = parse_wrapper_request_line("this-is-not-json").unwrap_err();
|
||||
let ToolError::Rejected(message) = err else {
|
||||
panic!("expected ToolError::Rejected");
|
||||
};
|
||||
assert!(message.starts_with("parse wrapper request payload:"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user