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
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 Some(digits) = s.strip_prefix('%') else {
165 return None;
166 };
167 if digits.is_empty() || digits.len() > 12 || !digits.chars().all(|c| c.is_ascii_digit()) {
168 return None;
169 }
170 Some(s.to_string())
171}
172
173/// A tmux window id (`@3`), same treatment as a pane id.
174///
175/// The sidebar names windows by id rather than index precisely because it goes
176/// into a command line: an id is `@` plus digits and nothing else, while an
177/// index is part of a `session:index.pane` target syntax with its own escapes.
178pub fn valid_window_id(raw: &str) -> Option<String> {
179 let s = raw.trim();
180 let Some(digits) = s.strip_prefix('@') else {
181 return None;
182 };
183 if digits.is_empty() || digits.len() > 12 || !digits.chars().all(|c| c.is_ascii_digit()) {
184 return None;
185 }
186 Some(s.to_string())
187}
188
189/// A tmux session id (`$1`). Session *names* can hold quotes and spaces, so
190/// when the daemon has to name a session in a command line of its own it uses
191/// the id, which is `$` plus digits and nothing else.
192pub fn valid_session_id(raw: &str) -> Option<String> {
193 let s = raw.trim();
194 let digits = s.strip_prefix('$')?;
195 if digits.is_empty() || digits.len() > 12 || !digits.chars().all(|c| c.is_ascii_digit()) {
196 return None;
197 }
198 Some(s.to_string())
199}
200
201/// Our own pty's slave path, on its way into `switch-client -c`. It comes from
202/// the kernel rather than a client, but it is still quoted into a command line,
203/// so it gets the same treatment.
204pub fn valid_tty(raw: &str) -> Option<&str> {
205 let s = raw.trim();
206 (s.starts_with("/dev/")
207 && s.len() < 64
208 && s.chars()
209 .all(|c| c.is_ascii_alphanumeric() || "/-_.".contains(c)))
210 .then_some(s)
211}
212
213impl Profile {
214 /// Fall back to a bare shell when tmux isn't installed, so the daemon is
215 /// still useful rather than failing to start.
216 pub fn shell_fallback() -> Self {
217 Self {
218 program: std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()),
219 args: vec!["-l".into()],
220 }
221 }
222
223 /// Build a tmux profile for a specific session.
224 ///
225 /// `-A` attaches to `session` if it exists and creates it otherwise, which
226 /// is what lets the sidebar rejoin your existing work rather than starting
227 /// something new every time.
228 pub fn tmux(session: &str) -> Self {
229 Self {
230 program: "tmux".into(),
231 args: vec![
232 "new-session".into(),
233 "-A".into(),
234 "-s".into(),
235 session.to_string(),
236 ],
237 }
238 }
239
240 /// The same profile pointed at a different session.
241 ///
242 /// Only the `-s` value changes, so the server-selection flags and any
243 /// trailing command survive — rebuilding with [`Profile::tmux`] would drop
244 /// both and silently address the default tmux server.
245 pub fn with_session(&self, session: &str) -> Self {
246 let mut args = self.args.clone();
247 match args.iter().position(|a| a == "-s") {
248 Some(i) if i + 1 < args.len() => {
249 args[i + 1] = session.to_string();
250 Self {
251 program: self.program.clone(),
252 args,
253 }
254 }
255 _ => Profile::tmux(session),
256 }
257 }
258
259 /// Flags that select which tmux *server* to talk to (`-L`, `-S`, `-f`),
260 /// taken from the front of the profile's argv. Every other tmux invocation
261 /// — the control client, session listings — has to repeat them or it
262 /// silently addresses a different server.
263 pub fn tmux_global_args(&self) -> Vec<String> {
264 let mut out = Vec::new();
265 let mut args = self.args.iter();
266 while let Some(a) = args.next() {
267 if !a.starts_with('-') {
268 break; // First command word; globals are all before it.
269 }
270 out.push(a.clone());
271 if matches!(a.as_str(), "-L" | "-S" | "-f") {
272 if let Some(value) = args.next() {
273 out.push(value.clone());
274 }
275 }
276 }
277 out
278 }
279
280 pub fn tmux_available() -> bool {
281 std::process::Command::new("tmux")
282 .arg("-V")
283 .stdout(std::process::Stdio::null())
284 .stderr(std::process::Stdio::null())
285 .status()
286 .map(|s| s.success())
287 .unwrap_or(false)
288 }
289}
290
291pub struct PtySession {
292 master: Box<dyn MasterPty + Send>,
293 writer_tx: mpsc::UnboundedSender<Vec<u8>>,
294 child: Arc<Mutex<Box<dyn Child + Send + Sync>>>,
295}
296
297pub struct Spawned {
298 pub session: PtySession,
299 /// Raw bytes from the pty. Chunked as they arrive; no framing, no encoding.
300 pub output: mpsc::Receiver<Vec<u8>>,
301 /// Fires once with the child's exit status.
302 pub exit: tokio::sync::oneshot::Receiver<i32>,
303}
304
305impl PtySession {
306 pub fn spawn(profile: &Profile, cols: u16, rows: u16) -> std::io::Result<Spawned> {
307 let pty = portable_pty::native_pty_system();
308 let pair = pty
309 .openpty(PtySize {
310 rows: rows.max(1),
311 cols: cols.max(1),
312 pixel_width: 0,
313 pixel_height: 0,
314 })
315 .map_err(|e| std::io::Error::other(e.to_string()))?;
316
317 let mut cmd = CommandBuilder::new(&profile.program);
318 for a in &profile.args {
319 cmd.arg(a);
320 }
321 // xterm-256color is what xterm.js actually implements. Advertising
322 // anything richer makes programs emit sequences the renderer drops.
323 cmd.env("TERM", "xterm-256color");
324 cmd.env("COLORTERM", "truecolor");
325 if let Some(home) = std::env::var_os("HOME") {
326 cmd.cwd(home);
327 }
328
329 let child = pair
330 .slave
331 .spawn_command(cmd)
332 .map_err(|e| std::io::Error::other(e.to_string()))?;
333 // Drop the slave in the parent, or the pty never reports EOF.
334 drop(pair.slave);
335
336 let mut reader = pair
337 .master
338 .try_clone_reader()
339 .map_err(|e| std::io::Error::other(e.to_string()))?;
340 let mut writer = pair
341 .master
342 .take_writer()
343 .map_err(|e| std::io::Error::other(e.to_string()))?;
344
345 // Bounded: if the browser can't keep up with a `yes` flood, block the
346 // reader thread rather than growing a queue until the daemon OOMs.
347 let (out_tx, output) = mpsc::channel::<Vec<u8>>(256);
348 std::thread::spawn(move || {
349 let mut buf = [0u8; 8192];
350 loop {
351 match reader.read(&mut buf) {
352 Ok(0) | Err(_) => break,
353 Ok(n) => {
354 if out_tx.blocking_send(buf[..n].to_vec()).is_err() {
355 break;
356 }
357 }
358 }
359 }
360 });
361
362 let (writer_tx, mut writer_rx) = mpsc::unbounded_channel::<Vec<u8>>();
363 std::thread::spawn(move || {
364 while let Some(chunk) = writer_rx.blocking_recv() {
365 if writer.write_all(&chunk).is_err() || writer.flush().is_err() {
366 break;
367 }
368 }
369 });
370
371 let child = Arc::new(Mutex::new(child));
372 let (exit_tx, exit) = tokio::sync::oneshot::channel();
373 let waiter = Arc::clone(&child);
374 std::thread::spawn(move || {
375 let code = loop {
376 let status = { waiter.lock().unwrap().try_wait() };
377 match status {
378 Ok(Some(s)) => break s.exit_code() as i32,
379 Ok(None) => std::thread::sleep(std::time::Duration::from_millis(100)),
380 Err(_) => break -1,
381 }
382 };
383 let _ = exit_tx.send(code);
384 });
385
386 Ok(Spawned {
387 session: PtySession {
388 master: pair.master,
389 writer_tx,
390 child,
391 },
392 output,
393 exit,
394 })
395 }
396
397 /// Path of the slave side, e.g. `/dev/pts/7`. This is how tmux names our
398 /// client, so it is the key for looking the client's session back up.
399 #[cfg(unix)]
400 pub fn tty_name(&self) -> Option<String> {
401 self.master
402 .tty_name()
403 .map(|p| p.to_string_lossy().into_owned())
404 }
405
406 #[cfg(not(unix))]
407 pub fn tty_name(&self) -> Option<String> {
408 None
409 }
410
411 /// Keystrokes from the browser, straight through. No interpretation.
412 pub fn write(&self, bytes: Vec<u8>) -> bool {
413 self.writer_tx.send(bytes).is_ok()
414 }
415
416 /// TIOCSWINSZ on the master; the child gets SIGWINCH and tmux reflows.
417 pub fn resize(&self, cols: u16, rows: u16) -> std::io::Result<()> {
418 self.master
419 .resize(PtySize {
420 rows: rows.max(1),
421 cols: cols.max(1),
422 pixel_width: 0,
423 pixel_height: 0,
424 })
425 .map_err(|e| std::io::Error::other(e.to_string()))
426 }
427
428 pub fn kill(&self) {
429 let _ = self.child.lock().unwrap().kill();
430 }
431}
432
433impl Drop for PtySession {
434 fn drop(&mut self) {
435 // Killing the tmux *client* on disconnect is correct and is the whole
436 // point: the tmux server keeps the session alive for the next attach.
437 self.kill();
438 }
439}