| 1 | //! The WebSocket listener. |
| 2 | //! |
| 3 | //! Spike scope: prove the *security-relevant* path end to end (bind, Origin, |
| 4 | //! Host, token, pairing) and echo afterwards. Attaching `tmux -C` replaces the |
| 5 | //! echo loop later and changes none of the code above it. |
| 6 | |
| 7 | use std::net::{IpAddr, Ipv4Addr, SocketAddr}; |
| 8 | use std::sync::Arc; |
| 9 | use std::sync::atomic::{AtomicUsize, Ordering}; |
| 10 | use std::time::{Duration, Instant}; |
| 11 | |
| 12 | use futures_util::{SinkExt, StreamExt}; |
| 13 | use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; |
| 14 | use tokio::net::{TcpListener, TcpStream}; |
| 15 | use tokio::sync::{Mutex, mpsc}; |
| 16 | use tokio_tungstenite::WebSocketStream; |
| 17 | use tokio_tungstenite::tungstenite::Message; |
| 18 | use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response}; |
| 19 | use tokio_tungstenite::tungstenite::http; |
| 20 | |
| 21 | use crate::auth::{self, Denied}; |
| 22 | |
| 23 | #[derive(Clone)] |
| 24 | pub struct Config { |
| 25 | pub token: String, |
| 26 | pub paired_origins: Vec<String>, |
| 27 | /// How long a client has to send its auth frame after the upgrade. |
| 28 | pub auth_timeout: Duration, |
| 29 | pub max_failures: u32, |
| 30 | pub lockout: Duration, |
| 31 | /// What we run in the pty. Fixed by the daemon, never client-supplied. |
| 32 | pub profile: crate::pty::Profile, |
| 33 | /// Session used when the client doesn't name one. |
| 34 | pub default_session: String, |
| 35 | /// When the client names no session and exactly one tmux session already |
| 36 | /// exists, attach to that one instead of `default_session`. Off when the |
| 37 | /// user pinned a session on the command line — an explicit `--session` is |
| 38 | /// a choice, not a default to be second-guessed. |
| 39 | pub adopt_sole_session: bool, |
| 40 | /// Skip the pty and echo frames back. Used by the security tests so they |
| 41 | /// exercise the auth path without spawning shells. |
| 42 | pub echo_only: bool, |
| 43 | /// TLS identity. When present the listener serves wss:// and ws:// on the |
| 44 | /// same port, chosen per-connection by sniffing the first byte. |
| 45 | pub tls: Option<tokio_rustls::TlsAcceptor>, |
| 46 | /// Exit once no client has been connected for this long. `None` runs |
| 47 | /// forever, which is what a hand-started daemon wants. |
| 48 | /// |
| 49 | /// Only sane because tmux holds the sessions: exiting drops no state, and |
| 50 | /// under socket activation the next connection starts us again. |
| 51 | pub idle_timeout: Option<Duration>, |
| 52 | } |
| 53 | |
| 54 | impl Config { |
| 55 | pub fn new(token: impl Into<String>, paired_origins: Vec<String>) -> Self { |
| 56 | Self { |
| 57 | token: token.into(), |
| 58 | paired_origins, |
| 59 | auth_timeout: Duration::from_secs(3), |
| 60 | max_failures: 10, |
| 61 | lockout: Duration::from_secs(30), |
| 62 | profile: crate::pty::Profile::default(), |
| 63 | default_session: crate::pty::DEFAULT_SESSION.to_string(), |
| 64 | // Opt-in: a caller that hands us a profile has picked its session, |
| 65 | // and only the CLI knows whether the user picked it or we did. |
| 66 | adopt_sole_session: false, |
| 67 | echo_only: false, |
| 68 | tls: None, |
| 69 | idle_timeout: None, |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | /// The lone existing session to join instead of the profile's own, if |
| 74 | /// adoption is on and tmux is holding exactly one. |
| 75 | /// |
| 76 | /// Resolved per connection rather than at startup: sessions come and go |
| 77 | /// while the daemon runs, and the answer should reflect what tmux holds at |
| 78 | /// the moment the sidebar connects. `None` means "use the profile as |
| 79 | /// configured". |
| 80 | pub fn adopted_session(&self) -> Option<String> { |
| 81 | if !self.adopt_sole_session || self.profile.program != "tmux" { |
| 82 | return None; |
| 83 | } |
| 84 | crate::pty::sole_session(&self.profile.tmux_global_args()) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 89 | pub enum Event { |
| 90 | Accepted { |
| 91 | origin: String, |
| 92 | }, |
| 93 | /// `idle_timeout` elapsed with nobody connected. The caller is expected to |
| 94 | /// stop the daemon; the listener keeps running until it does. |
| 95 | Idle, |
| 96 | Rejected { |
| 97 | origin: Option<String>, |
| 98 | why: Denied, |
| 99 | /// Underlying cause, when the failure happened below our own checks |
| 100 | /// (e.g. the request never was a WebSocket upgrade). |
| 101 | detail: Option<String>, |
| 102 | }, |
| 103 | } |
| 104 | |
| 105 | struct Failures { |
| 106 | count: u32, |
| 107 | locked_until: Option<tokio::time::Instant>, |
| 108 | } |
| 109 | |
| 110 | struct State { |
| 111 | config: Config, |
| 112 | failures: Mutex<Failures>, |
| 113 | events: mpsc::UnboundedSender<Event>, |
| 114 | /// Connections currently being served, and when the count last hit zero. |
| 115 | /// Only read by the idle watchdog. |
| 116 | live: AtomicUsize, |
| 117 | quiet_since: std::sync::Mutex<Instant>, |
| 118 | } |
| 119 | |
| 120 | /// Holds the live-connection count up for as long as one connection is being |
| 121 | /// served. Dropping it restarts the idle clock, so the timeout measures time |
| 122 | /// since the *last* client left rather than since startup. |
| 123 | struct Live(Arc<State>); |
| 124 | |
| 125 | impl Live { |
| 126 | fn new(state: &Arc<State>) -> Self { |
| 127 | state.live.fetch_add(1, Ordering::SeqCst); |
| 128 | Live(Arc::clone(state)) |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | impl Drop for Live { |
| 133 | fn drop(&mut self) { |
| 134 | if self.0.live.fetch_sub(1, Ordering::SeqCst) == 1 { |
| 135 | *self.0.quiet_since.lock().expect("not poisoned") = Instant::now(); |
| 136 | } |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | impl State { |
| 141 | async fn locked_out(&self) -> bool { |
| 142 | let f = self.failures.lock().await; |
| 143 | match f.locked_until { |
| 144 | Some(t) => tokio::time::Instant::now() < t, |
| 145 | None => false, |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | async fn record_failure(&self) { |
| 150 | let mut f = self.failures.lock().await; |
| 151 | f.count += 1; |
| 152 | if f.count >= self.config.max_failures { |
| 153 | f.locked_until = Some(tokio::time::Instant::now() + self.config.lockout); |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | async fn record_success(&self) { |
| 158 | let mut f = self.failures.lock().await; |
| 159 | f.count = 0; |
| 160 | f.locked_until = None; |
| 161 | } |
| 162 | |
| 163 | fn emit(&self, e: Event) { |
| 164 | let _ = self.events.send(e); |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | pub struct Server { |
| 169 | addr: SocketAddr, |
| 170 | events: mpsc::UnboundedReceiver<Event>, |
| 171 | _task: tokio::task::JoinHandle<()>, |
| 172 | } |
| 173 | |
| 174 | impl Server { |
| 175 | /// Bind and start accepting. |
| 176 | /// |
| 177 | /// The bind address is hardcoded to loopback and is not configurable. This |
| 178 | /// daemon hands out shell access; a `--bind` flag would be a footgun whose |
| 179 | /// only purpose is to create a remotely exploitable configuration. |
| 180 | pub async fn start(config: Config, port: u16) -> std::io::Result<Self> { |
| 181 | let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); |
| 182 | Self::start_on(config, TcpListener::bind(addr).await?) |
| 183 | } |
| 184 | |
| 185 | /// Serve on a socket someone else bound — in practice the one systemd |
| 186 | /// handed us via socket activation (see [`crate::activation`]). |
| 187 | /// |
| 188 | /// The listener must already be non-blocking; `TcpListener::from_std` |
| 189 | /// requires it. |
| 190 | pub fn from_std(config: Config, listener: std::net::TcpListener) -> std::io::Result<Self> { |
| 191 | Self::start_on(config, TcpListener::from_std(listener)?) |
| 192 | } |
| 193 | |
| 194 | fn start_on(config: Config, listener: TcpListener) -> std::io::Result<Self> { |
| 195 | let addr = listener.local_addr()?; |
| 196 | |
| 197 | // Belt and braces: if this ever regresses, fail loudly at startup |
| 198 | // rather than quietly listening on the network. Also the last line of |
| 199 | // defence for an activated socket, whose address came from a unit file. |
| 200 | assert!( |
| 201 | addr.ip().is_loopback(), |
| 202 | "refusing to serve on non-loopback address {addr}" |
| 203 | ); |
| 204 | |
| 205 | let (tx, rx) = mpsc::unbounded_channel(); |
| 206 | let idle_timeout = config.idle_timeout; |
| 207 | let state = Arc::new(State { |
| 208 | config, |
| 209 | failures: Mutex::new(Failures { |
| 210 | count: 0, |
| 211 | locked_until: None, |
| 212 | }), |
| 213 | events: tx, |
| 214 | live: AtomicUsize::new(0), |
| 215 | quiet_since: std::sync::Mutex::new(Instant::now()), |
| 216 | }); |
| 217 | |
| 218 | if let Some(timeout) = idle_timeout { |
| 219 | tokio::spawn(watch_idle(Arc::clone(&state), timeout)); |
| 220 | } |
| 221 | |
| 222 | let task = tokio::spawn(async move { |
| 223 | loop { |
| 224 | let Ok((stream, peer)) = listener.accept().await else { |
| 225 | continue; |
| 226 | }; |
| 227 | let state = Arc::clone(&state); |
| 228 | tokio::spawn(async move { |
| 229 | let _live = Live::new(&state); |
| 230 | let _ = handle_conn(stream, peer, state).await; |
| 231 | }); |
| 232 | } |
| 233 | }); |
| 234 | |
| 235 | Ok(Server { |
| 236 | addr, |
| 237 | events: rx, |
| 238 | _task: task, |
| 239 | }) |
| 240 | } |
| 241 | |
| 242 | pub fn addr(&self) -> SocketAddr { |
| 243 | self.addr |
| 244 | } |
| 245 | |
| 246 | pub fn url(&self) -> String { |
| 247 | format!("ws://{}", self.addr) |
| 248 | } |
| 249 | |
| 250 | pub async fn next_event(&mut self) -> Option<Event> { |
| 251 | self.events.recv().await |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | /// Emit [`Event::Idle`] once nothing has been connected for `timeout`. |
| 256 | /// |
| 257 | /// Fires at most once: after that the caller is shutting down, and a second |
| 258 | /// notice would only race the exit. |
| 259 | async fn watch_idle(state: Arc<State>, timeout: Duration) { |
| 260 | // Poll rather than wake on the transition: a client that connects and |
| 261 | // leaves during the wait has to restart the clock, and a coarse tick keeps |
| 262 | // that logic in one place. A second of overshoot on a five-minute timeout |
| 263 | // costs nothing. |
| 264 | let tick = (timeout / 10).clamp(Duration::from_millis(50), Duration::from_secs(5)); |
| 265 | loop { |
| 266 | tokio::time::sleep(tick).await; |
| 267 | if state.live.load(Ordering::SeqCst) > 0 { |
| 268 | continue; |
| 269 | } |
| 270 | let quiet = state.quiet_since.lock().expect("not poisoned").elapsed(); |
| 271 | if quiet >= timeout { |
| 272 | state.emit(Event::Idle); |
| 273 | return; |
| 274 | } |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | fn deny(why: Denied) -> ErrorResponse { |
| 279 | http::Response::builder() |
| 280 | .status(why.status()) |
| 281 | .body(Some(why.reason().to_string())) |
| 282 | .expect("static response builds") |
| 283 | } |
| 284 | |
| 285 | async fn handle_conn( |
| 286 | stream: TcpStream, |
| 287 | _peer: SocketAddr, |
| 288 | state: Arc<State>, |
| 289 | ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { |
| 290 | if state.locked_out().await { |
| 291 | state.emit(Event::Rejected { |
| 292 | origin: None, |
| 293 | why: Denied::RateLimited, |
| 294 | detail: None, |
| 295 | }); |
| 296 | // Still perform the reject through the handshake so the client sees 429. |
| 297 | // `ErrorResponse` is tungstenite's `Callback::on_request` return type, not |
| 298 | // ours to box. |
| 299 | #[allow(clippy::result_large_err)] |
| 300 | let _ = tokio_tungstenite::accept_hdr_async(stream, |_: &Request, _| { |
| 301 | Err(deny(Denied::RateLimited)) |
| 302 | }) |
| 303 | .await; |
| 304 | return Ok(()); |
| 305 | } |
| 306 | |
| 307 | // A TLS ClientHello starts with 0x16 (handshake record). Sniffing it lets |
| 308 | // one port serve both wss:// and ws://, which matters because Firefox's |
| 309 | // HTTPS-Only Mode rewrites ws:// to wss:// and the user should not have to |
| 310 | // weaken a browser security setting to run this. |
| 311 | let mut first = [0u8; 1]; |
| 312 | let is_tls = matches!(stream.peek(&mut first).await, Ok(1) if first[0] == 0x16); |
| 313 | |
| 314 | if is_tls { |
| 315 | let Some(acceptor) = state.config.tls.clone() else { |
| 316 | state.record_failure().await; |
| 317 | state.emit(Event::Rejected { |
| 318 | origin: None, |
| 319 | why: Denied::TlsAttempted, |
| 320 | detail: Some("this daemon was started without a TLS identity".into()), |
| 321 | }); |
| 322 | return Ok(()); |
| 323 | }; |
| 324 | let tls_stream = match acceptor.accept(stream).await { |
| 325 | Ok(s) => s, |
| 326 | Err(e) => { |
| 327 | // Almost always the browser refusing our self-signed cert. |
| 328 | state.emit(Event::Rejected { |
| 329 | origin: None, |
| 330 | why: Denied::TlsHandshakeFailed, |
| 331 | detail: Some(e.to_string()), |
| 332 | }); |
| 333 | return Ok(()); |
| 334 | } |
| 335 | }; |
| 336 | return dispatch(tls_stream, state).await; |
| 337 | } |
| 338 | |
| 339 | dispatch(stream, state).await |
| 340 | } |
| 341 | |
| 342 | /// Serve one already-negotiated stream: either the certificate-trust landing |
| 343 | /// page, or the WebSocket handshake. |
| 344 | async fn dispatch<S>( |
| 345 | mut stream: S, |
| 346 | state: Arc<State>, |
| 347 | ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> |
| 348 | where |
| 349 | S: AsyncRead + AsyncWrite + Unpin + Send + 'static, |
| 350 | { |
| 351 | // Look at the request head so a browser that navigated here to accept the |
| 352 | // certificate gets an explanation instead of a protocol error. |
| 353 | let head = crate::rewind::read_head(&mut stream, 8192) |
| 354 | .await |
| 355 | .unwrap_or_default(); |
| 356 | if !crate::rewind::is_websocket_upgrade(&head) { |
| 357 | state.emit(Event::Rejected { |
| 358 | origin: None, |
| 359 | why: Denied::NotAWebSocketUpgrade, |
| 360 | detail: Some("served the certificate-trust page".into()), |
| 361 | }); |
| 362 | let _ = stream.write_all(LANDING_PAGE.as_bytes()).await; |
| 363 | let _ = stream.flush().await; |
| 364 | return Ok(()); |
| 365 | } |
| 366 | let stream = crate::rewind::Rewind::new(head, stream); |
| 367 | handshake(stream, state).await |
| 368 | } |
| 369 | |
| 370 | const LANDING_PAGE: &str = concat!( |
| 371 | "HTTP/1.1 200 OK\r\n", |
| 372 | "Content-Type: text/html; charset=utf-8\r\n", |
| 373 | "Connection: close\r\n", |
| 374 | "Cache-Control: no-store\r\n", |
| 375 | "\r\n", |
| 376 | "<!doctype html><meta charset=utf-8><title>termbridge</title>", |
| 377 | "<style>body{font:15px/1.6 system-ui,sans-serif;max-width:34rem;margin:12vh auto;", |
| 378 | "padding:0 1.5rem;background:#14161a;color:#d7dae0}h1{font-size:1.1rem}", |
| 379 | "code{background:#1c1f25;padding:.15em .4em;border-radius:3px;font-size:.9em}", |
| 380 | "p{color:#9aa3b2}</style>", |
| 381 | "<h1>termbridge is running</h1>", |
| 382 | "<p>If you reached this page to accept the certificate, you're done \u{2014} ", |
| 383 | "close this tab and reconnect the sidebar.</p>", |
| 384 | "<p>This endpoint serves the terminal over WebSocket only. It exposes no ", |
| 385 | "other data, and in particular it never hands out the auth token.</p>" |
| 386 | ); |
| 387 | |
| 388 | async fn handshake<S>( |
| 389 | stream: S, |
| 390 | state: Arc<State>, |
| 391 | ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> |
| 392 | where |
| 393 | S: AsyncRead + AsyncWrite + Unpin + Send + 'static, |
| 394 | { |
| 395 | let seen_origin: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None)); |
| 396 | let handshake_err: Arc<std::sync::Mutex<Option<Denied>>> = |
| 397 | Arc::new(std::sync::Mutex::new(None)); |
| 398 | |
| 399 | let paired = state.config.paired_origins.clone(); |
| 400 | let so = Arc::clone(&seen_origin); |
| 401 | let he = Arc::clone(&handshake_err); |
| 402 | |
| 403 | // `ErrorResponse` is tungstenite's `Callback::on_request` return type, not |
| 404 | // ours to box. |
| 405 | #[allow(clippy::result_large_err)] |
| 406 | let ws = tokio_tungstenite::accept_hdr_async(stream, move |req: &Request, res: Response| { |
| 407 | let header = |name: &str| { |
| 408 | req.headers() |
| 409 | .get(name) |
| 410 | .and_then(|v| v.to_str().ok()) |
| 411 | .map(str::to_string) |
| 412 | }; |
| 413 | let origin = header("origin"); |
| 414 | let host = header("host"); |
| 415 | *so.lock().unwrap() = origin.clone(); |
| 416 | |
| 417 | match auth::check_handshake(origin.as_deref(), host.as_deref(), &paired) { |
| 418 | Ok(()) => Ok(res), |
| 419 | Err(why) => { |
| 420 | *he.lock().unwrap() = Some(why); |
| 421 | Err(deny(why)) |
| 422 | } |
| 423 | } |
| 424 | }) |
| 425 | .await; |
| 426 | |
| 427 | let origin = seen_origin.lock().unwrap().clone(); |
| 428 | let hs_err = *handshake_err.lock().unwrap(); |
| 429 | |
| 430 | let mut ws = match ws { |
| 431 | Ok(ws) => ws, |
| 432 | Err(e) => { |
| 433 | // If hs_err is set, we rejected it deliberately. If it is not, the |
| 434 | // handshake failed before our callback ever ran — which means the |
| 435 | // request was not a valid WebSocket upgrade at all. Reporting that |
| 436 | // as an auth problem sends people hunting for the wrong bug. |
| 437 | let (why, detail) = match hs_err { |
| 438 | Some(why) => (why, None), |
| 439 | None => (Denied::NotAWebSocketUpgrade, Some(e.to_string())), |
| 440 | }; |
| 441 | state.record_failure().await; |
| 442 | state.emit(Event::Rejected { |
| 443 | origin, |
| 444 | why, |
| 445 | detail, |
| 446 | }); |
| 447 | return Ok(()); |
| 448 | } |
| 449 | }; |
| 450 | |
| 451 | // Upgrade succeeded; now the auth frame, on a deadline. |
| 452 | let first = tokio::time::timeout(state.config.auth_timeout, ws.next()).await; |
| 453 | |
| 454 | let result = match first { |
| 455 | Err(_) => Err(Denied::AuthTimeout), |
| 456 | Ok(None) => Err(Denied::AuthTimeout), |
| 457 | Ok(Some(Err(_))) => Err(Denied::MalformedAuth), |
| 458 | Ok(Some(Ok(Message::Text(t)))) => auth::check_auth_frame(&t, &state.config.token), |
| 459 | Ok(Some(Ok(_))) => Err(Denied::MalformedAuth), |
| 460 | }; |
| 461 | |
| 462 | if let Err(why) = result { |
| 463 | state.record_failure().await; |
| 464 | state.emit(Event::Rejected { |
| 465 | origin, |
| 466 | why, |
| 467 | detail: None, |
| 468 | }); |
| 469 | let _ = ws |
| 470 | .send(Message::Text( |
| 471 | serde_json::json!({"type": "error", "reason": why.reason()}) |
| 472 | .to_string() |
| 473 | .into(), |
| 474 | )) |
| 475 | .await; |
| 476 | let _ = ws.close(None).await; |
| 477 | return Ok(()); |
| 478 | } |
| 479 | |
| 480 | state.record_success().await; |
| 481 | state.emit(Event::Accepted { |
| 482 | origin: origin.clone().unwrap_or_default(), |
| 483 | }); |
| 484 | |
| 485 | let is_tmux = state.config.profile.program == "tmux"; |
| 486 | ws.send(Message::Text( |
| 487 | serde_json::json!({ |
| 488 | "type": "ok", |
| 489 | "profile": state.config.profile.program, |
| 490 | "tmux": is_tmux, |
| 491 | "defaultSession": state.config.adopted_session() |
| 492 | .unwrap_or_else(|| state.config.default_session.clone()), |
| 493 | // Enumerated server-side; the client picks from reality rather |
| 494 | // than inventing names. |
| 495 | "sessions": if is_tmux { crate::pty::list_sessions(&state.config.profile.tmux_global_args()) } else { Vec::new() }, |
| 496 | // Here rather than in the status frames, which are for what tmux |
| 497 | // is doing right now: this list comes from files on disk that |
| 498 | // change between sessions, not during one, and re-sending it every |
| 499 | // tick would be a hundred names repeated for nothing. A reconnect |
| 500 | // picks up an edited ssh config. |
| 501 | "hosts": if is_tmux { crate::ssh::hosts() } else { Vec::new() }, |
| 502 | }) |
| 503 | .to_string() |
| 504 | .into(), |
| 505 | )) |
| 506 | .await?; |
| 507 | |
| 508 | if state.config.echo_only { |
| 509 | return echo_loop(ws).await; |
| 510 | } |
| 511 | pty_loop(ws, &state.config).await |
| 512 | } |
| 513 | |
| 514 | /// Post-auth session: binary frames are raw terminal bytes in both directions, |
| 515 | /// text frames are JSON control messages. |
| 516 | async fn pty_loop<S>( |
| 517 | mut ws: WebSocketStream<S>, |
| 518 | config: &Config, |
| 519 | ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> |
| 520 | where |
| 521 | S: AsyncRead + AsyncWrite + Unpin + Send + 'static, |
| 522 | { |
| 523 | // Wait for the client to tell us its size before spawning, so the shell's |
| 524 | // first prompt is drawn at the right width. |
| 525 | let (cols, rows, requested) = |
| 526 | match tokio::time::timeout(Duration::from_secs(5), ws.next()).await { |
| 527 | Ok(Some(Ok(Message::Text(t)))) => parse_open(&t).unwrap_or((80, 24, None)), |
| 528 | _ => (80, 24, None), |
| 529 | }; |
| 530 | |
| 531 | // The client may name a tmux session, but only a name that survives |
| 532 | // validation, and only when we are actually running tmux. Anything else |
| 533 | // silently falls back to the configured default rather than erroring — |
| 534 | // there is no path here from client input to an arbitrary program. |
| 535 | let profile = if config.profile.program == "tmux" { |
| 536 | match requested { |
| 537 | Some(name) => config.profile.with_session(&name), |
| 538 | None => match config.adopted_session() { |
| 539 | Some(only) => config.profile.with_session(&only), |
| 540 | None => config.profile.clone(), |
| 541 | }, |
| 542 | } |
| 543 | } else { |
| 544 | config.profile.clone() |
| 545 | }; |
| 546 | let profile = &profile; |
| 547 | |
| 548 | let spawned = match crate::pty::PtySession::spawn(profile, cols, rows) { |
| 549 | Ok(s) => s, |
| 550 | Err(e) => { |
| 551 | let _ = ws |
| 552 | .send(Message::Text( |
| 553 | serde_json::json!({"type": "error", "reason": e.to_string()}) |
| 554 | .to_string() |
| 555 | .into(), |
| 556 | )) |
| 557 | .await; |
| 558 | return Ok(()); |
| 559 | } |
| 560 | }; |
| 561 | |
| 562 | let crate::pty::Spawned { |
| 563 | session, |
| 564 | mut output, |
| 565 | mut exit, |
| 566 | } = spawned; |
| 567 | |
| 568 | // tmux is the source of truth for which session we're on, what else exists, |
| 569 | // and what Claude Code is doing in it — and all of that changes without |
| 570 | // anything crossing this socket. A control-mode client in its own task both |
| 571 | // watches for those changes and carries the sidebar's requests back. |
| 572 | let (status_tx, mut status_rx) = mpsc::channel::<String>(8); |
| 573 | let (tmux_tx, tmux_rx) = mpsc::channel::<TmuxRequest>(8); |
| 574 | // The same channel, for the frames this loop answers itself rather than |
| 575 | // getting from tmux — see the `path` query below. |
| 576 | let frames_tx = status_tx.clone(); |
| 577 | if profile.program == "tmux" { |
| 578 | // The session the interactive client was pointed at. Only used to give |
| 579 | // the control client something to attach to; from then on tmux tells us |
| 580 | // where that client actually is. |
| 581 | let started_on = profile |
| 582 | .args |
| 583 | .iter() |
| 584 | .position(|a| a == "-s") |
| 585 | .and_then(|i| profile.args.get(i + 1)) |
| 586 | .cloned() |
| 587 | .unwrap_or_else(|| config.default_session.clone()); |
| 588 | tokio::spawn(control_loop( |
| 589 | started_on, |
| 590 | profile.tmux_global_args(), |
| 591 | session.tty_name(), |
| 592 | status_tx, |
| 593 | tmux_rx, |
| 594 | )); |
| 595 | } |
| 596 | |
| 597 | loop { |
| 598 | tokio::select! { |
| 599 | // pty -> browser |
| 600 | chunk = output.recv() => { |
| 601 | match chunk { |
| 602 | Some(bytes) => ws.send(Message::Binary(bytes.into())).await?, |
| 603 | None => { |
| 604 | // EOF on the pty beats the child being reaped, so wait |
| 605 | // briefly for the status rather than dropping the |
| 606 | // sidebar with no explanation. |
| 607 | let code = tokio::time::timeout(Duration::from_secs(2), &mut exit) |
| 608 | .await |
| 609 | .ok() |
| 610 | .and_then(|r| r.ok()) |
| 611 | .unwrap_or(-1); |
| 612 | let _ = ws.send(Message::Text( |
| 613 | serde_json::json!({"type": "exit", "code": code}).to_string().into() |
| 614 | )).await; |
| 615 | break; |
| 616 | } |
| 617 | } |
| 618 | } |
| 619 | // browser -> pty |
| 620 | msg = ws.next() => { |
| 621 | match msg { |
| 622 | Some(Ok(Message::Binary(b))) => { |
| 623 | if !session.write(b.to_vec()) { break; } |
| 624 | } |
| 625 | // Text is control only. Keystrokes must arrive as binary so |
| 626 | // that non-UTF-8 input is never mangled. |
| 627 | Some(Ok(Message::Text(t))) => { |
| 628 | if let Some((c, r)) = parse_resize(&t) { |
| 629 | let _ = session.resize(c, r); |
| 630 | } |
| 631 | if let Some(req) = parse_tmux_request(&t) { |
| 632 | // Full queue means the control client is wedged; |
| 633 | // dropping the request beats stalling the terminal. |
| 634 | let _ = tmux_tx.try_send(req); |
| 635 | } |
| 636 | if let Some(q) = parse_path_query(&t) { |
| 637 | // On a thread of its own: a directory on a stalled |
| 638 | // network mount would otherwise hold up the pty |
| 639 | // this loop is also pumping. The answer comes back |
| 640 | // through the frame channel like any other. |
| 641 | let tx = frames_tx.clone(); |
| 642 | tokio::task::spawn_blocking(move || { |
| 643 | let _ = tx.blocking_send(path_answer(&q)); |
| 644 | }); |
| 645 | } |
| 646 | } |
| 647 | Some(Ok(Message::Close(_))) | None => break, |
| 648 | Some(Err(_)) => break, |
| 649 | _ => {} |
| 650 | } |
| 651 | } |
| 652 | Some(json) = status_rx.recv() => { |
| 653 | ws.send(Message::Text(json.into())).await?; |
| 654 | } |
| 655 | code = &mut exit => { |
| 656 | let code = code.unwrap_or(-1); |
| 657 | let _ = ws.send(Message::Text( |
| 658 | serde_json::json!({"type": "exit", "code": code}).to_string().into() |
| 659 | )).await; |
| 660 | break; |
| 661 | } |
| 662 | } |
| 663 | } |
| 664 | |
| 665 | // Dropping the session kills the tmux *client*. The tmux server, and the |
| 666 | // session itself, keep running for the next attach. |
| 667 | drop(session); |
| 668 | let _ = ws.close(None).await; |
| 669 | Ok(()) |
| 670 | } |
| 671 | |
| 672 | /// The complete set of things the sidebar may ask tmux to do. |
| 673 | /// |
| 674 | /// An enum rather than a command string, so the wire protocol cannot express |
| 675 | /// anything outside this list. Each variant's payload is validated at parse |
| 676 | /// time, and the command lines built from them are the only ones in the daemon |
| 677 | /// that contain client-supplied text. |
| 678 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 679 | pub enum TmuxRequest { |
| 680 | /// Move the live client to an existing session — no reconnect, no new pty. |
| 681 | Switch(String), |
| 682 | /// Create a detached session, then switch to it. |
| 683 | Create(String), |
| 684 | /// Bring a pane into view: select it, its window, and its session. |
| 685 | Focus(String), |
| 686 | /// Make a window of the attached session the current one — the sidebar's |
| 687 | /// tab click. Windows belong to the session, not to a client, so this |
| 688 | /// deliberately moves every client watching that session, exactly as |
| 689 | /// pressing `prefix 2` in the terminal would. |
| 690 | SelectWindow(String), |
| 691 | /// Select a window *and* bring our client to its session — the sidebar's |
| 692 | /// tab click in Tab Group mode, where the row holds windows from every |
| 693 | /// session at once and clicking one has to cross the session boundary. |
| 694 | /// |
| 695 | /// `Focus` is the same move addressed by pane; this one is addressed by |
| 696 | /// window, because a tab is a window and the pane it lands on is whichever |
| 697 | /// one that window last had selected. |
| 698 | GotoWindow(String), |
| 699 | /// Open a window in a session and select it — the sidebar's "+". |
| 700 | NewWindow(String), |
| 701 | /// Open a window running Claude on a prompt — the omnibar's "send to |
| 702 | /// Claude", which is what the box does with text that names nothing. |
| 703 | /// |
| 704 | /// The window starts in the session's own working directory rather than in |
| 705 | /// `$HOME`: the point of asking from *this* session is that the answer is |
| 706 | /// about the code this session is sitting in. |
| 707 | /// |
| 708 | /// The prompt is the one piece of free text the wire protocol carries. It |
| 709 | /// never reaches a tmux command line — see [`command_lines`]. |
| 710 | Claude { session: String, prompt: String }, |
| 711 | /// Open a window running a shell command — the omnibar's `!`, which is what |
| 712 | /// the box does with text that names nothing and was meant for a shell |
| 713 | /// rather than for Claude. |
| 714 | /// |
| 715 | /// The same window as [`TmuxRequest::Claude`] in every other respect: the |
| 716 | /// session's own working directory, and the command in a script rather than |
| 717 | /// on the tmux command line. The window outlives the command — see |
| 718 | /// [`crate::paths::write_command_script`]. |
| 719 | Run { session: String, command: String }, |
| 720 | /// Start a session in a directory, making the directory if it is not there |
| 721 | /// yet — the omnibar's `~/Code/foo` rows. |
| 722 | /// |
| 723 | /// The one request that touches the filesystem before it touches tmux: the |
| 724 | /// `mkdir -p` happens in [`prepare`], so a path that cannot be created is |
| 725 | /// an error the panel is told about rather than a session standing in the |
| 726 | /// wrong place. `path` is already expanded and absolute by the time it is |
| 727 | /// in here — see [`crate::project::expand`]. |
| 728 | /// |
| 729 | /// With a prompt, the first pane runs Claude on it. Without one it is a |
| 730 | /// shell: the prompt is what says Claude was wanted. |
| 731 | NewProject { |
| 732 | path: String, |
| 733 | name: String, |
| 734 | prompt: Option<String>, |
| 735 | }, |
| 736 | /// Open a window connected to another machine — the omnibar's ssh rows. |
| 737 | /// |
| 738 | /// Local in every way that matters to this daemon: an ordinary tmux window |
| 739 | /// on this host, running ssh. The panel's tabs, drag, status frames and |
| 740 | /// session model know nothing about it, which is what makes it cheap. |
| 741 | /// |
| 742 | /// What it is *not* is a remote session. A Claude running on the far end |
| 743 | /// has no pane on this tmux server and its hooks cannot reach this |
| 744 | /// daemon's socket, so it will not appear in the tab strip's agent glyphs |
| 745 | /// — reaching that needs a daemon on the far end, not a window here. |
| 746 | Ssh { session: String, host: String }, |
| 747 | /// Put one window next to another — the sidebar's tab drag. |
| 748 | /// |
| 749 | /// A real `move-window`, not a display order: the panel's tabs and the |
| 750 | /// terminal's own status line show the same windows in the same order, and |
| 751 | /// `prefix 2` still selects the second tab afterwards. Both ends are window |
| 752 | /// ids, so the move is expressed relative to a window rather than to an |
| 753 | /// index that may have shifted since the drag started. |
| 754 | MoveWindow { |
| 755 | window: String, |
| 756 | /// The window to land beside. |
| 757 | target: String, |
| 758 | /// After `target` rather than before it. |
| 759 | after: bool, |
| 760 | }, |
| 761 | /// Move a window into another session — dropping a tab on a collapsed |
| 762 | /// group's chip in Tab Group mode, where the group has no visible window |
| 763 | /// to express the move against. |
| 764 | MoveWindowToSession { window: String, session: String }, |
| 765 | /// Move a window into a session that does not exist yet — dragging a tab |
| 766 | /// onto the row's "+", which is Chrome's "drag a tab out into a window of |
| 767 | /// its own" written for tmux. |
| 768 | /// |
| 769 | /// tmux has no one command for this: `move-window` needs a session to move |
| 770 | /// *to*, and `new-session` cannot adopt a window. So the session is made |
| 771 | /// first, with a placeholder window nothing runs in, and that placeholder |
| 772 | /// is killed once the real window is in. See [`command_lines`]. |
| 773 | NewSessionWithWindow { window: String, name: String }, |
| 774 | /// Set or clear a session's group colour, which the panel keeps in a tmux |
| 775 | /// user option so it survives a rename and every panel agrees on it. |
| 776 | /// |
| 777 | /// The session is named by id rather than by name for the usual reason — |
| 778 | /// an id cannot contain a quote or a space — and because a rename between |
| 779 | /// the click and the command would otherwise send the colour to whichever |
| 780 | /// session inherited the name. |
| 781 | SetSessionColor { |
| 782 | session: String, |
| 783 | /// `None` unsets the option, which is how the panel goes back to |
| 784 | /// picking a colour itself. |
| 785 | color: Option<String>, |
| 786 | }, |
| 787 | /// Rename a session — the group chip's context menu in Tab Group mode. |
| 788 | /// |
| 789 | /// By id for the same reason the colour is: the name is what is being |
| 790 | /// changed, so naming the target by it would race with anyone else |
| 791 | /// renaming the same session, and an id cannot carry a quote or a space. |
| 792 | /// |
| 793 | /// The new name goes through [`crate::pty::valid_session_name`], which is |
| 794 | /// the same gate the sidebar's session field already passes — so a rename |
| 795 | /// can only produce a name the panel could have created in the first place. |
| 796 | RenameSession { session: String, name: String }, |
| 797 | /// Close a window and everything running in it — the sidebar's tab ✕. |
| 798 | /// |
| 799 | /// The one entry here that destroys anything, and tmux has no undo for it. |
| 800 | /// The window id keeps the blast radius to exactly one window; the sidebar |
| 801 | /// additionally refuses to offer it for a session's last window, which |
| 802 | /// would take the session and our own client with it. |
| 803 | KillWindow(String), |
| 804 | } |
| 805 | |
| 806 | /// The name of the throwaway window a session is born with when it is being |
| 807 | /// made to hold a window that already exists. It lives for three commands and |
| 808 | /// nothing runs in it; the name only has to be one no real window will have, |
| 809 | /// and `valid_session_name` cannot produce it as a session name either. |
| 810 | const PLACEHOLDER_WINDOW: &str = "termbridge-placeholder"; |
| 811 | |
| 812 | /// A group colour: a hue in degrees, or `-1` for grey. |
| 813 | /// |
| 814 | /// Deliberately not "any short string". The value is written into a tmux |
| 815 | /// command line, and the sidebar only ever produces these, so anything else is |
| 816 | /// a client that is not our sidebar and gets nothing. |
| 817 | fn valid_group_color(raw: &str) -> Option<String> { |
| 818 | let s = raw.trim(); |
| 819 | if s == "-1" { |
| 820 | return Some(s.to_string()); |
| 821 | } |
| 822 | let n: u16 = s.parse().ok()?; |
| 823 | (n < 360).then(|| n.to_string()) |
| 824 | } |
| 825 | |
| 826 | /// The omnibar asking about one directory: `{"type":"path","q":"~/Code"}`. |
| 827 | /// |
| 828 | /// The only client frame that gets an answer rather than an effect. It is a |
| 829 | /// question about the daemon's own filesystem, so it is deliberately not a |
| 830 | /// `TmuxRequest` — nothing about it reaches tmux, and it must not be able to. |
| 831 | fn parse_path_query(text: &str) -> Option<String> { |
| 832 | let v: serde_json::Value = serde_json::from_str(text).ok()?; |
| 833 | if v.get("type")?.as_str()? != "path" { |
| 834 | return None; |
| 835 | } |
| 836 | let q = v.get("q")?.as_str()?; |
| 837 | (q.len() <= crate::project::MAX_PATH).then(|| q.to_string()) |
| 838 | } |
| 839 | |
| 840 | /// The answer frame. `q` is echoed back so the panel can drop a reply that |
| 841 | /// arrives after the box has moved on — the queries are one per directory |
| 842 | /// typed, and they can land out of order. |
| 843 | fn path_answer(q: &str) -> String { |
| 844 | match crate::project::list(q) { |
| 845 | Some(l) => serde_json::json!({ |
| 846 | "type": "path", |
| 847 | "q": q, |
| 848 | "path": l.path.to_string_lossy(), |
| 849 | "kind": l.kind.as_str(), |
| 850 | "creates": l.creates, |
| 851 | "dirs": l.dirs, |
| 852 | "files": l.files, |
| 853 | }), |
| 854 | // Not a path we would expand — relative, `..`, `~someone-else`. Said |
| 855 | // out loud rather than left unanswered, so the panel shows "not a path" |
| 856 | // instead of waiting for a reply that is never coming. |
| 857 | None => serde_json::json!({ |
| 858 | "type": "path", "q": q, "path": "", "kind": "invalid", |
| 859 | "creates": 0, "dirs": [], "files": [], |
| 860 | }), |
| 861 | } |
| 862 | .to_string() |
| 863 | } |
| 864 | |
| 865 | fn parse_tmux_request(text: &str) -> Option<TmuxRequest> { |
| 866 | let v: serde_json::Value = serde_json::from_str(text).ok()?; |
| 867 | if v.get("type")?.as_str()? != "tmux" { |
| 868 | return None; |
| 869 | } |
| 870 | let arg = |k: &str| v.get(k).and_then(|x| x.as_str()); |
| 871 | match v.get("cmd")?.as_str()? { |
| 872 | "switch" => crate::pty::valid_session_name(arg("session")?).map(TmuxRequest::Switch), |
| 873 | "create" => crate::pty::valid_session_name(arg("session")?).map(TmuxRequest::Create), |
| 874 | "focus" => crate::pty::valid_pane_id(arg("pane")?).map(TmuxRequest::Focus), |
| 875 | "select-window" => { |
| 876 | crate::pty::valid_window_id(arg("window")?).map(TmuxRequest::SelectWindow) |
| 877 | } |
| 878 | "goto-window" => crate::pty::valid_window_id(arg("window")?).map(TmuxRequest::GotoWindow), |
| 879 | "new-window" => crate::pty::valid_session_name(arg("session")?).map(TmuxRequest::NewWindow), |
| 880 | "claude" => Some(TmuxRequest::Claude { |
| 881 | session: crate::pty::valid_session_name(arg("session")?)?, |
| 882 | prompt: crate::pty::valid_prompt(arg("prompt")?)?, |
| 883 | }), |
| 884 | "run" => Some(TmuxRequest::Run { |
| 885 | session: crate::pty::valid_session_name(arg("session")?)?, |
| 886 | command: crate::pty::valid_command(arg("command")?)?, |
| 887 | }), |
| 888 | "new-project" => Some(TmuxRequest::NewProject { |
| 889 | // Expanded here rather than in the panel: `~` is the daemon's home, |
| 890 | // not the browser's, and one implementation of what a path means is |
| 891 | // the only way the row and the mkdir can agree. |
| 892 | path: crate::project::expand(arg("path")?)? |
| 893 | .to_str() |
| 894 | .map(String::from)?, |
| 895 | name: crate::pty::valid_session_name(arg("name")?)?, |
| 896 | // Absent is a shell; present has to survive the prompt validator, |
| 897 | // because a request carrying an unusable prompt is one built by |
| 898 | // something other than our panel. |
| 899 | prompt: match arg("prompt") { |
| 900 | None => None, |
| 901 | Some(p) => Some(crate::pty::valid_prompt(p)?), |
| 902 | }, |
| 903 | }), |
| 904 | "ssh" => Some(TmuxRequest::Ssh { |
| 905 | session: crate::pty::valid_session_name(arg("session")?)?, |
| 906 | host: crate::ssh::valid_ssh_host(arg("host")?)?, |
| 907 | }), |
| 908 | "move-window-to-session" => Some(TmuxRequest::MoveWindowToSession { |
| 909 | window: crate::pty::valid_window_id(arg("window")?)?, |
| 910 | session: crate::pty::valid_session_name(arg("session")?)?, |
| 911 | }), |
| 912 | "new-session-with-window" => Some(TmuxRequest::NewSessionWithWindow { |
| 913 | window: crate::pty::valid_window_id(arg("window")?)?, |
| 914 | name: crate::pty::valid_session_name(arg("name")?)?, |
| 915 | }), |
| 916 | "move-window" => Some(TmuxRequest::MoveWindow { |
| 917 | window: crate::pty::valid_window_id(arg("window")?)?, |
| 918 | target: crate::pty::valid_window_id(arg("target")?)?, |
| 919 | after: v.get("after").and_then(|x| x.as_bool()).unwrap_or(false), |
| 920 | }), |
| 921 | "rename-session" => Some(TmuxRequest::RenameSession { |
| 922 | session: crate::pty::valid_session_id(arg("session")?)?, |
| 923 | name: crate::pty::valid_session_name(arg("name")?)?, |
| 924 | }), |
| 925 | "kill-window" => crate::pty::valid_window_id(arg("window")?).map(TmuxRequest::KillWindow), |
| 926 | "set-session-color" => Some(TmuxRequest::SetSessionColor { |
| 927 | session: crate::pty::valid_session_id(arg("session")?)?, |
| 928 | // Absent means unset. Present means it has to be a colour we would |
| 929 | // have produced ourselves — this string is quoted into a command |
| 930 | // line to a live tmux server, so "it looks like a number" is the |
| 931 | // whole of what may go through. |
| 932 | color: match arg("color") { |
| 933 | None => None, |
| 934 | Some(c) => Some(valid_group_color(c)?), |
| 935 | }, |
| 936 | }), |
| 937 | _ => None, |
| 938 | } |
| 939 | } |
| 940 | |
| 941 | /// Command lines for a request. `tty` identifies our interactive client, so the |
| 942 | /// switch moves *it* rather than whichever client tmux would otherwise pick. |
| 943 | /// What to call the window a `!` command runs in: its first word, when that |
| 944 | /// word is a bare one. |
| 945 | /// |
| 946 | /// The one place user text is allowed onto a tmux command line, and it is |
| 947 | /// allowed only after being narrowed to the characters a program name is made |
| 948 | /// of — no quote, no space, no `#`, so neither tmux's quoting nor its `#()` |
| 949 | /// expansion has anything to work with. Anything else is `run`, which costs a |
| 950 | /// worse window name and nothing more. |
| 951 | fn window_name_for(command: &str) -> &str { |
| 952 | let word = command.split_whitespace().next().unwrap_or_default(); |
| 953 | let word = word.rsplit('/').next().unwrap_or(word); |
| 954 | let plain = !word.is_empty() |
| 955 | && word.len() <= 32 |
| 956 | && word |
| 957 | .chars() |
| 958 | .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) |
| 959 | && !word.starts_with('-'); |
| 960 | if plain { word } else { "run" } |
| 961 | } |
| 962 | |
| 963 | /// Whatever a request has to do outside tmux before tmux hears about it. |
| 964 | /// |
| 965 | /// One request needs this and the rest are `Ok(())`: a new project's directory |
| 966 | /// has to exist before a session can start in it. It is here rather than inside |
| 967 | /// [`command_lines`] because it can fail in a way the person who asked needs to |
| 968 | /// hear about — a full disk, a read-only mount, a path under a file — and |
| 969 | /// `command_lines` has nowhere to say so. The error goes back as the same |
| 970 | /// `tmux-error` frame a rejected tmux command produces, which the panel already |
| 971 | /// shows. |
| 972 | fn prepare(req: &TmuxRequest) -> Result<(), String> { |
| 973 | match req { |
| 974 | TmuxRequest::NewProject { path, .. } => { |
| 975 | std::fs::create_dir_all(path).map_err(|e| format!("{path}: {e}")) |
| 976 | } |
| 977 | _ => Ok(()), |
| 978 | } |
| 979 | } |
| 980 | |
| 981 | fn command_lines(req: &TmuxRequest, tty: Option<&str>) -> Vec<String> { |
| 982 | let client = tty |
| 983 | .and_then(crate::pty::valid_tty) |
| 984 | .map(|t| format!(" -c '{t}'")) |
| 985 | .unwrap_or_default(); |
| 986 | match req { |
| 987 | TmuxRequest::Switch(name) => vec![format!("switch-client{client} -t '{name}'")], |
| 988 | TmuxRequest::Create(name) => vec![ |
| 989 | // -A so a name that already exists attaches instead of failing, |
| 990 | // matching what the sidebar's session field has always done. |
| 991 | format!("new-session -d -A -s '{name}'"), |
| 992 | format!("switch-client{client} -t '{name}'"), |
| 993 | ], |
| 994 | TmuxRequest::Focus(pane) => vec![format!( |
| 995 | "select-pane -t '{pane}' ; select-window -t '{pane}' ; switch-client{client} -t '{pane}'" |
| 996 | )], |
| 997 | // No `-c`: a window id already identifies its session, and selecting a |
| 998 | // window is a property of the session rather than of our client. |
| 999 | TmuxRequest::SelectWindow(id) => vec![format!("select-window -t '{id}'")], |
| 1000 | // Two halves that have to be in this order: select the window first, so |
| 1001 | // the client arrives at the session already looking at the right one |
| 1002 | // rather than at whatever was current and then jumping. |
| 1003 | TmuxRequest::GotoWindow(id) => vec![format!( |
| 1004 | "select-window -t '{id}' ; switch-client{client} -t '{id}'" |
| 1005 | )], |
| 1006 | // `-a` inserts after the current window instead of claiming an index |
| 1007 | // that may already be taken, which is an error rather than a shuffle. |
| 1008 | // The trailing colon targets the session's current window. |
| 1009 | // |
| 1010 | // Then the client follows it. `new-window` selects what it created |
| 1011 | // inside its own session, but the session may not be the one this panel |
| 1012 | // is on — the sidebar's "+" adds to the default session from anywhere, |
| 1013 | // and a group's menu adds to that group. A new window you are not taken |
| 1014 | // to is a worse answer than no new window, so the switch is part of the |
| 1015 | // same request rather than something the sidebar has to chase with the |
| 1016 | // id it does not have yet. |
| 1017 | // |
| 1018 | // `-c '#{pane_current_path}'` is a tmux format expanded against the |
| 1019 | // target — the session's current pane — so the window starts in the |
| 1020 | // directory the session is already in rather than in whatever the |
| 1021 | // daemon's own working directory happens to be. |
| 1022 | TmuxRequest::NewWindow(name) => vec![format!( |
| 1023 | "new-window -a -t '{name}:' -c '#{{pane_current_path}}' ; \ |
| 1024 | switch-client{client} -t '{name}:'" |
| 1025 | )], |
| 1026 | // The same window as above, with two differences. |
| 1027 | // |
| 1028 | // `-c '#{pane_current_path}'` is a tmux format, expanded against the |
| 1029 | // target — the session's current pane — so the daemon never has to |
| 1030 | // carry a path across the socket and a path with a quote in it cannot |
| 1031 | // become part of this line. |
| 1032 | // |
| 1033 | // The command is a script this daemon just wrote, holding the prompt, |
| 1034 | // because the prompt cannot be quoted onto a tmux command line safely: |
| 1035 | // tmux's single quotes admit no escape, and its double quotes expand |
| 1036 | // `#()` — which runs a shell. The path is hex we generated. |
| 1037 | TmuxRequest::Claude { session, prompt } => { |
| 1038 | match crate::paths::write_prompt_script(prompt) { |
| 1039 | Ok(script) => vec![format!( |
| 1040 | "new-window -a -t '{session}:' -n claude -c '#{{pane_current_path}}' \ |
| 1041 | '{}' ; switch-client{client} -t '{session}:'", |
| 1042 | script.display() |
| 1043 | )], |
| 1044 | // Nothing to run, so nothing is sent: a window that opened a |
| 1045 | // bare shell would look like it worked. |
| 1046 | Err(e) => { |
| 1047 | eprintln!("send to claude: {e}"); |
| 1048 | Vec::new() |
| 1049 | } |
| 1050 | } |
| 1051 | } |
| 1052 | // The Claude window again, with a shell command in place of the prompt |
| 1053 | // and the same reasoning behind every part of it — see above. |
| 1054 | // |
| 1055 | // The name is the command's first word when that word is plain enough |
| 1056 | // to be one (see [`window_name_for`]), because `run` on every window |
| 1057 | // tells you nothing when three of them are open. It is a name tmux |
| 1058 | // would otherwise have picked for itself, had the pane not been running |
| 1059 | // a generated script whose own name is hex. |
| 1060 | TmuxRequest::Run { session, command } => { |
| 1061 | match crate::paths::write_command_script(command) { |
| 1062 | Ok(script) => vec![format!( |
| 1063 | "new-window -a -t '{session}:' -n '{}' -c '#{{pane_current_path}}' \ |
| 1064 | '{}' ; switch-client{client} -t '{session}:'", |
| 1065 | window_name_for(command), |
| 1066 | script.display() |
| 1067 | )], |
| 1068 | Err(e) => { |
| 1069 | eprintln!("run command: {e}"); |
| 1070 | Vec::new() |
| 1071 | } |
| 1072 | } |
| 1073 | } |
| 1074 | // A session rather than a window, and the only one of these that names |
| 1075 | // a directory. The path is in the script for the reason the prompt is |
| 1076 | // — see [`crate::paths::write_project_script`] — so what reaches this |
| 1077 | // command line is a session name we validated and a path we generated. |
| 1078 | // |
| 1079 | // No `-A`: attaching to a session that happens to share the name would |
| 1080 | // silently ignore both the directory and the prompt, which is the whole |
| 1081 | // request. The panel picks a free name; a race that makes it unfree |
| 1082 | // arrives here as a tmux error, which is the honest answer. |
| 1083 | TmuxRequest::NewProject { path, name, prompt } => { |
| 1084 | match crate::paths::write_project_script(std::path::Path::new(path), prompt.as_deref()) |
| 1085 | { |
| 1086 | Ok(script) => vec![ |
| 1087 | format!( |
| 1088 | "new-session -d -s '{name}' -n '{}' '{}'", |
| 1089 | if prompt.is_some() { "claude" } else { name }, |
| 1090 | script.display() |
| 1091 | ), |
| 1092 | format!("switch-client{client} -t '{name}:'"), |
| 1093 | ], |
| 1094 | Err(e) => { |
| 1095 | eprintln!("new project: {e}"); |
| 1096 | Vec::new() |
| 1097 | } |
| 1098 | } |
| 1099 | } |
| 1100 | // The `run` window with `ssh <host>` as its command, and the same |
| 1101 | // script for the same two reasons it uses one: the window outlives the |
| 1102 | // connection, so a refused or dropped one leaves its message on screen |
| 1103 | // rather than closing over it, and what is left behind is a local |
| 1104 | // shell you can reconnect from. |
| 1105 | // |
| 1106 | // The host would in fact survive tmux's single quotes — `valid_ssh_host` |
| 1107 | // admits no quote to end them with — but going through the script keeps |
| 1108 | // one path for "open a window on a command" rather than two. |
| 1109 | // |
| 1110 | // Named for the host, because `ssh` on all four of them tells you |
| 1111 | // nothing about which is which. |
| 1112 | TmuxRequest::Ssh { session, host } => { |
| 1113 | match crate::paths::write_command_script(&format!("ssh {host}")) { |
| 1114 | Ok(script) => vec![format!( |
| 1115 | "new-window -a -t '{session}:' -n '{}' -c '#{{pane_current_path}}' \ |
| 1116 | '{}' ; switch-client{client} -t '{session}:'", |
| 1117 | crate::ssh::window_name(host), |
| 1118 | script.display() |
| 1119 | )], |
| 1120 | Err(e) => { |
| 1121 | eprintln!("ssh: {e}"); |
| 1122 | Vec::new() |
| 1123 | } |
| 1124 | } |
| 1125 | } |
| 1126 | // `-a`/`-b` place the window after or before the target and renumber |
| 1127 | // whatever has to move, so no free index has to be found first and no |
| 1128 | // existing window is overwritten (which is what `-k` would risk). |
| 1129 | TmuxRequest::MoveWindow { |
| 1130 | window, |
| 1131 | target, |
| 1132 | after, |
| 1133 | } => vec![format!( |
| 1134 | "move-window {} -s '{window}' -t '{target}'", |
| 1135 | if *after { "-a" } else { "-b" } |
| 1136 | )], |
| 1137 | // The trailing colon targets the session's current window, and `-a` |
| 1138 | // lands after it — the same "no free index to find" reasoning as the |
| 1139 | // reorder above, applied to a session with no window we can name. |
| 1140 | TmuxRequest::MoveWindowToSession { window, session } => { |
| 1141 | vec![format!("move-window -a -s '{window}' -t '{session}:'")] |
| 1142 | } |
| 1143 | // Four lines because tmux offers no one command that does it, and they |
| 1144 | // are separate entries rather than a single `;` chain on purpose: the |
| 1145 | // caller stops at the first failure, so a name that already exists |
| 1146 | // fails at `new-session` and the window stays exactly where it was. |
| 1147 | // |
| 1148 | // The placeholder is the window `new-session` insists on making. It is |
| 1149 | // killed by name and not by index, because the index it got depends on |
| 1150 | // the server's `base-index`; the moved window lands after it with `-a`, |
| 1151 | // and no window this panel can drag carries that name. |
| 1152 | // |
| 1153 | // Then the client follows the window — by window id rather than by the |
| 1154 | // new session's name, which is the one thing here that a rename racing |
| 1155 | // the drag could change out from under us. |
| 1156 | TmuxRequest::NewSessionWithWindow { window, name } => vec![ |
| 1157 | format!("new-session -d -s '{name}' -n '{PLACEHOLDER_WINDOW}'"), |
| 1158 | format!("move-window -a -s '{window}' -t '{name}:'"), |
| 1159 | format!("kill-window -t '{name}:{PLACEHOLDER_WINDOW}'"), |
| 1160 | format!("switch-client{client} -t '{window}'"), |
| 1161 | ], |
| 1162 | TmuxRequest::RenameSession { session, name } => { |
| 1163 | vec![format!("rename-session -t '{session}' '{name}'")] |
| 1164 | } |
| 1165 | TmuxRequest::KillWindow(id) => vec![format!("kill-window -t '{id}'")], |
| 1166 | // `-u` unsets rather than setting an empty string: an option set to "" |
| 1167 | // still reads back as set, and the panel's "no colour chosen" test is |
| 1168 | // exactly whether the option is there. |
| 1169 | TmuxRequest::SetSessionColor { session, color } => vec![match color { |
| 1170 | Some(c) => format!( |
| 1171 | "set-option -t '{session}' {} '{c}'", |
| 1172 | crate::status::COLOR_OPTION |
| 1173 | ), |
| 1174 | None => format!( |
| 1175 | "set-option -t '{session}' -u {}", |
| 1176 | crate::status::COLOR_OPTION |
| 1177 | ), |
| 1178 | }], |
| 1179 | } |
| 1180 | } |
| 1181 | |
| 1182 | /// Owns the control-mode client: pushes a status frame whenever tmux says |
| 1183 | /// something changed, and runs the sidebar's requests. |
| 1184 | async fn control_loop( |
| 1185 | session: String, |
| 1186 | global_args: Vec<String>, |
| 1187 | tty: Option<String>, |
| 1188 | status_tx: mpsc::Sender<String>, |
| 1189 | mut requests: mpsc::Receiver<TmuxRequest>, |
| 1190 | ) { |
| 1191 | let Some((control, mut notifications)) = attach_with_retry(&session, &global_args).await else { |
| 1192 | return; |
| 1193 | }; |
| 1194 | // Claude Code's hook records live on disk and change without tmux noticing, |
| 1195 | // so a slow tick backs up the push notifications. It reads a handful of |
| 1196 | // small files; there is no process spawn on this path at all. |
| 1197 | let mut ticker = tokio::time::interval(Duration::from_secs(1)); |
| 1198 | let mut last: Option<crate::status::Snapshot> = None; |
| 1199 | // Sessions we have already checked `detach-on-destroy` on, by id. |
| 1200 | let mut checked_detach: std::collections::HashSet<String> = std::collections::HashSet::new(); |
| 1201 | // The session browser.conf is currently applied to, by id. |
| 1202 | let mut styled: Option<String> = None; |
| 1203 | |
| 1204 | loop { |
| 1205 | let snap = crate::status::snapshot(&control, tty.as_deref()).await; |
| 1206 | // Every session the client lands on, not just the first: the sidebar's |
| 1207 | // switcher moves it, and the option belongs to the session. |
| 1208 | if let Some(id) = snap |
| 1209 | .session_id |
| 1210 | .as_deref() |
| 1211 | .and_then(crate::pty::valid_session_id) |
| 1212 | { |
| 1213 | if checked_detach.insert(id.clone()) { |
| 1214 | ensure_detach_on_destroy(&control, &id).await; |
| 1215 | } |
| 1216 | if styled.as_deref() != Some(id.as_str()) { |
| 1217 | if let Some(previous) = styled.take() { |
| 1218 | source_tmux_config(&global_args, &previous, BROWSER_RESET_CONF).await; |
| 1219 | } |
| 1220 | if source_tmux_config(&global_args, &id, BROWSER_CONF).await { |
| 1221 | styled = Some(id); |
| 1222 | } |
| 1223 | } |
| 1224 | } |
| 1225 | if last.as_ref() != Some(&snap) { |
| 1226 | let json = serde_json::json!({ |
| 1227 | "type": "status", |
| 1228 | "session": snap.session, |
| 1229 | "sessions": snap.sessions, |
| 1230 | "agents": snap.agents, |
| 1231 | }) |
| 1232 | .to_string(); |
| 1233 | if status_tx.send(json).await.is_err() { |
| 1234 | break; // Connection gone. |
| 1235 | } |
| 1236 | last = Some(snap); |
| 1237 | } |
| 1238 | |
| 1239 | tokio::select! { |
| 1240 | _ = ticker.tick() => {} |
| 1241 | note = notifications.recv() => { |
| 1242 | match note { |
| 1243 | // Uninteresting notifications are the common case; skip the |
| 1244 | // round trip rather than rebuilding the snapshot for a |
| 1245 | // layout change nobody displays. |
| 1246 | Some(n) if !crate::control::is_interesting(&n) => continue, |
| 1247 | Some(_) => {} |
| 1248 | None => break, |
| 1249 | } |
| 1250 | } |
| 1251 | req = requests.recv() => { |
| 1252 | let Some(req) = req else { break }; |
| 1253 | if let Err(reason) = prepare(&req) { |
| 1254 | let json = serde_json::json!({ |
| 1255 | "type": "tmux-error", "reason": reason, |
| 1256 | }).to_string(); |
| 1257 | let _ = status_tx.send(json).await; |
| 1258 | continue; |
| 1259 | } |
| 1260 | for line in command_lines(&req, tty.as_deref()) { |
| 1261 | if let Err(reason) = control.run(line).await { |
| 1262 | let json = serde_json::json!({ |
| 1263 | "type": "tmux-error", "reason": reason, |
| 1264 | }).to_string(); |
| 1265 | let _ = status_tx.send(json).await; |
| 1266 | break; |
| 1267 | } |
| 1268 | } |
| 1269 | } |
| 1270 | } |
| 1271 | } |
| 1272 | |
| 1273 | // The panel is going away, so the session it was on goes back to the look |
| 1274 | // the terminal clients expect. Reached on every exit from the loop above, |
| 1275 | // which is why they all break rather than return. |
| 1276 | if let Some(session) = styled { |
| 1277 | source_tmux_config(&global_args, &session, BROWSER_RESET_CONF).await; |
| 1278 | } |
| 1279 | } |
| 1280 | |
| 1281 | /// Overrides for sessions a browser client is looking at, relative to the |
| 1282 | /// user's tmux config dir. tmux options are server- and session-scoped, never |
| 1283 | /// per-client, so a browser client cannot simply be handed its own config: the |
| 1284 | /// only thing it can do is apply session options to the session it is on, and |
| 1285 | /// take them back off on the way out. Both files are optional; a user without |
| 1286 | /// them gets the same tmux the terminal gets. |
| 1287 | const BROWSER_CONF: &str = "browser.conf"; |
| 1288 | const BROWSER_RESET_CONF: &str = "browser-reset.conf"; |
| 1289 | |
| 1290 | /// `~/.config/tmux`, where tmux itself looks for its config. |
| 1291 | /// |
| 1292 | /// `TERMBRIDGE_TMUX_CONFIG_DIR` overrides it, which is how the tests point at |
| 1293 | /// overrides of their own instead of the ones the user is running. |
| 1294 | fn tmux_config_dir() -> std::path::PathBuf { |
| 1295 | if let Some(dir) = std::env::var_os("TERMBRIDGE_TMUX_CONFIG_DIR") { |
| 1296 | return std::path::PathBuf::from(dir); |
| 1297 | } |
| 1298 | std::env::var_os("XDG_CONFIG_HOME") |
| 1299 | .map(std::path::PathBuf::from) |
| 1300 | .unwrap_or_else(|| { |
| 1301 | let home = std::env::var_os("HOME") |
| 1302 | .map(std::path::PathBuf::from) |
| 1303 | .unwrap_or_default(); |
| 1304 | home.join(".config") |
| 1305 | }) |
| 1306 | .join("tmux") |
| 1307 | } |
| 1308 | |
| 1309 | /// Source one of the files above into `session`, reporting whether it ran. |
| 1310 | /// |
| 1311 | /// Deliberately a `tmux` process of its own rather than a line on the control |
| 1312 | /// client: in control mode `source-file` answers with *two* `%begin`/`%end` |
| 1313 | /// blocks — one for itself and one for the commands it runs — and the control |
| 1314 | /// client pairs replies to commands by order. The spare block would be taken |
| 1315 | /// for the next command's answer and every reply after it would be off by one, |
| 1316 | /// which the sidebar sees as `list-clients` output where its session list |
| 1317 | /// should be. A process per session switch is nothing; a shifted reply queue |
| 1318 | /// is everything the panel draws. |
| 1319 | /// |
| 1320 | /// The path is checked here rather than left to tmux so that a missing file is |
| 1321 | /// silence rather than an error, and so that a session is only recorded as |
| 1322 | /// styled when there was something to apply. `session` must already be an id |
| 1323 | /// from [`crate::pty::valid_session_id`], and `global_args` must be the |
| 1324 | /// profile's server-selection flags or this addresses the wrong tmux server. |
| 1325 | async fn source_tmux_config(global_args: &[String], session: &str, name: &str) -> bool { |
| 1326 | let path = tmux_config_dir().join(name); |
| 1327 | if !path.is_file() { |
| 1328 | return false; |
| 1329 | } |
| 1330 | // Arguments, not a command line: nothing here is quoted or split, so a |
| 1331 | // path with a space or a quote in it is just a path. |
| 1332 | tokio::process::Command::new("tmux") |
| 1333 | .args(global_args) |
| 1334 | .arg("source-file") |
| 1335 | .arg("-t") |
| 1336 | .arg(session) |
| 1337 | .arg(path) |
| 1338 | .stdin(std::process::Stdio::null()) |
| 1339 | .stdout(std::process::Stdio::null()) |
| 1340 | .stderr(std::process::Stdio::null()) |
| 1341 | .status() |
| 1342 | .await |
| 1343 | .is_ok_and(|s| s.success()) |
| 1344 | } |
| 1345 | |
| 1346 | /// Keep the sidebar's client alive when a session is destroyed under it. |
| 1347 | /// |
| 1348 | /// tmux's default is `detach-on-destroy on`: exiting the last shell of a |
| 1349 | /// session detaches every client attached to it. For a terminal emulator that |
| 1350 | /// is fine — the window closes. For the sidebar it means the pty hits EOF, the |
| 1351 | /// socket closes, and the whole panel goes dead even though other sessions are |
| 1352 | /// still running, which reads as a crash rather than as closing a pane. `off` |
| 1353 | /// moves the client to another session instead, and only falls back to |
| 1354 | /// detaching when there is genuinely nothing left to show. |
| 1355 | /// |
| 1356 | /// Only the tmux default is overridden. `no-detached` and `previous` are |
| 1357 | /// deliberate choices with the same effect we want, so a user who set one keeps |
| 1358 | /// it. `session` must already be an id from [`crate::pty::valid_session_id`]. |
| 1359 | async fn ensure_detach_on_destroy(control: &crate::control::Control, session: &str) { |
| 1360 | // `-A` because the option is usually unset at the session level and |
| 1361 | // inherited from the global one; without it the reply is empty and the |
| 1362 | // effective value stays invisible. |
| 1363 | let current = control |
| 1364 | .run(format!( |
| 1365 | "show-options -t '{session}' -v -A detach-on-destroy" |
| 1366 | )) |
| 1367 | .await; |
| 1368 | let Ok(lines) = current else { return }; |
| 1369 | if lines.first().map(|l| l.trim()) != Some("on") { |
| 1370 | return; |
| 1371 | } |
| 1372 | let _ = control |
| 1373 | .run(format!("set-option -t '{session}' detach-on-destroy off")) |
| 1374 | .await; |
| 1375 | } |
| 1376 | |
| 1377 | /// The interactive client creates the session, and we may get here first. |
| 1378 | async fn attach_with_retry( |
| 1379 | session: &str, |
| 1380 | global_args: &[String], |
| 1381 | ) -> Option<( |
| 1382 | crate::control::Control, |
| 1383 | mpsc::Receiver<crate::control::Notification>, |
| 1384 | )> { |
| 1385 | for _ in 0..10 { |
| 1386 | if let Ok(pair) = crate::control::Control::attach(session, global_args).await { |
| 1387 | // A failed attach still spawns: confirm the client is actually |
| 1388 | // talking before handing it out. |
| 1389 | if pair.0.run("display-message -p ok").await.is_ok() { |
| 1390 | return Some(pair); |
| 1391 | } |
| 1392 | } |
| 1393 | tokio::time::sleep(Duration::from_millis(200)).await; |
| 1394 | } |
| 1395 | None |
| 1396 | } |
| 1397 | |
| 1398 | fn parse_open(text: &str) -> Option<(u16, u16, Option<String>)> { |
| 1399 | let v: serde_json::Value = serde_json::from_str(text).ok()?; |
| 1400 | if v.get("type")?.as_str()? != "open" { |
| 1401 | return None; |
| 1402 | } |
| 1403 | let (cols, rows) = dims(&v); |
| 1404 | let session = v |
| 1405 | .get("session") |
| 1406 | .and_then(|x| x.as_str()) |
| 1407 | .and_then(crate::pty::valid_session_name); |
| 1408 | Some((cols, rows, session)) |
| 1409 | } |
| 1410 | |
| 1411 | fn parse_resize(text: &str) -> Option<(u16, u16)> { |
| 1412 | let v: serde_json::Value = serde_json::from_str(text).ok()?; |
| 1413 | match v.get("type")?.as_str()? { |
| 1414 | "resize" | "open" => Some(dims(&v)), |
| 1415 | _ => None, |
| 1416 | } |
| 1417 | } |
| 1418 | |
| 1419 | fn dims(v: &serde_json::Value) -> (u16, u16) { |
| 1420 | let get = |k: &str, d: u64| { |
| 1421 | v.get(k) |
| 1422 | .and_then(|x| x.as_u64()) |
| 1423 | .unwrap_or(d) |
| 1424 | .clamp(1, 1000) as u16 |
| 1425 | }; |
| 1426 | (get("cols", 80), get("rows", 24)) |
| 1427 | } |
| 1428 | |
| 1429 | async fn echo_loop<S>( |
| 1430 | mut ws: WebSocketStream<S>, |
| 1431 | ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> |
| 1432 | where |
| 1433 | S: AsyncRead + AsyncWrite + Unpin + Send + 'static, |
| 1434 | { |
| 1435 | while let Some(Ok(msg)) = ws.next().await { |
| 1436 | match msg { |
| 1437 | Message::Text(t) => ws.send(Message::Text(t)).await?, |
| 1438 | Message::Binary(b) => ws.send(Message::Binary(b)).await?, |
| 1439 | Message::Close(_) => break, |
| 1440 | _ => {} |
| 1441 | } |
| 1442 | } |
| 1443 | Ok(()) |
| 1444 | } |
| 1445 | |
| 1446 | #[cfg(test)] |
| 1447 | mod tests { |
| 1448 | use super::*; |
| 1449 | |
| 1450 | /// A lone session is what the user is already working in, so a client that |
| 1451 | /// names none should land there rather than in a fresh `default` session. |
| 1452 | /// Two sessions is ambiguous, and the configured default wins again. |
| 1453 | /// |
| 1454 | /// Runs against a real tmux on a private socket; skipped where there is no |
| 1455 | /// tmux to talk to. |
| 1456 | #[test] |
| 1457 | fn a_single_existing_session_is_adopted() { |
| 1458 | if !crate::pty::Profile::tmux_available() { |
| 1459 | eprintln!("skipping: no tmux on PATH"); |
| 1460 | return; |
| 1461 | } |
| 1462 | let socket = "termbridge-sole-session-test"; |
| 1463 | let tmux = |args: &[&str]| { |
| 1464 | std::process::Command::new("tmux") |
| 1465 | .args(["-L", socket]) |
| 1466 | .args(args) |
| 1467 | .stdout(std::process::Stdio::null()) |
| 1468 | .stderr(std::process::Stdio::null()) |
| 1469 | .status() |
| 1470 | }; |
| 1471 | let _ = tmux(&["kill-server"]); |
| 1472 | |
| 1473 | let mut config = Config::new("t", vec![]); |
| 1474 | config.profile = crate::pty::Profile { |
| 1475 | program: "tmux".into(), |
| 1476 | args: vec![ |
| 1477 | "-L".into(), |
| 1478 | socket.into(), |
| 1479 | "new-session".into(), |
| 1480 | "-A".into(), |
| 1481 | "-s".into(), |
| 1482 | crate::pty::DEFAULT_SESSION.into(), |
| 1483 | ], |
| 1484 | }; |
| 1485 | config.default_session = crate::pty::DEFAULT_SESSION.into(); |
| 1486 | config.adopt_sole_session = true; |
| 1487 | |
| 1488 | // No server running: nothing to adopt. |
| 1489 | assert_eq!(config.adopted_session(), None); |
| 1490 | |
| 1491 | let _ = tmux(&["new-session", "-d", "-s", "work"]); |
| 1492 | assert_eq!(config.adopted_session().as_deref(), Some("work")); |
| 1493 | |
| 1494 | // Pinned by the command line: the user's choice is not overridden. |
| 1495 | config.adopt_sole_session = false; |
| 1496 | assert_eq!(config.adopted_session(), None); |
| 1497 | config.adopt_sole_session = true; |
| 1498 | |
| 1499 | // Two sessions is ambiguous, so the profile's own session stands. |
| 1500 | let _ = tmux(&["new-session", "-d", "-s", "other"]); |
| 1501 | assert_eq!(config.adopted_session(), None); |
| 1502 | |
| 1503 | let _ = tmux(&["kill-server"]); |
| 1504 | } |
| 1505 | |
| 1506 | /// Ctrl-D in the last shell of a session must not take the sidebar's client |
| 1507 | /// with it. Needs a real tmux; skipped where there isn't one. |
| 1508 | #[tokio::test] |
| 1509 | async fn the_default_detach_on_destroy_is_turned_off() { |
| 1510 | if !crate::pty::Profile::tmux_available() { |
| 1511 | eprintln!("skipping: no tmux on PATH"); |
| 1512 | return; |
| 1513 | } |
| 1514 | let socket = "termbridge-detach-test"; |
| 1515 | let tmux = |args: &[&str]| { |
| 1516 | std::process::Command::new("tmux") |
| 1517 | .args(["-L", socket, "-f", "/dev/null"]) |
| 1518 | .args(args) |
| 1519 | .output() |
| 1520 | }; |
| 1521 | let _ = tmux(&["kill-server"]); |
| 1522 | let _ = tmux(&["new-session", "-d", "-s", "one"]); |
| 1523 | let _ = tmux(&["new-session", "-d", "-s", "deliberate"]); |
| 1524 | |
| 1525 | let global = ["-L", socket, "-f", "/dev/null"].map(String::from).to_vec(); |
| 1526 | let Ok((control, _notes)) = crate::control::Control::attach("one", &global).await else { |
| 1527 | let _ = tmux(&["kill-server"]); |
| 1528 | return; |
| 1529 | }; |
| 1530 | let effective = |session: &str| { |
| 1531 | let out = tmux(&[ |
| 1532 | "show-options", |
| 1533 | "-t", |
| 1534 | session, |
| 1535 | "-v", |
| 1536 | "-A", |
| 1537 | "detach-on-destroy", |
| 1538 | ]); |
| 1539 | String::from_utf8_lossy(&out.expect("tmux ran").stdout) |
| 1540 | .trim() |
| 1541 | .to_string() |
| 1542 | }; |
| 1543 | |
| 1544 | // tmux's default, so ours wins. |
| 1545 | assert_eq!(effective("one"), "on"); |
| 1546 | ensure_detach_on_destroy(&control, "$0").await; |
| 1547 | assert_eq!(effective("one"), "off"); |
| 1548 | |
| 1549 | // Deliberately set to something else with the same effect: left alone. |
| 1550 | let _ = tmux(&[ |
| 1551 | "set-option", |
| 1552 | "-t", |
| 1553 | "deliberate", |
| 1554 | "detach-on-destroy", |
| 1555 | "previous", |
| 1556 | ]); |
| 1557 | ensure_detach_on_destroy(&control, "$1").await; |
| 1558 | assert_eq!(effective("deliberate"), "previous"); |
| 1559 | |
| 1560 | let _ = tmux(&["kill-server"]); |
| 1561 | } |
| 1562 | |
| 1563 | #[test] |
| 1564 | fn session_ids_are_dollar_and_digits() { |
| 1565 | assert_eq!(crate::pty::valid_session_id("$0").as_deref(), Some("$0")); |
| 1566 | assert_eq!( |
| 1567 | crate::pty::valid_session_id(" $12 ").as_deref(), |
| 1568 | Some("$12") |
| 1569 | ); |
| 1570 | for bad in ["$", "1", "$1a", "$1 ; kill-server", "@1", "$-1", ""] { |
| 1571 | assert_eq!(crate::pty::valid_session_id(bad), None, "{bad:?}"); |
| 1572 | } |
| 1573 | } |
| 1574 | |
| 1575 | /// The wire protocol must not be able to name a tmux command. Anything the |
| 1576 | /// sidebar sends either maps to one of the allowlisted variants or is |
| 1577 | /// dropped. |
| 1578 | #[test] |
| 1579 | fn only_the_allowlisted_requests_parse() { |
| 1580 | let req = |s: &str| parse_tmux_request(s); |
| 1581 | assert_eq!( |
| 1582 | req(r#"{"type":"tmux","cmd":"switch","session":"work"}"#), |
| 1583 | Some(TmuxRequest::Switch("work".into())) |
| 1584 | ); |
| 1585 | assert_eq!( |
| 1586 | req(r#"{"type":"tmux","cmd":"focus","pane":"%12"}"#), |
| 1587 | Some(TmuxRequest::Focus("%12".into())) |
| 1588 | ); |
| 1589 | assert_eq!( |
| 1590 | req(r#"{"type":"tmux","cmd":"select-window","window":"@3"}"#), |
| 1591 | Some(TmuxRequest::SelectWindow("@3".into())) |
| 1592 | ); |
| 1593 | assert_eq!( |
| 1594 | req(r#"{"type":"tmux","cmd":"goto-window","window":"@3"}"#), |
| 1595 | Some(TmuxRequest::GotoWindow("@3".into())) |
| 1596 | ); |
| 1597 | assert_eq!( |
| 1598 | req(r#"{"type":"tmux","cmd":"new-window","session":"work"}"#), |
| 1599 | Some(TmuxRequest::NewWindow("work".into())) |
| 1600 | ); |
| 1601 | assert_eq!( |
| 1602 | req(r#"{"type":"tmux","cmd":"move-window-to-session","window":"@3","session":"work"}"#), |
| 1603 | Some(TmuxRequest::MoveWindowToSession { |
| 1604 | window: "@3".into(), |
| 1605 | session: "work".into(), |
| 1606 | }) |
| 1607 | ); |
| 1608 | assert_eq!( |
| 1609 | req(r#"{"type":"tmux","cmd":"kill-window","window":"@3"}"#), |
| 1610 | Some(TmuxRequest::KillWindow("@3".into())) |
| 1611 | ); |
| 1612 | assert_eq!( |
| 1613 | req(r#"{"type":"tmux","cmd":"set-session-color","session":"$1","color":"210"}"#), |
| 1614 | Some(TmuxRequest::SetSessionColor { |
| 1615 | session: "$1".into(), |
| 1616 | color: Some("210".into()), |
| 1617 | }) |
| 1618 | ); |
| 1619 | // Grey, which is not a hue and so gets its own sentinel. |
| 1620 | assert_eq!( |
| 1621 | req(r#"{"type":"tmux","cmd":"set-session-color","session":"$1","color":"-1"}"#), |
| 1622 | Some(TmuxRequest::SetSessionColor { |
| 1623 | session: "$1".into(), |
| 1624 | color: Some("-1".into()), |
| 1625 | }) |
| 1626 | ); |
| 1627 | // No colour at all is the "back to automatic" request, not a malformed |
| 1628 | // one: it unsets the option. |
| 1629 | assert_eq!( |
| 1630 | req(r#"{"type":"tmux","cmd":"set-session-color","session":"$1"}"#), |
| 1631 | Some(TmuxRequest::SetSessionColor { |
| 1632 | session: "$1".into(), |
| 1633 | color: None, |
| 1634 | }) |
| 1635 | ); |
| 1636 | assert_eq!( |
| 1637 | req(r#"{"type":"tmux","cmd":"rename-session","session":"$1","name":"work"}"#), |
| 1638 | Some(TmuxRequest::RenameSession { |
| 1639 | session: "$1".into(), |
| 1640 | name: "work".into(), |
| 1641 | }) |
| 1642 | ); |
| 1643 | assert_eq!( |
| 1644 | req(r#"{"type":"tmux","cmd":"ssh","session":"work","host":"collin@mini"}"#), |
| 1645 | Some(TmuxRequest::Ssh { |
| 1646 | session: "work".into(), |
| 1647 | host: "collin@mini".into(), |
| 1648 | }) |
| 1649 | ); |
| 1650 | assert_eq!( |
| 1651 | req(r#"{"type":"tmux","cmd":"move-window","window":"@3","target":"@1","after":true}"#), |
| 1652 | Some(TmuxRequest::MoveWindow { |
| 1653 | window: "@3".into(), |
| 1654 | target: "@1".into(), |
| 1655 | after: true, |
| 1656 | }) |
| 1657 | ); |
| 1658 | // Both ends are window ids, and a missing one is not a move at all. |
| 1659 | assert_eq!( |
| 1660 | req(r#"{"type":"tmux","cmd":"move-window","window":"@3"}"#), |
| 1661 | None |
| 1662 | ); |
| 1663 | assert_eq!( |
| 1664 | req(r#"{"type":"tmux","cmd":"move-window","window":"@3","target":"work:1"}"#), |
| 1665 | None |
| 1666 | ); |
| 1667 | // Killing anything larger than a window is still not expressible. |
| 1668 | assert_eq!( |
| 1669 | req(r#"{"type":"tmux","cmd":"kill-session","session":"work"}"#), |
| 1670 | None |
| 1671 | ); |
| 1672 | assert_eq!( |
| 1673 | req(r#"{"type":"tmux","cmd":"kill-pane","pane":"%3"}"#), |
| 1674 | None |
| 1675 | ); |
| 1676 | assert_eq!(req(r#"{"type":"tmux","cmd":"kill-server"}"#), None); |
| 1677 | assert_eq!( |
| 1678 | req(r#"{"type":"tmux","cmd":"run","command":"rm -rf /"}"#), |
| 1679 | None |
| 1680 | ); |
| 1681 | assert_eq!(req(r#"{"type":"resize","cols":80,"rows":24}"#), None); |
| 1682 | } |
| 1683 | |
| 1684 | /// Names and pane ids are quoted into a command line, so the validators are |
| 1685 | /// the boundary. Quotes, semicolons and spaces must not survive parsing. |
| 1686 | #[test] |
| 1687 | fn hostile_arguments_are_rejected_not_escaped() { |
| 1688 | let req = |s: &str| parse_tmux_request(s); |
| 1689 | for hostile in [ |
| 1690 | r#"{"type":"tmux","cmd":"switch","session":"a' ; kill-server ; '"}"#, |
| 1691 | r#"{"type":"tmux","cmd":"switch","session":"-C"}"#, |
| 1692 | r#"{"type":"tmux","cmd":"switch","session":"a b"}"#, |
| 1693 | r#"{"type":"tmux","cmd":"focus","pane":"%1 ; kill-server"}"#, |
| 1694 | r#"{"type":"tmux","cmd":"focus","pane":"$1"}"#, |
| 1695 | r#"{"type":"tmux","cmd":"focus","pane":"%"}"#, |
| 1696 | r#"{"type":"tmux","cmd":"select-window","window":"@1 ; kill-server"}"#, |
| 1697 | r#"{"type":"tmux","cmd":"select-window","window":"%1"}"#, |
| 1698 | r#"{"type":"tmux","cmd":"select-window","window":"@"}"#, |
| 1699 | // An index is not an id: `2` would be a bare target, and |
| 1700 | // `session:2.0` carries syntax of its own. |
| 1701 | r#"{"type":"tmux","cmd":"select-window","window":"2"}"#, |
| 1702 | // Crossing sessions widens what a click can reach, not what it can |
| 1703 | // say: the id goes through the same validator. |
| 1704 | r#"{"type":"tmux","cmd":"goto-window","window":"@1 ; kill-server"}"#, |
| 1705 | r#"{"type":"tmux","cmd":"goto-window","window":"work:1"}"#, |
| 1706 | r#"{"type":"tmux","cmd":"goto-window","window":""}"#, |
| 1707 | r#"{"type":"tmux","cmd":"move-window-to-session","window":"@1","session":"a' ; x ; '"}"#, |
| 1708 | r#"{"type":"tmux","cmd":"move-window-to-session","window":"@1","session":"work:1"}"#, |
| 1709 | r#"{"type":"tmux","cmd":"move-window-to-session","window":"-a","session":"work"}"#, |
| 1710 | r#"{"type":"tmux","cmd":"new-window","session":"a' ; kill-server ; '"}"#, |
| 1711 | r#"{"type":"tmux","cmd":"new-window","session":"work:1"}"#, |
| 1712 | // The destructive one gets the same validator, and `-a` is the |
| 1713 | // difference between one window and every window. |
| 1714 | r#"{"type":"tmux","cmd":"kill-window","window":"@1 ; kill-server"}"#, |
| 1715 | r#"{"type":"tmux","cmd":"kill-window","window":"-a"}"#, |
| 1716 | r#"{"type":"tmux","cmd":"kill-window","window":""}"#, |
| 1717 | // The colour is quoted into a command line, so it is a number this |
| 1718 | // panel would have produced or it is nothing. |
| 1719 | r#"{"type":"tmux","cmd":"set-session-color","session":"$1","color":"red"}"#, |
| 1720 | r#"{"type":"tmux","cmd":"set-session-color","session":"$1","color":"210' ; kill-server ; '"}"#, |
| 1721 | r#"{"type":"tmux","cmd":"set-session-color","session":"$1","color":"360"}"#, |
| 1722 | r#"{"type":"tmux","cmd":"set-session-color","session":"$1","color":"-2"}"#, |
| 1723 | r#"{"type":"tmux","cmd":"set-session-color","session":"$1","color":""}"#, |
| 1724 | // And the session is an id, never a name. |
| 1725 | r#"{"type":"tmux","cmd":"set-session-color","session":"work","color":"210"}"#, |
| 1726 | r#"{"type":"tmux","cmd":"set-session-color","session":"@1","color":"210"}"#, |
| 1727 | // A rename names its target by id and its new name by the same |
| 1728 | // gate the session field passes — both halves land in a command |
| 1729 | // line, and neither may carry anything a shell word cannot. |
| 1730 | r#"{"type":"tmux","cmd":"rename-session","session":"work","name":"other"}"#, |
| 1731 | r#"{"type":"tmux","cmd":"rename-session","session":"$1","name":"a' ; kill-server ; '"}"#, |
| 1732 | r#"{"type":"tmux","cmd":"rename-session","session":"$1","name":"work:1"}"#, |
| 1733 | r#"{"type":"tmux","cmd":"rename-session","session":"$1","name":"-C"}"#, |
| 1734 | r#"{"type":"tmux","cmd":"rename-session","session":"$1","name":""}"#, |
| 1735 | r#"{"type":"tmux","cmd":"rename-session","session":"$1"}"#, |
| 1736 | // A destination is a name. The dangerous shape is not a shell |
| 1737 | // metacharacter but an ssh option — `-oProxyCommand=` runs one. |
| 1738 | r#"{"type":"tmux","cmd":"ssh","session":"work","host":"-oProxyCommand=sh"}"#, |
| 1739 | r#"{"type":"tmux","cmd":"ssh","session":"work","host":"me@-oProxyCommand=sh"}"#, |
| 1740 | r#"{"type":"tmux","cmd":"ssh","session":"work","host":"mini' ; kill-server ; '"}"#, |
| 1741 | r#"{"type":"tmux","cmd":"ssh","session":"work","host":"mini -D 1080"}"#, |
| 1742 | r#"{"type":"tmux","cmd":"ssh","session":"work","host":""}"#, |
| 1743 | r#"{"type":"tmux","cmd":"ssh","session":"work"}"#, |
| 1744 | r#"{"type":"tmux","cmd":"ssh","session":"work:1","host":"mini"}"#, |
| 1745 | ] { |
| 1746 | assert_eq!(req(hostile), None, "accepted {hostile}"); |
| 1747 | } |
| 1748 | } |
| 1749 | |
| 1750 | #[test] |
| 1751 | fn window_commands_stay_inside_their_session() { |
| 1752 | // No -c: windows belong to the session, so this moves whoever is |
| 1753 | // watching it — the same thing `prefix 2` in the terminal does. |
| 1754 | let lines = command_lines(&TmuxRequest::SelectWindow("@3".into()), Some("/dev/pts/7")); |
| 1755 | assert_eq!(lines, vec!["select-window -t '@3'"]); |
| 1756 | |
| 1757 | // -a rather than an index, which could collide with an existing window. |
| 1758 | // The client follows: "+" adds to the default session from wherever you |
| 1759 | // are, so the window it makes may be in a session we are not on. |
| 1760 | let lines = command_lines(&TmuxRequest::NewWindow("work".into()), Some("/dev/pts/7")); |
| 1761 | assert_eq!( |
| 1762 | lines, |
| 1763 | vec![ |
| 1764 | "new-window -a -t 'work:' -c '#{pane_current_path}' ; \ |
| 1765 | switch-client -c '/dev/pts/7' -t 'work:'" |
| 1766 | ] |
| 1767 | ); |
| 1768 | let lines = command_lines(&TmuxRequest::NewWindow("work".into()), None); |
| 1769 | assert_eq!( |
| 1770 | lines, |
| 1771 | vec!["new-window -a -t 'work:' -c '#{pane_current_path}' ; switch-client -t 'work:'"] |
| 1772 | ); |
| 1773 | |
| 1774 | // One window, named by id. No -a, which would kill all *but* it. |
| 1775 | let lines = command_lines(&TmuxRequest::KillWindow("@3".into()), Some("/dev/pts/7")); |
| 1776 | assert_eq!(lines, vec!["kill-window -t '@3'"]); |
| 1777 | |
| 1778 | // A drag lands the window beside another one, and tmux renumbers the |
| 1779 | // rest — no index is named, so none can collide. |
| 1780 | let drag = |after| { |
| 1781 | command_lines( |
| 1782 | &TmuxRequest::MoveWindow { |
| 1783 | window: "@3".into(), |
| 1784 | target: "@1".into(), |
| 1785 | after, |
| 1786 | }, |
| 1787 | Some("/dev/pts/7"), |
| 1788 | ) |
| 1789 | }; |
| 1790 | assert_eq!(drag(true), vec!["move-window -a -s '@3' -t '@1'"]); |
| 1791 | assert_eq!(drag(false), vec!["move-window -b -s '@3' -t '@1'"]); |
| 1792 | |
| 1793 | // A collapsed group has no window to land beside, so the session is the |
| 1794 | // target and tmux picks the index. |
| 1795 | let lines = command_lines( |
| 1796 | &TmuxRequest::MoveWindowToSession { |
| 1797 | window: "@3".into(), |
| 1798 | session: "work".into(), |
| 1799 | }, |
| 1800 | Some("/dev/pts/7"), |
| 1801 | ); |
| 1802 | assert_eq!(lines, vec!["move-window -a -s '@3' -t 'work:'"]); |
| 1803 | } |
| 1804 | |
| 1805 | /// Dragging a tab onto "+": the session has to exist before a window can be |
| 1806 | /// moved into it, and the window `new-session` comes with has to go. |
| 1807 | #[test] |
| 1808 | fn a_window_dragged_onto_plus_gets_a_session_of_its_own() { |
| 1809 | let lines = command_lines( |
| 1810 | &TmuxRequest::NewSessionWithWindow { |
| 1811 | window: "@3".into(), |
| 1812 | name: "nvim".into(), |
| 1813 | }, |
| 1814 | Some("/dev/pts/7"), |
| 1815 | ); |
| 1816 | assert_eq!( |
| 1817 | lines, |
| 1818 | vec![ |
| 1819 | "new-session -d -s 'nvim' -n 'termbridge-placeholder'", |
| 1820 | "move-window -a -s '@3' -t 'nvim:'", |
| 1821 | "kill-window -t 'nvim:termbridge-placeholder'", |
| 1822 | "switch-client -c '/dev/pts/7' -t '@3'", |
| 1823 | ] |
| 1824 | ); |
| 1825 | |
| 1826 | // Both halves are validated, and neither can carry a quote into the |
| 1827 | // command lines above. |
| 1828 | assert!(parse_tmux_request( |
| 1829 | r#"{"type":"tmux","cmd":"new-session-with-window","window":"@3","name":"a' ; x ; '"}"# |
| 1830 | ) |
| 1831 | .is_none()); |
| 1832 | assert!( |
| 1833 | parse_tmux_request( |
| 1834 | r#"{"type":"tmux","cmd":"new-session-with-window","window":"work","name":"nvim"}"# |
| 1835 | ) |
| 1836 | .is_none() |
| 1837 | ); |
| 1838 | assert_eq!( |
| 1839 | parse_tmux_request( |
| 1840 | r#"{"type":"tmux","cmd":"new-session-with-window","window":"@3","name":"nvim"}"# |
| 1841 | ), |
| 1842 | Some(TmuxRequest::NewSessionWithWindow { |
| 1843 | window: "@3".into(), |
| 1844 | name: "nvim".into(), |
| 1845 | }) |
| 1846 | ); |
| 1847 | } |
| 1848 | |
| 1849 | /// The colour goes into a tmux user option on the session, which is what |
| 1850 | /// makes it survive a rename and reach every panel on the server. |
| 1851 | #[test] |
| 1852 | fn session_colour_sets_and_unsets_a_user_option() { |
| 1853 | let set = command_lines( |
| 1854 | &TmuxRequest::SetSessionColor { |
| 1855 | session: "$1".into(), |
| 1856 | color: Some("210".into()), |
| 1857 | }, |
| 1858 | Some("/dev/pts/7"), |
| 1859 | ); |
| 1860 | assert_eq!(set, vec!["set-option -t '$1' @termbridge_color '210'"]); |
| 1861 | |
| 1862 | // `-u`, not an empty value: an option set to "" still reads back as |
| 1863 | // set, and "nobody chose" is exactly the option being absent. |
| 1864 | let clear = command_lines( |
| 1865 | &TmuxRequest::SetSessionColor { |
| 1866 | session: "$1".into(), |
| 1867 | color: None, |
| 1868 | }, |
| 1869 | None, |
| 1870 | ); |
| 1871 | assert_eq!(clear, vec!["set-option -t '$1' -u @termbridge_color"]); |
| 1872 | } |
| 1873 | |
| 1874 | /// The rename targets an id, so it cannot land on whichever session |
| 1875 | /// inherited the old name between the menu opening and the click. |
| 1876 | #[test] |
| 1877 | fn rename_session_targets_an_id() { |
| 1878 | let lines = command_lines( |
| 1879 | &TmuxRequest::RenameSession { |
| 1880 | session: "$1".into(), |
| 1881 | name: "work".into(), |
| 1882 | }, |
| 1883 | Some("/dev/pts/7"), |
| 1884 | ); |
| 1885 | assert_eq!(lines, vec!["rename-session -t '$1' 'work'"]); |
| 1886 | } |
| 1887 | |
| 1888 | /// Tab Group mode's click. Unlike SelectWindow this *does* take the client |
| 1889 | /// with it, because the tab it came from may belong to a session the panel |
| 1890 | /// is not on. |
| 1891 | #[test] |
| 1892 | fn goto_window_selects_then_brings_the_client() { |
| 1893 | let lines = command_lines(&TmuxRequest::GotoWindow("@3".into()), Some("/dev/pts/7")); |
| 1894 | assert_eq!( |
| 1895 | lines, |
| 1896 | vec!["select-window -t '@3' ; switch-client -c '/dev/pts/7' -t '@3'"] |
| 1897 | ); |
| 1898 | // No tty: tmux picks a client, exactly as Switch degrades. |
| 1899 | let lines = command_lines(&TmuxRequest::GotoWindow("@3".into()), None); |
| 1900 | assert_eq!(lines, vec!["select-window -t '@3' ; switch-client -t '@3'"]); |
| 1901 | } |
| 1902 | |
| 1903 | #[test] |
| 1904 | fn switch_targets_our_own_client() { |
| 1905 | let lines = command_lines(&TmuxRequest::Switch("work".into()), Some("/dev/pts/7")); |
| 1906 | assert_eq!(lines, vec!["switch-client -c '/dev/pts/7' -t 'work'"]); |
| 1907 | // No tty is a degraded but safe case: tmux picks a client itself. |
| 1908 | let lines = command_lines(&TmuxRequest::Switch("work".into()), None); |
| 1909 | assert_eq!(lines, vec!["switch-client -t 'work'"]); |
| 1910 | // A tty that doesn't look like one never reaches the command line. |
| 1911 | let lines = command_lines(&TmuxRequest::Switch("work".into()), Some("nope'; x")); |
| 1912 | assert_eq!(lines, vec!["switch-client -t 'work'"]); |
| 1913 | } |
| 1914 | |
| 1915 | #[test] |
| 1916 | fn claude_parses_only_with_a_session_and_a_prompt() { |
| 1917 | let req = |s: &str| parse_tmux_request(s); |
| 1918 | assert_eq!( |
| 1919 | req(r#"{"type":"tmux","cmd":"claude","session":"work","prompt":"fix the parser"}"#), |
| 1920 | Some(TmuxRequest::Claude { |
| 1921 | session: "work".into(), |
| 1922 | prompt: "fix the parser".into(), |
| 1923 | }) |
| 1924 | ); |
| 1925 | // Punctuation belongs in a sentence, so it is allowed through — the |
| 1926 | // prompt reaches a file, never a command line. |
| 1927 | assert_eq!( |
| 1928 | req(r#"{"type":"tmux","cmd":"claude","session":"work","prompt":"what's '; kill?"}"#), |
| 1929 | Some(TmuxRequest::Claude { |
| 1930 | session: "work".into(), |
| 1931 | prompt: "what's '; kill?".into(), |
| 1932 | }) |
| 1933 | ); |
| 1934 | // An escape a terminal would obey rather than print is not text. |
| 1935 | assert_eq!( |
| 1936 | req(r#"{"type":"tmux","cmd":"claude","session":"work","prompt":"a\u001b[2Jb"}"#), |
| 1937 | None |
| 1938 | ); |
| 1939 | assert_eq!( |
| 1940 | req(r#"{"type":"tmux","cmd":"claude","session":"work","prompt":" "}"#), |
| 1941 | None |
| 1942 | ); |
| 1943 | assert_eq!(req(r#"{"type":"tmux","cmd":"claude","prompt":"hi"}"#), None); |
| 1944 | } |
| 1945 | |
| 1946 | /// The prompt is in the script, the script's path is in the command line, |
| 1947 | /// and the cwd is a tmux format tmux expands for itself. |
| 1948 | #[test] |
| 1949 | fn claude_puts_the_prompt_in_a_script_and_not_on_the_command_line() { |
| 1950 | let prompt = "why does 'this' break; really?"; |
| 1951 | let lines = command_lines( |
| 1952 | &TmuxRequest::Claude { |
| 1953 | session: "work".into(), |
| 1954 | prompt: prompt.into(), |
| 1955 | }, |
| 1956 | Some("/dev/pts/7"), |
| 1957 | ); |
| 1958 | assert_eq!(lines.len(), 1); |
| 1959 | let line = &lines[0]; |
| 1960 | assert!(!line.contains("really"), "prompt leaked into: {line}"); |
| 1961 | assert!(line.contains("-c '#{pane_current_path}'"), "{line}"); |
| 1962 | assert!( |
| 1963 | line.ends_with("switch-client -c '/dev/pts/7' -t 'work:'"), |
| 1964 | "{line}" |
| 1965 | ); |
| 1966 | |
| 1967 | // And the script it named runs claude on exactly that text. |
| 1968 | let script = line |
| 1969 | .split('\'') |
| 1970 | .find(|s| s.contains("prompt-")) |
| 1971 | .expect("script path in the line"); |
| 1972 | let body = std::fs::read_to_string(script).expect("script written"); |
| 1973 | assert!( |
| 1974 | body.contains(r"prompt='why does '\''this'\'' break; really?'"), |
| 1975 | "{body}" |
| 1976 | ); |
| 1977 | assert!(body.contains("claude \"$prompt\""), "{body}"); |
| 1978 | std::fs::remove_file(script).ok(); |
| 1979 | } |
| 1980 | |
| 1981 | #[test] |
| 1982 | fn run_parses_only_with_a_session_and_a_single_line_command() { |
| 1983 | let req = |s: &str| parse_tmux_request(s); |
| 1984 | assert_eq!( |
| 1985 | req(r#"{"type":"tmux","cmd":"run","session":"work","command":"cargo test | less"}"#), |
| 1986 | Some(TmuxRequest::Run { |
| 1987 | session: "work".into(), |
| 1988 | command: "cargo test | less".into(), |
| 1989 | }) |
| 1990 | ); |
| 1991 | // Shell punctuation is the point of the feature, and it reaches a file. |
| 1992 | assert_eq!( |
| 1993 | req(r#"{"type":"tmux","cmd":"run","session":"work","command":"grep 'a; b' *.rs"}"#), |
| 1994 | Some(TmuxRequest::Run { |
| 1995 | session: "work".into(), |
| 1996 | command: "grep 'a; b' *.rs".into(), |
| 1997 | }) |
| 1998 | ); |
| 1999 | // A command is one line, unlike a prompt. |
| 2000 | assert_eq!( |
| 2001 | req(r#"{"type":"tmux","cmd":"run","session":"work","command":"ls\nrm -rf /"}"#), |
| 2002 | None |
| 2003 | ); |
| 2004 | assert_eq!( |
| 2005 | req(r#"{"type":"tmux","cmd":"run","session":"work","command":" "}"#), |
| 2006 | None |
| 2007 | ); |
| 2008 | assert_eq!(req(r#"{"type":"tmux","cmd":"run","command":"ls"}"#), None); |
| 2009 | } |
| 2010 | |
| 2011 | /// The command is in the script; only the script's path and a window name |
| 2012 | /// narrowed to a bare word reach the tmux command line. |
| 2013 | #[test] |
| 2014 | fn run_puts_the_command_in_a_script_and_not_on_the_command_line() { |
| 2015 | let lines = command_lines( |
| 2016 | &TmuxRequest::Run { |
| 2017 | session: "work".into(), |
| 2018 | command: "cargo test -- --nocapture 'it works'".into(), |
| 2019 | }, |
| 2020 | Some("/dev/pts/7"), |
| 2021 | ); |
| 2022 | assert_eq!(lines.len(), 1); |
| 2023 | let line = &lines[0]; |
| 2024 | assert!(!line.contains("nocapture"), "command leaked into: {line}"); |
| 2025 | assert!(line.contains("-n 'cargo'"), "{line}"); |
| 2026 | assert!(line.contains("-c '#{pane_current_path}'"), "{line}"); |
| 2027 | assert!( |
| 2028 | line.ends_with("switch-client -c '/dev/pts/7' -t 'work:'"), |
| 2029 | "{line}" |
| 2030 | ); |
| 2031 | |
| 2032 | let script = line |
| 2033 | .split('\'') |
| 2034 | .find(|s| s.contains("run-")) |
| 2035 | .expect("script path in the line"); |
| 2036 | let body = std::fs::read_to_string(script).expect("script written"); |
| 2037 | assert!( |
| 2038 | body.contains(r"cmd='cargo test -- --nocapture '\''it works'\'''"), |
| 2039 | "{body}" |
| 2040 | ); |
| 2041 | // The user's own shell runs it, and the pane outlives it. |
| 2042 | assert!(body.contains(r#""${SHELL:-/bin/sh}" -c "$cmd""#), "{body}"); |
| 2043 | assert!(body.contains(r#"exec "${SHELL:-/bin/sh}""#), "{body}"); |
| 2044 | std::fs::remove_file(script).ok(); |
| 2045 | } |
| 2046 | |
| 2047 | /// An ssh connection is the `run` window with `ssh <host>` in it, named |
| 2048 | /// for the machine rather than for the login. |
| 2049 | #[test] |
| 2050 | fn ssh_opens_a_local_window_running_ssh() { |
| 2051 | let lines = command_lines( |
| 2052 | &TmuxRequest::Ssh { |
| 2053 | session: "work".into(), |
| 2054 | host: "collin@mini".into(), |
| 2055 | }, |
| 2056 | Some("/dev/pts/7"), |
| 2057 | ); |
| 2058 | assert_eq!(lines.len(), 1); |
| 2059 | let line = &lines[0]; |
| 2060 | assert!(line.contains("-n 'mini'"), "{line}"); |
| 2061 | assert!( |
| 2062 | line.ends_with("switch-client -c '/dev/pts/7' -t 'work:'"), |
| 2063 | "{line}" |
| 2064 | ); |
| 2065 | |
| 2066 | let script = line |
| 2067 | .split('\'') |
| 2068 | .find(|s| s.contains("run-")) |
| 2069 | .expect("script path in the line"); |
| 2070 | let body = std::fs::read_to_string(script).expect("script written"); |
| 2071 | assert!(body.contains("cmd='ssh collin@mini'"), "{body}"); |
| 2072 | // The window outlives the connection, so a refusal stays on screen. |
| 2073 | assert!(body.contains(r#"exec "${SHELL:-/bin/sh}""#), "{body}"); |
| 2074 | std::fs::remove_file(script).ok(); |
| 2075 | } |
| 2076 | |
| 2077 | /// A first word that is not a bare word never reaches the command line. |
| 2078 | #[test] |
| 2079 | fn run_window_name_falls_back_when_the_command_is_not_a_plain_word() { |
| 2080 | assert_eq!(window_name_for("cargo test"), "cargo"); |
| 2081 | assert_eq!(window_name_for("/usr/bin/env ls"), "env"); |
| 2082 | assert_eq!(window_name_for("FOO=1 ls"), "run"); |
| 2083 | assert_eq!(window_name_for("'weird thing'"), "run"); |
| 2084 | assert_eq!(window_name_for("#(whoami)"), "run"); |
| 2085 | assert_eq!(window_name_for("-x"), "run"); |
| 2086 | assert_eq!(window_name_for(""), "run"); |
| 2087 | } |
| 2088 | |
| 2089 | #[test] |
| 2090 | fn new_project_takes_a_rooted_path_and_nothing_else() { |
| 2091 | let req = |s: &str| parse_tmux_request(s); |
| 2092 | assert_eq!( |
| 2093 | req(r#"{"type":"tmux","cmd":"new-project","path":"/srv/foo","name":"foo"}"#), |
| 2094 | Some(TmuxRequest::NewProject { |
| 2095 | path: "/srv/foo".into(), |
| 2096 | name: "foo".into(), |
| 2097 | prompt: None, |
| 2098 | }) |
| 2099 | ); |
| 2100 | assert_eq!( |
| 2101 | req( |
| 2102 | r#"{"type":"tmux","cmd":"new-project","path":"/srv/foo","name":"foo","prompt":"go"}"# |
| 2103 | ), |
| 2104 | Some(TmuxRequest::NewProject { |
| 2105 | path: "/srv/foo".into(), |
| 2106 | name: "foo".into(), |
| 2107 | prompt: Some("go".into()), |
| 2108 | }) |
| 2109 | ); |
| 2110 | for bad in [ |
| 2111 | // Relative, and traversal: neither is a path this daemon expands. |
| 2112 | r#"{"type":"tmux","cmd":"new-project","path":"foo","name":"foo"}"#, |
| 2113 | r#"{"type":"tmux","cmd":"new-project","path":"/srv/../etc","name":"foo"}"#, |
| 2114 | // A session name that would reach tmux as a flag, or as two words. |
| 2115 | r#"{"type":"tmux","cmd":"new-project","path":"/srv/foo","name":"-C"}"#, |
| 2116 | r#"{"type":"tmux","cmd":"new-project","path":"/srv/foo","name":"a b"}"#, |
| 2117 | r#"{"type":"tmux","cmd":"new-project","path":"/srv/foo","name":"a' ; kill-server ; '"}"#, |
| 2118 | // Present but unusable is a rejection, not a shell. |
| 2119 | r#"{"type":"tmux","cmd":"new-project","path":"/srv/foo","name":"foo","prompt":""}"#, |
| 2120 | r#"{"type":"tmux","cmd":"new-project","name":"foo"}"#, |
| 2121 | r#"{"type":"tmux","cmd":"new-project","path":"/srv/foo"}"#, |
| 2122 | ] { |
| 2123 | assert_eq!(req(bad), None, "should be refused: {bad}"); |
| 2124 | } |
| 2125 | } |
| 2126 | |
| 2127 | /// The directory is in the script, not on the tmux command line — a path is |
| 2128 | /// allowed to contain the quote that line is held together with. |
| 2129 | #[test] |
| 2130 | fn new_project_puts_the_directory_in_a_script() { |
| 2131 | let dir = std::env::temp_dir().join("tb-it's-here"); |
| 2132 | let lines = command_lines( |
| 2133 | &TmuxRequest::NewProject { |
| 2134 | path: dir.to_string_lossy().into(), |
| 2135 | name: "here".into(), |
| 2136 | prompt: Some("do the thing".into()), |
| 2137 | }, |
| 2138 | Some("/dev/pts/7"), |
| 2139 | ); |
| 2140 | assert_eq!(lines.len(), 2); |
| 2141 | assert!(!lines[0].contains("it's"), "path leaked into: {}", lines[0]); |
| 2142 | assert!(!lines[0].contains("do the thing"), "{}", lines[0]); |
| 2143 | assert!(lines[0].starts_with("new-session -d -s 'here' -n 'claude' '")); |
| 2144 | assert_eq!(lines[1], "switch-client -c '/dev/pts/7' -t 'here:'"); |
| 2145 | |
| 2146 | let script = lines[0] |
| 2147 | .split('\'') |
| 2148 | .find(|s| s.contains("project-")) |
| 2149 | .expect("script path in the line"); |
| 2150 | let body = std::fs::read_to_string(script).expect("script written"); |
| 2151 | assert!(body.contains(r"dir='/tmp/tb-it'\''s-here'"), "{body}"); |
| 2152 | assert!(body.contains("prompt='do the thing'"), "{body}"); |
| 2153 | assert!(body.contains("claude \"$prompt\""), "{body}"); |
| 2154 | let _ = std::fs::remove_file(script); |
| 2155 | |
| 2156 | // Without a prompt it is a shell, and the window is named for the |
| 2157 | // session rather than for Claude. |
| 2158 | let lines = command_lines( |
| 2159 | &TmuxRequest::NewProject { |
| 2160 | path: "/srv/foo".into(), |
| 2161 | name: "foo".into(), |
| 2162 | prompt: None, |
| 2163 | }, |
| 2164 | None, |
| 2165 | ); |
| 2166 | assert!(lines[0].starts_with("new-session -d -s 'foo' -n 'foo' '")); |
| 2167 | let script = lines[0] |
| 2168 | .split('\'') |
| 2169 | .find(|s| s.contains("project-")) |
| 2170 | .expect("script path in the line"); |
| 2171 | let body = std::fs::read_to_string(script).expect("script written"); |
| 2172 | assert!(!body.contains("claude"), "{body}"); |
| 2173 | let _ = std::fs::remove_file(script); |
| 2174 | } |
| 2175 | |
| 2176 | /// A path query is answered, and it can never become a tmux command. |
| 2177 | #[test] |
| 2178 | fn path_queries_are_their_own_frame() { |
| 2179 | assert_eq!( |
| 2180 | parse_path_query(r#"{"type":"path","q":"~/Code"}"#), |
| 2181 | Some("~/Code".into()) |
| 2182 | ); |
| 2183 | assert_eq!(parse_path_query(r#"{"type":"tmux","cmd":"switch"}"#), None); |
| 2184 | assert_eq!(parse_tmux_request(r#"{"type":"path","q":"~/Code"}"#), None); |
| 2185 | assert_eq!( |
| 2186 | parse_path_query(&format!(r#"{{"type":"path","q":"{}"}}"#, "a".repeat(9000))), |
| 2187 | None |
| 2188 | ); |
| 2189 | |
| 2190 | let answer = path_answer("../etc"); |
| 2191 | assert!(answer.contains(r#""kind":"invalid""#), "{answer}"); |
| 2192 | let tmp = std::env::temp_dir(); |
| 2193 | let answer = path_answer(&tmp.to_string_lossy()); |
| 2194 | assert!(answer.contains(r#""kind":"dir""#), "{answer}"); |
| 2195 | } |
| 2196 | |
| 2197 | #[test] |
| 2198 | fn create_attaches_rather_than_failing_on_an_existing_name() { |
| 2199 | let lines = command_lines(&TmuxRequest::Create("scratch".into()), None); |
| 2200 | assert_eq!(lines[0], "new-session -d -A -s 'scratch'"); |
| 2201 | assert_eq!(lines[1], "switch-client -t 'scratch'"); |
| 2202 | } |
| 2203 | } |