Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 31 additions & 10 deletions src-tauri/src/console/pty/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,12 @@ pub(crate) fn plan_launch(
/// (a loud red warning + nearest-existing-ancestor fallback when it's `cwd_missing`, nothing when
/// `cwd` is empty), install the OSC-7 cwd + `__bsc_state` run/idle markers and the `claude()`
/// wrapper (`claude_fn`), source the bsc-* helpers from `rc_bash` into the interactive shell,
/// wire `PROMPT_COMMAND`, clear the screen, and append the optional `launch` command. Pure (no
/// fs/env) so the exact wire string is unit-testable; `to_bash_path` is a no-op off Windows.
/// wire `PROMPT_COMMAND`, clear the screen, and append the optional `launch` command — EXCEPT when
/// the cwd is missing (#2438): the launch is suppressed, since it would start the agent in the
/// fallback ancestor (for a fleet worktree that's the parent of EVERY worktree) with no role gate
/// (`ensure_session_settings` wrote into the missing configured path), and the agent's TUI would
/// clear the warning off screen. Pure (no fs/env) so the exact wire string is unit-testable;
/// `to_bash_path` is a no-op off Windows.
pub(super) fn build_bash_init_line(
cwd: &str,
cwd_missing: bool,
Expand All @@ -53,18 +57,25 @@ pub(super) fn build_bash_init_line(
claude_fn: &str,
rc_bash: &str,
) -> String {
let init_suffix = launch.map(|s| format!("; {}", s)).unwrap_or_default();
// The suffix runs AFTER the final screen clear, so it's what the user actually sees: the launch
// command normally, or — when the configured cwd is gone — the red warning as an inert shell's
// banner (printing it any earlier gets wiped by the clear; launching anyway would start the agent
// in the fallback ancestor, ungated).
let init_suffix = if cwd_missing {
format!(
"; printf '\\033[1;31m[bsc] WARNING: configured directory %s does not exist; this session did NOT start in its project directory and the agent was NOT started. Relaunch the fleet to recreate the worktree, or remove this pane.\\033[0m\\n' \"{disp}\"",
disp = to_bash_path(cwd),
)
} else {
launch.map(|s| format!("; {}", s)).unwrap_or_default()
};
// Explicit cd after .bashrc runs so any `cd ~` in .bashrc doesn't win.
// Uses a bash-compatible POSIX path so Git Bash on Windows handles it.
let cd_prefix = if cwd.is_empty() {
String::new()
} else if cwd_missing {
// Loud, visible warning instead of a silent home fallback, then sit in the
// nearest existing ancestor (not $HOME) so the agent is at least near the project.
format!(
"printf '\\033[1;31m[bsc] WARNING: configured directory %s does not exist; this session did NOT start in its project directory.\\033[0m\\n' \"{disp}\"; cd \"{anc}\" 2>/dev/null; ",
disp = to_bash_path(cwd), anc = to_bash_path(effective_cwd),
)
// Sit in the nearest existing ancestor (not $HOME); the warning prints post-clear (above).
format!("cd \"{}\" 2>/dev/null; ", to_bash_path(effective_cwd))
} else {
format!("cd \"{}\" 2>/dev/null; ", to_bash_path(cwd))
};
Expand Down Expand Up @@ -113,12 +124,22 @@ mod tests {

#[test]
fn build_bash_init_line_missing_cwd_warns_and_falls_to_ancestor() {
// `launch` is Some on purpose: a missing cwd must suppress it anyway (#2438) — launching
// would start the agent ungated in the fallback ancestor (for a fleet worktree, the parent
// of EVERY worktree; the role gate was written into the missing configured path).
let line = build_bash_init_line(
"/home/u/projects/gone", true, "/home/u/projects",
None, "claude() { :; }; ", "/rc.sh",
Some("claude --continue"), "claude() { :; }; ", "/rc.sh",
);
assert!(line.contains("WARNING: configured directory"), "loud warning printed: {line}");
assert!(line.contains("cd \"/home/u/projects\" 2>/dev/null; "), "cd's into the nearest ancestor: {line}");
assert!(line.contains("the agent was NOT started"), "warning states the launch suppression: {line}");
assert!(!line.contains("claude --continue"), "launch suppressed when the cwd is missing: {line}");
// The warning is the POST-clear suffix — printing it earlier gets wiped by the final
// `printf '\033[2J\033[H'` screen clear (#2438).
let clear = line.find("printf '\\033[2J\\033[H'").expect("clears the screen");
let warn = line.find("WARNING").expect("warns");
assert!(warn > clear, "warning must print after the screen clear: {line}");
}

#[test]
Expand Down
55 changes: 41 additions & 14 deletions src-tauri/src/platform/shell/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,12 @@ pub(crate) fn resolve_interactive_shell() -> ResolvedShell {
/// and startup-prompt baking are bash-only, so under PowerShell/cmd we cd into the
/// project, clear the screen, and print a VISIBLE notice that those helpers are
/// unavailable in this session (explicit degradation, never silent), optionally
/// launching `claude` so the session is still usable. Pure (no I/O) for testing.
/// launching `claude` so the session is still usable. When the configured cwd is
/// MISSING (#2438) the launch is suppressed — starting the agent in the fallback
/// ancestor would run it ungated outside its project (for a fleet worktree, the
/// parent of EVERY worktree; `ensure_session_settings` wrote into the missing
/// configured path) — and the red warning prints AFTER the clear (`Clear-Host` /
/// `cls`) so the clear can't wipe it. Pure (no I/O) for testing.
pub(crate) fn non_bash_init(
kind: ShellKind,
cwd: &str,
Expand Down Expand Up @@ -291,17 +296,17 @@ pub(crate) fn non_bash_init(
// Single-quote-escape for PowerShell literal strings (' -> '').
s.push_str(&format!("Set-Location -LiteralPath '{}'; ", dir.replace('\'', "''")));
}
if cwd_missing {
s.push_str(&format!(
"Write-Host '[bsc] WARNING: configured directory {} does not exist; this session did NOT start in its project directory.' -ForegroundColor Red; ",
cwd.replace('\'', "''"),
));
}
s.push_str("Clear-Host; ");
s.push_str(&format!(
"Write-Host '[bsc] Running under PowerShell -- {NOTICE}.' -ForegroundColor Yellow; "
));
if launch_claude {
if cwd_missing {
// Post-clear so the warning survives; launch suppressed (#2438).
s.push_str(&format!(
"Write-Host '[bsc] WARNING: configured directory {} does not exist; this session did NOT start in its project directory and the agent was NOT started. Relaunch the fleet to recreate the worktree, or remove this pane.' -ForegroundColor Red; ",
cwd.replace('\'', "''"),
));
} else if launch_claude {
s.push_str(&format!("claude{model_arg}; "));
}
s.push('\n');
Expand All @@ -312,14 +317,14 @@ pub(crate) fn non_bash_init(
if !dir.is_empty() {
s.push_str(&format!("cd /d \"{dir}\" & "));
}
s.push_str("cls & ");
s.push_str(&format!("echo [bsc] Running under cmd.exe -- {NOTICE}. & "));
if cwd_missing {
// Post-clear so the warning survives; launch suppressed (#2438).
s.push_str(&format!(
"echo [bsc] WARNING: configured directory {cwd} does not exist; this session did NOT start in its project directory. & "
"echo [bsc] WARNING: configured directory {cwd} does not exist; this session did NOT start in its project directory and the agent was NOT started. Relaunch the fleet to recreate the worktree, or remove this pane. & "
));
}
s.push_str("cls & ");
s.push_str(&format!("echo [bsc] Running under cmd.exe -- {NOTICE}. & "));
if launch_claude {
} else if launch_claude {
s.push_str(&format!("claude{model_arg} & "));
}
s.push_str("echo.\n");
Expand Down Expand Up @@ -465,11 +470,33 @@ mod tests {
#[test]
fn non_bash_init_warns_loudly_when_cwd_is_missing() {
use super::{non_bash_init, ShellKind};
let ps = non_bash_init(ShellKind::PowerShell, "C:/gone", true, "C:/", false, None);
// launch_claude = true on purpose: a missing cwd must suppress the launch anyway (#2438).
let ps = non_bash_init(ShellKind::PowerShell, "C:/gone", true, "C:/", true, None);
// Lands in the existing ancestor, not the missing dir, and shouts about it.
assert!(ps.contains("Set-Location -LiteralPath 'C:/'"));
assert!(ps.contains("does not exist"));
assert!(ps.contains("-ForegroundColor Red"));
assert!(ps.contains("the agent was NOT started"), "warning states the launch suppression: {ps}");
// The agent is NOT started in the fallback ancestor (it would run ungated there).
assert!(!ps.contains("claude"), "no claude launch when the cwd is missing: {ps}");
// The warning prints AFTER Clear-Host, so the clear can't wipe it off screen.
let clear = ps.find("Clear-Host").expect("clears the screen");
let warn = ps.find("WARNING").expect("warns");
assert!(warn > clear, "warning must print after the clear: {ps}");
}

#[test]
fn non_bash_init_cmd_suppresses_launch_and_warns_after_cls_when_cwd_is_missing() {
use super::{non_bash_init, ShellKind};
// Same #2438 contract for the cmd.exe branch.
let cmd = non_bash_init(ShellKind::Cmd, r"C:\gone", true, r"C:\projects", true, Some("opus"));
assert!(cmd.contains("cd /d \"C:\\projects\""), "cd's into the nearest ancestor: {cmd}");
assert!(cmd.contains("does not exist"));
assert!(cmd.contains("the agent was NOT started"), "warning states the launch suppression: {cmd}");
assert!(!cmd.contains("claude"), "no claude launch when the cwd is missing: {cmd}");
let clear = cmd.find("cls").expect("clears the screen");
let warn = cmd.find("WARNING").expect("warns");
assert!(warn > clear, "warning must print after the clear: {cmd}");
}

#[test]
Expand Down
Loading