anvilsign in

collin/browser-terminal-extension

main / daemon / src / pty.rs
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
34/// The session the sidebar lands in with nothing configured, and the one the
35/// panel's "+" puts a new window in when it is not adding to a group.
36///
37/// Named for the role rather than for this client: it is where windows go when
38/// nothing has said otherwise, which is what a user's other tmux sessions are
39/// *not*. `--session` overrides it, and a lone pre-existing session is adopted
40/// ahead of creating this one — see `adopt_sole_session`.
41pub const DEFAULT_SESSION: &str = "default";
42
43/// Session names reach `execvp` as a separate argv element, never a shell, so
44/// metacharacters cannot inject a command. The one real hazard is a leading
45/// dash, which tmux would parse as a flag. The charset restriction on top of
46/// that is belt-and-braces, and keeps names to something tmux is happy with.
47pub fn valid_session_name(raw: &str) -> Option<String> {
48 let s = raw.trim();
49 if s.is_empty() || s.len() > 64 || s.starts_with('-') {
50 return None;
51 }
52 // tmux itself rejects '.' and ':' in session names.
53 if s.contains('.') || s.contains(':') {
54 return None;
55 }
56 if !s
57 .chars()
58 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
59 {
60 return None;
61 }
62 Some(s.to_string())
63}
64
65/// The most text the omnibar may hand to a new Claude. Long enough for a
66/// paragraph pasted into the box, short enough that nothing can push a
67/// megabyte of it through the socket a keystroke at a time.
68const MAX_PROMPT: usize = 8192;
69
70/// A prompt for "send to Claude": any text, minus the bytes that would make it
71/// something other than text.
72///
73/// Deliberately permissive about punctuation, unlike the name validators above
74/// — a prompt is a sentence, and quotes and semicolons belong in one. It is
75/// safe to be, because the prompt never reaches a command line: it is written
76/// to a file, single-quoted, by [`crate::paths::write_prompt_script`]. What is
77/// rejected is control characters, which would arrive at a terminal as
78/// something to obey rather than something to read.
79pub fn valid_prompt(raw: &str) -> Option<String> {
80 let s = raw.trim();
81 if s.is_empty() || s.len() > MAX_PROMPT {
82 return None;
83 }
84 if s.chars().any(|c| c.is_control() && c != '\n' && c != '\t') {
85 return None;
86 }
87 Some(s.to_string())
88}
89
90/// The most text the omnibar may hand to a shell. A command line is a line,
91/// not a document; anything past this is a paste that meant to go somewhere
92/// else.
93const MAX_COMMAND: usize = 4096;
94
95/// A shell command for the omnibar's `!` — a line handed to `$SHELL -c`.
96///
97/// As permissive about punctuation as [`valid_prompt`], and for the same
98/// reason: the text is shell source, so pipes, quotes and `&&` are the point of
99/// it, and it never reaches a *tmux* command line — it is written to a file,
100/// single-quoted, by [`crate::paths::write_command_script`].
101///
102/// Newlines are rejected where a prompt allows them. The box is one line, the
103/// text is run as one, and a newline arriving here means something built the
104/// request by hand.
105pub fn valid_command(raw: &str) -> Option<String> {
106 let s = raw.trim();
107 if s.is_empty() || s.len() > MAX_COMMAND {
108 return None;
109 }
110 if s.chars().any(|c| c.is_control()) {
111 return None;
112 }
113 Some(s.to_string())
114}
115
116/// Existing tmux sessions, so the sidebar can offer them instead of guessing.
117///
118/// Oldest first, by session id, for the same reason the status frames are —
119/// this list draws the first row of tabs, and it would otherwise be in tmux's
120/// alphabetical order until the first status frame silently reshuffled it.
121///
122/// `global_args` carries the server-selection flags from the profile; without
123/// them this asks the *default* tmux server, which is a different set of
124/// sessions than the one the sidebar is attached to.
125pub fn list_sessions(global_args: &[String]) -> Vec<String> {
126 let Ok(out) = std::process::Command::new("tmux")
127 .args(global_args)
128 .args(["list-sessions", "-F", "#{session_id}\t#{session_name}"])
129 .output()
130 else {
131 return Vec::new();
132 };
133 if !out.status.success() {
134 return Vec::new(); // no server running yet
135 }
136 let text = String::from_utf8_lossy(&out.stdout);
137 let mut sessions: Vec<(u64, String)> = text
138 .lines()
139 .filter_map(|line| {
140 // Name last: a session name is allowed to contain a tab.
141 let (id, name) = line.split_once('\t')?;
142 let name = name.trim_end_matches(['\r', '\n']);
143 (!name.is_empty()).then(|| (crate::status::session_ordinal(id), name.to_string()))
144 })
145 .collect();
146 sessions.sort_by_key(|(ordinal, _)| *ordinal);
147 sessions.into_iter().map(|(_, name)| name).collect()
148}
149
150/// The one existing tmux session, when there is exactly one.
151///
152/// With a single session running there is no ambiguity about which one the
153/// user meant, so joining it beats standing up a second session beside it.
154/// Zero sessions, or more than one, and the caller falls back to its default.
155pub fn sole_session(global_args: &[String]) -> Option<String> {
156 let mut sessions = list_sessions(global_args);
157 (sessions.len() == 1).then(|| sessions.remove(0))
158}
159
160/// tmux pane ids are `%` followed by digits. Client-supplied ones end up in a
161/// command line sent to a live tmux server, so nothing else is allowed through.
162pub fn valid_pane_id(raw: &str) -> Option<String> {
163 let s = raw.trim();
164 let digits = s.strip_prefix('%')?;
165 if digits.is_empty() || digits.len() > 12 || !digits.chars().all(|c| c.is_ascii_digit()) {
166 return None;
167 }
168 Some(s.to_string())
169}
170
171/// A tmux window id (`@3`), same treatment as a pane id.
172///
173/// The sidebar names windows by id rather than index precisely because it goes
174/// into a command line: an id is `@` plus digits and nothing else, while an
175/// index is part of a `session:index.pane` target syntax with its own escapes.
176pub fn valid_window_id(raw: &str) -> Option<String> {
177 let s = raw.trim();
178 let digits = s.strip_prefix('@')?;
179 if digits.is_empty() || digits.len() > 12 || !digits.chars().all(|c| c.is_ascii_digit()) {
180 return None;
181 }
182 Some(s.to_string())
183}
184
185/// A tmux session id (`$1`). Session *names* can hold quotes and spaces, so
186/// when the daemon has to name a session in a command line of its own it uses
187/// the id, which is `$` plus digits and nothing else.
188pub fn valid_session_id(raw: &str) -> Option<String> {
189 let s = raw.trim();
190 let digits = s.strip_prefix('$')?;
191 if digits.is_empty() || digits.len() > 12 || !digits.chars().all(|c| c.is_ascii_digit()) {
192 return None;
193 }
194 Some(s.to_string())
195}
196
197/// Our own pty's slave path, on its way into `switch-client -c`. It comes from
198/// the kernel rather than a client, but it is still quoted into a command line,
199/// so it gets the same treatment.
200pub fn valid_tty(raw: &str) -> Option<&str> {
201 let s = raw.trim();
202 (s.starts_with("/dev/")
203 && s.len() < 64
204 && s.chars()
205 .all(|c| c.is_ascii_alphanumeric() || "/-_.".contains(c)))
206 .then_some(s)
207}
208
209impl Profile {
210 /// Fall back to a bare shell when tmux isn't installed, so the daemon is
211 /// still useful rather than failing to start.
212 pub fn shell_fallback() -> Self {
213 Self {
214 program: std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()),
215 args: vec!["-l".into()],
216 }
217 }
218
219 /// Build a tmux profile for a specific session.
220 ///
221 /// `-A` attaches to `session` if it exists and creates it otherwise, which
222 /// is what lets the sidebar rejoin your existing work rather than starting
223 /// something new every time.
224 pub fn tmux(session: &str) -> Self {
225 Self {
226 program: "tmux".into(),
227 args: vec![
228 "new-session".into(),
229 "-A".into(),
230 "-s".into(),
231 session.to_string(),
232 ],
233 }
234 }
235
236 /// The same profile pointed at a different session.
237 ///
238 /// Only the `-s` value changes, so the server-selection flags and any
239 /// trailing command survive — rebuilding with [`Profile::tmux`] would drop
240 /// both and silently address the default tmux server.
241 pub fn with_session(&self, session: &str) -> Self {
242 let mut args = self.args.clone();
243 match args.iter().position(|a| a == "-s") {
244 Some(i) if i + 1 < args.len() => {
245 args[i + 1] = session.to_string();
246 Self {
247 program: self.program.clone(),
248 args,
249 }
250 }
251 _ => Profile::tmux(session),
252 }
253 }
254
255 /// Flags that select which tmux *server* to talk to (`-L`, `-S`, `-f`),
256 /// taken from the front of the profile's argv. Every other tmux invocation
257 /// — the control client, session listings — has to repeat them or it
258 /// silently addresses a different server.
259 pub fn tmux_global_args(&self) -> Vec<String> {
260 let mut out = Vec::new();
261 let mut args = self.args.iter();
262 while let Some(a) = args.next() {
263 if !a.starts_with('-') {
264 break; // First command word; globals are all before it.
265 }
266 out.push(a.clone());
267 if matches!(a.as_str(), "-L" | "-S" | "-f")
268 && let Some(value) = args.next()
269 {
270 out.push(value.clone());
271 }
272 }
273 out
274 }
275
276 pub fn tmux_available() -> bool {
277 std::process::Command::new("tmux")
278 .arg("-V")
279 .stdout(std::process::Stdio::null())
280 .stderr(std::process::Stdio::null())
281 .status()
282 .map(|s| s.success())
283 .unwrap_or(false)
284 }
285}
286
287pub struct PtySession {
288 master: Box<dyn MasterPty + Send>,
289 writer_tx: mpsc::UnboundedSender<Vec<u8>>,
290 child: Arc<Mutex<Box<dyn Child + Send + Sync>>>,
291}
292
293pub struct Spawned {
294 pub session: PtySession,
295 /// Raw bytes from the pty. Chunked as they arrive; no framing, no encoding.
296 pub output: mpsc::Receiver<Vec<u8>>,
297 /// Fires once with the child's exit status.
298 pub exit: tokio::sync::oneshot::Receiver<i32>,
299}
300
301impl PtySession {
302 pub fn spawn(profile: &Profile, cols: u16, rows: u16) -> std::io::Result<Spawned> {
303 let pty = portable_pty::native_pty_system();
304 let pair = pty
305 .openpty(PtySize {
306 rows: rows.max(1),
307 cols: cols.max(1),
308 pixel_width: 0,
309 pixel_height: 0,
310 })
311 .map_err(|e| std::io::Error::other(e.to_string()))?;
312
313 let mut cmd = CommandBuilder::new(&profile.program);
314 for a in &profile.args {
315 cmd.arg(a);
316 }
317 // xterm-256color is what xterm.js actually implements. Advertising
318 // anything richer makes programs emit sequences the renderer drops.
319 cmd.env("TERM", "xterm-256color");
320 cmd.env("COLORTERM", "truecolor");
321 if let Some(home) = std::env::var_os("HOME") {
322 cmd.cwd(home);
323 }
324
325 let child = pair
326 .slave
327 .spawn_command(cmd)
328 .map_err(|e| std::io::Error::other(e.to_string()))?;
329 // Drop the slave in the parent, or the pty never reports EOF.
330 drop(pair.slave);
331
332 let mut reader = pair
333 .master
334 .try_clone_reader()
335 .map_err(|e| std::io::Error::other(e.to_string()))?;
336 let mut writer = pair
337 .master
338 .take_writer()
339 .map_err(|e| std::io::Error::other(e.to_string()))?;
340
341 // Bounded: if the browser can't keep up with a `yes` flood, block the
342 // reader thread rather than growing a queue until the daemon OOMs.
343 let (out_tx, output) = mpsc::channel::<Vec<u8>>(256);
344 std::thread::spawn(move || {
345 let mut buf = [0u8; 8192];
346 loop {
347 match reader.read(&mut buf) {
348 Ok(0) | Err(_) => break,
349 Ok(n) => {
350 if out_tx.blocking_send(buf[..n].to_vec()).is_err() {
351 break;
352 }
353 }
354 }
355 }
356 });
357
358 let (writer_tx, mut writer_rx) = mpsc::unbounded_channel::<Vec<u8>>();
359 std::thread::spawn(move || {
360 while let Some(chunk) = writer_rx.blocking_recv() {
361 if writer.write_all(&chunk).is_err() || writer.flush().is_err() {
362 break;
363 }
364 }
365 });
366
367 let child = Arc::new(Mutex::new(child));
368 let (exit_tx, exit) = tokio::sync::oneshot::channel();
369 let waiter = Arc::clone(&child);
370 std::thread::spawn(move || {
371 let code = loop {
372 let status = { waiter.lock().unwrap().try_wait() };
373 match status {
374 Ok(Some(s)) => break s.exit_code() as i32,
375 Ok(None) => std::thread::sleep(std::time::Duration::from_millis(100)),
376 Err(_) => break -1,
377 }
378 };
379 let _ = exit_tx.send(code);
380 });
381
382 Ok(Spawned {
383 session: PtySession {
384 master: pair.master,
385 writer_tx,
386 child,
387 },
388 output,
389 exit,
390 })
391 }
392
393 /// Path of the slave side, e.g. `/dev/pts/7`. This is how tmux names our
394 /// client, so it is the key for looking the client's session back up.
395 #[cfg(unix)]
396 pub fn tty_name(&self) -> Option<String> {
397 self.master
398 .tty_name()
399 .map(|p| p.to_string_lossy().into_owned())
400 }
401
402 #[cfg(not(unix))]
403 pub fn tty_name(&self) -> Option<String> {
404 None
405 }
406
407 /// Keystrokes from the browser, straight through. No interpretation.
408 pub fn write(&self, bytes: Vec<u8>) -> bool {
409 self.writer_tx.send(bytes).is_ok()
410 }
411
412 /// TIOCSWINSZ on the master; the child gets SIGWINCH and tmux reflows.
413 pub fn resize(&self, cols: u16, rows: u16) -> std::io::Result<()> {
414 self.master
415 .resize(PtySize {
416 rows: rows.max(1),
417 cols: cols.max(1),
418 pixel_width: 0,
419 pixel_height: 0,
420 })
421 .map_err(|e| std::io::Error::other(e.to_string()))
422 }
423
424 pub fn kill(&self) {
425 let _ = self.child.lock().unwrap().kill();
426 }
427}
428
429impl Drop for PtySession {
430 fn drop(&mut self) {
431 // Killing the tmux *client* on disconnect is correct and is the whole
432 // point: the tmux server keeps the session alive for the next attach.
433 self.kill();
434 }
435}