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 (after a rebuild)
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 Ok(())
103 }
104 Some("token") => {
105 let token = if args.iter().any(|a| a == "--rotate") {
106 paths::generate_token(&dir)?
107 } else {
108 paths::load_or_create_token(&dir)?
109 };
110 println!("{token}");
111 println!("\n(from {})", paths::token_path().display());
112 Ok(())
113 }
114 Some("pair") => {
115 let origin = args.get(1).unwrap_or_else(|| usage());
116 if paths::pair_origin(&dir, origin)? {
117 println!("paired: {origin}");
118 } else {
119 println!("already paired: {origin}");
120 }
121 Ok(())
122 }
123 Some("unpair") => {
124 let origin = args.get(1).unwrap_or_else(|| usage());
125 if paths::unpair_origin(&dir, origin)? {
126 println!("unpaired: {origin}");
127 } else {
128 println!("not paired: {origin}");
129 }
130 Ok(())
131 }
132 Some("cert") => {
133 let id = if args.iter().any(|a| a == "--regenerate") {
134 termbridge::tls::generate(&dir)?
135 } else {
136 termbridge::tls::load_or_create(&dir)?
137 };
138 println!(
139 "certificate: {}",
140 dir.join(termbridge::tls::CERT_FILE).display()
141 );
142 println!(
143 "private key: {}",
144 dir.join(termbridge::tls::KEY_FILE).display()
145 );
146 println!("SHA-256: {}", id.fingerprint);
147 Ok(())
148 }
149 // Runs on every hook event, so it must be quiet and must never fail in
150 // a way Claude would surface. Any error here is a status panel that is
151 // briefly stale, which is not worth interrupting the user's session.
152 Some("hook") => {
153 let mut input = String::new();
154 use std::io::Read;
155 let _ = std::io::stdin().read_to_string(&mut input);
156 if let Ok(event) = serde_json::from_str::<serde_json::Value>(&input) {
157 let _ = termbridge::agents::apply(&event);
158 // After the record is on disk, so the status line is computed
159 // from the same state the sidebar will read. Runs even when
160 // the event removed the record: that is what clears the glyph
161 // from a window whose Claude has gone.
162 termbridge::window_status::publish();
163 }
164 Ok(())
165 }
166 Some("hooks") => {
167 let exe = std::env::current_exe()
168 .map(|p| p.display().to_string())
169 .unwrap_or_else(|_| "termbridge".into());
170 println!(
171 "Add this to ~/.claude/settings.json (merge with any hooks you already have):\n"
172 );
173 println!("{}", termbridge::agents::hook_settings(&exe));
174 println!(
175 "\nRecords are written to {}\n\
176 Claude sessions then show up in the sidebar with their state and permission mode.",
177 termbridge::agents::dir().display()
178 );
179 Ok(())
180 }
181 Some("tmux") => {
182 println!("Add this to ~/.tmux.conf, then `tmux source-file ~/.tmux.conf`:\n");
183 print!("{}", termbridge::window_status::tmux_conf());
184 println!(
185 "\nThe hooks are what set it, so install those first if you haven't:\n\
186 \x20 termbridge hooks"
187 );
188 Ok(())
189 }
190 Some("list") => {
191 let origins = paths::load_paired_origins(&dir);
192 if origins.is_empty() {
193 println!(
194 "no paired origins ({})",
195 paths::paired_origins_path().display()
196 );
197 } else {
198 for o in origins {
199 println!("{o}");
200 }
201 }
202 Ok(())
203 }
204 _ => usage(),
205 }
206}
207
208async fn serve(
209 port: u16,
210 session: Option<String>,
211 idle_timeout: Option<Duration>,
212 activated: Option<std::net::TcpListener>,
213) -> Result<(), Box<dyn std::error::Error>> {
214 let dir = paths::config_dir();
215 let token = paths::load_or_create_token(&dir)?;
216 let paired = paths::load_paired_origins(&dir);
217
218 let identity = termbridge::tls::load_or_create(&dir)?;
219 let mut config = Config::new(token, paired.clone());
220 config.auth_timeout = Duration::from_secs(3);
221 config.idle_timeout = idle_timeout;
222 config.tls = Some(termbridge::tls::acceptor(&identity)?);
223 let pinned = session
224 .as_deref()
225 .and_then(termbridge::pty::valid_session_name);
226 let default_session = pinned
227 .clone()
228 .unwrap_or_else(|| termbridge::pty::DEFAULT_SESSION.to_string());
229 config.default_session = default_session.clone();
230 // A pinned --session is a choice; without one, a lone existing session is
231 // a better guess than a fresh "default" beside it.
232 config.adopt_sole_session = pinned.is_none();
233 config.profile = if Profile::tmux_available() {
234 Profile::tmux(&default_session)
235 } else {
236 eprintln!("warning: tmux not found on PATH — falling back to a plain shell.");
237 eprintln!(" Sessions will NOT survive closing the sidebar.");
238 Profile::shell_fallback()
239 };
240 let profile = config.profile.clone();
241 let config_adopts_sole = config.adopt_sole_session && profile.program == "tmux";
242
243 let mut server = match activated {
244 Some(listener) => Server::from_std(config, listener)?,
245 None => Server::start(config, port).await?,
246 };
247 println!(
248 "termbridge listening on wss://{a} (ws:// also accepted on the same port)",
249 a = server.addr()
250 );
251 println!("running: {} {}", profile.program, profile.args.join(" "));
252 let existing = termbridge::pty::list_sessions(&profile.tmux_global_args());
253 if !existing.is_empty() {
254 println!("tmux sessions available: {}", existing.join(", "));
255 }
256 if config_adopts_sole {
257 match existing.as_slice() {
258 [only] => println!("joining the only existing session: {only}"),
259 _ => println!("new clients start in session: {default_session}"),
260 }
261 }
262 println!("token: {}", paths::token_path().display());
263 println!("cert: SHA-256 {}", identity.fingerprint);
264 println!(
265 "\nFirst time only — the certificate is self-signed, so trust it once:\n\
266 \n open https://{a}/ and accept the warning\n",
267 a = server.addr()
268 );
269 if paired.is_empty() {
270 println!(
271 "\nNo origins paired yet — every connection will be refused.\n\
272 Load the extension, copy its origin from the sidebar, then run:\n\
273 \n termbridge pair <origin>\n"
274 );
275 } else {
276 println!("paired origins: {}", paired.join(", "));
277 }
278
279 while let Some(event) = server.next_event().await {
280 match event {
281 Event::Accepted { origin } => println!("[accept] {origin}"),
282 // Under socket activation this is the normal way to stop: the
283 // socket unit keeps listening, so the next sidebar connection
284 // starts a fresh daemon and tmux hands it back the same sessions.
285 Event::Idle => {
286 println!(
287 "[idle] no clients for {:?} — exiting",
288 idle_timeout.unwrap_or_default()
289 );
290 break;
291 }
292 Event::Rejected {
293 origin,
294 why,
295 detail,
296 } => {
297 let o = origin.unwrap_or_else(|| "<none>".into());
298 println!("[reject] {o}: {}", why.reason());
299 if let Some(d) = detail {
300 println!(" cause: {d}");
301 }
302 match why {
303 termbridge::auth::Denied::UnpairedOrigin => {
304 println!(" to approve: termbridge pair {o}");
305 }
306 // By far the most common first-run mistake: the extension
307 // was loaded but nobody ever pasted a token into it. The
308 // wire-level reason ("invalid token") does not say where
309 // the right token comes from, so spell it out.
310 termbridge::auth::Denied::BadToken
311 | termbridge::auth::Denied::MalformedAuth
312 | termbridge::auth::Denied::AuthTimeout => {
313 println!(
314 " the extension did not present the token in {}",
315 paths::token_path().display()
316 );
317 println!(
318 " to fix: run termbridge token and paste the 64 hex chars\n\
319 \x20 into the sidebar's settings → Token field"
320 );
321 }
322 _ => {}
323 }
324 }
325 }
326 }
327 Ok(())
328}