anvilsign in

collin/browser-terminal-extension

1//! PTY session backend.
2//!
3//! One PTY per authenticated connection, running tmux. tmux does the
4//! multiplexing, so the daemon deliberately has no concept of tabs, panes or
5//! sessions — that would be reimplementing tmux badly.
6//!
7//! `portable-pty` is blocking, so reads and writes each get a dedicated thread
8//! and talk to the async side over channels.
9
10use std::io::{Read, Write};
11use std::sync::{Arc, Mutex};
12
13use portable_pty::{Child, CommandBuilder, MasterPty, PtySize};
14use tokio::sync::mpsc;
15
16/// What the daemon is allowed to run.
17///
18/// Deliberately *not* client-supplied. An authenticated client can type
19/// anything into the resulting shell anyway, so this isn't a privilege
20/// boundary — but it means the WebSocket API is not literally "exec arbitrary
21/// argv", which keeps the blast radius of any future auth bug much smaller.
22#[derive(Debug, Clone)]
23pub struct Profile {
24 pub program: String,
25 pub args: Vec<String>,
26}
27
28impl Default for Profile {
29 fn default() -> Self {
30 Profile::tmux(DEFAULT_SESSION)
31 }
32}
33
34pub const DEFAULT_SESSION: &str = "browser";
35
36/// Session names reach `execvp` as a separate argv element, never a shell, so
37/// metacharacters cannot inject a command. The one real hazard is a leading
38/// dash, which tmux would parse as a flag. The charset restriction on top of
39/// that is belt-and-braces, and keeps names to something tmux is happy with.
40pub fn valid_session_name(raw: &str) -> Option<String> {
41 let s = raw.trim();
42 if s.is_empty() || s.len() > 64 || s.starts_with('-') {
43 return None;
44 }
45 // tmux itself rejects '.' and ':' in session names.
46 if s.contains('.') || s.contains(':') {
47 return None;
48 }
49 if !s
50 .chars()
51 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
52 {
53 return None;
54 }
55 Some(s.to_string())
56}
57
58/// Existing tmux sessions, so the sidebar can offer them instead of guessing.
59///
60/// `global_args` carries the server-selection flags from the profile; without
61/// them this asks the *default* tmux server, which is a different set of
62/// sessions than the one the sidebar is attached to.
63pub fn list_sessions(global_args: &[String]) -> Vec<String> {
64 let Ok(out) = std::process::Command::new("tmux")
65 .args(global_args)
66 .args(["list-sessions", "-F", "#{session_name}"])
67 .output()
68 else {
69 return Vec::new();
70 };
71 if !out.status.success() {
72 return Vec::new(); // no server running yet
73 }
74 String::from_utf8_lossy(&out.stdout)
75 .lines()
76 .map(str::trim)
77 .filter(|l| !l.is_empty())
78 .map(str::to_string)
79 .collect()
80}
81
82/// The one existing tmux session, when there is exactly one.
83///
84/// With a single session running there is no ambiguity about which one the
85/// user meant, so joining it beats standing up a second session beside it.
86/// Zero sessions, or more than one, and the caller falls back to its default.
87pub fn sole_session(global_args: &[String]) -> Option<String> {
88 let mut sessions = list_sessions(global_args);
89 (sessions.len() == 1).then(|| sessions.remove(0))
90}
91
92/// tmux pane ids are `%` followed by digits. Client-supplied ones end up in a
93/// command line sent to a live tmux server, so nothing else is allowed through.
94pub fn valid_pane_id(raw: &str) -> Option<String> {
95 let s = raw.trim();
96 let Some(digits) = s.strip_prefix('%') else {
97 return None;
98 };
99 if digits.is_empty() || digits.len() > 12 || !digits.chars().all(|c| c.is_ascii_digit()) {
100 return None;
101 }
102 Some(s.to_string())
103}
104
105/// A tmux window id (`@3`), same treatment as a pane id.
106///
107/// The sidebar names windows by id rather than index precisely because it goes
108/// into a command line: an id is `@` plus digits and nothing else, while an
109/// index is part of a `session:index.pane` target syntax with its own escapes.
110pub fn valid_window_id(raw: &str) -> Option<String> {
111 let s = raw.trim();
112 let Some(digits) = s.strip_prefix('@') else {
113 return None;
114 };
115 if digits.is_empty() || digits.len() > 12 || !digits.chars().all(|c| c.is_ascii_digit()) {
116 return None;
117 }
118 Some(s.to_string())
119}
120
121/// Our own pty's slave path, on its way into `switch-client -c`. It comes from
122/// the kernel rather than a client, but it is still quoted into a command line,
123/// so it gets the same treatment.
124pub fn valid_tty(raw: &str) -> Option<&str> {
125 let s = raw.trim();
126 (s.starts_with("/dev/")
127 && s.len() < 64
128 && s.chars()
129 .all(|c| c.is_ascii_alphanumeric() || "/-_.".contains(c)))
130 .then_some(s)
131}
132
133impl Profile {
134 /// Fall back to a bare shell when tmux isn't installed, so the daemon is
135 /// still useful rather than failing to start.
136 pub fn shell_fallback() -> Self {
137 Self {
138 program: std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()),
139 args: vec!["-l".into()],
140 }
141 }
142
143 /// Build a tmux profile for a specific session.
144 ///
145 /// `-A` attaches to `session` if it exists and creates it otherwise, which
146 /// is what lets the sidebar rejoin your existing work rather than starting
147 /// something new every time.
148 pub fn tmux(session: &str) -> Self {
149 Self {
150 program: "tmux".into(),
151 args: vec![
152 "new-session".into(),
153 "-A".into(),
154 "-s".into(),
155 session.to_string(),
156 ],
157 }
158 }
159
160 /// The same profile pointed at a different session.
161 ///
162 /// Only the `-s` value changes, so the server-selection flags and any
163 /// trailing command survive — rebuilding with [`Profile::tmux`] would drop
164 /// both and silently address the default tmux server.
165 pub fn with_session(&self, session: &str) -> Self {
166 let mut args = self.args.clone();
167 match args.iter().position(|a| a == "-s") {
168 Some(i) if i + 1 < args.len() => {
169 args[i + 1] = session.to_string();
170 Self {
171 program: self.program.clone(),
172 args,
173 }
174 }
175 _ => Profile::tmux(session),
176 }
177 }
178
179 /// Flags that select which tmux *server* to talk to (`-L`, `-S`, `-f`),
180 /// taken from the front of the profile's argv. Every other tmux invocation
181 /// — the control client, session listings — has to repeat them or it
182 /// silently addresses a different server.
183 pub fn tmux_global_args(&self) -> Vec<String> {
184 let mut out = Vec::new();
185 let mut args = self.args.iter();
186 while let Some(a) = args.next() {
187 if !a.starts_with('-') {
188 break; // First command word; globals are all before it.
189 }
190 out.push(a.clone());
191 if matches!(a.as_str(), "-L" | "-S" | "-f") {
192 if let Some(value) = args.next() {
193 out.push(value.clone());
194 }
195 }
196 }
197 out
198 }
199
200 pub fn tmux_available() -> bool {
201 std::process::Command::new("tmux")
202 .arg("-V")
203 .stdout(std::process::Stdio::null())
204 .stderr(std::process::Stdio::null())
205 .status()
206 .map(|s| s.success())
207 .unwrap_or(false)
208 }
209}
210
211pub struct PtySession {
212 master: Box<dyn MasterPty + Send>,
213 writer_tx: mpsc::UnboundedSender<Vec<u8>>,
214 child: Arc<Mutex<Box<dyn Child + Send + Sync>>>,
215}
216
217pub struct Spawned {
218 pub session: PtySession,
219 /// Raw bytes from the pty. Chunked as they arrive; no framing, no encoding.
220 pub output: mpsc::Receiver<Vec<u8>>,
221 /// Fires once with the child's exit status.
222 pub exit: tokio::sync::oneshot::Receiver<i32>,
223}
224
225impl PtySession {
226 pub fn spawn(profile: &Profile, cols: u16, rows: u16) -> std::io::Result<Spawned> {
227 let pty = portable_pty::native_pty_system();
228 let pair = pty
229 .openpty(PtySize {
230 rows: rows.max(1),
231 cols: cols.max(1),
232 pixel_width: 0,
233 pixel_height: 0,
234 })
235 .map_err(|e| std::io::Error::other(e.to_string()))?;
236
237 let mut cmd = CommandBuilder::new(&profile.program);
238 for a in &profile.args {
239 cmd.arg(a);
240 }
241 // xterm-256color is what xterm.js actually implements. Advertising
242 // anything richer makes programs emit sequences the renderer drops.
243 cmd.env("TERM", "xterm-256color");
244 cmd.env("COLORTERM", "truecolor");
245 if let Some(home) = std::env::var_os("HOME") {
246 cmd.cwd(home);
247 }
248
249 let child = pair
250 .slave
251 .spawn_command(cmd)
252 .map_err(|e| std::io::Error::other(e.to_string()))?;
253 // Drop the slave in the parent, or the pty never reports EOF.
254 drop(pair.slave);
255
256 let mut reader = pair
257 .master
258 .try_clone_reader()
259 .map_err(|e| std::io::Error::other(e.to_string()))?;
260 let mut writer = pair
261 .master
262 .take_writer()
263 .map_err(|e| std::io::Error::other(e.to_string()))?;
264
265 // Bounded: if the browser can't keep up with a `yes` flood, block the
266 // reader thread rather than growing a queue until the daemon OOMs.
267 let (out_tx, output) = mpsc::channel::<Vec<u8>>(256);
268 std::thread::spawn(move || {
269 let mut buf = [0u8; 8192];
270 loop {
271 match reader.read(&mut buf) {
272 Ok(0) | Err(_) => break,
273 Ok(n) => {
274 if out_tx.blocking_send(buf[..n].to_vec()).is_err() {
275 break;
276 }
277 }
278 }
279 }
280 });
281
282 let (writer_tx, mut writer_rx) = mpsc::unbounded_channel::<Vec<u8>>();
283 std::thread::spawn(move || {
284 while let Some(chunk) = writer_rx.blocking_recv() {
285 if writer.write_all(&chunk).is_err() || writer.flush().is_err() {
286 break;
287 }
288 }
289 });
290
291 let child = Arc::new(Mutex::new(child));
292 let (exit_tx, exit) = tokio::sync::oneshot::channel();
293 let waiter = Arc::clone(&child);
294 std::thread::spawn(move || {
295 let code = loop {
296 let status = { waiter.lock().unwrap().try_wait() };
297 match status {
298 Ok(Some(s)) => break s.exit_code() as i32,
299 Ok(None) => std::thread::sleep(std::time::Duration::from_millis(100)),
300 Err(_) => break -1,
301 }
302 };
303 let _ = exit_tx.send(code);
304 });
305
306 Ok(Spawned {
307 session: PtySession {
308 master: pair.master,
309 writer_tx,
310 child,
311 },
312 output,
313 exit,
314 })
315 }
316
317 /// Path of the slave side, e.g. `/dev/pts/7`. This is how tmux names our
318 /// client, so it is the key for looking the client's session back up.
319 #[cfg(unix)]
320 pub fn tty_name(&self) -> Option<String> {
321 self.master
322 .tty_name()
323 .map(|p| p.to_string_lossy().into_owned())
324 }
325
326 #[cfg(not(unix))]
327 pub fn tty_name(&self) -> Option<String> {
328 None
329 }
330
331 /// Keystrokes from the browser, straight through. No interpretation.
332 pub fn write(&self, bytes: Vec<u8>) -> bool {
333 self.writer_tx.send(bytes).is_ok()
334 }
335
336 /// TIOCSWINSZ on the master; the child gets SIGWINCH and tmux reflows.
337 pub fn resize(&self, cols: u16, rows: u16) -> std::io::Result<()> {
338 self.master
339 .resize(PtySize {
340 rows: rows.max(1),
341 cols: cols.max(1),
342 pixel_width: 0,
343 pixel_height: 0,
344 })
345 .map_err(|e| std::io::Error::other(e.to_string()))
346 }
347
348 pub fn kill(&self) {
349 let _ = self.child.lock().unwrap().kill();
350 }
351}
352
353impl Drop for PtySession {
354 fn drop(&mut self) {
355 // Killing the tmux *client* on disconnect is correct and is the whole
356 // point: the tmux server keeps the session alive for the next attach.
357 self.kill();
358 }
359}