anvilsign in

collin/browser-terminal-extension

1use std::time::Duration;
2
3use termbridge::paths;
4use termbridge::pty::Profile;
5use termbridge::{Config, Event, Server};
6
7use termbridge::DEFAULT_PORT;
8
9fn usage() -> ! {
10 eprintln!(
11 "termbridge — loopback WebSocket bridge (security spike)
12
13USAGE:
14 termbridge serve [--port N] [--session NAME]
15 start the daemon
16 termbridge serve --systemd-socket [--idle-timeout SECS]
17 serve on the socket systemd passed us
18 termbridge install [--port N] [--idle-timeout SECS] [--session NAME]
19 start the daemon on demand, from now on
20 termbridge uninstall stop doing that
21 termbridge reload restart the running daemon, re-source tmux.conf
22 termbridge token print the auth token (paste into extension)
23 termbridge token --rotate generate a new token
24 termbridge pair <origin> approve an extension origin
25 termbridge unpair <origin> revoke an origin
26 termbridge list show paired origins
27 termbridge cert show the TLS certificate path + fingerprint
28 termbridge cert --regenerate make a new certificate
29 termbridge hooks print the Claude Code hook settings to install
30 termbridge hook record one hook event (reads JSON on stdin)
31 termbridge tmux print the tmux.conf line for the status glyph
32
33Origins look like:
34 chrome-extension://abcdefghijklmnopabcdefghijklmnop
35 moz-extension://11111111-2222-3333-4444-555555555555
36"
37 );
38 std::process::exit(2)
39}
40
41#[tokio::main]
42async fn main() -> Result<(), Box<dyn std::error::Error>> {
43 let args: Vec<String> = std::env::args().skip(1).collect();
44 let dir = paths::config_dir();
45 let flag = |name: &str| {
46 args.iter()
47 .position(|a| a == name)
48 .and_then(|i| args.get(i + 1))
49 .cloned()
50 };
51
52 match args.first().map(String::as_str) {
53 Some("serve") => {
54 let port = flag("--port")
55 .and_then(|p| p.parse().ok())
56 .unwrap_or(DEFAULT_PORT);
57 let session = flag("--session");
58 // 0 disables it, so a unit file can turn the timeout off without
59 // being rewritten into a different shape.
60 let idle = flag("--idle-timeout")
61 .and_then(|s| s.parse::<u64>().ok())
62 .filter(|s| *s > 0)
63 .map(Duration::from_secs);
64 let listener = if args.iter().any(|a| a == "--systemd-socket") {
65 match termbridge::activation::systemd_listener()? {
66 Some(l) => Some(l),
67 None => {
68 eprintln!(
69 "--systemd-socket was passed but no socket arrived. Start this \
70 through termbridge.socket (see `termbridge install`), not directly."
71 );
72 std::process::exit(2);
73 }
74 }
75 } else {
76 None
77 };
78 serve(port, session, idle, listener).await
79 }
80 Some("install") => {
81 let opts = termbridge::install::Options {
82 port: flag("--port")
83 .and_then(|p| p.parse().ok())
84 .unwrap_or(DEFAULT_PORT),
85 idle_secs: flag("--idle-timeout")
86 .and_then(|s| s.parse().ok())
87 .unwrap_or(termbridge::install::Options::default().idle_secs),
88 session: flag("--session"),
89 };
90 termbridge::install::install(&opts)?;
91 Ok(())
92 }
93 Some("uninstall") => {
94 termbridge::install::uninstall()?;
95 Ok(())
96 }
97 // Deliberately takes no flags. Everything `install` accepts is written
98 // into the unit file, and a reload that quietly rewrote it would be an
99 // install under another name.
100 Some("reload") => {
101 termbridge::install::reload()?;
102 // The other half of an edit loop. The formats in ~/.tmux.conf name
103 // options this daemon's hooks set, so the two are changed together
104 // often enough that reloading one and not the other is the state
105 // you end up debugging.
106 for file in termbridge::window_status::reload_conf() {
107 println!("sourced {file}");
108 }
109 Ok(())
110 }
111 Some("token") => {
112 let token = if args.iter().any(|a| a == "--rotate") {
113 paths::generate_token(&dir)?
114 } else {
115 paths::load_or_create_token(&dir)?
116 };
117 println!("{token}");
118 println!("\n(from {})", paths::token_path().display());
119 Ok(())
120 }
121 Some("pair") => {
122 let origin = args.get(1).unwrap_or_else(|| usage());
123 if paths::pair_origin(&dir, origin)? {
124 println!("paired: {origin}");
125 } else {
126 println!("already paired: {origin}");
127 }
128 Ok(())
129 }
130 Some("unpair") => {
131 let origin = args.get(1).unwrap_or_else(|| usage());
132 if paths::unpair_origin(&dir, origin)? {
133 println!("unpaired: {origin}");
134 } else {
135 println!("not paired: {origin}");
136 }
137 Ok(())
138 }
139 Some("cert") => {
140 let id = if args.iter().any(|a| a == "--regenerate") {
141 termbridge::tls::generate(&dir)?
142 } else {
143 termbridge::tls::load_or_create(&dir)?
144 };
145 println!(
146 "certificate: {}",
147 dir.join(termbridge::tls::CERT_FILE).display()
148 );
149 println!(
150 "private key: {}",
151 dir.join(termbridge::tls::KEY_FILE).display()
152 );
153 println!("SHA-256: {}", id.fingerprint);
154 Ok(())
155 }
156 // Runs on every hook event, so it must be quiet and must never fail in
157 // a way Claude would surface. Any error here is a status panel that is
158 // briefly stale, which is not worth interrupting the user's session.
159 Some("hook") => {
160 let mut input = String::new();
161 use std::io::Read;
162 let _ = std::io::stdin().read_to_string(&mut input);
163 if let Ok(event) = serde_json::from_str::<serde_json::Value>(&input) {
164 let _ = termbridge::agents::apply(&event);
165 // After the record is on disk, so the status line is computed
166 // from the same state the sidebar will read. Runs even when
167 // the event removed the record: that is what clears the glyph
168 // from a window whose Claude has gone.
169 termbridge::window_status::publish();
170 // Same order and the same reason: the title is read back out of
171 // the record that was just written.
172 termbridge::pane_title::publish();
173 }
174 Ok(())
175 }
176 Some("hooks") => {
177 let exe = std::env::current_exe()
178 .map(|p| p.display().to_string())
179 .unwrap_or_else(|_| "termbridge".into());
180 println!(
181 "Add this to ~/.claude/settings.json (merge with any hooks you already have):\n"
182 );
183 println!("{}", termbridge::agents::hook_settings(&exe));
184 println!(
185 "\nRecords are written to {}\n\
186 Claude sessions then show up in the sidebar with their state and permission mode.",
187 termbridge::agents::dir().display()
188 );
189 Ok(())
190 }
191 Some("tmux") => {
192 println!("Add this to ~/.tmux.conf, then `tmux source-file ~/.tmux.conf`:\n");
193 print!("{}", termbridge::window_status::tmux_conf());
194 println!(
195 "\nThe hooks are what set it, so install those first if you haven't:\n\
196 \x20 termbridge hooks"
197 );
198 Ok(())
199 }
200 Some("list") => {
201 let origins = paths::load_paired_origins(&dir);
202 if origins.is_empty() {
203 println!(
204 "no paired origins ({})",
205 paths::paired_origins_path().display()
206 );
207 } else {
208 for o in origins {
209 println!("{o}");
210 }
211 }
212 Ok(())
213 }
214 _ => usage(),
215 }
216}
217
218async fn serve(
219 port: u16,
220 session: Option<String>,
221 idle_timeout: Option<Duration>,
222 activated: Option<std::net::TcpListener>,
223) -> Result<(), Box<dyn std::error::Error>> {
224 let dir = paths::config_dir();
225 let token = paths::load_or_create_token(&dir)?;
226 let paired = paths::load_paired_origins(&dir);
227
228 let identity = termbridge::tls::load_or_create(&dir)?;
229 let mut config = Config::new(token, paired.clone());
230 config.auth_timeout = Duration::from_secs(3);
231 config.idle_timeout = idle_timeout;
232 config.tls = Some(termbridge::tls::acceptor(&identity)?);
233 let pinned = session
234 .as_deref()
235 .and_then(termbridge::pty::valid_session_name);
236 let default_session = pinned
237 .clone()
238 .unwrap_or_else(|| termbridge::pty::DEFAULT_SESSION.to_string());
239 config.default_session = default_session.clone();
240 // A pinned --session is a choice; without one, a lone existing session is
241 // a better guess than a fresh "default" beside it.
242 config.adopt_sole_session = pinned.is_none();
243 config.profile = if Profile::tmux_available() {
244 Profile::tmux(&default_session)
245 } else {
246 eprintln!("warning: tmux not found on PATH — falling back to a plain shell.");
247 eprintln!(" Sessions will NOT survive closing the sidebar.");
248 Profile::shell_fallback()
249 };
250 let profile = config.profile.clone();
251 let config_adopts_sole = config.adopt_sole_session && profile.program == "tmux";
252
253 let mut server = match activated {
254 Some(listener) => Server::from_std(config, listener)?,
255 None => Server::start(config, port).await?,
256 };
257 println!(
258 "termbridge listening on wss://{a} (ws:// also accepted on the same port)",
259 a = server.addr()
260 );
261 println!("running: {} {}", profile.program, profile.args.join(" "));
262 let existing = termbridge::pty::list_sessions(&profile.tmux_global_args());
263 if !existing.is_empty() {
264 println!("tmux sessions available: {}", existing.join(", "));
265 }
266 if config_adopts_sole {
267 match existing.as_slice() {
268 [only] => println!("joining the only existing session: {only}"),
269 _ => println!("new clients start in session: {default_session}"),
270 }
271 }
272 println!("token: {}", paths::token_path().display());
273 println!("cert: SHA-256 {}", identity.fingerprint);
274 println!(
275 "\nFirst time only — the certificate is self-signed, so trust it once:\n\
276 \n open https://{a}/ and accept the warning\n",
277 a = server.addr()
278 );
279 if paired.is_empty() {
280 println!(
281 "\nNo origins paired yet — every connection will be refused.\n\
282 Load the extension, copy its origin from the sidebar, then run:\n\
283 \n termbridge pair <origin>\n"
284 );
285 } else {
286 println!("paired origins: {}", paired.join(", "));
287 }
288
289 while let Some(event) = server.next_event().await {
290 match event {
291 Event::Accepted { origin } => println!("[accept] {origin}"),
292 // Under socket activation this is the normal way to stop: the
293 // socket unit keeps listening, so the next sidebar connection
294 // starts a fresh daemon and tmux hands it back the same sessions.
295 Event::Idle => {
296 println!(
297 "[idle] no clients for {:?} — exiting",
298 idle_timeout.unwrap_or_default()
299 );
300 break;
301 }
302 Event::Rejected {
303 origin,
304 why,
305 detail,
306 } => {
307 let o = origin.unwrap_or_else(|| "<none>".into());
308 println!("[reject] {o}: {}", why.reason());
309 if let Some(d) = detail {
310 println!(" cause: {d}");
311 }
312 match why {
313 termbridge::auth::Denied::UnpairedOrigin => {
314 println!(" to approve: termbridge pair {o}");
315 }
316 // By far the most common first-run mistake: the extension
317 // was loaded but nobody ever pasted a token into it. The
318 // wire-level reason ("invalid token") does not say where
319 // the right token comes from, so spell it out.
320 termbridge::auth::Denied::BadToken
321 | termbridge::auth::Denied::MalformedAuth
322 | termbridge::auth::Denied::AuthTimeout => {
323 println!(
324 " the extension did not present the token in {}",
325 paths::token_path().display()
326 );
327 println!(
328 " to fix: run termbridge token and paste the 64 hex chars\n\
329 \x20 into the sidebar's settings → Token field"
330 );
331 }
332 _ => {}
333 }
334 }
335 }
336 }
337 Ok(())
338}