| 1 | //! Installed-dispatcher acceptance for the pipe-only credential handoff. |
| 2 | |
| 3 | #![cfg(unix)] |
| 4 | |
| 5 | use std::fs::{self, File}; |
| 6 | use std::os::fd::FromRawFd; |
| 7 | use std::path::{Path, PathBuf}; |
| 8 | use std::process::{Command, Output, Stdio}; |
| 9 | |
| 10 | use codewhale_secrets::{FileKeyringStore, KeyringStore}; |
| 11 | use tempfile::TempDir; |
| 12 | |
| 13 | const SENTINEL: &str = "cw-handoff-child-sentinel-7b30"; |
| 14 | const TERMINAL_ERROR: &str = |
| 15 | "error: refusing terminal output; pipe credential handoff to the intended local client\n"; |
| 16 | |
| 17 | #[test] |
| 18 | fn terminal_refusal_precedes_config_reads_and_secret_migration() { |
| 19 | let malformed = TempDir::new().expect("malformed fixture root"); |
| 20 | let malformed_home = malformed.path().join("home"); |
| 21 | let malformed_codewhale_home = malformed.path().join("codewhale-home"); |
| 22 | fs::create_dir_all(&malformed_codewhale_home).expect("create explicit Codewhale home"); |
| 23 | let malformed_config = malformed_codewhale_home.join("config.toml"); |
| 24 | let malformed_bytes = format!("provider = \"openrouter\"\ninvalid = [{SENTINEL}\n"); |
| 25 | fs::write(&malformed_config, &malformed_bytes).expect("write malformed config trap"); |
| 26 | |
| 27 | let mut command = isolated_command(&malformed_home, Some(&malformed_codewhale_home)); |
| 28 | command.args(["auth", "print-api-key", "--provider", "openrouter"]); |
| 29 | let output = output_with_terminal_stdout(command); |
| 30 | assert!(!output.status.success(), "terminal handoff must refuse"); |
| 31 | assert_eq!(String::from_utf8_lossy(&output.stderr), TERMINAL_ERROR); |
| 32 | assert_eq!( |
| 33 | fs::read_to_string(&malformed_config).expect("malformed config remains readable"), |
| 34 | malformed_bytes, |
| 35 | ); |
| 36 | assert!( |
| 37 | !malformed_codewhale_home.join("secrets").exists(), |
| 38 | "terminal preflight must not construct durable credential state" |
| 39 | ); |
| 40 | |
| 41 | let migration = TempDir::new().expect("migration fixture root"); |
| 42 | let sealed_home = migration.path().join("home"); |
| 43 | let primary_home = sealed_home.join(".codewhale"); |
| 44 | fs::create_dir_all(&primary_home).expect("create primary home"); |
| 45 | fs::write( |
| 46 | primary_home.join("config.toml"), |
| 47 | "provider = \"openrouter\"\n", |
| 48 | ) |
| 49 | .expect("write valid config"); |
| 50 | let legacy_store = sealed_home |
| 51 | .join(".deepseek") |
| 52 | .join("secrets") |
| 53 | .join("secrets.json"); |
| 54 | FileKeyringStore::new(&legacy_store) |
| 55 | .set("openrouter", SENTINEL) |
| 56 | .expect("seed synthetic legacy credential"); |
| 57 | let legacy_before = fs::read(&legacy_store).expect("read legacy fixture"); |
| 58 | let primary_store = primary_home.join("secrets").join("secrets.json"); |
| 59 | |
| 60 | let mut command = isolated_command(&sealed_home, None); |
| 61 | command.args(["auth", "print-api-key", "--provider", "openrouter"]); |
| 62 | let output = output_with_terminal_stdout(command); |
| 63 | assert!(!output.status.success(), "terminal handoff must refuse"); |
| 64 | assert_eq!(String::from_utf8_lossy(&output.stderr), TERMINAL_ERROR); |
| 65 | assert_eq!( |
| 66 | fs::read(&legacy_store).expect("legacy store remains readable"), |
| 67 | legacy_before, |
| 68 | "terminal preflight must not rewrite the legacy credential store" |
| 69 | ); |
| 70 | assert!( |
| 71 | !primary_store.exists(), |
| 72 | "terminal preflight must not migrate a legacy credential" |
| 73 | ); |
| 74 | } |
| 75 | |
| 76 | #[test] |
| 77 | fn initialization_failures_are_source_free() { |
| 78 | let fixture = TempDir::new().expect("fixture root"); |
| 79 | let sealed_home = fixture.path().join("home"); |
| 80 | let codewhale_home = fixture.path().join(format!("codewhale-home-{SENTINEL}")); |
| 81 | fs::create_dir_all(&codewhale_home).expect("create Codewhale home"); |
| 82 | fs::write( |
| 83 | codewhale_home.join("config.toml"), |
| 84 | format!("invalid = [{SENTINEL}\n"), |
| 85 | ) |
| 86 | .expect("write malformed config trap"); |
| 87 | |
| 88 | let mut command = isolated_command(&sealed_home, Some(&codewhale_home)); |
| 89 | let output = command |
| 90 | .args([ |
| 91 | "--api-key", |
| 92 | SENTINEL, |
| 93 | "auth", |
| 94 | "print-api-key", |
| 95 | "--provider", |
| 96 | "openrouter", |
| 97 | ]) |
| 98 | .output() |
| 99 | .expect("run pipe handoff with malformed initialization"); |
| 100 | |
| 101 | assert!(!output.status.success()); |
| 102 | assert!(output.stdout.is_empty()); |
| 103 | assert_eq!( |
| 104 | String::from_utf8_lossy(&output.stderr), |
| 105 | "error: unavailable credential\n" |
| 106 | ); |
| 107 | assert!( |
| 108 | !String::from_utf8_lossy(&output.stderr).contains(SENTINEL), |
| 109 | "neither a secret nor a sentinel-bearing path may reach stderr" |
| 110 | ); |
| 111 | } |
| 112 | |
| 113 | #[test] |
| 114 | fn installed_unix_dispatcher_settles_a_closed_pipe_cleanly() { |
| 115 | let fixture = TempDir::new().expect("fixture root"); |
| 116 | let sealed_home = fixture.path().join("home"); |
| 117 | let codewhale_home = fixture.path().join("codewhale-home"); |
| 118 | let mut command = isolated_command(&sealed_home, Some(&codewhale_home)); |
| 119 | let output = command |
| 120 | .args([ |
| 121 | "--api-key", |
| 122 | SENTINEL, |
| 123 | "auth", |
| 124 | "print-api-key", |
| 125 | "--provider", |
| 126 | "openrouter", |
| 127 | ]) |
| 128 | .stdout(closed_pipe_writer()) |
| 129 | .output() |
| 130 | .expect("run installed dispatcher against a closed pipe"); |
| 131 | |
| 132 | assert!( |
| 133 | output.status.success(), |
| 134 | "closed consumer must be a clean settlement, not SIGPIPE or failure: {:?}\nstderr:\n{}", |
| 135 | output.status, |
| 136 | String::from_utf8_lossy(&output.stderr) |
| 137 | ); |
| 138 | assert!(output.stderr.is_empty()); |
| 139 | } |
| 140 | |
| 141 | fn isolated_command(home: &Path, codewhale_home: Option<&Path>) -> Command { |
| 142 | let mut command = Command::new(codewhale_binary()); |
| 143 | command |
| 144 | .env_clear() |
| 145 | .env("HOME", home) |
| 146 | .env("USERPROFILE", home) |
| 147 | .env("CODEWHALE_SECRET_BACKEND", "file") |
| 148 | .stdin(Stdio::null()) |
| 149 | .stderr(Stdio::piped()); |
| 150 | if let Some(codewhale_home) = codewhale_home { |
| 151 | command.env("CODEWHALE_HOME", codewhale_home); |
| 152 | } |
| 153 | command |
| 154 | } |
| 155 | |
| 156 | fn output_with_terminal_stdout(mut command: Command) -> Output { |
| 157 | let mut master_fd = -1; |
| 158 | let mut slave_fd = -1; |
| 159 | // SAFETY: `openpty` initializes both descriptors on success. Each is |
| 160 | // immediately transferred into exactly one `File`, which owns the close. |
| 161 | let result = unsafe { |
| 162 | libc::openpty( |
| 163 | &mut master_fd, |
| 164 | &mut slave_fd, |
| 165 | std::ptr::null_mut(), |
| 166 | std::ptr::null_mut(), |
| 167 | std::ptr::null_mut(), |
| 168 | ) |
| 169 | }; |
| 170 | assert_eq!(result, 0, "open a pseudo-terminal"); |
| 171 | // SAFETY: successful `openpty` returned two fresh owned descriptors. |
| 172 | let master = unsafe { File::from_raw_fd(master_fd) }; |
| 173 | // SAFETY: successful `openpty` returned two fresh owned descriptors. |
| 174 | let slave = unsafe { File::from_raw_fd(slave_fd) }; |
| 175 | let output = command |
| 176 | .stdout(Stdio::from(slave)) |
| 177 | .output() |
| 178 | .expect("run dispatcher with terminal stdout"); |
| 179 | drop(master); |
| 180 | output |
| 181 | } |
| 182 | |
| 183 | fn closed_pipe_writer() -> Stdio { |
| 184 | let mut descriptors = [-1; 2]; |
| 185 | // SAFETY: `pipe` initializes both descriptors on success. |
| 186 | let result = unsafe { libc::pipe(descriptors.as_mut_ptr()) }; |
| 187 | assert_eq!(result, 0, "create pipe fixture"); |
| 188 | // No reader exists before the child starts, so its first write |
| 189 | // deterministically receives EPIPE (or SIGPIPE if the guard regresses). |
| 190 | // SAFETY: `descriptors[0]` is a fresh descriptor and is closed once here. |
| 191 | assert_eq!(unsafe { libc::close(descriptors[0]) }, 0); |
| 192 | // SAFETY: `descriptors[1]` is the remaining fresh owned descriptor. |
| 193 | let writer = unsafe { File::from_raw_fd(descriptors[1]) }; |
| 194 | Stdio::from(writer) |
| 195 | } |
| 196 | |
| 197 | fn codewhale_binary() -> PathBuf { |
| 198 | if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale") { |
| 199 | return PathBuf::from(path); |
| 200 | } |
| 201 | if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale") { |
| 202 | return PathBuf::from(path); |
| 203 | } |
| 204 | |
| 205 | let mut path = std::env::current_exe().expect("current test executable path"); |
| 206 | path.pop(); |
| 207 | if path.ends_with("deps") { |
| 208 | path.pop(); |
| 209 | } |
| 210 | path.push(format!("codewhale{}", std::env::consts::EXE_SUFFIX)); |
| 211 | path |
| 212 | } |
| 213 |