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`), creating its parent directory (`0700`).
89fn write_token(path: &Path, token: &str) -> Result<()> {
90    if let Some(parent) = path.parent() {
91        paths::ensure_dir_0700(parent)?;
92    }
93    std::fs::write(path, token)
94        .with_context(|| format!("failed to write token file {}", path.display()))?;
95    paths::set_file_0600(path)?;
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    #[tokio::test]
340    async fn start_writes_token_and_reports_status() {
341        let dir = tempfile::tempdir().unwrap();
342        let svc = temp_service(dir.path()).await;
343
344        // Token file exists and is owner-only.
345        let token_path = dir.path().join("bridge.token");
346        assert!(token_path.exists());
347        #[cfg(unix)]
348        {
349            use std::os::unix::fs::PermissionsExt;
350            let mode = std::fs::metadata(&token_path).unwrap().permissions().mode() & 0o777;
351            assert_eq!(mode, 0o600);
352        }
353
354        // Status: healthy, no tab connected, ports surfaced in detail.
355        let status = svc.status().await;
356        assert_eq!(status.name, "browser-bridge");
357        assert!(status.healthy);
358        assert!(status.detail.get("control_port").is_some());
359
360        // `status` op round-trips a StatusResponse payload.
361        let payload = svc.handle("status", Value::Null).await.unwrap();
362        assert_eq!(payload.get("connected"), Some(&json!(false)));
363
364        // `token` op returns the raw session key, matching the persisted file.
365        let token = svc
366            .handle("token", Value::Null)
367            .await
368            .unwrap()
369            .get("token")
370            .and_then(Value::as_str)
371            .unwrap()
372            .to_string();
373        assert!(!token.is_empty());
374        assert_eq!(std::fs::read_to_string(&token_path).unwrap().trim(), token);
375
376        // `request-command` op embeds the key in an OMNI_BRIDGE_TOKEN assignment.
377        let cmd = svc
378            .handle("request-command", Value::Null)
379            .await
380            .unwrap()
381            .get("command")
382            .and_then(Value::as_str)
383            .unwrap()
384            .to_string();
385        assert!(cmd.starts_with(&format!("OMNI_BRIDGE_TOKEN='{token}'")));
386        assert!(cmd.contains("browser bridge request --control-port"));
387
388        // Unknown op is an error, not a panic.
389        assert!(svc.handle("frobnicate", Value::Null).await.is_err());
390
391        svc.shutdown().await;
392        // Token file removed on shutdown.
393        assert!(!token_path.exists());
394    }
395
396    #[tokio::test]
397    async fn menu_lists_status_line_and_restart() {
398        let dir = tempfile::tempdir().unwrap();
399        let svc = temp_service(dir.path()).await;
400        let menu = svc.menu();
401        assert_eq!(menu.title, "Browser Bridge");
402        assert!(matches!(menu.items.first(), Some(MenuItem::Label(_))));
403        // The key label is masked: the full token never reaches the menu bar,
404        // only its last few characters (#997). The full value stays copyable.
405        let token = svc.token.as_str();
406        let key_label = menu
407            .items
408            .iter()
409            .find_map(|i| match i {
410                MenuItem::Label(text) if text.starts_with("Key: ") => Some(text.as_str()),
411                _ => None,
412            })
413            .expect("menu has a masked Key label");
414        assert!(
415            !key_label.contains(token),
416            "full token leaked into tray label: {key_label}"
417        );
418        let tail: String = token
419            .chars()
420            .skip(token.chars().count() - TOKEN_LABEL_VISIBLE_CHARS)
421            .collect();
422        assert!(key_label.ends_with(&tail));
423        assert!(menu.items.iter().any(|i| matches!(
424            i,
425            MenuItem::Action(a) if a.id == "copy-key"
426        )));
427        assert!(menu.items.iter().any(|i| matches!(
428            i,
429            MenuItem::Action(a) if a.id == "copy-request"
430        )));
431        assert!(menu.items.iter().any(|i| matches!(
432            i,
433            MenuItem::Action(a) if a.id == "restart-bridge"
434        )));
435        svc.shutdown().await;
436    }
437
438    #[test]
439    fn masked_key_label_hides_all_but_the_tail() {
440        // A long token reveals only its last four characters behind the mask.
441        assert_eq!(
442            masked_key_label("abcdefghijklmnop"),
443            "Key: \u{2022}\u{2022}\u{2022}\u{2022}mnop"
444        );
445        // A token too short to keep most of its entropy hidden is fully masked.
446        assert_eq!(
447            masked_key_label("secret"),
448            "Key: \u{2022}\u{2022}\u{2022}\u{2022}"
449        );
450        assert_eq!(
451            masked_key_label(""),
452            "Key: \u{2022}\u{2022}\u{2022}\u{2022}"
453        );
454    }
455
456    #[tokio::test]
457    async fn restart_keeps_service_serving() {
458        let dir = tempfile::tempdir().unwrap();
459        let svc = temp_service(dir.path()).await;
460        svc.handle("restart", Value::Null).await.unwrap();
461        // Still healthy after a restart.
462        assert!(svc.status().await.healthy);
463        svc.shutdown().await;
464    }
465}