Skip to content

Commit 16ec6d4

Browse files
authored
Detect shell exit in setup command (#14506)
## Description Contributes to the largest cluster of https://linear.app/warpdotdev/issue/REMOTE-2338/sandbox-unexpectedly-failed When a setup command causes the shell to exit, we weren't reporting this failure. The run would end up stuck at `Session Started` until eventually the sandbox closes 12 hours later. Now, we detect shell exit in setup commands and report the failure with an error message, closing the sandbox. We also update the `AgentExitedShell` error to include the command name. ## Testing <!-- How did you test this change? What automated tests did you add? If you didn't add any new tests, what's your justification for not adding any? Manual testing is required for changes that can be manually tested, and almost all changes can be manually tested. If your change can be manually tested, please include screenshots or a screen recording that show it working end to end. You can run the app locally using `./script/run` - see AGENTS.md for more details on how to get set up. --> - [x] I have manually tested my changes locally with `./script/run` Reproduced the original issue using an environment with `exit` as a setup command. In staging the run is stuck at session started. The sandbox is alive, but the shared session is dead. 12 hours later the sandbox will close, putting it in error with `Sandbox unexpectedly closed` https://oz.staging.warp.dev/runs/019fb010-6a48-7b9e-9ea5-9fe0f0df939c?createdBy=5biMwfUWwagla4lNcR9OSWcZW9H3 Testing locally, it now reports the failure ### Screenshots / Videos <!-- Attach screenshots or a short video demonstrating the change, where appropriate. Remove this section if it is not relevant to your PR. --> <img width="441" height="854" alt="Screenshot 2026-07-29 at 4 15 07 PM" src="https://github.com/user-attachments/assets/6d3e37ff-fe95-46b3-a89f-6d086baa5cb0" />
1 parent 08ad6e8 commit 16ec6d4

13 files changed

Lines changed: 258 additions & 43 deletions

app/src/ai/agent/mod.rs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -726,9 +726,11 @@ pub enum RenderableAIError {
726726
is_user_error: bool,
727727
},
728728
/// An agent-issued command caused the shell process to exit, so the run
729-
/// cannot continue. Surfaced as a terminal failure (FAILED) rather than a
730-
/// user cancellation.
731-
AgentExitedShell,
729+
/// cannot continue. Surfaced as a terminal failure (FAILED).
730+
/// `command` is the (secret-redacted) command that exited the shell.
731+
AgentExitedShell {
732+
command: String,
733+
},
732734
/// A cloud-mode startup failure. Carries the raw server error message and
733735
/// surfaces it without the generic apology prefix, matching the dedicated
734736
/// GUI error card (`render_cloud_mode_error_screen`) which shows the
@@ -739,8 +741,6 @@ pub enum RenderableAIError {
739741
impl RenderableAIError {
740742
const TRANSIENT_NETWORK_ERROR_MESSAGE: &'static str =
741743
"Warp lost connection while receiving the agent response. This is usually temporary.";
742-
/// User-facing message shown when an agent-issued command exits the shell.
743-
pub const AGENT_EXITED_SHELL_MESSAGE: &'static str = "The shell exited while the agent was running a command, so the run could not continue. Ensure the agent is not asked to run commands or source scripts that can exit the shell.";
744744
/// Creates a transient network error. `kind` is the structured cause (including the raw API
745745
/// error where one exists), preserved so user reports can disambiguate the different causes
746746
/// behind the shared user-facing copy.
@@ -918,7 +918,12 @@ impl Display for RenderableAIError {
918918
)
919919
}
920920
Self::Other { error_message, .. } => write!(f, "{error_message}"),
921-
Self::AgentExitedShell => write!(f, "{}", Self::AGENT_EXITED_SHELL_MESSAGE),
921+
Self::AgentExitedShell { command } => write!(
922+
f,
923+
"The shell exited while the agent was running the command `{command}`, so the run \
924+
could not continue. Ensure the agent is not asked to run commands or source \
925+
scripts that can exit the shell."
926+
),
922927
Self::CloudStartupFailed(msg) => write!(f, "{msg}"),
923928
}
924929
}

