Skip to main content

roder_api/
chrome.rs

1//! Shared Chrome browser-bridge contract.
2//!
3//! This module is the single source of truth that ties together the four layers
4//! of the Roder Chrome integration:
5//!
6//! * the Manifest V3 browser extension (`/Users/pz/w/roder-web-extention`), which
7//!   speaks the JSON bridge envelope described below over the remote WebSocket
8//!   app-server;
9//! * the remote WebSocket transport in `roder-app-server` (`remote.rs`), which
10//!   registers each connected extension with the [`ChromeBridge`] and forwards
11//!   commands to it;
12//! * the `chrome/*` app-server methods, which call [`ChromeController::dispatch`];
13//! * the model-facing `chrome_*` tools in `roder-ext-chrome`, which are generic
14//!   over an injected [`ChromeController`] so they can be unit-tested against a
15//!   fake bridge.
16//!
17//! The single live [`ChromeBridge`] instance is reachable from every layer via
18//! [`bridge`], a process-global singleton (there is exactly one browser bridge
19//! per Roder process). Keeping the bridge here in `roder-api` lets both
20//! `roder-app-server` and `roder-ext-chrome` share it without `roder-ext-chrome`
21//! depending on `roder-core`.
22//!
23//! # Wire envelope
24//!
25//! * Roder → extension (command): `{ "type": "<command>", "id": "<corr>", ...params }`
26//! * extension → Roder (command result):
27//!   `{ "type": "command/result", "id": "<corr>", "ok": bool, "result"?: any, "error"?: string }`
28//! * extension → Roder (unsolicited event):
29//!   `{ "type": "hello"|"state"|"tabs/list"|"tab/updated"|"page/snapshot"|"activity"|"chat"|"output"|"debug/console"|"debug/network", ... }`
30//!
31//! Browser page content, DOM text, console output and network metadata are
32//! **untrusted input** and are tagged with `untrusted: true` so model prompts do
33//! not treat page content as user or system instructions.
34
35use std::collections::HashMap;
36use std::sync::atomic::{AtomicU64, Ordering};
37use std::sync::{Arc, Mutex, OnceLock};
38use std::time::Duration;
39
40use serde::{Deserialize, Serialize};
41use tokio::sync::{mpsc, oneshot};
42
43#[path = "chrome/pending.rs"]
44mod pending;
45
46/// How aggressively Roder may act in the browser on the user's behalf.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
48#[serde(rename_all = "snake_case")]
49pub enum ChromePermissionMode {
50    /// Chat, tab status and connection state only.
51    Observe,
52    /// Inspect actions can run when the site permits; privileged actions queue
53    /// for approval.
54    #[default]
55    Assist,
56    /// Enabled actions execute inside the approved plan and site scope;
57    /// protected actions still require explicit approval.
58    Control,
59}
60
61impl ChromePermissionMode {
62    pub fn as_str(self) -> &'static str {
63        match self {
64            ChromePermissionMode::Observe => "observe",
65            ChromePermissionMode::Assist => "assist",
66            ChromePermissionMode::Control => "control",
67        }
68    }
69
70    pub fn parse(value: &str) -> Option<Self> {
71        match value.trim().to_ascii_lowercase().as_str() {
72            "observe" => Some(ChromePermissionMode::Observe),
73            "assist" => Some(ChromePermissionMode::Assist),
74            "control" => Some(ChromePermissionMode::Control),
75            _ => None,
76        }
77    }
78}
79
80/// A browser the extension can drive (Chrome is P0, Edge is P1).
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct ChromeBrowser {
84    pub id: String,
85    pub name: String,
86    /// `chrome` or `edge`.
87    pub kind: String,
88}
89
90/// A tab visible to the extension.
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct ChromeTab {
94    pub id: i64,
95    #[serde(default)]
96    pub window_id: Option<i64>,
97    #[serde(default)]
98    pub title: Option<String>,
99    #[serde(default)]
100    pub url: Option<String>,
101    #[serde(default)]
102    pub fav_icon_url: Option<String>,
103    #[serde(default)]
104    pub active: bool,
105}
106
107/// Per-origin site permission record stored by the extension.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
109#[serde(rename_all = "camelCase")]
110pub struct ChromeSitePermission {
111    pub origin: String,
112    #[serde(default)]
113    pub inspect: bool,
114    #[serde(default)]
115    pub interact: bool,
116    #[serde(default)]
117    pub eval: bool,
118    #[serde(default)]
119    pub debugger: bool,
120    #[serde(default)]
121    pub download: bool,
122    #[serde(default)]
123    pub upload: bool,
124    #[serde(default)]
125    pub recording: bool,
126    #[serde(default)]
127    pub schedule: bool,
128    #[serde(default)]
129    pub always_allow: bool,
130}
131
132/// Snapshot of the connection, mirrored to TUI/CLI/SDK clients via `chrome/status`.
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
134#[serde(rename_all = "camelCase")]
135pub struct ChromeStatus {
136    /// At least one extension is connected.
137    pub connected: bool,
138    /// Number of connected extension clients.
139    pub client_count: usize,
140    /// Whether Chrome tools are enabled for the session.
141    pub enabled: bool,
142    /// Capabilities advertised by the connected extension's `hello`.
143    pub capabilities: Vec<String>,
144    pub mode: ChromePermissionMode,
145    #[serde(default)]
146    pub active_tab: Option<ChromeTab>,
147    #[serde(default)]
148    pub browser: Option<ChromeBrowser>,
149    #[serde(default)]
150    pub last_error: Option<String>,
151    #[serde(default)]
152    pub remote_addr: Option<String>,
153}
154
155/// A command Roder asks the extension to run. `kind` is the wire `type`
156/// (e.g. `"page/snapshot"`); `params` is merged into the outgoing frame.
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158pub struct ChromeCommand {
159    pub kind: String,
160    #[serde(default)]
161    pub params: serde_json::Value,
162}
163
164impl ChromeCommand {
165    pub fn new(kind: impl Into<String>) -> Self {
166        Self {
167            kind: kind.into(),
168            params: serde_json::Value::Null,
169        }
170    }
171
172    pub fn with_params(kind: impl Into<String>, params: serde_json::Value) -> Self {
173        Self {
174            kind: kind.into(),
175            params,
176        }
177    }
178}
179
180/// Failure surface for browser commands.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum ChromeError {
183    /// No extension is connected.
184    NotConnected,
185    /// Chrome tools are not enabled for this session.
186    Disabled,
187    /// The command was rejected by the user or by site/mode policy.
188    Rejected(String),
189    /// The extension did not respond within the dispatch deadline.
190    Timeout,
191    /// The connection dropped before a result arrived.
192    Disconnected,
193    /// The extension returned an error result.
194    Remote(String),
195}
196
197impl std::fmt::Display for ChromeError {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        match self {
200            ChromeError::NotConnected => write!(f, "no Chrome extension is connected"),
201            ChromeError::Disabled => write!(f, "Chrome tools are not enabled for this session"),
202            ChromeError::Rejected(reason) => write!(f, "browser action rejected: {reason}"),
203            ChromeError::Timeout => write!(f, "Chrome extension did not respond in time"),
204            ChromeError::Disconnected => {
205                write!(f, "Chrome extension disconnected before responding")
206            }
207            ChromeError::Remote(message) => write!(f, "Chrome extension error: {message}"),
208        }
209    }
210}
211
212impl std::error::Error for ChromeError {}
213
214/// Default time a `dispatch` will wait for the extension to answer.
215pub const CHROME_DISPATCH_TIMEOUT: Duration = Duration::from_secs(30);
216
217/// Abstraction over the live browser bridge so model tools can be tested against
218/// a fake. Implemented by [`ChromeBridge`].
219#[async_trait::async_trait]
220pub trait ChromeController: Send + Sync + 'static {
221    /// Current connection status.
222    fn status(&self) -> ChromeStatus;
223
224    /// Whether Chrome tools are enabled for the session.
225    fn is_enabled(&self) -> bool {
226        self.status().enabled
227    }
228
229    /// Enable/disable Chrome tools for the session.
230    fn set_enabled(&self, enabled: bool);
231
232    /// Set the permission mode.
233    fn set_mode(&self, mode: ChromePermissionMode);
234
235    /// Forward a command to the connected extension and await its result.
236    async fn dispatch(&self, command: ChromeCommand) -> Result<serde_json::Value, ChromeError>;
237}
238
239/// Handle returned to the transport when an extension connects: the transport
240/// drains `commands` and writes each frame to the socket.
241pub struct ChromeClientRegistration {
242    pub client_id: u64,
243    pub commands: mpsc::UnboundedReceiver<serde_json::Value>,
244}
245
246struct ClientHandle {
247    commands: mpsc::UnboundedSender<serde_json::Value>,
248    capabilities: Vec<String>,
249    remote_addr: Option<String>,
250    active_tab: Option<ChromeTab>,
251    browser: Option<ChromeBrowser>,
252}
253
254#[derive(Default)]
255struct BridgeState {
256    clients: HashMap<u64, ClientHandle>,
257    pending: HashMap<String, oneshot::Sender<Result<serde_json::Value, String>>>,
258    enabled: bool,
259    mode: ChromePermissionMode,
260    last_error: Option<String>,
261}
262
263impl BridgeState {
264    fn new() -> Self {
265        Self {
266            clients: HashMap::new(),
267            pending: HashMap::new(),
268            enabled: true,
269            mode: ChromePermissionMode::default(),
270            last_error: None,
271        }
272    }
273}
274
275/// The live connection registry. One instance per process (see [`bridge`]).
276///
277/// Transport integration (in `roder-app-server::remote`), on each connection:
278/// ```ignore
279/// // on first `hello` frame:
280/// let reg = bridge().register_client(Some(remote_addr.clone()), &frame);
281/// // spawn a task draining reg.commands -> websocket as Message::Text(JSON)
282/// // for every inbound bridge frame: bridge().ingest_frame(Some(reg.client_id), frame);
283/// // on disconnect: bridge().unregister_client(reg.client_id);
284/// ```
285pub struct ChromeBridge {
286    state: Mutex<BridgeState>,
287    next_client_id: AtomicU64,
288    next_corr_id: AtomicU64,
289    dispatch_timeout: Duration,
290}
291
292impl Default for ChromeBridge {
293    fn default() -> Self {
294        Self {
295            state: Mutex::new(BridgeState::new()),
296            next_client_id: AtomicU64::new(1),
297            next_corr_id: AtomicU64::new(1),
298            dispatch_timeout: CHROME_DISPATCH_TIMEOUT,
299        }
300    }
301}
302
303impl ChromeBridge {
304    pub fn new() -> Self {
305        Self::default()
306    }
307
308    #[cfg(test)]
309    fn with_timeout(timeout: Duration) -> Self {
310        Self {
311            dispatch_timeout: timeout,
312            ..Self::default()
313        }
314    }
315
316    fn lock(&self) -> std::sync::MutexGuard<'_, BridgeState> {
317        self.state.lock().expect("chrome bridge mutex poisoned")
318    }
319
320    /// Register a newly-connected extension. Returns the receiver the transport
321    /// must drain to deliver commands to that extension.
322    pub fn register_client(
323        &self,
324        remote_addr: Option<String>,
325        hello: &serde_json::Value,
326    ) -> ChromeClientRegistration {
327        let client_id = self.next_client_id.fetch_add(1, Ordering::SeqCst);
328        let (tx, rx) = mpsc::unbounded_channel();
329        let capabilities = string_array(hello.get("capabilities"));
330        let mut state = self.lock();
331        state.clients.insert(
332            client_id,
333            ClientHandle {
334                commands: tx,
335                capabilities,
336                remote_addr,
337                active_tab: None,
338                browser: None,
339            },
340        );
341        state.last_error = None;
342        ChromeClientRegistration {
343            client_id,
344            commands: rx,
345        }
346    }
347
348    /// Remove a disconnected extension and fail any of its pending commands.
349    pub fn unregister_client(&self, client_id: u64) {
350        let mut state = self.lock();
351        state.clients.remove(&client_id);
352        if state.clients.is_empty() {
353            // Fail in-flight commands; the sockets are gone.
354            for (_, tx) in state.pending.drain() {
355                let _ = tx.send(Err("extension disconnected".to_string()));
356            }
357        }
358    }
359
360    /// Route an inbound bridge frame from the extension: resolve a pending
361    /// command result or fold an unsolicited event into bridge state.
362    pub fn ingest_frame(&self, client_id: Option<u64>, frame: serde_json::Value) {
363        let kind = frame.get("type").and_then(|v| v.as_str()).unwrap_or("");
364        match kind {
365            "command/result" => {
366                let Some(id) = frame.get("id").and_then(|v| v.as_str()) else {
367                    return;
368                };
369                let sender = {
370                    let mut state = self.lock();
371                    state.pending.remove(id)
372                };
373                if let Some(sender) = sender {
374                    let ok = frame.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
375                    let payload = if ok {
376                        Ok(frame
377                            .get("result")
378                            .cloned()
379                            .unwrap_or(serde_json::Value::Null))
380                    } else {
381                        Err(frame
382                            .get("error")
383                            .and_then(|v| v.as_str())
384                            .unwrap_or("unknown extension error")
385                            .to_string())
386                    };
387                    let _ = sender.send(payload);
388                }
389            }
390            "hello" => {
391                let caps = string_array(frame.get("capabilities"));
392                let mut state = self.lock();
393                if let Some(id) = client_id
394                    && let Some(handle) = state.clients.get_mut(&id)
395                {
396                    handle.capabilities = caps;
397                }
398            }
399            "state" => {
400                let tab = frame
401                    .get("state")
402                    .and_then(|s| s.get("activeTab"))
403                    .and_then(|t| serde_json::from_value::<ChromeTab>(t.clone()).ok());
404                let mut state = self.lock();
405                if let Some(id) = client_id
406                    && let Some(handle) = state.clients.get_mut(&id)
407                {
408                    handle.active_tab = tab;
409                }
410            }
411            "tab/updated" => {
412                let tab = frame
413                    .get("tab")
414                    .and_then(|t| serde_json::from_value::<ChromeTab>(t.clone()).ok());
415                let mut state = self.lock();
416                if let Some(id) = client_id
417                    && let Some(handle) = state.clients.get_mut(&id)
418                {
419                    handle.active_tab = tab;
420                }
421            }
422            _ => {}
423        }
424    }
425
426    fn next_correlation_id(&self) -> String {
427        format!("rc-{}", self.next_corr_id.fetch_add(1, Ordering::SeqCst))
428    }
429}
430
431#[async_trait::async_trait]
432impl ChromeController for ChromeBridge {
433    fn status(&self) -> ChromeStatus {
434        let state = self.lock();
435        let primary = state.clients.values().next();
436        ChromeStatus {
437            connected: !state.clients.is_empty(),
438            client_count: state.clients.len(),
439            enabled: state.enabled,
440            capabilities: primary.map(|c| c.capabilities.clone()).unwrap_or_default(),
441            mode: state.mode,
442            active_tab: primary.and_then(|c| c.active_tab.clone()),
443            browser: primary.and_then(|c| c.browser.clone()),
444            last_error: state.last_error.clone(),
445            remote_addr: primary.and_then(|c| c.remote_addr.clone()),
446        }
447    }
448
449    fn set_enabled(&self, enabled: bool) {
450        self.lock().enabled = enabled;
451    }
452
453    fn set_mode(&self, mode: ChromePermissionMode) {
454        self.lock().mode = mode;
455    }
456
457    async fn dispatch(&self, command: ChromeCommand) -> Result<serde_json::Value, ChromeError> {
458        let corr = self.next_correlation_id();
459        let (res_tx, res_rx) = oneshot::channel();
460
461        let sender;
462        // Build the outgoing frame `{ type, id, ...params }` and enqueue it on the
463        // first connected client. Hold the lock only for the send.
464        {
465            let mut state = self.lock();
466            if !state.enabled {
467                return Err(ChromeError::Disabled);
468            }
469            let Some(handle) = state.clients.values().next() else {
470                return Err(ChromeError::NotConnected);
471            };
472            let mut frame = serde_json::Map::new();
473            frame.insert("type".to_string(), serde_json::Value::String(command.kind));
474            frame.insert("id".to_string(), serde_json::Value::String(corr.clone()));
475            if let serde_json::Value::Object(params) = command.params {
476                for (key, value) in params {
477                    if key != "type" && key != "id" {
478                        frame.insert(key, value);
479                    }
480                }
481            }
482            sender = handle.commands.clone();
483            if handle
484                .commands
485                .send(serde_json::Value::Object(frame))
486                .is_err()
487            {
488                return Err(ChromeError::Disconnected);
489            }
490            state.pending.insert(corr.clone(), res_tx);
491        }
492
493        // A dropped dispatch future must cancel browser work as well as remove
494        // its waiter. Successful results remove the waiter before this guard drops.
495        let _pending = pending::Pending {
496            bridge: self,
497            corr: &corr,
498            sender,
499        };
500        match tokio::time::timeout(self.dispatch_timeout, res_rx).await {
501            Ok(Ok(Ok(result))) => Ok(result),
502            Ok(Ok(Err(message))) => Err(ChromeError::Remote(message)),
503            Ok(Err(_)) => Err(ChromeError::Disconnected),
504            Err(_) => Err(ChromeError::Timeout),
505        }
506    }
507}
508
509/// The process-global browser bridge. Every layer shares this instance.
510pub fn bridge() -> Arc<ChromeBridge> {
511    static BRIDGE: OnceLock<Arc<ChromeBridge>> = OnceLock::new();
512    BRIDGE.get_or_init(|| Arc::new(ChromeBridge::new())).clone()
513}
514
515fn string_array(value: Option<&serde_json::Value>) -> Vec<String> {
516    value
517        .and_then(|v| v.as_array())
518        .map(|items| {
519            items
520                .iter()
521                .filter_map(|item| item.as_str().map(ToString::to_string))
522                .collect()
523        })
524        .unwrap_or_default()
525}
526
527#[cfg(test)]
528#[path = "chrome/tests.rs"]
529mod tests;