anvilsign in

collin/browser-terminal-extension

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
7use std::net::{IpAddr, Ipv4Addr, SocketAddr};
8use std::sync::Arc;
9use std::sync::atomic::{AtomicUsize, Ordering};
10use std::time::{Duration, Instant};
11
12use futures_util::{SinkExt, StreamExt};
13use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
14use tokio::net::{TcpListener, TcpStream};
15use tokio::sync::{Mutex, mpsc};
16use tokio_tungstenite::WebSocketStream;
17use tokio_tungstenite::tungstenite::Message;
18use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
19use tokio_tungstenite::tungstenite::http;
20
21use crate::auth::{self, Denied};
22
23#[derive(Clone)]
24pub 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
54impl 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)]
89pub 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
105struct Failures {
106 count: u32,
107 locked_until: Option<tokio::time::Instant>,
108}
109
110struct 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.
123struct Live(Arc<State>);
124
125impl Live {
126 fn new(state: &Arc<State>) -> Self {
127 state.live.fetch_add(1, Ordering::SeqCst);
128 Live(Arc::clone(state))
129 }
130}
131
132impl 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
140impl 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
168pub struct Server {
169 addr: SocketAddr,
170 events: mpsc::UnboundedReceiver<Event>,
171 _task: tokio::task::JoinHandle<()>,
172}
173
174impl 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.
259async 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
278fn 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
285async 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.
344async fn dispatch<S>(
345 mut stream: S,
346 state: Arc<State>,
347) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
348where
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
370const 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
388async fn handshake<S>(
389 stream: S,
390 state: Arc<State>,
391) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
392where
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.
516async fn pty_loop<S>(
517 mut ws: WebSocketStream<S>,
518 config: &Config,
519) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
520where
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)]
679pub 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.
810const 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.
817fn 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.
831fn 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.
843fn 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
865fn 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.
951fn 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.
972fn 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
981fn 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.
1184async 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.
1287const BROWSER_CONF: &str = "browser.conf";
1288const 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.
1294fn 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.
1325async 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`].
1359async 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.
1378async 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
1398fn 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
1411fn 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
1419fn 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
1429async fn echo_loop<S>(
1430 mut ws: WebSocketStream<S>,
1431) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
1432where
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)]
1447mod 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}