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::time::Duration;
10
11use futures_util::{SinkExt, StreamExt};
12use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
13use tokio::net::{TcpListener, TcpStream};
14use tokio::sync::{mpsc, Mutex};
15use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
16use tokio_tungstenite::tungstenite::http;
17use tokio_tungstenite::tungstenite::Message;
18use tokio_tungstenite::WebSocketStream;
19
20use crate::auth::{self, Denied};
21
22#[derive(Clone)]
23pub struct Config {
24 pub token: String,
25 pub paired_origins: Vec<String>,
26 /// How long a client has to send its auth frame after the upgrade.
27 pub auth_timeout: Duration,
28 pub max_failures: u32,
29 pub lockout: Duration,
30 /// What we run in the pty. Fixed by the daemon, never client-supplied.
31 pub profile: crate::pty::Profile,
32 /// Session used when the client doesn't name one.
33 pub default_session: String,
34 /// When the client names no session and exactly one tmux session already
35 /// exists, attach to that one instead of `default_session`. Off when the
36 /// user pinned a session on the command line — an explicit `--session` is
37 /// a choice, not a default to be second-guessed.
38 pub adopt_sole_session: bool,
39 /// Skip the pty and echo frames back. Used by the security tests so they
40 /// exercise the auth path without spawning shells.
41 pub echo_only: bool,
42 /// TLS identity. When present the listener serves wss:// and ws:// on the
43 /// same port, chosen per-connection by sniffing the first byte.
44 pub tls: Option<tokio_rustls::TlsAcceptor>,
45}
46
47impl Config {
48 pub fn new(token: impl Into<String>, paired_origins: Vec<String>) -> Self {
49 Self {
50 token: token.into(),
51 paired_origins,
52 auth_timeout: Duration::from_secs(3),
53 max_failures: 10,
54 lockout: Duration::from_secs(30),
55 profile: crate::pty::Profile::default(),
56 default_session: crate::pty::DEFAULT_SESSION.to_string(),
57 // Opt-in: a caller that hands us a profile has picked its session,
58 // and only the CLI knows whether the user picked it or we did.
59 adopt_sole_session: false,
60 echo_only: false,
61 tls: None,
62 }
63 }
64
65 /// The lone existing session to join instead of the profile's own, if
66 /// adoption is on and tmux is holding exactly one.
67 ///
68 /// Resolved per connection rather than at startup: sessions come and go
69 /// while the daemon runs, and the answer should reflect what tmux holds at
70 /// the moment the sidebar connects. `None` means "use the profile as
71 /// configured".
72 pub fn adopted_session(&self) -> Option<String> {
73 if !self.adopt_sole_session || self.profile.program != "tmux" {
74 return None;
75 }
76 crate::pty::sole_session(&self.profile.tmux_global_args())
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum Event {
82 Accepted { origin: String },
83 Rejected {
84 origin: Option<String>,
85 why: Denied,
86 /// Underlying cause, when the failure happened below our own checks
87 /// (e.g. the request never was a WebSocket upgrade).
88 detail: Option<String>,
89 },
90}
91
92struct Failures {
93 count: u32,
94 locked_until: Option<tokio::time::Instant>,
95}
96
97struct State {
98 config: Config,
99 failures: Mutex<Failures>,
100 events: mpsc::UnboundedSender<Event>,
101}
102
103impl State {
104 async fn locked_out(&self) -> bool {
105 let f = self.failures.lock().await;
106 match f.locked_until {
107 Some(t) => tokio::time::Instant::now() < t,
108 None => false,
109 }
110 }
111
112 async fn record_failure(&self) {
113 let mut f = self.failures.lock().await;
114 f.count += 1;
115 if f.count >= self.config.max_failures {
116 f.locked_until = Some(tokio::time::Instant::now() + self.config.lockout);
117 }
118 }
119
120 async fn record_success(&self) {
121 let mut f = self.failures.lock().await;
122 f.count = 0;
123 f.locked_until = None;
124 }
125
126 fn emit(&self, e: Event) {
127 let _ = self.events.send(e);
128 }
129}
130
131pub struct Server {
132 addr: SocketAddr,
133 events: mpsc::UnboundedReceiver<Event>,
134 _task: tokio::task::JoinHandle<()>,
135}
136
137impl Server {
138 /// Bind and start accepting.
139 ///
140 /// The bind address is hardcoded to loopback and is not configurable. This
141 /// daemon hands out shell access; a `--bind` flag would be a footgun whose
142 /// only purpose is to create a remotely exploitable configuration.
143 pub async fn start(config: Config, port: u16) -> std::io::Result<Self> {
144 let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
145 let listener = TcpListener::bind(addr).await?;
146 let addr = listener.local_addr()?;
147
148 // Belt and braces: if this ever regresses, fail loudly at startup
149 // rather than quietly listening on the network.
150 assert!(
151 addr.ip().is_loopback(),
152 "refusing to serve on non-loopback address {addr}"
153 );
154
155 let (tx, rx) = mpsc::unbounded_channel();
156 let state = Arc::new(State {
157 config,
158 failures: Mutex::new(Failures {
159 count: 0,
160 locked_until: None,
161 }),
162 events: tx,
163 });
164
165 let task = tokio::spawn(async move {
166 loop {
167 let Ok((stream, peer)) = listener.accept().await else {
168 continue;
169 };
170 let state = Arc::clone(&state);
171 tokio::spawn(async move {
172 let _ = handle_conn(stream, peer, state).await;
173 });
174 }
175 });
176
177 Ok(Server {
178 addr,
179 events: rx,
180 _task: task,
181 })
182 }
183
184 pub fn addr(&self) -> SocketAddr {
185 self.addr
186 }
187
188 pub fn url(&self) -> String {
189 format!("ws://{}", self.addr)
190 }
191
192 pub async fn next_event(&mut self) -> Option<Event> {
193 self.events.recv().await
194 }
195}
196
197fn deny(why: Denied) -> ErrorResponse {
198 http::Response::builder()
199 .status(why.status())
200 .body(Some(why.reason().to_string()))
201 .expect("static response builds")
202}
203
204async fn handle_conn(
205 stream: TcpStream,
206 _peer: SocketAddr,
207 state: Arc<State>,
208) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
209 if state.locked_out().await {
210 state.emit(Event::Rejected {
211 origin: None,
212 why: Denied::RateLimited,
213 detail: None,
214 });
215 // Still perform the reject through the handshake so the client sees 429.
216 let _ = tokio_tungstenite::accept_hdr_async(stream, |_: &Request, _| {
217 Err(deny(Denied::RateLimited))
218 })
219 .await;
220 return Ok(());
221 }
222
223 // A TLS ClientHello starts with 0x16 (handshake record). Sniffing it lets
224 // one port serve both wss:// and ws://, which matters because Firefox's
225 // HTTPS-Only Mode rewrites ws:// to wss:// and the user should not have to
226 // weaken a browser security setting to run this.
227 let mut first = [0u8; 1];
228 let is_tls = matches!(stream.peek(&mut first).await, Ok(1) if first[0] == 0x16);
229
230 if is_tls {
231 let Some(acceptor) = state.config.tls.clone() else {
232 state.record_failure().await;
233 state.emit(Event::Rejected {
234 origin: None,
235 why: Denied::TlsAttempted,
236 detail: Some("this daemon was started without a TLS identity".into()),
237 });
238 return Ok(());
239 };
240 let tls_stream = match acceptor.accept(stream).await {
241 Ok(s) => s,
242 Err(e) => {
243 // Almost always the browser refusing our self-signed cert.
244 state.emit(Event::Rejected {
245 origin: None,
246 why: Denied::TlsHandshakeFailed,
247 detail: Some(e.to_string()),
248 });
249 return Ok(());
250 }
251 };
252 return dispatch(tls_stream, state).await;
253 }
254
255 dispatch(stream, state).await
256}
257
258/// Serve one already-negotiated stream: either the certificate-trust landing
259/// page, or the WebSocket handshake.
260async fn dispatch<S>(mut stream: S, state: Arc<State>) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
261where
262 S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
263{
264 // Look at the request head so a browser that navigated here to accept the
265 // certificate gets an explanation instead of a protocol error.
266 let head = crate::rewind::read_head(&mut stream, 8192).await.unwrap_or_default();
267 if !crate::rewind::is_websocket_upgrade(&head) {
268 state.emit(Event::Rejected {
269 origin: None,
270 why: Denied::NotAWebSocketUpgrade,
271 detail: Some("served the certificate-trust page".into()),
272 });
273 let _ = stream.write_all(LANDING_PAGE.as_bytes()).await;
274 let _ = stream.flush().await;
275 return Ok(());
276 }
277 let stream = crate::rewind::Rewind::new(head, stream);
278 handshake(stream, state).await
279}
280
281const LANDING_PAGE: &str = concat!(
282 "HTTP/1.1 200 OK\r\n",
283 "Content-Type: text/html; charset=utf-8\r\n",
284 "Connection: close\r\n",
285 "Cache-Control: no-store\r\n",
286 "\r\n",
287 "<!doctype html><meta charset=utf-8><title>termbridge</title>",
288 "<style>body{font:15px/1.6 system-ui,sans-serif;max-width:34rem;margin:12vh auto;",
289 "padding:0 1.5rem;background:#14161a;color:#d7dae0}h1{font-size:1.1rem}",
290 "code{background:#1c1f25;padding:.15em .4em;border-radius:3px;font-size:.9em}",
291 "p{color:#9aa3b2}</style>",
292 "<h1>termbridge is running</h1>",
293 "<p>If you reached this page to accept the certificate, you're done \u{2014} ",
294 "close this tab and reconnect the sidebar.</p>",
295 "<p>This endpoint serves the terminal over WebSocket only. It exposes no ",
296 "other data, and in particular it never hands out the auth token.</p>"
297);
298
299async fn handshake<S>(stream: S, state: Arc<State>) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
300where
301 S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
302{
303 let seen_origin: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
304 let handshake_err: Arc<std::sync::Mutex<Option<Denied>>> =
305 Arc::new(std::sync::Mutex::new(None));
306
307 let paired = state.config.paired_origins.clone();
308 let so = Arc::clone(&seen_origin);
309 let he = Arc::clone(&handshake_err);
310
311 let ws = tokio_tungstenite::accept_hdr_async(stream, move |req: &Request, res: Response| {
312 let header = |name: &str| {
313 req.headers()
314 .get(name)
315 .and_then(|v| v.to_str().ok())
316 .map(str::to_string)
317 };
318 let origin = header("origin");
319 let host = header("host");
320 *so.lock().unwrap() = origin.clone();
321
322 match auth::check_handshake(origin.as_deref(), host.as_deref(), &paired) {
323 Ok(()) => Ok(res),
324 Err(why) => {
325 *he.lock().unwrap() = Some(why);
326 Err(deny(why))
327 }
328 }
329 })
330 .await;
331
332 let origin = seen_origin.lock().unwrap().clone();
333 let hs_err = *handshake_err.lock().unwrap();
334
335 let mut ws = match ws {
336 Ok(ws) => ws,
337 Err(e) => {
338 // If hs_err is set, we rejected it deliberately. If it is not, the
339 // handshake failed before our callback ever ran — which means the
340 // request was not a valid WebSocket upgrade at all. Reporting that
341 // as an auth problem sends people hunting for the wrong bug.
342 let (why, detail) = match hs_err {
343 Some(why) => (why, None),
344 None => (Denied::NotAWebSocketUpgrade, Some(e.to_string())),
345 };
346 state.record_failure().await;
347 state.emit(Event::Rejected { origin, why, detail });
348 return Ok(());
349 }
350 };
351
352 // Upgrade succeeded; now the auth frame, on a deadline.
353 let first = tokio::time::timeout(state.config.auth_timeout, ws.next()).await;
354
355 let result = match first {
356 Err(_) => Err(Denied::AuthTimeout),
357 Ok(None) => Err(Denied::AuthTimeout),
358 Ok(Some(Err(_))) => Err(Denied::MalformedAuth),
359 Ok(Some(Ok(Message::Text(t)))) => auth::check_auth_frame(&t, &state.config.token),
360 Ok(Some(Ok(_))) => Err(Denied::MalformedAuth),
361 };
362
363 if let Err(why) = result {
364 state.record_failure().await;
365 state.emit(Event::Rejected {
366 origin,
367 why,
368 detail: None,
369 });
370 let _ = ws
371 .send(Message::Text(
372 serde_json::json!({"type": "error", "reason": why.reason()})
373 .to_string()
374 .into(),
375 ))
376 .await;
377 let _ = ws.close(None).await;
378 return Ok(());
379 }
380
381 state.record_success().await;
382 state.emit(Event::Accepted {
383 origin: origin.clone().unwrap_or_default(),
384 });
385
386 let is_tmux = state.config.profile.program == "tmux";
387 ws.send(Message::Text(
388 serde_json::json!({
389 "type": "ok",
390 "profile": state.config.profile.program,
391 "tmux": is_tmux,
392 "defaultSession": state.config.adopted_session()
393 .unwrap_or_else(|| state.config.default_session.clone()),
394 // Enumerated server-side; the client picks from reality rather
395 // than inventing names.
396 "sessions": if is_tmux { crate::pty::list_sessions(&state.config.profile.tmux_global_args()) } else { Vec::new() },
397 })
398 .to_string()
399 .into(),
400 ))
401 .await?;
402
403 if state.config.echo_only {
404 return echo_loop(ws).await;
405 }
406 pty_loop(ws, &state.config).await
407}
408
409/// Post-auth session: binary frames are raw terminal bytes in both directions,
410/// text frames are JSON control messages.
411async fn pty_loop<S>(
412 mut ws: WebSocketStream<S>,
413 config: &Config,
414) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
415where
416 S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
417{
418 // Wait for the client to tell us its size before spawning, so the shell's
419 // first prompt is drawn at the right width.
420 let (cols, rows, requested) = match tokio::time::timeout(Duration::from_secs(5), ws.next()).await
421 {
422 Ok(Some(Ok(Message::Text(t)))) => parse_open(&t).unwrap_or((80, 24, None)),
423 _ => (80, 24, None),
424 };
425
426 // The client may name a tmux session, but only a name that survives
427 // validation, and only when we are actually running tmux. Anything else
428 // silently falls back to the configured default rather than erroring —
429 // there is no path here from client input to an arbitrary program.
430 let profile = if config.profile.program == "tmux" {
431 match requested {
432 Some(name) => config.profile.with_session(&name),
433 None => match config.adopted_session() {
434 Some(only) => config.profile.with_session(&only),
435 None => config.profile.clone(),
436 },
437 }
438 } else {
439 config.profile.clone()
440 };
441 let profile = &profile;
442
443 let spawned = match crate::pty::PtySession::spawn(profile, cols, rows) {
444 Ok(s) => s,
445 Err(e) => {
446 let _ = ws
447 .send(Message::Text(
448 serde_json::json!({"type": "error", "reason": e.to_string()})
449 .to_string()
450 .into(),
451 ))
452 .await;
453 return Ok(());
454 }
455 };
456
457 let crate::pty::Spawned {
458 session,
459 mut output,
460 mut exit,
461 } = spawned;
462
463 // tmux is the source of truth for which session we're on, what else exists,
464 // and what Claude Code is doing in it — and all of that changes without
465 // anything crossing this socket. A control-mode client in its own task both
466 // watches for those changes and carries the sidebar's requests back.
467 let (status_tx, mut status_rx) = mpsc::channel::<String>(8);
468 let (tmux_tx, tmux_rx) = mpsc::channel::<TmuxRequest>(8);
469 if profile.program == "tmux" {
470 // The session the interactive client was pointed at. Only used to give
471 // the control client something to attach to; from then on tmux tells us
472 // where that client actually is.
473 let started_on = profile
474 .args
475 .iter()
476 .position(|a| a == "-s")
477 .and_then(|i| profile.args.get(i + 1))
478 .cloned()
479 .unwrap_or_else(|| config.default_session.clone());
480 tokio::spawn(control_loop(
481 started_on,
482 profile.tmux_global_args(),
483 session.tty_name(),
484 status_tx,
485 tmux_rx,
486 ));
487 }
488
489 loop {
490 tokio::select! {
491 // pty -> browser
492 chunk = output.recv() => {
493 match chunk {
494 Some(bytes) => ws.send(Message::Binary(bytes.into())).await?,
495 None => {
496 // EOF on the pty beats the child being reaped, so wait
497 // briefly for the status rather than dropping the
498 // sidebar with no explanation.
499 let code = tokio::time::timeout(Duration::from_secs(2), &mut exit)
500 .await
501 .ok()
502 .and_then(|r| r.ok())
503 .unwrap_or(-1);
504 let _ = ws.send(Message::Text(
505 serde_json::json!({"type": "exit", "code": code}).to_string().into()
506 )).await;
507 break;
508 }
509 }
510 }
511 // browser -> pty
512 msg = ws.next() => {
513 match msg {
514 Some(Ok(Message::Binary(b))) => {
515 if !session.write(b.to_vec()) { break; }
516 }
517 // Text is control only. Keystrokes must arrive as binary so
518 // that non-UTF-8 input is never mangled.
519 Some(Ok(Message::Text(t))) => {
520 if let Some((c, r)) = parse_resize(&t) {
521 let _ = session.resize(c, r);
522 }
523 if let Some(req) = parse_tmux_request(&t) {
524 // Full queue means the control client is wedged;
525 // dropping the request beats stalling the terminal.
526 let _ = tmux_tx.try_send(req);
527 }
528 }
529 Some(Ok(Message::Close(_))) | None => break,
530 Some(Err(_)) => break,
531 _ => {}
532 }
533 }
534 Some(json) = status_rx.recv() => {
535 ws.send(Message::Text(json.into())).await?;
536 }
537 code = &mut exit => {
538 let code = code.unwrap_or(-1);
539 let _ = ws.send(Message::Text(
540 serde_json::json!({"type": "exit", "code": code}).to_string().into()
541 )).await;
542 break;
543 }
544 }
545 }
546
547 // Dropping the session kills the tmux *client*. The tmux server, and the
548 // session itself, keep running for the next attach.
549 drop(session);
550 let _ = ws.close(None).await;
551 Ok(())
552}
553
554/// The complete set of things the sidebar may ask tmux to do.
555///
556/// An enum rather than a command string, so the wire protocol cannot express
557/// anything outside this list. Each variant's payload is validated at parse
558/// time, and the command lines built from them are the only ones in the daemon
559/// that contain client-supplied text.
560#[derive(Debug, Clone, PartialEq, Eq)]
561pub enum TmuxRequest {
562 /// Move the live client to an existing session — no reconnect, no new pty.
563 Switch(String),
564 /// Create a detached session, then switch to it.
565 Create(String),
566 /// Bring a pane into view: select it, its window, and its session.
567 Focus(String),
568 /// Make a window of the attached session the current one — the sidebar's
569 /// tab click. Windows belong to the session, not to a client, so this
570 /// deliberately moves every client watching that session, exactly as
571 /// pressing `prefix 2` in the terminal would.
572 SelectWindow(String),
573 /// Open a window in a session and select it — the sidebar's "+".
574 NewWindow(String),
575 /// Close a window and everything running in it — the sidebar's tab ✕.
576 ///
577 /// The one entry here that destroys anything, and tmux has no undo for it.
578 /// The window id keeps the blast radius to exactly one window; the sidebar
579 /// additionally refuses to offer it for a session's last window, which
580 /// would take the session and our own client with it.
581 KillWindow(String),
582}
583
584fn parse_tmux_request(text: &str) -> Option<TmuxRequest> {
585 let v: serde_json::Value = serde_json::from_str(text).ok()?;
586 if v.get("type")?.as_str()? != "tmux" {
587 return None;
588 }
589 let arg = |k: &str| v.get(k).and_then(|x| x.as_str());
590 match v.get("cmd")?.as_str()? {
591 "switch" => crate::pty::valid_session_name(arg("session")?).map(TmuxRequest::Switch),
592 "create" => crate::pty::valid_session_name(arg("session")?).map(TmuxRequest::Create),
593 "focus" => crate::pty::valid_pane_id(arg("pane")?).map(TmuxRequest::Focus),
594 "select-window" => {
595 crate::pty::valid_window_id(arg("window")?).map(TmuxRequest::SelectWindow)
596 }
597 "new-window" => crate::pty::valid_session_name(arg("session")?).map(TmuxRequest::NewWindow),
598 "kill-window" => crate::pty::valid_window_id(arg("window")?).map(TmuxRequest::KillWindow),
599 _ => None,
600 }
601}
602
603/// Command lines for a request. `tty` identifies our interactive client, so the
604/// switch moves *it* rather than whichever client tmux would otherwise pick.
605fn command_lines(req: &TmuxRequest, tty: Option<&str>) -> Vec<String> {
606 let client = tty
607 .and_then(crate::pty::valid_tty)
608 .map(|t| format!(" -c '{t}'"))
609 .unwrap_or_default();
610 match req {
611 TmuxRequest::Switch(name) => vec![format!("switch-client{client} -t '{name}'")],
612 TmuxRequest::Create(name) => vec![
613 // -A so a name that already exists attaches instead of failing,
614 // matching what the sidebar's session field has always done.
615 format!("new-session -d -A -s '{name}'"),
616 format!("switch-client{client} -t '{name}'"),
617 ],
618 TmuxRequest::Focus(pane) => vec![format!(
619 "select-pane -t '{pane}' ; select-window -t '{pane}' ; switch-client{client} -t '{pane}'"
620 )],
621 // No `-c`: a window id already identifies its session, and selecting a
622 // window is a property of the session rather than of our client.
623 TmuxRequest::SelectWindow(id) => vec![format!("select-window -t '{id}'")],
624 // `-a` inserts after the current window instead of claiming an index
625 // that may already be taken, which is an error rather than a shuffle.
626 // The trailing colon targets the session's current window.
627 TmuxRequest::NewWindow(name) => vec![format!("new-window -a -t '{name}:'")],
628 TmuxRequest::KillWindow(id) => vec![format!("kill-window -t '{id}'")],
629 }
630}
631
632/// Owns the control-mode client: pushes a status frame whenever tmux says
633/// something changed, and runs the sidebar's requests.
634async fn control_loop(
635 session: String,
636 global_args: Vec<String>,
637 tty: Option<String>,
638 status_tx: mpsc::Sender<String>,
639 mut requests: mpsc::Receiver<TmuxRequest>,
640) {
641 let Some((control, mut notifications)) = attach_with_retry(&session, &global_args).await else {
642 return;
643 };
644 // Claude Code's hook records live on disk and change without tmux noticing,
645 // so a slow tick backs up the push notifications. It reads a handful of
646 // small files; there is no process spawn on this path at all.
647 let mut ticker = tokio::time::interval(Duration::from_secs(1));
648 let mut last: Option<crate::status::Snapshot> = None;
649
650 loop {
651 let snap = crate::status::snapshot(&control, tty.as_deref()).await;
652 if last.as_ref() != Some(&snap) {
653 let json = serde_json::json!({
654 "type": "status",
655 "session": snap.session,
656 "sessions": snap.sessions,
657 "windows": snap.windows,
658 "agents": snap.agents,
659 })
660 .to_string();
661 if status_tx.send(json).await.is_err() {
662 return; // Connection gone.
663 }
664 last = Some(snap);
665 }
666
667 tokio::select! {
668 _ = ticker.tick() => {}
669 note = notifications.recv() => {
670 match note {
671 // Uninteresting notifications are the common case; skip the
672 // round trip rather than rebuilding the snapshot for a
673 // layout change nobody displays.
674 Some(n) if !crate::control::is_interesting(&n) => continue,
675 Some(_) => {}
676 None => return,
677 }
678 }
679 req = requests.recv() => {
680 let Some(req) = req else { return };
681 for line in command_lines(&req, tty.as_deref()) {
682 if let Err(reason) = control.run(line).await {
683 let json = serde_json::json!({
684 "type": "tmux-error", "reason": reason,
685 }).to_string();
686 let _ = status_tx.send(json).await;
687 break;
688 }
689 }
690 }
691 }
692 }
693}
694
695/// The interactive client creates the session, and we may get here first.
696async fn attach_with_retry(
697 session: &str,
698 global_args: &[String],
699) -> Option<(
700 crate::control::Control,
701 mpsc::Receiver<crate::control::Notification>,
702)> {
703 for _ in 0..10 {
704 if let Ok(pair) = crate::control::Control::attach(session, global_args).await {
705 // A failed attach still spawns: confirm the client is actually
706 // talking before handing it out.
707 if pair.0.run("display-message -p ok").await.is_ok() {
708 return Some(pair);
709 }
710 }
711 tokio::time::sleep(Duration::from_millis(200)).await;
712 }
713 None
714}
715
716fn parse_open(text: &str) -> Option<(u16, u16, Option<String>)> {
717 let v: serde_json::Value = serde_json::from_str(text).ok()?;
718 if v.get("type")?.as_str()? != "open" {
719 return None;
720 }
721 let (cols, rows) = dims(&v);
722 let session = v
723 .get("session")
724 .and_then(|x| x.as_str())
725 .and_then(crate::pty::valid_session_name);
726 Some((cols, rows, session))
727}
728
729fn parse_resize(text: &str) -> Option<(u16, u16)> {
730 let v: serde_json::Value = serde_json::from_str(text).ok()?;
731 match v.get("type")?.as_str()? {
732 "resize" | "open" => Some(dims(&v)),
733 _ => None,
734 }
735}
736
737fn dims(v: &serde_json::Value) -> (u16, u16) {
738 let get = |k: &str, d: u64| v.get(k).and_then(|x| x.as_u64()).unwrap_or(d).clamp(1, 1000) as u16;
739 (get("cols", 80), get("rows", 24))
740}
741
742async fn echo_loop<S>(
743 mut ws: WebSocketStream<S>,
744) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
745where
746 S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
747{
748 while let Some(Ok(msg)) = ws.next().await {
749 match msg {
750 Message::Text(t) => ws.send(Message::Text(t)).await?,
751 Message::Binary(b) => ws.send(Message::Binary(b)).await?,
752 Message::Close(_) => break,
753 _ => {}
754 }
755 }
756 Ok(())
757}
758
759#[cfg(test)]
760mod tests {
761 use super::*;
762
763 /// A lone session is what the user is already working in, so a client that
764 /// names none should land there rather than in a fresh `browser` session.
765 /// Two sessions is ambiguous, and the configured default wins again.
766 ///
767 /// Runs against a real tmux on a private socket; skipped where there is no
768 /// tmux to talk to.
769 #[test]
770 fn a_single_existing_session_is_adopted() {
771 if !crate::pty::Profile::tmux_available() {
772 eprintln!("skipping: no tmux on PATH");
773 return;
774 }
775 let socket = "termbridge-sole-session-test";
776 let tmux = |args: &[&str]| {
777 std::process::Command::new("tmux")
778 .args(["-L", socket])
779 .args(args)
780 .stdout(std::process::Stdio::null())
781 .stderr(std::process::Stdio::null())
782 .status()
783 };
784 let _ = tmux(&["kill-server"]);
785
786 let mut config = Config::new("t", vec![]);
787 config.profile = crate::pty::Profile {
788 program: "tmux".into(),
789 args: vec![
790 "-L".into(),
791 socket.into(),
792 "new-session".into(),
793 "-A".into(),
794 "-s".into(),
795 "browser".into(),
796 ],
797 };
798 config.default_session = "browser".into();
799 config.adopt_sole_session = true;
800
801 // No server running: nothing to adopt.
802 assert_eq!(config.adopted_session(), None);
803
804 let _ = tmux(&["new-session", "-d", "-s", "work"]);
805 assert_eq!(config.adopted_session().as_deref(), Some("work"));
806
807 // Pinned by the command line: the user's choice is not overridden.
808 config.adopt_sole_session = false;
809 assert_eq!(config.adopted_session(), None);
810 config.adopt_sole_session = true;
811
812 // Two sessions is ambiguous, so the profile's own session stands.
813 let _ = tmux(&["new-session", "-d", "-s", "other"]);
814 assert_eq!(config.adopted_session(), None);
815
816 let _ = tmux(&["kill-server"]);
817 }
818
819 /// The wire protocol must not be able to name a tmux command. Anything the
820 /// sidebar sends either maps to one of the allowlisted variants or is
821 /// dropped.
822 #[test]
823 fn only_the_allowlisted_requests_parse() {
824 let req = |s: &str| parse_tmux_request(s);
825 assert_eq!(
826 req(r#"{"type":"tmux","cmd":"switch","session":"work"}"#),
827 Some(TmuxRequest::Switch("work".into()))
828 );
829 assert_eq!(
830 req(r#"{"type":"tmux","cmd":"focus","pane":"%12"}"#),
831 Some(TmuxRequest::Focus("%12".into()))
832 );
833 assert_eq!(
834 req(r#"{"type":"tmux","cmd":"select-window","window":"@3"}"#),
835 Some(TmuxRequest::SelectWindow("@3".into()))
836 );
837 assert_eq!(
838 req(r#"{"type":"tmux","cmd":"new-window","session":"work"}"#),
839 Some(TmuxRequest::NewWindow("work".into()))
840 );
841 assert_eq!(
842 req(r#"{"type":"tmux","cmd":"kill-window","window":"@3"}"#),
843 Some(TmuxRequest::KillWindow("@3".into()))
844 );
845 // Killing anything larger than a window is still not expressible.
846 assert_eq!(req(r#"{"type":"tmux","cmd":"kill-session","session":"work"}"#), None);
847 assert_eq!(req(r#"{"type":"tmux","cmd":"kill-pane","pane":"%3"}"#), None);
848 assert_eq!(req(r#"{"type":"tmux","cmd":"kill-server"}"#), None);
849 assert_eq!(req(r#"{"type":"tmux","cmd":"run","command":"rm -rf /"}"#), None);
850 assert_eq!(req(r#"{"type":"resize","cols":80,"rows":24}"#), None);
851 }
852
853 /// Names and pane ids are quoted into a command line, so the validators are
854 /// the boundary. Quotes, semicolons and spaces must not survive parsing.
855 #[test]
856 fn hostile_arguments_are_rejected_not_escaped() {
857 let req = |s: &str| parse_tmux_request(s);
858 for hostile in [
859 r#"{"type":"tmux","cmd":"switch","session":"a' ; kill-server ; '"}"#,
860 r#"{"type":"tmux","cmd":"switch","session":"-C"}"#,
861 r#"{"type":"tmux","cmd":"switch","session":"a b"}"#,
862 r#"{"type":"tmux","cmd":"focus","pane":"%1 ; kill-server"}"#,
863 r#"{"type":"tmux","cmd":"focus","pane":"$1"}"#,
864 r#"{"type":"tmux","cmd":"focus","pane":"%"}"#,
865 r#"{"type":"tmux","cmd":"select-window","window":"@1 ; kill-server"}"#,
866 r#"{"type":"tmux","cmd":"select-window","window":"%1"}"#,
867 r#"{"type":"tmux","cmd":"select-window","window":"@"}"#,
868 // An index is not an id: `2` would be a bare target, and
869 // `session:2.0` carries syntax of its own.
870 r#"{"type":"tmux","cmd":"select-window","window":"2"}"#,
871 r#"{"type":"tmux","cmd":"new-window","session":"a' ; kill-server ; '"}"#,
872 r#"{"type":"tmux","cmd":"new-window","session":"work:1"}"#,
873 // The destructive one gets the same validator, and `-a` is the
874 // difference between one window and every window.
875 r#"{"type":"tmux","cmd":"kill-window","window":"@1 ; kill-server"}"#,
876 r#"{"type":"tmux","cmd":"kill-window","window":"-a"}"#,
877 r#"{"type":"tmux","cmd":"kill-window","window":""}"#,
878 ] {
879 assert_eq!(req(hostile), None, "accepted {hostile}");
880 }
881 }
882
883 #[test]
884 fn window_commands_stay_inside_their_session() {
885 // No -c: windows belong to the session, so this moves whoever is
886 // watching it — the same thing `prefix 2` in the terminal does.
887 let lines = command_lines(&TmuxRequest::SelectWindow("@3".into()), Some("/dev/pts/7"));
888 assert_eq!(lines, vec!["select-window -t '@3'"]);
889
890 // -a rather than an index, which could collide with an existing window.
891 let lines = command_lines(&TmuxRequest::NewWindow("work".into()), None);
892 assert_eq!(lines, vec!["new-window -a -t 'work:'"]);
893
894 // One window, named by id. No -a, which would kill all *but* it.
895 let lines = command_lines(&TmuxRequest::KillWindow("@3".into()), Some("/dev/pts/7"));
896 assert_eq!(lines, vec!["kill-window -t '@3'"]);
897 }
898
899 #[test]
900 fn switch_targets_our_own_client() {
901 let lines = command_lines(&TmuxRequest::Switch("work".into()), Some("/dev/pts/7"));
902 assert_eq!(lines, vec!["switch-client -c '/dev/pts/7' -t 'work'"]);
903 // No tty is a degraded but safe case: tmux picks a client itself.
904 let lines = command_lines(&TmuxRequest::Switch("work".into()), None);
905 assert_eq!(lines, vec!["switch-client -t 'work'"]);
906 // A tty that doesn't look like one never reaches the command line.
907 let lines = command_lines(&TmuxRequest::Switch("work".into()), Some("nope'; x"));
908 assert_eq!(lines, vec!["switch-client -t 'work'"]);
909 }
910
911 #[test]
912 fn create_attaches_rather_than_failing_on_an_existing_name() {
913 let lines = command_lines(&TmuxRequest::Create("scratch".into()), None);
914 assert_eq!(lines[0], "new-session -d -A -s 'scratch'");
915 assert_eq!(lines[1], "switch-client -t 'scratch'");
916 }
917}