Compare commits

...

1 Commits

Author SHA1 Message Date
Abhishek Bhardwaj
9518387ddb 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.
2025-09-13 17:24:18 -07:00
8 changed files with 307 additions and 15 deletions

View File

@@ -128,6 +128,7 @@ use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::ShellToolCallParams;
use codex_protocol::protocol::InitialHistory;
use uuid::Uuid;
mod compact;
@@ -770,6 +771,7 @@ impl Session {
command_for_display,
cwd,
apply_patch,
user_initiated_shell_command,
} = exec_command_context;
let msg = match apply_patch {
Some(ApplyPatchCommandContext {
@@ -792,6 +794,7 @@ impl Session {
.into_iter()
.map(Into::into)
.collect(),
user_initiated_shell_command,
}),
};
let event = Event {
@@ -1029,6 +1032,7 @@ pub(crate) struct ExecCommandContext {
pub(crate) command_for_display: Vec<String>,
pub(crate) cwd: PathBuf,
pub(crate) apply_patch: Option<ApplyPatchCommandContext>,
pub(crate) user_initiated_shell_command: bool,
}
#[derive(Clone, Debug)]
@@ -1474,6 +1478,101 @@ async fn submission_loop(
};
sess.send_event(event).await;
}
Op::RunUserShellCommand { command } => {
// Spawn a cancellable one-off shell command task so we can process
// further Ops (e.g., Interrupt) while it runs.
let sess_clone = sess.clone();
let turn_context = Arc::clone(&turn_context);
let sub_id = sub.id.clone();
let handle = tokio::spawn(async move {
// Announce a running task so the UI can show a spinner and block input.
let event = Event {
id: sub_id.clone(),
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: turn_context.client.get_model_context_window(),
}),
};
sess_clone.send_event(event).await;
// Build a shell invocation in the user's default shell.
let shell_invocation = sess_clone
.user_shell
// Why we pass a ["bash", "-lc", <script>] sentinel instead of the raw command:
// - The shell adapter (core/src/shell.rs) first calls `strip_bash_lc`. When it sees this
// exact shape it extracts <script> and then builds the correct argv for the user shell
// (e.g., `/bin/zsh -lc "source ~/.zshrc && (<script>)"`).
// - If we pass the whole command as a single string (e.g., ["cat Cargo.toml | wc -l"]) the
// adapter may quote it when joining/embedding, and shells can treat the entire value as a
// single program name or a single quoted token.
.format_default_shell_invocation(vec![
"bash".to_string(),
"-lc".to_string(),
command.clone(),
])
.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,
};
// Use a fresh diff tracker (no patch application expected for ! commands).
let mut turn_diff_tracker = TurnDiffTracker::new();
// Initiated by user, not by the model. Hence, we generate a new call_id.
let call_id = format!("call_{}", Uuid::new_v4());
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,
user_initiated_shell_command: true,
};
// Run without sandboxing or approval — this is a user-initiated command.
// Output is not captured as it's sent to the TUI inside `run_exec_with_events`.
let _ = sess_clone
.run_exec_with_events(
&mut turn_diff_tracker,
exec_ctx,
ExecInvokeArgs {
params,
sandbox_type: SandboxType::None,
sandbox_policy: &turn_context.sandbox_policy,
codex_linux_sandbox_exe: &sess_clone.codex_linux_sandbox_exe,
stdout_stream: Some(StdoutStream {
sub_id: sub_id.clone(),
call_id: call_id.clone(),
tx_event: sess_clone.tx_event.clone(),
}),
},
)
.await;
// Signal completion so the UI regains control.
let complete = Event {
id: sub_id.clone(),
msg: EventMsg::TaskComplete(TaskCompleteEvent {
last_agent_message: None,
}),
};
sess_clone.send_event(complete).await;
})
.abort_handle();
// Track this as the current task so Interrupt can abort it.
sess.set_task(AgentTask {
sess: sess.clone(),
sub_id: sub.id,
handle,
kind: AgentTaskKind::Regular,
});
}
Op::Review { review_request } => {
spawn_review_thread(
sess.clone(),
@@ -2813,6 +2912,7 @@ async fn handle_container_exec_with_params(
changes: convert_apply_patch_to_protocol(&action),
},
),
user_initiated_shell_command: false,
};
let params = maybe_translate_shell_command(params, sess, turn_context);

View File

@@ -1,5 +1,6 @@
// Aggregates all former standalone integration tests as modules.
mod user_shell_cmd;
mod cli_stream;
mod client;
mod compact;

View File

