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