anvilsign in

collin/browser-terminal-extension

1//! On-disk state: the auth token and the explicitly-paired origin list.
2//!
3//! Both live in a 0700 config dir. The token file is 0600 and we *refuse to use
4//! it* if the mode ever loosens — on a multi-user box, 127.0.0.1 is reachable by
5//! every local uid, so the file mode is the only thing keeping other users out.
6
7use std::fs;
8use std::io::{self, Write};
9use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
10use std::path::{Path, PathBuf};
11
12pub const TOKEN_BYTES: usize = 32;
13
14#[derive(Debug)]
15pub enum TokenError {
16 Io(io::Error),
17 /// The token file is readable by group or other. Refuse rather than
18 /// silently authenticate against a world-readable secret.
19 TooPermissive {
20 path: PathBuf,
21 mode: u32,
22 },
23 Malformed,
24}
25
26impl std::fmt::Display for TokenError {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 match self {
29 TokenError::Io(e) => write!(f, "{e}"),
30 TokenError::TooPermissive { path, mode } => write!(
31 f,
32 "token file {} has mode {:04o}; expected 0600. \
33 Fix with: chmod 600 {}",
34 path.display(),
35 mode,
36 path.display()
37 ),
38 TokenError::Malformed => write!(f, "token file is empty or malformed"),
39 }
40 }
41}
42
43impl std::error::Error for TokenError {}
44
45impl From<io::Error> for TokenError {
46 fn from(e: io::Error) -> Self {
47 TokenError::Io(e)
48 }
49}
50
51pub fn config_dir() -> PathBuf {
52 if let Some(x) = std::env::var_os("TERMBRIDGE_CONFIG_DIR") {
53 return PathBuf::from(x);
54 }
55 let base = std::env::var_os("XDG_CONFIG_HOME")
56 .map(PathBuf::from)
57 .unwrap_or_else(|| {
58 let home = std::env::var_os("HOME")
59 .map(PathBuf::from)
60 .unwrap_or_default();
61 home.join(".config")
62 });
63 base.join("termbridge")
64}
65
66pub fn token_path() -> PathBuf {
67 config_dir().join("token")
68}
69
70pub fn paired_origins_path() -> PathBuf {
71 config_dir().join("paired-origins")
72}
73
74fn ensure_config_dir(dir: &Path) -> io::Result<()> {
75 fs::create_dir_all(dir)?;
76 // 0700: the dir itself should not be traversable by other users.
77 fs::set_permissions(dir, fs::Permissions::from_mode(0o700))
78}
79
80/// Generate a fresh CSPRNG token and write it 0600, replacing any existing one.
81pub fn generate_token(dir: &Path) -> Result<String, TokenError> {
82 ensure_config_dir(dir)?;
83 let token = random_hex(TOKEN_BYTES);
84 let path = dir.join("token");
85
86 // Create with 0600 *at open time* — never create-then-chmod, which leaves a
87 // window where the secret is world-readable.
88 let mut f = fs::OpenOptions::new()
89 .write(true)
90 .create(true)
91 .truncate(true)
92 .mode(0o600)
93 .open(&path)?;
94 f.write_all(token.as_bytes())?;
95 f.write_all(b"\n")?;
96 f.sync_all()?;
97
98 // An existing file keeps its old mode through O_CREAT, so enforce it too.
99 fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
100 Ok(token)
101}
102
103/// Load the token, refusing if the file is group/other accessible.
104pub fn load_token(dir: &Path) -> Result<String, TokenError> {
105 let path = dir.join("token");
106 let meta = fs::metadata(&path)?;
107 let mode = meta.permissions().mode() & 0o777;
108 if mode & 0o077 != 0 {
109 return Err(TokenError::TooPermissive { path, mode });
110 }
111 let token = fs::read_to_string(&path)?.trim().to_string();
112 if token.is_empty() {
113 return Err(TokenError::Malformed);
114 }
115 Ok(token)
116}
117
118pub fn load_or_create_token(dir: &Path) -> Result<String, TokenError> {
119 match load_token(dir) {
120 Ok(t) => Ok(t),
121 Err(TokenError::Io(e)) if e.kind() == io::ErrorKind::NotFound => generate_token(dir),
122 Err(e) => Err(e),
123 }
124}
125
126/// Origins the user has *explicitly* approved. Absence of this file means no
127/// client can connect — pairing is never implicit.
128pub fn load_paired_origins(dir: &Path) -> Vec<String> {
129 let path = dir.join("paired-origins");
130 let Ok(contents) = fs::read_to_string(path) else {
131 return Vec::new();
132 };
133 contents
134 .lines()
135 .map(str::trim)
136 .filter(|l| !l.is_empty() && !l.starts_with('#'))
137 .map(|l| l.to_ascii_lowercase())
138 .collect()
139}
140
141pub fn pair_origin(dir: &Path, origin: &str) -> io::Result<bool> {
142 ensure_config_dir(dir)?;
143 let origin = origin.trim().to_ascii_lowercase();
144 let mut existing = load_paired_origins(dir);
145 if existing.iter().any(|o| o == &origin) {
146 return Ok(false);
147 }
148 existing.push(origin);
149 let path = dir.join("paired-origins");
150 let mut f = fs::OpenOptions::new()
151 .write(true)
152 .create(true)
153 .truncate(true)
154 .mode(0o600)
155 .open(&path)?;
156 writeln!(
157 f,
158 "# Origins approved to connect to termbridge. One per line."
159 )?;
160 for o in &existing {
161 writeln!(f, "{o}")?;
162 }
163 Ok(true)
164}
165
166pub fn unpair_origin(dir: &Path, origin: &str) -> io::Result<bool> {
167 let origin = origin.trim().to_ascii_lowercase();
168 let existing = load_paired_origins(dir);
169 if !existing.iter().any(|o| o == &origin) {
170 return Ok(false);
171 }
172 let path = dir.join("paired-origins");
173 let mut f = fs::OpenOptions::new()
174 .write(true)
175 .create(true)
176 .truncate(true)
177 .mode(0o600)
178 .open(&path)?;
179 writeln!(
180 f,
181 "# Origins approved to connect to termbridge. One per line."
182 )?;
183 for o in existing.iter().filter(|o| *o != &origin) {
184 writeln!(f, "{o}")?;
185 }
186 Ok(true)
187}
188
189/// Where the throwaway launcher scripts live: the per-user runtime dir when
190/// there is one (0700 already, and cleared on logout), the temp dir otherwise.
191/// Either way the subdirectory is made 0700, because on a shared box `/tmp` is
192/// world-traversable and a prompt is the user's text.
193fn script_dir() -> PathBuf {
194 let base = std::env::var_os("XDG_RUNTIME_DIR")
195 .map(PathBuf::from)
196 .unwrap_or_else(std::env::temp_dir);
197 base.join("termbridge")
198}
199
200/// Write a script that runs `claude` on `prompt`, and return its path.
201///
202/// The prompt exists as a file rather than as part of a command line because it
203/// is arbitrary user text and the command line it would otherwise land in is a
204/// *tmux* one — tmux's single quotes have no escape, so there is no way to put
205/// a quote through them, and its double quotes expand `#()`, which runs a
206/// shell. A path this daemon generated is the only client text on that line,
207/// and it is hex.
208///
209/// The script drops the shell it came from at the end rather than exiting: a
210/// window that vanishes the moment Claude does takes the transcript with it.
211pub fn write_prompt_script(prompt: &str) -> io::Result<PathBuf> {
212 let dir = script_dir();
213 fs::create_dir_all(&dir)?;
214 fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
215 let path = dir.join(format!("prompt-{}.sh", random_hex(8)));
216 let mut f = fs::OpenOptions::new()
217 .write(true)
218 .create_new(true)
219 .mode(0o700)
220 .open(&path)?;
221 // Single quotes, with the one escape sh allows: end the quote, an escaped
222 // quote, start it again. Nothing else in the prompt is special inside them.
223 let quoted = prompt.replace('\'', r"'\''");
224 write!(
225 f,
226 "#!/bin/sh\n\
227 prompt='{quoted}'\n\
228 # Unlinked while the shell still holds it open, so this reads on.\n\
229 rm -f -- \"$0\"\n\
230 claude \"$prompt\"\n\
231 exec \"${{SHELL:-/bin/sh}}\"\n"
232 )?;
233 Ok(path)
234}
235
236/// Write a script that runs `command` in the user's shell, and return its path.
237///
238/// A file for the same reason [`write_prompt_script`] is one: the text is the
239/// user's, and the line it would otherwise be spliced into is tmux's, which
240/// cannot be escaped into safely.
241///
242/// The command runs under `$SHELL -c` rather than `/bin/sh -c` because the box
243/// it was typed into looks like the shell in the pane beside it — the aliases,
244/// functions and syntax that work there are what someone types here, and for a
245/// fish user `sh` would reject half of them.
246///
247/// Then the shell is dropped into interactively rather than exited, which is
248/// the whole point of running it here instead of in a scratch window: the
249/// output stays on screen, in the directory the command ran in, and the pane is
250/// a shell you can carry on in. A non-zero status is printed first, because the
251/// prompt that replaces it is not going to say so.
252pub fn write_command_script(command: &str) -> io::Result<PathBuf> {
253 let dir = script_dir();
254 fs::create_dir_all(&dir)?;
255 fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
256 let path = dir.join(format!("run-{}.sh", random_hex(8)));
257 let mut f = fs::OpenOptions::new()
258 .write(true)
259 .create_new(true)
260 .mode(0o700)
261 .open(&path)?;
262 // The same single-quote escape as the prompt script: end, escaped quote,
263 // start again. Inside them nothing else in the command is special, so what
264 // the shell below is handed is exactly what was typed.
265 let quoted = command.replace('\'', r"'\''");
266 write!(
267 f,
268 "#!/bin/sh\n\
269 cmd='{quoted}'\n\
270 # Unlinked while the shell still holds it open, so this reads on.\n\
271 rm -f -- \"$0\"\n\
272 \"${{SHELL:-/bin/sh}}\" -c \"$cmd\"\n\
273 status=$?\n\
274 [ \"$status\" -eq 0 ] || printf '\\n[exit %s]\\n' \"$status\"\n\
275 exec \"${{SHELL:-/bin/sh}}\"\n"
276 )?;
277 Ok(path)
278}
279
280/// Write a script that starts a session's first pane in `dir`, optionally with
281/// Claude running on `prompt`, and return its path.
282///
283/// The directory goes in a file for the reason the prompt and the command do:
284/// the line it would otherwise be spliced into is a *tmux* one, and a path is
285/// allowed to contain a quote. `new-session -c` would be the direct way to say
286/// this and there is no safe way to write it.
287///
288/// `cd` rather than anything cleverer, because what the *pane's* working
289/// directory is is what tmux reports as the session's — so every window opened
290/// in this session afterwards, by the panel's "+" or its `!`, inherits the
291/// project directory without anything having to remember it.
292///
293/// A `cd` that fails does not close the window: it says so and hands over a
294/// shell, which is the one state from which you can see what went wrong.
295pub fn write_project_script(dir: &Path, prompt: Option<&str>) -> io::Result<PathBuf> {
296 let dir = dir
297 .to_str()
298 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path is not valid UTF-8"))?;
299 let script_dir = script_dir();
300 fs::create_dir_all(&script_dir)?;
301 fs::set_permissions(&script_dir, fs::Permissions::from_mode(0o700))?;
302 let path = script_dir.join(format!("project-{}.sh", random_hex(8)));
303 let mut f = fs::OpenOptions::new()
304 .write(true)
305 .create_new(true)
306 .mode(0o700)
307 .open(&path)?;
308 // The same single-quote escape the two scripts above use: end the quote, an
309 // escaped quote, start it again.
310 let quoted = |s: &str| s.replace('\'', r"'\''");
311 write!(
312 f,
313 "#!/bin/sh\n\
314 dir='{}'\n\
315 # Unlinked while the shell still holds it open, so this reads on.\n\
316 rm -f -- \"$0\"\n\
317 cd \"$dir\" || printf '\\ncannot enter %s\\n' \"$dir\"\n",
318 quoted(dir)
319 )?;
320 if let Some(prompt) = prompt {
321 write!(
322 f,
323 "prompt='{}'\n\
324 claude \"$prompt\"\n",
325 quoted(prompt)
326 )?;
327 }
328 writeln!(f, "exec \"${{SHELL:-/bin/sh}}\"")?;
329 Ok(path)
330}
331
332fn random_hex(n: usize) -> String {
333 let mut buf = vec![0u8; n];
334 // Straight from the OS CSPRNG. Deliberately not a userspace PRNG — this is
335 // the only thing standing between another local uid and a shell.
336 getrandom::fill(&mut buf).expect("OS CSPRNG unavailable");
337 let mut s = String::with_capacity(n * 2);
338 for b in buf {
339 use std::fmt::Write as _;
340 let _ = write!(s, "{b:02x}");
341 }
342 s
343}