@@ -0,0 +1,112 @@
#![cfg(unix)]
use codex_core::ConversationManager;
use codex_core::NewConversation;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::Op;
use codex_core::protocol::TurnAbortReason;
use core_test_support::load_default_config_for_test;
use core_test_support::wait_for_event;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn user_shell_cmd_ls_and_cat_in_temp_dir() {
// No env overrides needed; test build hard-codes a hermetic zsh with empty rc.
// Create a temporary working directory with a known file.
let cwd = TempDir::new().unwrap();
let file_name = "hello.txt";
let file_path: PathBuf = cwd.path().join(file_name);
let contents = "hello from bang test\n";
fs::write(&file_path, contents).expect("write temp file");
// Load config and pin cwd to the temp dir so ls/cat operate there.
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
config.cwd = cwd.path().to_path_buf();
let conversation_manager =
ConversationManager::with_auth(codex_core::CodexAuth::from_api_key("dummy"));
let NewConversation {
conversation: codex,
..
} = conversation_manager
.new_conversation(config)
.await
.expect("create new conversation");
// 1) ls should list the file
codex
.submit(Op::RunUserShellCommand {
command: "ls".to_string(),
})
.await
.unwrap();
let msg = wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandEnd(_))).await;
let EventMsg::ExecCommandEnd(ExecCommandEndEvent {
stdout, exit_code, ..
}) = msg
else {
unreachable!()
};
assert_eq!(exit_code, 0);
assert!(
stdout.contains(file_name),
"ls output should include {file_name}, got: {stdout:?}"
);
// 2) cat the file should return exact contents
codex
.submit(Op::RunUserShellCommand {
command: format!("cat {}", file_name),
})
.await
.unwrap();
let msg = wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandEnd(_))).await;
let EventMsg::ExecCommandEnd(ExecCommandEndEvent {
stdout, exit_code, ..
}) = msg
else {
unreachable!()
};
assert_eq!(exit_code, 0);
assert_eq!(stdout, contents);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn user_shell_cmd_can_be_interrupted() {
// No env overrides needed; test build hard-codes a hermetic zsh with empty rc.
// Set up isolated config and conversation.
let codex_home = TempDir::new().unwrap();
let config = load_default_config_for_test(&codex_home);
let conversation_manager =
ConversationManager::with_auth(codex_core::CodexAuth::from_api_key("dummy"));
let NewConversation {
conversation: codex,
..
} = conversation_manager
.new_conversation(config)
.await
.expect("create new conversation");
// Start a long-running command and then interrupt it.
codex
.submit(Op::RunUserShellCommand {
command: "sleep 5".to_string(),
})
.await
.unwrap();
// Wait until it has started (ExecCommandBegin), then interrupt.
let _ = wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandBegin(_))).await;
codex.submit(Op::Interrupt).await.unwrap();
// Expect a TurnAborted(Interrupted) notification.
let msg = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnAborted(_))).await;
let EventMsg::TurnAborted(ev) = msg else {
unreachable!()
};
assert_eq!(ev.reason, TurnAbortReason::Interrupted);
}

View File

