feature: Add "!cmd" user shell execution

- protocol: add Op::RunUserShellCommand to model a user-initiated one-off command
- core: handle new Op by spawning a cancellable task that runs the command using the user’s default shell; stream output via ExecCommand* events; send TaskStarted/TaskComplete; track as current_task so Interrupt works
- tui: detect leading '!' in composer submission and dispatch Op::RunUserShellCommand instead of sending a user message

No changes to sandbox env var behavior; uses existing exec pipeline and event types.
This commit is contained in:
Abhishek Bhardwaj
2025-09-12 22:33:09 -07:00
parent e85742635f
commit 2f0bc514b7
10 changed files with 352 additions and 17 deletions

View File

@@ -135,6 +135,7 @@ use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::ShellToolCallParams;
use codex_protocol::protocol::InitialHistory;
use uuid::Uuid;
pub mod compact;
use self::compact::build_compacted_history;
@@ -812,6 +813,7 @@ impl Session {
command_for_display,
cwd,
apply_patch,
is_user_shell_command,
} = exec_command_context;
let msg = match apply_patch {
Some(ApplyPatchCommandContext {
@@ -834,6 +836,7 @@ impl Session {
.into_iter()
.map(Into::into)
.collect(),
is_user_shell_command,
}),
};
let event = Event {
@@ -1063,6 +1066,7 @@ pub(crate) struct ExecCommandContext {
pub(crate) command_for_display: Vec<String>,
pub(crate) cwd: PathBuf,
pub(crate) apply_patch: Option<ApplyPatchCommandContext>,
pub(crate) is_user_shell_command: bool,
}
#[derive(Clone, Debug)]
@@ -1518,6 +1522,9 @@ async fn submission_loop(
};
sess.send_event(event).await;
}
Op::RunUserShellCommand { command } => {
spawn_user_shell_command_task(sess.clone(), &turn_context, sub.id, command).await;
}
Op::Review { review_request } => {
spawn_review_thread(
sess.clone(),
@@ -1536,6 +1543,101 @@ async fn submission_loop(
debug!("Agent loop exited");
}
async fn spawn_user_shell_command_task(
sess: Arc<Session>,
turn_context: &Arc<TurnContext>,
sub_id: String,
command: String,
) {
let handle = {
let sess = sess.clone();
let turn_context = turn_context.clone();
let spawn_sub_id = sub_id.clone();
tokio::spawn(async move {
run_user_shell_command(sess, turn_context, spawn_sub_id, command).await;
})
.abort_handle()
};
sess.set_task(AgentTask {
sess: sess.clone(),
sub_id,
handle,
kind: AgentTaskKind::Regular,
})
.await;
}
async fn run_user_shell_command(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
sub_id: String,
command: String,
) {
let event = Event {
id: sub_id.clone(),
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: turn_context.client.get_model_context_window(),
}),
};
sess.send_event(event).await;
let shell_invocation = sess
.user_shell
.format_user_shell_script(&command)
.unwrap_or_else(|| vec![command.clone()]);
let params = ExecParams {
command: shell_invocation.clone(),
cwd: turn_context.cwd.clone(),
timeout_ms: None,
env: create_env(&turn_context.shell_environment_policy),
with_escalated_permissions: None,
justification: None,
};
let mut turn_diff_tracker = TurnDiffTracker::new();
let call_id = Uuid::new_v4().to_string();
let exec_ctx = ExecCommandContext {
sub_id: sub_id.clone(),
call_id: call_id.clone(),
command_for_display: shell_invocation,
cwd: params.cwd.clone(),
apply_patch: None,
is_user_shell_command: true,
};
let sandbox_policy = SandboxPolicy::DangerFullAccess;
let _ = sess
.run_exec_with_events(
&mut turn_diff_tracker,
exec_ctx,
ExecInvokeArgs {
params,
sandbox_type: SandboxType::None,
sandbox_policy: &sandbox_policy,
sandbox_cwd: &turn_context.cwd,
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
stdout_stream: Some(StdoutStream {
sub_id: sub_id.clone(),
call_id: call_id.clone(),
tx_event: sess.tx_event.clone(),
}),
},
)
.await;
let complete = Event {
id: sub_id,
msg: EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: None,
}),
};
sess.send_event(complete).await;
}
/// Spawn a review thread using the given prompt.
async fn spawn_review_thread(
sess: Arc<Session>,
@@ -2800,6 +2902,7 @@ async fn handle_container_exec_with_params(
changes: convert_apply_patch_to_protocol(&action),
},
),
is_user_shell_command: false,
};
let params = maybe_translate_shell_command(params, sess, turn_context);

View File

@@ -30,6 +30,28 @@ pub enum Shell {
}
impl Shell {
pub fn format_user_shell_script(&self, script: &str) -> Option<Vec<String>> {
match self {
Shell::Zsh(zsh) => Some(format_shell_script_with_rc(
&zsh.shell_path,
&zsh.zshrc_path,
script,
)),
Shell::Bash(bash) => Some(format_shell_script_with_rc(
&bash.shell_path,
&bash.bashrc_path,
script,
)),
Shell::PowerShell(ps) => Some(vec![
ps.exe.clone(),
"-NoProfile".to_string(),
"-Command".to_string(),
script.to_string(),
]),
Shell::Unknown => None,
}
}
pub fn format_default_shell_invocation(&self, command: Vec<String>) -> Option<Vec<String>> {
match self {
Shell::Zsh(zsh) => format_shell_invocation_with_rc(
@@ -113,13 +135,7 @@ fn format_shell_invocation_with_rc(
let joined = strip_bash_lc(command)
.or_else(|| shlex::try_join(command.iter().map(String::as_str)).ok())?;
let rc_command = if std::path::Path::new(rc_path).exists() {
format!("source {rc_path} && ({joined})")
} else {
joined
};
Some(vec![shell_path.to_string(), "-lc".to_string(), rc_command])
Some(format_shell_script_with_rc(shell_path, rc_path, &joined))
}
fn strip_bash_lc(command: &[String]) -> Option<String> {
@@ -135,6 +151,16 @@ fn strip_bash_lc(command: &[String]) -> Option<String> {
}
}
fn format_shell_script_with_rc(shell_path: &str, rc_path: &str, script: &str) -> Vec<String> {
let rc_command = if std::path::Path::new(rc_path).exists() {
format!("source {rc_path} && ({script})")
} else {
script.to_string()
};
vec![shell_path.to_string(), "-lc".to_string(), rc_command]
}
#[cfg(unix)]
fn detect_default_user_shell() -> Shell {
use libc::getpwuid;