Skip to main content

omni_dev/daemon/services/
bridge.rs

1//! The browser-bridge daemon service: hosts the bridge's loopback-TCP planes
2//! under the daemon's lifecycle and exposes status/control to it.
3//!
4//! The security model is unchanged from ADR-0036 — the bridge keeps both its
5//! TCP planes and their bearer-token auth, and the daemon never proxies browser
6//! traffic. The only additive delta is that the resolved session token is
7//! persisted to a `0600` file so thin clients (`request`/`harvest`) can
8//! discover it without the foreground stdout a standalone `serve` prints.
9
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, Mutex as StdMutex, MutexGuard};
12
13use anyhow::{anyhow, bail, Context, Result};
14use async_trait::async_trait;
15use serde_json::{json, Value};
16
17use crate::browser::protocol::StatusResponse;
18use crate::browser::{auth, snippet, BridgeConfig, BridgeServer};
19use crate::daemon::paths;
20use crate::daemon::service::{DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus};
21
22/// The browser-bridge service name (the control-socket routing key).
23pub const SERVICE_NAME: &str = "browser-bridge";
24
25/// Number of trailing characters revealed in the tray's masked `Key:` label.
26/// The full token never appears in the menu bar — it stays behind the "Copy
27/// bridge key" action — so this only has to disambiguate *which* key is live.
28const TOKEN_LABEL_VISIBLE_CHARS: usize = 4;
29
30/// Hosts a [`BridgeServer`] under the daemon, persisting its session token for
31/// thin-client discovery and allowing in-place restart.
32pub struct BridgeService {
33    /// The running server; `None` only transiently during a restart.
34    inner: StdMutex<Option<BridgeServer>>,
35    config: BridgeConfig,
36    token: Arc<String>,
37    token_path: PathBuf,
38}
39
40impl BridgeService {
41    /// Resolves the session token, persists it to a `0600` file, starts the
42    /// bridge planes, and returns the service.
43    pub async fn start(
44        config: BridgeConfig,
45        token_file: Option<&Path>,
46        token_path: PathBuf,
47    ) -> Result<Self> {
48        let token = auth::resolve_token(token_file)?;
49        write_token(&token_path, &token)?;
50        let server = BridgeServer::start(config.clone(), token.clone()).await?;
51        Ok(Self {
52            inner: StdMutex::new(Some(server)),
53            config,
54            token: Arc::new(token),
55            token_path,
56        })
57    }
58
59    /// Locks the inner server slot, recovering from a poisoned mutex.
60    fn lock(&self) -> MutexGuard<'_, Option<BridgeServer>> {
61        self.inner
62            .lock()
63            .unwrap_or_else(std::sync::PoisonError::into_inner)
64    }
65
66    /// Stops the running bridge and starts a fresh one with the same config and
67    /// token. The lock is never held across the awaits.
68    ///
69    /// On the daemon's fixed ports the old planes must release before the new
70    /// ones can bind, so this is teardown-then-rebind rather than a zero-downtime
71    /// swap. The rebind is made reliable by `SO_REUSEADDR` on the bridge
72    /// listeners (see `BridgeServer::start`), which lets it succeed even when a
73    /// just-closed connection still holds the address in `TIME_WAIT` (#990). A
74    /// genuine bind failure (another process owns the port) still returns the
75    /// error, but leaves `inner` empty and recoverable: a later restart simply
76    /// retries the bind — it is not a permanent kill.
77    async fn restart(&self) -> Result<()> {
78        let old = self.lock().take();
79        if let Some(server) = old {
80            server.shutdown().await;
81        }
82        let server = BridgeServer::start(self.config.clone(), (*self.token).clone()).await?;
83        *self.lock() = Some(server);
84        Ok(())
85    }
86}
87
88/// Writes `token` to `path` (`0600` from birth, #1132), creating its parent
89/// directory (`0700`).
90fn write_token(path: &Path, token: &str) -> Result<()> {
91    if let Some(parent) = path.parent() {
92        paths::ensure_dir_0700(parent)?;
93    }
94    paths::write_file_0600(path, token.as_bytes())
95        .with_context(|| format!("failed to write token file {}", path.display()))?;
96    Ok(())
97}
98
99/// Renders the tray's `Key:` label, revealing only the last
100/// [`TOKEN_LABEL_VISIBLE_CHARS`] characters of `token`. A visible full token is
101/// redundant exposure to shoulder-surfing, screen-sharing, and screenshots
102/// (#997); the complete value stays behind the "Copy bridge key" action. Tokens
103/// too short to keep most of their entropy hidden are masked outright.
104fn masked_key_label(token: &str) -> String {
105    let len = token.chars().count();
106    if len > TOKEN_LABEL_VISIBLE_CHARS * 2 {
107        let tail: String = token
108            .chars()
109            .skip(len - TOKEN_LABEL_VISIBLE_CHARS)
110            .collect();
111        format!("Key: \u{2022}\u{2022}\u{2022}\u{2022}{tail}")
112    } else {
113        "Key: \u{2022}\u{2022}\u{2022}\u{2022}".to_string()
114    }
115}
116
117/// A one-line summary for `daemon status` / the tray.
118fn summarize(status: &StatusResponse, control_port: u16, ws_port: u16) -> String {
119    if status.connected {
120        format!(
121            "{} tab(s), {} pending (control :{control_port}, ws :{ws_port})",
122            status.tabs.len(),
123            status.pending
124        )
125    } else {
126        format!("no tab connected (control :{control_port}, ws :{ws_port})")
127    }
128}
129
130#[async_trait]
131impl DaemonService for BridgeService {
132    fn name(&self) -> &'static str {
133        SERVICE_NAME
134    }
135
136    async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
137        match op {
138            "status" => {
139                let snapshot = self.lock().as_ref().map(BridgeServer::status);
140                match snapshot {
141                    Some(status) => Ok(serde_json::to_value(status)?),
142                    None => Ok(json!({ "running": false })),
143                }
144            }
145            "disconnect-tab" => {
146                let id = payload
147                    .get("id")
148                    .and_then(Value::as_u64)
149                    .ok_or_else(|| anyhow!("`disconnect-tab` requires a numeric `id`"))?;
150                let guard = self.lock();
151                let server = guard
152                    .as_ref()
153                    .ok_or_else(|| anyhow!("bridge is not running"))?;
154                server.disconnect_tab(id)?;
155                Ok(json!({ "disconnected": id }))
156            }
157            "restart" => {
158                self.restart().await?;
159                Ok(json!({ "restarted": true }))
160            }
161            "snippet" => {
162                // The paste-ready DevTools snippet (includes the session token).
163                // Exposed for the tray's "Copy console snippet" action; the
164                // control socket is owner-only (`0600`), same trust as the token
165                // file.
166                let ws_port = self.lock().as_ref().map(BridgeServer::ws_port);
167                match ws_port {
168                    Some(port) => Ok(json!({ "snippet": snippet::render(port, &self.token) })),
169                    None => bail!("bridge is not running"),
170                }
171            }
172            "token" => {
173                // The raw session token (the "bridge key"). Exposed for the
174                // tray's "Copy bridge key" action; the control socket is
175                // owner-only (`0600`), same trust as the token file.
176                Ok(json!({ "token": self.token.as_str() }))
177            }
178            "request-command" => {
179                // A ready-to-run `request` command with the session key assigned
180                // to OMNI_BRIDGE_TOKEN. The token is URL-safe base64, so it is
181                // shell-safe; single-quoted for robustness. Backs the tray's
182                // "Copy request command" action.
183                let control_port = self.lock().as_ref().map(BridgeServer::control_port);
184                match control_port {
185                    Some(port) => Ok(json!({
186                        "command": format!(
187                            "{}='{}' omni-dev browser bridge request --control-port {port} --url /",
188                            auth::TOKEN_ENV, self.token
189                        )
190                    })),
191                    None => bail!("bridge is not running"),
192                }
193            }
194            other => bail!("unknown browser-bridge op: {other}"),
195        }
196    }
197
198    fn menu(&self) -> MenuSnapshot {
199        let info = self
200            .lock()
201            .as_ref()
202            .map(|s| (s.status(), s.control_port(), s.ws_port()));
203        let items = match info {
204            Some((status, _control, _ws)) => {
205                let line = if status.connected {
206                    let origins: Vec<&str> = status
207                        .tabs
208                        .iter()
209                        .filter_map(|t| t.origin.as_deref())
210                        .collect();
211                    if origins.is_empty() {
212                        format!(
213                            "Connected — {} tab(s) — {} pending",
214                            status.tabs.len(),
215                            status.pending
216                        )
217                    } else {
218                        format!(
219                            "Connected — {} — {} pending",
220                            origins.join(", "),
221                            status.pending
222                        )
223                    }
224                } else {
225                    "No tab connected".to_string()
226                };
227                let mut items = vec![
228                    MenuItem::Label(line),
229                    MenuItem::Label(masked_key_label(&self.token)),
230                    MenuItem::Separator,
231                ];
232                items.push(MenuItem::Action(MenuAction {
233                    id: "copy-key".to_string(),
234                    label: "Copy bridge key".to_string(),
235                    enabled: true,
236                }));
237                items.push(MenuItem::Action(MenuAction {
238                    id: "copy-snippet".to_string(),
239                    label: "Copy console snippet".to_string(),
240                    enabled: true,
241                }));
242                items.push(MenuItem::Action(MenuAction {
243                    id: "copy-request".to_string(),
244                    label: "Copy request command".to_string(),
245                    enabled: true,
246                }));
247                for tab in &status.tabs {
248                    items.push(MenuItem::Action(MenuAction {
249                        id: format!("disconnect-tab:{}", tab.id),
250                        label: format!("Disconnect tab {}", tab.id),
251                        enabled: true,
252                    }));
253                }
254                items.push(MenuItem::Action(MenuAction {
255                    id: "restart-bridge".to_string(),
256                    label: "Restart bridge".to_string(),
257                    enabled: true,
258                }));
259                items
260            }
261            None => vec![MenuItem::Label("Not running".to_string())],
262        };
263        MenuSnapshot {
264            title: "Browser Bridge".to_string(),
265            items,
266        }
267    }
268
269    async fn menu_action(&self, action_id: &str) -> Result<()> {
270        if action_id == "restart-bridge" {
271            return self.restart().await;
272        }
273        if let Some(id_str) = action_id.strip_prefix("disconnect-tab:") {
274            let id: u64 = id_str
275                .parse()
276                .with_context(|| format!("invalid tab id in action {action_id}"))?;
277            let guard = self.lock();
278            let server = guard
279                .as_ref()
280                .ok_or_else(|| anyhow!("bridge is not running"))?;
281            return server.disconnect_tab(id);
282        }
283        bail!("unknown browser-bridge menu action: {action_id}")
284    }
285
286    async fn status(&self) -> ServiceStatus {
287        let info = self
288            .lock()
289            .as_ref()
290            .map(|s| (s.status(), s.control_port(), s.ws_port()));
291        match info {
292            Some((status, control_port, ws_port)) => ServiceStatus {
293                name: SERVICE_NAME.to_string(),
294                healthy: true,
295                summary: summarize(&status, control_port, ws_port),
296                detail: json!({
297                    "control_port": control_port,
298                    "ws_port": ws_port,
299                    "status": status,
300                }),
301            },
302            None => ServiceStatus {
303                name: SERVICE_NAME.to_string(),
304                healthy: false,
305                summary: "not running".to_string(),
306                detail: Value::Null,
307            },
308        }
309    }
310
311    async fn shutdown(&self) {
312        let server = self.lock().take();
313        if let Some(server) = server {
314            server.shutdown().await;
315        }
316        // Best-effort: don't leave a stale token file behind.
317        let _ = std::fs::remove_file(&self.token_path);
318    }
319}
320
321#[cfg(test)]
322#[allow(clippy::unwrap_used, clippy::expect_used)]
323mod tests {
324    use super::*;
325
326    /// A bridge service on random ports with its token written to a temp dir.
327    async fn temp_service(dir: &Path) -> BridgeService {
328        let config = BridgeConfig {
329            ws_port: 0,
330            control_port: 0,
331            ..BridgeConfig::default()
332        };
333        let token_path = dir.join("bridge.token");
334        BridgeService::start(config, None, token_path)
335            .await
336            .unwrap()
337    }
338
339    #[cfg(unix)]
340    #[test]
341    fn write_token_retightens_preexisting_loose_file() {
342        use std::os::unix::fs::PermissionsExt;
343        let dir = tempfile::tempdir().unwrap();
344        let path = dir.path().join("bridge.token");
345        std::fs::write(&path, "stale-token").unwrap();
346        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
347        write_token(&path, "fresh-token").unwrap();
348        assert_eq!(std::fs::read_to_string(&path).unwrap(), "fresh-token");
349        assert_eq!(
350            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
351            0o600
352        );
353    }
354
355    #[tokio::test]
356    async fn start_writes_token_and_reports_status() {
357        let dir = tempfile::tempdir().unwrap();
358        let svc = temp_service(dir.path()).await;
359
360        // Token file exists and is owner-only.
361        let token_path = dir.path().join("bridge.token");
362        assert!(token_path.exists());
363        #[cfg(unix)]
364        {
365            use std::os::unix::fs::PermissionsExt;
366            let mode = std::fs::metadata(&token_path).unwrap().permissions().mode() & 0o777;
367            assert_eq!(mode, 0o600);
368        }
369
370        // Status: healthy, no tab connected, ports surfaced in detail.
371        let status = svc.status().await;
372        assert_eq!(status.name, "browser-bridge");
373        assert!(status.healthy);
374        assert!(status.detail.get("control_port").is_some());
375
376        // `status` op round-trips a StatusResponse payload.
377        let payload = svc.handle("status", Value::Null).await.unwrap();
378        assert_eq!(payload.get("connected"), Some(&json!(false)));
379
380        // `token` op returns the raw session key, matching the persisted file.
381        let token = svc
382            .handle("token", Value::Null)
383            .await
384            .unwrap()
385            .get("token")
386            .and_then(Value::as_str)
387            .unwrap()
388            .to_string();
389        assert!(!token.is_empty());
390        assert_eq!(std::fs::read_to_string(&token_path).unwrap().trim(), token);
391
392        // `request-command` op embeds the key in an OMNI_BRIDGE_TOKEN assignment.
393        let cmd = svc
394            .handle("request-command", Value::Null)
395            .await
396            .unwrap()
397            .get("command")
398            .and_then(Value::as_str)
399            .unwrap()
400            .to_string();
401        assert!(cmd.starts_with(&format!("OMNI_BRIDGE_TOKEN='{token}'")));
402        assert!(cmd.contains("browser bridge request --control-port"));
403
404        // Unknown op is an error, not a panic.
405        assert!(svc.handle("frobnicate", Value::Null).await.is_err());
406
407        svc.shutdown().await;
408        // Token file removed on shutdown.
409        assert!(!token_path.exists());
410    }
411
412    #[tokio::test]
413    async fn menu_lists_status_line_and_restart() {
414        let dir = tempfile::tempdir().unwrap();
415        let svc = temp_service(dir.path()).await;
416        let menu = svc.menu();
417        assert_eq!(menu.title, "Browser Bridge");
418        assert!(matches!(menu.items.first(), Some(MenuItem::Label(_))));
419        // The key label is masked: the full token never reaches the menu bar,
420        // only its last few characters (#997). The full value stays copyable.
421        let token = svc.token.as_str();
422        let key_label = menu
423            .items
424            .iter()
425            .find_map(|i| match i {
426                MenuItem::Label(text) if text.starts_with("Key: ") => Some(text.as_str()),
427                _ => None,
428            })
429            .expect("menu has a masked Key label");
430        assert!(
431            !key_label.contains(token),
432            "full token leaked into tray label: {key_label}"
433        );
434        let tail: String = token
435            .chars()
436            .skip(token.chars().count() - TOKEN_LABEL_VISIBLE_CHARS)
437            .collect();
438        assert!(key_label.ends_with(&tail));
439        assert!(menu.items.iter().any(|i| matches!(
440            i,
441            MenuItem::Action(a) if a.id == "copy-key"
442        )));
443        assert!(menu.items.iter().any(|i| matches!(
444            i,
445            MenuItem::Action(a) if a.id == "copy-request"
446        )));
447        assert!(menu.items.iter().any(|i| matches!(
448            i,
449            MenuItem::Action(a) if a.id == "restart-bridge"
450        )));
451        svc.shutdown().await;
452    }
453
454    #[test]
455    fn masked_key_label_hides_all_but_the_tail() {
456        // A long token reveals only its last four characters behind the mask.
457        assert_eq!(
458            masked_key_label("abcdefghijklmnop"),
459            "Key: \u{2022}\u{2022}\u{2022}\u{2022}mnop"
460        );
461        // A token too short to keep most of its entropy hidden is fully masked.
462        assert_eq!(
463            masked_key_label("secret"),
464            "Key: \u{2022}\u{2022}\u{2022}\u{2022}"
465        );
466        assert_eq!(
467            masked_key_label(""),
468            "Key: \u{2022}\u{2022}\u{2022}\u{2022}"
469        );
470    }
471
472    #[tokio::test]
473    async fn restart_keeps_service_serving() {
474        let dir = tempfile::tempdir().unwrap();
475        let svc = temp_service(dir.path()).await;
476        svc.handle("restart", Value::Null).await.unwrap();
477        // Still healthy after a restart.
478        assert!(svc.status().await.healthy);
479        svc.shutdown().await;
480    }
481}