app/src/ai/agent_sdk/driver.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,15 @@ pub enum AgentDriverError {
526526
ConversationCancelled { reason: CancellationReason },
527527
#[error("The agent got stuck waiting for user confirmation on the action: {blocked_action}")]
528528
ConversationBlocked { blocked_action: String },
529+
/// The shell process exited while an environment setup command was
530+
/// running (e.g. the command ran `exit`), so the run cannot continue.
531+
/// `command` is the (secret-redacted) command that was in flight (or
532+
/// most recently submitted) when the shell died.
533+
#[error(
534+
"The shell exited during setup command `{command}`, so the run could not continue. \
535+
Check the setup commands for this environment."
536+
)]
537+
SetupCommandExitedShell { command: String },
529538
#[error("Timed out refreshing team metadata")]
530539
TeamMetadataRefreshTimeout,
531540
#[error("{0}")]
@@ -1074,7 +1083,11 @@ impl AgentDriver {
10741083
// Success/blocked/cancelled are handled by LocalAgentTaskSyncModel.
10751084
if let (Some(task_id), Err(err)) = (task_id, &result) {
10761085
report_driver_error(task_id, err, &server_api_for_error).await;
1077-
if matches!(err, AgentDriverError::EnvironmentSetupFailed(_)) {
1086+
if matches!(
1087+
err,
1088+
AgentDriverError::EnvironmentSetupFailed(_)
1089+
| AgentDriverError::SetupCommandExitedShell { .. }
1090+
) {
10781091
let _ = foreground_for_error
10791092
.spawn(|me, ctx| {
10801093
me.extend_shared_session_retention(

app/src/ai/agent_sdk/driver/error_classification.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,16 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
173173
PlatformErrorCode::EnvironmentSetupFailed,
174174
),
175175
),
176+
// The shell died while an environment setup command was running
177+
// (e.g. the command ran `exit`). This is a user-side environment
178+
// configuration problem, so classify as FAILED.
179+
AgentDriverError::SetupCommandExitedShell { .. } => (
180+
AgentTaskState::Failed,
181+
TaskStatusUpdate::with_error_code(
182+
error.to_string(),
183+
PlatformErrorCode::EnvironmentSetupFailed,
184+
),
185+
),
176186
AgentDriverError::InvalidWorkingDirectory { path, .. } => (
177187
AgentTaskState::Failed,
178188
TaskStatusUpdate::with_error_code(

app/src/ai/agent_sdk/driver/error_classification_tests.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,28 @@ fn environment_setup_failed_is_failed() {
163163
);
164164
}
165165

166+
#[test]
167+
fn setup_command_exited_shell_is_failed_with_env_setup_and_names_command() {
168+
let (state, update) = classify_driver_error(&AgentDriverError::SetupCommandExitedShell {
169+
command: "./setup.sh".into(),
170+
});
171+
assert_eq!(state, AgentTaskState::Failed);
172+
assert_eq!(
173+
update.error_code,
174+
Some(PlatformErrorCode::EnvironmentSetupFailed)
175+
);
176+
// The message must name the setup command that exited the shell and
177+
// point the user at the environment's setup commands.
178+
assert!(update.message.contains("./setup.sh"), "{}", update.message);
179+
assert!(
180+
update
181+
.message
182+
.contains("Check the setup commands for this environment"),
183+
"{}",
184+
update.message
185+
);
186+
}
187+
166188
#[test]
167189
fn profile_error_is_failed_with_resource_not_found() {
168190
assert_state_and_code(

app/src/ai/agent_sdk/driver/terminal.rs

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use warpui::r#async::FutureExt;
2020
use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity as _, ViewHandle};
2121

2222
use super::AgentDriverError;
23+
use crate::ai::agent::redaction::redact_secrets;
2324
use crate::ai::ambient_agents::AmbientAgentTaskId;
2425
use crate::ai::attachment_utils::attachments_download_dir;
2526
use crate::pane_group::NewTerminalOptions;
@@ -145,12 +146,33 @@ pub(crate) struct TerminalDriver {
145146
/// and `wait_for_session_shared` has not yet been called.
146147
session_share_rx: Option<oneshot::Receiver<Result<(), ShareSessionError>>>,
147148
pending_share_requests: Vec<ShareRequest>,
148-
waiting_command: Option<oneshot::Sender<ExitCode>>,
149+
/// Resolves the in-flight command's exit status. Sent `Ok` when the
150+
/// command's block completes, or
151+
/// `Err(AgentDriverError::SetupCommandExitedShell)` if the shell process
152+
/// exits while the command is still running.
153+
waiting_command: Option<oneshot::Sender<Result<ExitCode, AgentDriverError>>>,
149154

150155
/// State for the pending command we're expecting to start executing.
151156
/// The `String` is the expected command text, and the sender is used
152-
/// to send the block ID to the waiting caller.
153-
pending_command_start: Option<(String, oneshot::Sender<BlockId>)>,
157+
/// to send the block ID to the waiting caller (or a shell-exit error if
158+
/// the shell dies before the command starts).
159+
pending_command_start: Option<(String, oneshot::Sender<Result<BlockId, AgentDriverError>>)>,
160+
161+
/// True once the shell process backing this session has exited
162+
/// post-bootstrap. No further commands can execute, so
163+
/// [`Self::execute_command`] fails fast with
164+
/// [`AgentDriverError::SetupCommandExitedShell`].
165+
shell_exited: bool,
166+
167+
/// The most recently submitted command (secret-redacted), used to
168+
/// attribute a shell exit to the command that caused it. When the shell
169+
/// dies mid-command, the exit path force-finishes the command's block
170+
/// (with exit code 0) before `Event::Exited` is delivered, so at exit
171+
/// time this — not any still-pending command — names the culprit.
172+
///
173+
/// Stored redacted because it flows into error reports (server task
174+
/// status, Sentry) via [`AgentDriverError::SetupCommandExitedShell`].
175+
last_command: Option<String>,
154176
}
155177

156178
impl Entity for TerminalDriver {
@@ -303,6 +325,8 @@ impl TerminalDriver {
303325
pending_share_requests: Vec::new(),
304326
waiting_command: None,
305327
pending_command_start: None,
328+
shell_exited: false,
329+
last_command: None,
306330
}
307331
}
308332

@@ -464,6 +488,17 @@ impl TerminalDriver {
464488
})
465489
}
466490

491+
/// The error reported for commands affected by a shell exit, attributing
492+
/// the most recently submitted command as the cause.
493+
fn shell_exited_error(&self) -> AgentDriverError {
494+
AgentDriverError::SetupCommandExitedShell {
495+
command: self
496+
.last_command
497+
.clone()
498+
.unwrap_or_else(|| "<unknown>".to_string()),
499+
}
500+
}
501+
467502
/// Execute a command in the terminal and return a future that resolves to a
468503
/// [`CommandHandle`] once the command starts executing.
469504
pub fn execute_command(
@@ -474,8 +509,16 @@ impl TerminalDriver {
474509
impl Future<Output = Result<CommandHandle, AgentDriverError>> + use<>,
475510
AgentDriverError,
476511
> {
477-
let (exit_tx, exit_rx) = oneshot::channel::<ExitCode>();
478-
let (start_tx, start_rx) = oneshot::channel::<BlockId>();
512+
// The shell process has exited, so no further commands can run in
513+
// this session. Fail fast with the shell-exit error so callers
514+
// (e.g. environment setup) report the failure instead of waiting
515+
// forever on a command that can never start.
516+
if self.shell_exited {
517+
return Err(self.shell_exited_error());
518+
}
519+
520+
let (exit_tx, exit_rx) = oneshot::channel::<Result<ExitCode, AgentDriverError>>();
521+
let (start_tx, start_rx) = oneshot::channel::<Result<BlockId, AgentDriverError>>();
479522

480523
// We should not be able to execute a command while we are still waiting on another one.
481524
// This is enforced by the caller by waiting on rx before continuing.
@@ -484,6 +527,12 @@ impl TerminalDriver {
484527
}
485528

486529
let command_string = command.to_string();
530+
// Store a secret-redacted copy for shell-exit attribution: the text
531+
// flows into error reports (server task status, Sentry) if the shell
532+
// dies, so never retain the raw command here.
533+
let mut redacted_command = command_string.clone();
534+
redact_secrets(&mut redacted_command);
535+
self.last_command = Some(redacted_command);
487536
self.terminal_view.update(ctx, |terminal, ctx| {
488537
self.waiting_command = Some(exit_tx);
489538
self.pending_command_start = Some((command_string, start_tx));
@@ -493,7 +542,7 @@ impl TerminalDriver {
493542
Ok(async move {
494543
let block_id = start_rx
495544
.await
496-
.map_err(|_| AgentDriverError::InvalidRuntimeState)?;
545+
.map_err(|_| AgentDriverError::InvalidRuntimeState)??;
497546
Ok(CommandHandle {
498547
exit_status_rx: exit_rx,
499548
block_id,
@@ -689,7 +738,7 @@ pub(crate) struct BlockOutputMatch {
689738
/// Also carries the [`BlockId`] so callers can retrieve the block snapshot
690739
/// after completion.
691740
pub(crate) struct CommandHandle {
692-
exit_status_rx: oneshot::Receiver<ExitCode>,
741+
exit_status_rx: oneshot::Receiver<Result<ExitCode, AgentDriverError>>,
693742
block_id: BlockId,
694743
}
695744

@@ -706,7 +755,10 @@ impl Future for CommandHandle {
706755
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
707756
Pin::new(&mut self.exit_status_rx)
708757
.poll(cx)
709-
.map(|result| result.map_err(|_| AgentDriverError::InvalidRuntimeState))
758+
.map(|result| match result {
759+
Ok(exit_status) => exit_status,
760+
Err(_) => Err(AgentDriverError::InvalidRuntimeState),
761+
})
710762
}
711763
}
712764

@@ -742,6 +794,18 @@ impl TerminalDriver {
742794
if let Some(tx) = self.bootstrap_tx.take() {
743795
let _ = tx.send(Err(BootstrapError::PtySpawnFailed { reason: None }));
744796
}
797+
798+
// The shell is gone: no further command can start or finish.
799+
// Fail any in-flight command (e.g. an environment setup
800+
// command) with the shell-exit error so the run reports the
801+
// failure instead of hanging until the sandbox is killed.
802+
self.shell_exited = true;
803+
if let Some((_, sender)) = self.pending_command_start.take() {
804+
let _ = sender.send(Err(self.shell_exited_error()));
805+
}
806+
if let Some(sender) = self.waiting_command.take() {
807+
let _ = sender.send(Err(self.shell_exited_error()));
808+
}
745809
}
746810
crate::terminal::view::Event::SlowBootstrap => {
747811
ctx.emit(TerminalDriverEvent::SlowBootstrap);
@@ -777,7 +841,7 @@ impl TerminalDriver {
777841
let block_id = self.terminal_view.read(ctx, |terminal, _| {
778842
terminal.model.lock().block_list().active_block_id().clone()
779843
});
780-
let _ = sender.send(block_id);
844+
let _ = sender.send(Ok(block_id));
781845
}
782846
}
783847
crate::terminal::view::Event::BlockCompleted { block, .. } => {
@@ -796,7 +860,7 @@ impl TerminalDriver {
796860
// we instead simply make sure it was not a background block.
797861
bootstrapping_done && !block.is_background
798862
}) {
799-
let _ = sender.send(block.exit_code);
863+
let _ = sender.send(Ok(block.exit_code));
800864
}
801865
}
802866
_ => (),

app/src/ai/agent_sdk/driver/terminal_tests.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
use std::cell::RefCell;
22
use std::rc::Rc;
33

4+
use regex::Regex;
5+
use serial_test::serial;
46
use session_sharing_protocol::sharer::SessionRetentionReason;
57
use warpui::App;
68

79
use super::TerminalDriver;
10+
use crate::ai::agent_sdk::driver::AgentDriverError;
11+
use crate::terminal::model::secrets::set_user_and_enterprise_secret_regexes;
812
use crate::terminal::shared_session::SharedSessionStatus;
913
use crate::terminal::view::Event;
1014
use crate::test_util::add_window_with_terminal;
@@ -56,3 +60,75 @@ fn extend_shared_session_retention_emits_event_for_active_sharer() {
5660
));
5761
});
5862
}
63+
64+
// #[serial] because the secret regexes configured below are global state.
65+
#[test]
66+
#[serial]
67+
fn shell_exit_fails_in_flight_and_subsequent_commands() {
68+
App::test((), |mut app| async move {
69+
initialize_app_for_terminal_view(&mut app);
70+
71+
// Configure a secret pattern (a GitHub classic PAT). In production
72+
// these are populated from the user's/enterprise's privacy settings
73+
// via CustomSecretRegexUpdater.
74+
set_user_and_enterprise_secret_regexes(
75+
[&Regex::new(r"\bghp_[A-Za-z0-9_]{36}\b").expect("pattern should compile")],
76+
std::iter::empty(),
77+
);
78+
79+
let terminal_view = add_window_with_terminal(&mut app, None);
80+
let terminal_driver =
81+
app.update(|ctx| TerminalDriver::create_from_existing_view(terminal_view.clone(), ctx));
82+
83+
// A command containing a secret matching the configured pattern. The
84+
// attributed command in the shell-exit error must have the token
85+
// redacted, since the error flows into server task status and Sentry
86+
// reports.
87+
let token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
88+
let submitted = format!("echo {token}");
89+
let expected_redacted = format!("echo {}", "*".repeat(token.len()));
90+
91+
// Start a command (e.g. an environment setup command) so the driver
92+
// has an in-flight command waiting on the terminal session.
93+
let command_future = terminal_driver
94+
.update(&mut app, |driver, ctx| {
95+
driver.execute_command(&submitted, ctx)
96+
})
97+
.expect("command should be accepted before the shell exits");
98+
99+
// The shell process dies (e.g. the command ran `exit 1`).
100+
terminal_view.update(&mut app, |_, ctx| ctx.emit(Event::Exited));
101+
102+
// The in-flight command must resolve with the shell-exit error
103+
// instead of hanging forever, regardless of whether it had already
104+
// started executing. The error must name the (redacted) command that
105+
// was running when the shell died.
106+
let result = match command_future.await {
107+
Ok(handle) => handle.await,
108+
Err(error) => Err(error),
109+
};
110+
match &result {
111+
Err(AgentDriverError::SetupCommandExitedShell { command }) => {
112+
assert_eq!(command, &expected_redacted);
113+
}
114+
other => {
115+
panic!("in-flight command should fail with SetupCommandExitedShell, got {other:?}")
116+
}
117+
}
118+
119+
// Any further command must fail fast with the same error, still
120+
// attributing the (redacted) command that killed the shell (not the
121+
// newly attempted one).
122+
let fail_fast = terminal_driver.update(&mut app, |driver, ctx| {
123+
driver.execute_command("echo again", ctx).err()
124+
});
125+
match &fail_fast {
126+
Some(AgentDriverError::SetupCommandExitedShell { command }) => {
127+
assert_eq!(command, &expected_redacted);
128+
}
129+
other => panic!(
130+
"commands after shell exit should fail fast with SetupCommandExitedShell, got {other:?}"
131+
),
132+
}
133+
});
134+
}

app/src/ai/blocklist/agent_view/agent_message_bar.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -782,7 +782,7 @@ fn should_fork_from_last_known_good_state(
782782
| RenderableAIError::GeminiEnterpriseCredentialsExpiredOrInvalid => false,
783783
// A shell-exit failure can't resume in this (now-dead) pane, but the user
784784
// can fork from the last known good state to continue in a fresh one.
785-
RenderableAIError::InternalWarpError | RenderableAIError::AgentExitedShell => true,
785+
RenderableAIError::InternalWarpError | RenderableAIError::AgentExitedShell { .. } => true,
786786
RenderableAIError::Other {
787787
will_attempt_resume,
788788
..

0 commit comments

Comments
 (0)