@@ -278,6 +278,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
command,
cwd,
parsed_cmd: _,
..
}) => {
self.call_id_to_command.insert(
call_id,

View File

@@ -172,6 +172,16 @@ pub enum Op {
/// Request to shut down codex instance.
Shutdown,
/// Execute a user-initiated one-off shell command (triggered by "!cmd").
///
/// The command string is executed using the user's default shell and may
/// include shell syntax (pipes, redirects, etc.). Output is streamed via
/// `ExecCommand*` events and the UI regains control upon `TaskComplete`.
RunUserShellCommand {
/// The raw command string after '!'
command: String,
},
}
/// Determines the conditions under which the user is consulted to approve
@@ -1032,6 +1042,10 @@ pub struct ExecCommandBeginEvent {
/// The command's working directory if not the default cwd for the agent.
pub cwd: PathBuf,
pub parsed_cmd: Vec<ParsedCommand>,
/// True when this exec was initiated directly by the user (e.g. bang command),
/// not by the agent/model. Defaults to false for backwards compatibility.
#[serde(default)]
pub user_initiated_shell_command: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
@@ -1298,4 +1312,19 @@ mod tests {
let deserialized: ExecCommandOutputDeltaEvent = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized, event);
}
#[test]
fn serialize_run_user_shell_command_op() {
let op = Op::RunUserShellCommand {
command: "echo hi".to_string(),
};
let value = serde_json::to_value(op).unwrap();
assert_eq!(
value,
json!({
"type": "run_user_shell_command",
"command": "echo hi",
})
);
}
}

View File

@@ -91,6 +91,7 @@ use codex_protocol::mcp_protocol::ConversationId;
struct RunningCommand {
command: Vec<String>,
parsed_cmd: Vec<ParsedCommand>,
user_initiated_shell_command: bool,
}
/// Common initialization parameters shared by all `ChatWidget` constructors.
@@ -477,9 +478,9 @@ impl ChatWidget {
pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) {
let running = self.running_commands.remove(&ev.call_id);
let (command, parsed) = match running {
Some(rc) => (rc.command, rc.parsed_cmd),
None => (vec![ev.call_id.clone()], Vec::new()),
let (command, parsed, user_initiated_shell_command) = match running {
Some(rc) => (rc.command, rc.parsed_cmd, rc.user_initiated_shell_command),
None => (vec![ev.call_id.clone()], Vec::new(), false),
};
if self.active_exec_cell.is_none() {
@@ -489,6 +490,7 @@ impl ChatWidget {
ev.call_id.clone(),
command,
parsed,
user_initiated_shell_command,
));
}
if let Some(cell) = self.active_exec_cell.as_mut() {
@@ -561,6 +563,7 @@ impl ChatWidget {
RunningCommand {
command: ev.command.clone(),
parsed_cmd: ev.parsed_cmd.clone(),
user_initiated_shell_command: ev.user_initiated_shell_command,
},
);
if let Some(exec) = &self.active_exec_cell {
@@ -568,6 +571,7 @@ impl ChatWidget {
ev.call_id.clone(),
ev.command.clone(),
ev.parsed_cmd.clone(),
ev.user_initiated_shell_command,
) {
self.active_exec_cell = Some(new_exec);
} else {
@@ -577,6 +581,7 @@ impl ChatWidget {
ev.call_id.clone(),
ev.command.clone(),
ev.parsed_cmd,
ev.user_initiated_shell_command,
));
}
} else {
@@ -584,6 +589,7 @@ impl ChatWidget {
ev.call_id.clone(),
ev.command.clone(),
ev.parsed_cmd,
ev.user_initiated_shell_command,
));
}
@@ -959,6 +965,15 @@ impl ChatWidget {
let UserMessage { text, image_paths } = user_message;
let mut items: Vec<InputItem> = Vec::new();
// Special-case: "!cmd" executes a local shell command instead of sending to the model.
if let Some(stripped) = text.strip_prefix('!') {
let cmd = stripped.trim().to_string();
if !cmd.is_empty() {
self.submit_op(Op::RunUserShellCommand { command: cmd });
return;
}
}
if !text.is_empty() {
items.push(InputItem::Text { text: text.clone() });
}

View File

@@ -399,6 +399,7 @@ fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) {
command,
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
parsed_cmd,
user_initiated_shell_command: false,
}),
});
}
@@ -691,6 +692,7 @@ async fn binary_size_transcript_snapshot() {
command: e.command,
cwd: e.cwd,
parsed_cmd: parsed_cmd.into_iter().map(|c| c.into()).collect(),
user_initiated_shell_command: false,
}),
}
}
@@ -1674,6 +1676,7 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() {
}
.into(),
],
user_initiated_shell_command: false,
}),
});
chat.handle_codex_event(Event {

View File

@@ -213,6 +213,7 @@ pub(crate) struct ExecCall {
pub(crate) command: Vec<String>,
pub(crate) parsed: Vec<ParsedCommand>,
pub(crate) output: Option<CommandOutput>,
pub(crate) user_initiated_shell_command: bool,
start_time: Option<Instant>,
duration: Option<Duration>,
}
@@ -426,17 +427,28 @@ impl ExecCell {
body_lines.extend(wrapped_borrowed.iter().map(|l| line_to_static(l)));
}
}
if let Some(output) = call.output.as_ref()
&& output.exit_code != 0
{
let out = output_lines(Some(output), false, false, false)
.into_iter()
.join("\n");
if !out.trim().is_empty() {
// Wrap the output.
for line in out.lines() {
let wrapped = textwrap::wrap(line, TwOptions::new(width as usize - 4));
body_lines.extend(wrapped.into_iter().map(|l| Line::from(l.to_string().dim())));
if let Some(output) = call.output.as_ref() {
// ExecCell-level classification: if any call in the cell represents
// a user shell command (ParsedCommand::Unknown), show output
// unconditionally. Otherwise, only show on errors.
let is_shell_exec_cell = self.calls.iter().any(|c| c.user_initiated_shell_command);
let should_show_output = is_shell_exec_cell || output.exit_code != 0;
if should_show_output {
let line_limit = if is_shell_exec_cell {
USER_SHELL_TOOL_CALL_MAX_LINES
} else {
TOOL_CALL_MAX_LINES
};
let out = output_lines(Some(output), false, false, false, line_limit)
.into_iter()
.join("\n");
if !out.trim().is_empty() {
// Wrap the output.
for line in out.lines() {
let wrapped = textwrap::wrap(line, TwOptions::new(width as usize - 4));
body_lines
.extend(wrapped.into_iter().map(|l| Line::from(l.to_string().dim())));
}
}
}
}
@@ -519,11 +531,13 @@ impl ExecCell {
call_id: String,
command: Vec<String>,
parsed: Vec<ParsedCommand>,
user_initiated_shell_command: bool,
) -> Option<Self> {
let call = ExecCall {
call_id,
command,
parsed,
user_initiated_shell_command,
output: None,
start_time: Some(Instant::now()),
duration: None,
@@ -566,6 +580,7 @@ impl HistoryCell for CompletedMcpToolCallWithImageOutput {
}
const TOOL_CALL_MAX_LINES: usize = 5;
const USER_SHELL_TOOL_CALL_MAX_LINES: usize = 50;
fn title_case(s: &str) -> String {
if s.is_empty() {
@@ -676,11 +691,13 @@ pub(crate) fn new_active_exec_command(
call_id: String,
command: Vec<String>,
parsed: Vec<ParsedCommand>,
user_initiated_shell_command: bool,
) -> ExecCell {
ExecCell::new(ExecCall {
call_id,
command,
parsed,
user_initiated_shell_command,
output: None,
start_time: Some(Instant::now()),
duration: None,
@@ -1174,6 +1191,7 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
true,
true,
true,
TOOL_CALL_MAX_LINES,
));
}
@@ -1259,6 +1277,7 @@ fn output_lines(
only_err: bool,
include_angle_pipe: bool,
include_prefix: bool,
line_limit: usize,
) -> Vec<Line<'static>> {
let CommandOutput {
exit_code,
@@ -1274,7 +1293,7 @@ fn output_lines(
let src = if *exit_code == 0 { stdout } else { stderr };
let lines: Vec<&str> = src.lines().collect();
let total = lines.len();
let limit = TOOL_CALL_MAX_LINES;
let limit = line_limit;
let mut out = Vec::new();
@@ -1397,6 +1416,7 @@ mod tests {
},
],
output: None,
user_initiated_shell_command: false,
start_time: Some(Instant::now()),
duration: None,
});
@@ -1428,6 +1448,7 @@ mod tests {
cmd: "rg shimmer_spans".into(),
}],
output: None,
user_initiated_shell_command: false,
start_time: Some(Instant::now()),
duration: None,
});
@@ -1451,6 +1472,7 @@ mod tests {
name: "shimmer.rs".into(),
cmd: "cat shimmer.rs".into(),
}],
false,
)
.unwrap();
cell.complete_call(
@@ -1472,6 +1494,7 @@ mod tests {
name: "status_indicator_widget.rs".into(),
cmd: "cat status_indicator_widget.rs".into(),
}],
false,
)
.unwrap();
cell.complete_call(
@@ -1510,6 +1533,7 @@ mod tests {
},
],
output: None,
user_initiated_shell_command: false,
start_time: Some(Instant::now()),
duration: None,
});
@@ -1538,6 +1562,7 @@ mod tests {
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
user_initiated_shell_command: false,
start_time: Some(Instant::now()),
duration: None,
});
@@ -1568,6 +1593,7 @@ mod tests {
command: vec!["echo".into(), "ok".into()],
parsed: Vec::new(),
output: None,
user_initiated_shell_command: false,
start_time: Some(Instant::now()),
duration: None,
});
@@ -1596,6 +1622,7 @@ mod tests {
command: vec!["bash".into(), "-lc".into(), long],
parsed: Vec::new(),
output: None,
user_initiated_shell_command: false,
start_time: Some(Instant::now()),
duration: None,
});
@@ -1623,6 +1650,7 @@ mod tests {
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
user_initiated_shell_command: false,
start_time: Some(Instant::now()),
duration: None,
});
@@ -1651,6 +1679,7 @@ mod tests {
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
user_initiated_shell_command: false,
start_time: Some(Instant::now()),
duration: None,
});
@@ -1679,6 +1708,7 @@ mod tests {
command: vec!["bash".into(), "-lc".into(), "seq 1 10 1>&2 && false".into()],
parsed: Vec::new(),
output: None,
user_initiated_shell_command: false,
start_time: Some(Instant::now()),
duration: None,
});
@@ -1725,6 +1755,7 @@ mod tests {
command: vec!["bash".into(), "-lc".into(), long_cmd.to_string()],
parsed: Vec::new(),
output: None,
user_initiated_shell_command: false,
start_time: Some(Instant::now()),
duration: None,
});