anvilsign in

collin/browser-terminal-extension

1use std::time::Duration;
2
3use termbridge::paths;
4use termbridge::pty::Profile;
5use termbridge::{Config, Event, Server};
6
7const DEFAULT_PORT: u16 = 7681;
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 token print the auth token (paste into extension)
17 termbridge token --rotate generate a new token
18 termbridge pair <origin> approve an extension origin
19 termbridge unpair <origin> revoke an origin
20 termbridge list show paired origins
21 termbridge cert show the TLS certificate path + fingerprint
22 termbridge cert --regenerate make a new certificate
23 termbridge hooks print the Claude Code hook settings to install
24 termbridge hook record one hook event (reads JSON on stdin)
25 termbridge tmux print the tmux.conf line for the status glyph
26
27Origins look like:
28 chrome-extension://abcdefghijklmnopabcdefghijklmnop
29 moz-extension://11111111-2222-3333-4444-555555555555
30"
31 );
32 std::process::exit(2)
33}
34
35#[tokio::main]
36async fn main() -> Result<(), Box<dyn std::error::Error>> {
37 let args: Vec<String> = std::env::args().skip(1).collect();
38 let dir = paths::config_dir();
39
40 match args.first().map(String::as_str) {
41 Some("serve") => {
42 let port = match args.iter().position(|a| a == "--port") {
43 Some(i) => args
44 .get(i + 1)
45 .and_then(|p| p.parse().ok())
46 .unwrap_or(DEFAULT_PORT),
47 None => DEFAULT_PORT,
48 };
49 let session = args
50 .iter()
51 .position(|a| a == "--session")
52 .and_then(|i| args.get(i + 1))
53 .cloned();
54 serve(port, session).await
55 }
56 Some("token") => {
57 let token = if args.iter().any(|a| a == "--rotate") {
58 paths::generate_token(&dir)?
59 } else {
60 paths::load_or_create_token(&dir)?
61 };
62 println!("{token}");
63 println!("\n(from {})", paths::token_path().display());
64 Ok(())
65 }
66 Some("pair") => {
67 let origin = args.get(1).unwrap_or_else(|| usage());
68 if paths::pair_origin(&dir, origin)? {
69 println!("paired: {origin}");
70 } else {
71 println!("already paired: {origin}");
72 }
73 Ok(())
74 }
75 Some("unpair") => {
76 let origin = args.get(1).unwrap_or_else(|| usage());
77 if paths::unpair_origin(&dir, origin)? {
78 println!("unpaired: {origin}");
79 } else {
80 println!("not paired: {origin}");
81 }
82 Ok(())
83 }
84 Some("cert") => {
85 let id = if args.iter().any(|a| a == "--regenerate") {
86 termbridge::tls::generate(&dir)?
87 } else {
88 termbridge::tls::load_or_create(&dir)?
89 };
90 println!("certificate: {}", dir.join(termbridge::tls::CERT_FILE).display());
91 println!("private key: {}", dir.join(termbridge::tls::KEY_FILE).display());
92 println!("SHA-256: {}", id.fingerprint);
93 Ok(())
94 }
95 // Runs on every hook event, so it must be quiet and must never fail in
96 // a way Claude would surface. Any error here is a status panel that is
97 // briefly stale, which is not worth interrupting the user's session.
98 Some("hook") => {
99 let mut input = String::new();
100 use std::io::Read;
101 let _ = std::io::stdin().read_to_string(&mut input);
102 if let Ok(event) = serde_json::from_str::<serde_json::Value>(&input) {
103 let _ = termbridge::agents::apply(&event);
104 // After the record is on disk, so the status line is computed
105 // from the same state the sidebar will read. Runs even when
106 // the event removed the record: that is what clears the glyph
107 // from a window whose Claude has gone.
108 termbridge::window_status::publish();
109 }
110 Ok(())
111 }
112 Some("hooks") => {
113 let exe = std::env::current_exe()
114 .map(|p| p.display().to_string())
115 .unwrap_or_else(|_| "termbridge".into());
116 println!(
117 "Add this to ~/.claude/settings.json (merge with any hooks you already have):\n"
118 );
119 println!("{}", termbridge::agents::hook_settings(&exe));
120 println!(
121 "\nRecords are written to {}\n\
122 Claude sessions then show up in the sidebar with their state and permission mode.",
123 termbridge::agents::dir().display()
124 );
125 Ok(())
126 }
127 Some("tmux") => {
128 println!(
129 "Add this to ~/.tmux.conf, then `tmux source-file ~/.tmux.conf`:\n"
130 );
131 print!("{}", termbridge::window_status::tmux_conf());
132 println!(
133 "\nThe hooks are what set it, so install those first if you haven't:\n\
134 \x20 termbridge hooks"
135 );
136 Ok(())
137 }
138 Some("list") => {
139 let origins = paths::load_paired_origins(&dir);
140 if origins.is_empty() {
141 println!(
142 "no paired origins ({})",
143 paths::paired_origins_path().display()
144 );
145 } else {
146 for o in origins {
147 println!("{o}");
148 }
149 }
150 Ok(())
151 }
152 _ => usage(),
153 }
154}
155
156async fn serve(port: u16, session: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
157 let dir = paths::config_dir();
158 let token = paths::load_or_create_token(&dir)?;
159 let paired = paths::load_paired_origins(&dir);
160
161 let identity = termbridge::tls::load_or_create(&dir)?;
162 let mut config = Config::new(token, paired.clone());
163 config.auth_timeout = Duration::from_secs(3);
164 config.tls = Some(termbridge::tls::acceptor(&identity)?);
165 let pinned = session
166 .as_deref()
167 .and_then(termbridge::pty::valid_session_name);
168 let default_session = pinned
169 .clone()
170 .unwrap_or_else(|| termbridge::pty::DEFAULT_SESSION.to_string());
171 config.default_session = default_session.clone();
172 // A pinned --session is a choice; without one, a lone existing session is
173 // a better guess than a fresh "browser" beside it.
174 config.adopt_sole_session = pinned.is_none();
175 config.profile = if Profile::tmux_available() {
176 Profile::tmux(&default_session)
177 } else {
178 eprintln!("warning: tmux not found on PATH — falling back to a plain shell.");
179 eprintln!(" Sessions will NOT survive closing the sidebar.");
180 Profile::shell_fallback()
181 };
182 let profile = config.profile.clone();
183 let config_adopts_sole = config.adopt_sole_session && profile.program == "tmux";
184
185 let mut server = Server::start(config, port).await?;
186 println!("termbridge listening on wss://{a} (ws:// also accepted on the same port)", a = server.addr());
187 println!("running: {} {}", profile.program, profile.args.join(" "));
188 let existing = termbridge::pty::list_sessions(&profile.tmux_global_args());
189 if !existing.is_empty() {
190 println!("tmux sessions available: {}", existing.join(", "));
191 }
192 if config_adopts_sole {
193 match existing.as_slice() {
194 [only] => println!("joining the only existing session: {only}"),
195 _ => println!("new clients start in session: {default_session}"),
196 }
197 }
198 println!("token: {}", paths::token_path().display());
199 println!("cert: SHA-256 {}", identity.fingerprint);
200 println!(
201 "\nFirst time only — the certificate is self-signed, so trust it once:\n\
202 \n open https://{a}/ and accept the warning\n",
203 a = server.addr()
204 );
205 if paired.is_empty() {
206 println!(
207 "\nNo origins paired yet — every connection will be refused.\n\
208 Load the extension, copy its origin from the sidebar, then run:\n\
209 \n termbridge pair <origin>\n"
210 );
211 } else {
212 println!("paired origins: {}", paired.join(", "));
213 }
214
215 while let Some(event) = server.next_event().await {
216 match event {
217 Event::Accepted { origin } => println!("[accept] {origin}"),
218 Event::Rejected { origin, why, detail } => {
219 let o = origin.unwrap_or_else(|| "<none>".into());
220 println!("[reject] {o}: {}", why.reason());
221 if let Some(d) = detail {
222 println!(" cause: {d}");
223 }
224 match why {
225 termbridge::auth::Denied::UnpairedOrigin => {
226 println!(" to approve: termbridge pair {o}");
227 }
228 // By far the most common first-run mistake: the extension
229 // was loaded but nobody ever pasted a token into it. The
230 // wire-level reason ("invalid token") does not say where
231 // the right token comes from, so spell it out.
232 termbridge::auth::Denied::BadToken
233 | termbridge::auth::Denied::MalformedAuth
234 | termbridge::auth::Denied::AuthTimeout => {
235 println!(
236 " the extension did not present the token in {}",
237 paths::token_path().display()
238 );
239 println!(
240 " to fix: run termbridge token and paste the 64 hex chars\n\
241 \x20 into the sidebar's settings → Token field"
242 );
243 }
244 _ => {}
245 }
246 }
247 }
248 }
249 Ok(())
250}