| 1 | //! `codewhale --resume <id>`, `--resume=<id>`, `-r <id>` and `--session-id <id>` |
| 2 | //! are root dispatcher flags (the operations runbook advertises the first) |
| 3 | //! that forward to the in-process TUI's `--resume`. They used to be swallowed |
| 4 | //! by the trailing prompt positional and forwarded as `--prompt "--resume |
| 5 | //! <id>"`, which the TUI's own parser rejected (`error: unexpected argument |
| 6 | //! '--resume <id>' found`, exit 2). A non-TTY launch must now get past |
| 7 | //! argument parsing and fail only on the interactive-terminal contract, and |
| 8 | //! must not create session state under a sealed HOME on the way out. |
| 9 | //! Fresh-session and mouse flags exercise the same boundary: an unrecognized |
| 10 | //! `--fresh` previously swallowed the remaining flags as one prompt value. |
| 11 | |
| 12 | #![cfg(unix)] |
| 13 | |
| 14 | use std::path::{Path, PathBuf}; |
| 15 | use std::process::{Command, Stdio}; |
| 16 | |
| 17 | use tempfile::TempDir; |
| 18 | |
| 19 | fn codewhale_binary() -> PathBuf { |
| 20 | if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale") { |
| 21 | return PathBuf::from(path); |
| 22 | } |
| 23 | if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale") { |
| 24 | return PathBuf::from(path); |
| 25 | } |
| 26 | let mut path = std::env::current_exe().expect("current test executable path"); |
| 27 | path.pop(); |
| 28 | if path.ends_with("deps") { |
| 29 | path.pop(); |
| 30 | } |
| 31 | path.push(format!("codewhale{}", std::env::consts::EXE_SUFFIX)); |
| 32 | path |
| 33 | } |
| 34 | |
| 35 | fn isolated_command(home: &Path, codewhale_home: &Path, workspace: &Path) -> Command { |
| 36 | let mut command = Command::new(codewhale_binary()); |
| 37 | command |
| 38 | .env_clear() |
| 39 | .env("HOME", home) |
| 40 | .env("USERPROFILE", home) |
| 41 | .env("CODEWHALE_HOME", codewhale_home) |
| 42 | .env("CODEWHALE_SECRET_BACKEND", "file") |
| 43 | .env("CODEWHALE_NO_UPDATE_CHECK", "1") |
| 44 | .env("CODEWHALE_DISABLE_MODELS_DEV_FETCH", "1") |
| 45 | .env("CODEWHALE_TELEMETRY", "0") |
| 46 | .env("NO_COLOR", "1") |
| 47 | .current_dir(workspace) |
| 48 | .stdin(Stdio::null()) |
| 49 | .stdout(Stdio::piped()) |
| 50 | .stderr(Stdio::piped()); |
| 51 | command |
| 52 | } |
| 53 | |
| 54 | #[test] |
| 55 | fn root_session_flags_reach_the_tui_launch_contract_without_touching_state() { |
| 56 | for argv in [ |
| 57 | &["--resume", "800596e6"][..], |
| 58 | &["--resume=800596e6"][..], |
| 59 | &["-r", "800596e6"][..], |
| 60 | &["--session-id", "800596e6"][..], |
| 61 | &["--continue"][..], |
| 62 | &["--no-project-config", "--fresh", "--mouse-capture"][..], |
| 63 | &["--no-project-config", "--mouse-capture", "--fresh"][..], |
| 64 | ] { |
| 65 | let fixture = TempDir::new().expect("fixture root"); |
| 66 | let home = fixture.path().join("sealed-home"); |
| 67 | let codewhale_home = fixture.path().join("sealed-codewhale-home"); |
| 68 | let workspace = fixture.path().join("workspace with spaces"); |
| 69 | std::fs::create_dir_all(&home).expect("home"); |
| 70 | std::fs::create_dir_all(&workspace).expect("workspace"); |
| 71 | |
| 72 | let output = isolated_command(&home, &codewhale_home, &workspace) |
| 73 | .arg("--workspace") |
| 74 | .arg(&workspace) |
| 75 | .args(argv) |
| 76 | .output() |
| 77 | .expect("run codewhale"); |
| 78 | let stderr = String::from_utf8_lossy(&output.stderr); |
| 79 | let stdout = String::from_utf8_lossy(&output.stdout); |
| 80 | |
| 81 | assert!( |
| 82 | !output.status.success(), |
| 83 | "{argv:?} must not start an interactive TUI without a TTY:\n{stdout}\n{stderr}" |
| 84 | ); |
| 85 | assert_ne!( |
| 86 | output.status.code(), |
| 87 | Some(2), |
| 88 | "{argv:?} must parse at the root dispatcher (clap usage error):\n{stderr}" |
| 89 | ); |
| 90 | assert!( |
| 91 | !stderr.contains("unexpected argument") && !stderr.contains("--prompt <PROMPT>"), |
| 92 | "{argv:?} must not be forwarded as a prompt:\n{stderr}" |
| 93 | ); |
| 94 | assert!( |
| 95 | stderr.contains("requires an interactive terminal"), |
| 96 | "{argv:?} must fail on the TTY contract, not earlier:\n{stdout}\n{stderr}" |
| 97 | ); |
| 98 | assert!( |
| 99 | !stderr.contains("Recovered interrupted session"), |
| 100 | "{argv:?} must not consume a checkpoint when the TUI cannot start:\n{stderr}" |
| 101 | ); |
| 102 | let sessions_dir = codewhale_home.join("sessions"); |
| 103 | let persisted: Vec<_> = std::fs::read_dir(&sessions_dir) |
| 104 | .into_iter() |
| 105 | .flatten() |
| 106 | .flatten() |
| 107 | .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json")) |
| 108 | .collect(); |
| 109 | assert!( |
| 110 | persisted.is_empty(), |
| 111 | "{argv:?} must not write session files under a sealed home: {persisted:?}" |
| 112 | ); |
| 113 | } |
| 114 | } |
| 115 |