use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use car_browser::{Modifier, ScreencastFrame};
use futures::SinkExt;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tokio::sync::Mutex;
use tokio_tungstenite::tungstenite::Message;
use crate::assistant::browser_control::{ControlEffect, ControlOwner, Presentation};
use crate::assistant::browser_tools::{AlwaysConnected, BrowserTools, ControlStatus};
use crate::browser_attention::{BrowserSignInSnapshot, SignInAttention};
use crate::browser_relay::{ProducerRegistry, RelayProducer};
use crate::handler::JsonRpcMessage;
use crate::session::{ClientSession, ServerState, WsChannel};
pub const BROWSER_VIEW_CHANNEL_CAP: usize = 32;
pub const AGENT_HOLDS_CONTROL: &str =
"the agent holds control of this browser — call browser.view.take_control first";
pub const CONTROL_GRACE: Duration = Duration::from_secs(30);
#[derive(Clone, Copy, Debug)]
enum GraceArming {
Arm { require_unwatched: bool },
AlreadyArmed,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WireTab {
pub id: String,
pub url: String,
pub title: String,
pub active: bool,
pub can_go_back: bool,
pub can_go_forward: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WireOwner {
None,
Agent,
User,
}
impl From<ControlOwner> for WireOwner {
fn from(owner: ControlOwner) -> Self {
match owner {
ControlOwner::NoAgent => WireOwner::None,
ControlOwner::Agent => WireOwner::Agent,
ControlOwner::User => WireOwner::User,
}
}
}
impl From<WireOwner> for ControlOwner {
fn from(owner: WireOwner) -> Self {
match owner {
WireOwner::None => ControlOwner::NoAgent,
WireOwner::Agent => ControlOwner::Agent,
WireOwner::User => ControlOwner::User,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WirePresentation {
pub revision: u64,
pub owner: WireOwner,
pub current_action: Option<String>,
pub pending_signin: Option<String>,
pub blackout_active: bool,
pub tabs: Vec<WireTab>,
pub active_tab: Option<String>,
pub url: Option<String>,
pub title: Option<String>,
}
impl WirePresentation {
pub(crate) fn empty() -> Self {
Self {
revision: 0,
owner: WireOwner::None,
current_action: None,
pending_signin: None,
blackout_active: false,
tabs: Vec::new(),
active_tab: None,
url: None,
title: None,
}
}
}
impl From<&Presentation> for WirePresentation {
fn from(p: &Presentation) -> Self {
let tabs: Vec<WireTab> = p
.tabs
.iter()
.map(|t| WireTab {
id: t.id.to_string(),
url: t.url.clone(),
title: t.title.clone(),
active: t.active,
can_go_back: t.can_go_back,
can_go_forward: t.can_go_forward,
})
.collect();
let active = tabs.iter().find(|t| t.active);
Self {
revision: p.revision,
owner: p.owner.into(),
current_action: p.current_action.clone(),
pending_signin: p.pending_signin.as_ref().map(|s| s.message.clone()),
blackout_active: p.blackout_active,
active_tab: active.map(|t| t.id.clone()),
url: active.map(|t| t.url.clone()),
title: active.map(|t| t.title.clone()),
tabs,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WireFrame {
pub jpeg_base64: String,
pub width: u32,
pub height: u32,
pub device_pixel_ratio: f64,
pub captured_at: f64,
}
impl From<ScreencastFrame> for WireFrame {
fn from(frame: ScreencastFrame) -> Self {
Self {
jpeg_base64: BASE64.encode(&frame.jpeg),
width: frame.viewport.width,
height: frame.viewport.height,
device_pixel_ratio: frame.viewport.device_pixel_ratio,
captured_at: frame.captured_at,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum BrowserViewPayload {
Presentation { presentation: WirePresentation },
Frame { frame: WireFrame },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BrowserViewEvent {
pub conversation_id: Option<String>,
pub cursor: u64,
#[serde(flatten)]
pub payload: BrowserViewPayload,
}
static NEXT_SUBSCRIBER_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
pub struct BrowserViewSubscriber {
tx: tokio::sync::mpsc::Sender<BrowserViewEvent>,
view: Arc<std::sync::Mutex<std::sync::Weak<BrowserView>>>,
epoch: u64,
}
impl BrowserViewSubscriber {
const MAX_CONSECUTIVE_STALLS: u32 = 3;
fn rebind(&self, view: std::sync::Weak<BrowserView>) {
match self.view.lock() {
Ok(mut slot) => *slot = view,
Err(poisoned) => *poisoned.into_inner() = view,
}
}
pub fn spawn(
view: std::sync::Weak<BrowserView>,
client_id: String,
epoch: u64,
channel: Arc<WsChannel>,
) -> Self {
let (tx, mut rx) = tokio::sync::mpsc::channel::<BrowserViewEvent>(BROWSER_VIEW_CHANNEL_CAP);
let view = Arc::new(std::sync::Mutex::new(view));
let task_view = Arc::clone(&view);
tokio::spawn(async move {
let mut stalls = 0u32;
while let Some(event) = rx.recv().await {
let Ok(json) = serde_json::to_string(&json!({
"jsonrpc": "2.0",
"method": "browser.view.event",
"params": event,
})) else {
continue;
};
let mut guard = channel.write.lock().await;
let send = tokio::time::timeout(
Duration::from_secs(10),
guard.send(Message::Text(json.into())),
)
.await;
drop(guard);
match send {
Ok(Ok(())) => stalls = 0,
Ok(Err(_)) => break,
Err(_) => {
stalls += 1;
if stalls >= Self::MAX_CONSECUTIVE_STALLS {
tracing::debug!(
client_id,
"browser view: subscriber dropped after {} consecutive write stalls",
stalls
);
break;
}
tracing::debug!(
client_id,
"browser view: a stalled socket did not accept an event in time"
);
}
}
}
let current = match task_view.lock() {
Ok(view) => view.upgrade(),
Err(poisoned) => poisoned.into_inner().upgrade(),
};
if let Some(view) = current {
view.unsubscribe_epoch(&client_id, epoch).await;
}
});
Self { tx, epoch, view }
}
pub fn push(&self, event: BrowserViewEvent) -> bool {
self.tx.try_send(event).is_ok()
}
}
struct ViewFanout {
cursor: u64,
last: WirePresentation,
subscribers: HashMap<String, BrowserViewSubscriber>,
}
#[derive(Default)]
struct ControlHolder {
holder: Option<String>,
generation: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewControl {
TakeControl,
HandBack,
RunEnded,
HolderDisconnected,
GraceExpired,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ViewInput {
Navigate {
url: String,
},
Click {
x: f64,
y: f64,
},
Type {
text: String,
},
Keypress {
key: String,
modifiers: Vec<Modifier>,
},
Scroll {
delta_y: i32,
},
Paste {
text: String,
},
Back,
Forward,
Reload,
TabOpen,
TabClose {
tab_id: String,
},
TabSwitch {
tab_id: String,
},
}
impl ViewControl {
pub async fn apply(self, tools: &BrowserTools) -> (Presentation, Vec<ControlEffect>) {
match self {
ViewControl::TakeControl => tools.take_control().await,
ViewControl::HandBack => tools.hand_back().await,
ViewControl::RunEnded => tools.note_run_ended().await,
ViewControl::HolderDisconnected => tools.control_holder_disconnected().await,
ViewControl::GraceExpired => tools.grace_period_expired().await,
}
}
}
impl ViewInput {
pub async fn apply(self, tools: &BrowserTools) -> Result<Option<String>, String> {
let status = tools.control_status().await;
if status.owner == ControlOwner::Agent && !status.signin_pending {
return Err(AGENT_HOLDS_CONTROL.to_string());
}
tools.note_user_input().await;
match self {
ViewInput::Navigate { url } => {
tools.user_navigate(&url).await?;
Ok(None)
}
ViewInput::Click { x, y } => {
tools.user_click(x, y).await?;
Ok(None)
}
ViewInput::Type { text } => {
tools.user_type(&text).await?;
Ok(None)
}
ViewInput::Keypress { key, modifiers } => {
tools.user_keypress(&key, &modifiers).await?;
Ok(None)
}
ViewInput::Scroll { delta_y } => {
tools.user_scroll(delta_y).await?;
Ok(None)
}
ViewInput::Paste { text } => {
tools.user_paste(&text).await?;
Ok(None)
}
ViewInput::Back => {
tools.user_go_back().await?;
Ok(None)
}
ViewInput::Forward => {
tools.user_go_forward().await?;
Ok(None)
}
ViewInput::Reload => {
tools.user_reload().await?;
Ok(None)
}
ViewInput::TabOpen => Ok(Some(tools.user_tab_open().await?.to_string())),
ViewInput::TabClose { tab_id } => {
let id = tools.resolve_tab(&tab_id).await?;
tools.user_tab_close(id).await?;
Ok(None)
}
ViewInput::TabSwitch { tab_id } => {
let id = tools.resolve_tab(&tab_id).await?;
tools.user_tab_switch(id).await?;
Ok(None)
}
}
}
}
pub enum ViewBrowser {
Local(Arc<BrowserTools>),
Relay(Arc<RelayProducer>),
}
impl ViewBrowser {
async fn presentation(&self) -> WirePresentation {
match self {
Self::Local(tools) => WirePresentation::from(&tools.presentation().await),
Self::Relay(producer) => producer.presentation().await,
}
}
async fn control_status(&self) -> ControlStatus {
match self {
Self::Local(tools) => tools.control_status().await,
Self::Relay(producer) => producer.control_status().await,
}
}
async fn control(
&self,
control: ViewControl,
) -> Result<(ControlOwner, Vec<ControlEffect>), String> {
match self {
Self::Local(tools) => {
let (presentation, effects) = control.apply(tools).await;
Ok((presentation.owner, effects))
}
Self::Relay(producer) => producer.control(control).await,
}
}
async fn control_best_effort(&self, control: ViewControl) -> Vec<ControlEffect> {
match self.control(control).await {
Ok((_, effects)) => effects,
Err(error) => {
tracing::warn!(
?control,
%error,
"browser view: a daemon-originated control transition did not land"
);
Vec::new()
}
}
}
async fn input(&self, input: ViewInput) -> Result<Option<String>, String> {
match self {
Self::Local(tools) => input.apply(tools).await,
Self::Relay(producer) => producer.input(input).await,
}
}
async fn start_capture(
&self,
view: std::sync::Weak<BrowserView>,
) -> Option<tokio::task::JoinHandle<()>> {
match self {
Self::Local(tools) => {
let tools = Arc::clone(tools);
Some(tokio::spawn(async move { stream(view, tools).await }))
}
Self::Relay(producer) => {
producer.start_capture();
None
}
}
}
async fn stop_capture(&self) {
match self {
Self::Local(tools) => tools.release_frames().await,
Self::Relay(producer) => producer.stop_capture(),
}
}
}
#[derive(Default)]
struct CaptureState {
active: bool,
task: Option<tokio::task::JoinHandle<()>>,
}
pub struct BrowserView {
key: Option<String>,
browser: ViewBrowser,
fanout: Mutex<ViewFanout>,
control: Mutex<ControlHolder>,
capture: Mutex<CaptureState>,
run_ended: std::sync::atomic::AtomicBool,
}
impl BrowserView {
pub(crate) fn key(&self) -> Option<&str> {
self.key.as_deref()
}
fn new(key: Option<String>, tools: Arc<BrowserTools>) -> Self {
Self::with_browser(key, ViewBrowser::Local(tools))
}
fn with_browser(key: Option<String>, browser: ViewBrowser) -> Self {
Self {
key,
browser,
fanout: Mutex::new(ViewFanout {
cursor: 0,
last: WirePresentation::empty(),
subscribers: HashMap::new(),
}),
control: Mutex::new(ControlHolder::default()),
capture: Mutex::new(CaptureState::default()),
run_ended: std::sync::atomic::AtomicBool::new(false),
}
}
fn is_served_by(&self, producer: &Arc<RelayProducer>) -> bool {
match &self.browser {
ViewBrowser::Local(_) => false,
ViewBrowser::Relay(mine) => Arc::ptr_eq(mine, producer),
}
}
#[cfg(test)]
pub(crate) fn tools(&self) -> &Arc<BrowserTools> {
match &self.browser {
ViewBrowser::Local(tools) => tools,
ViewBrowser::Relay(_) => panic!("this view is served by an agent process"),
}
}
async fn subscribe(
self: &Arc<Self>,
client_id: &str,
channel: Arc<WsChannel>,
) -> (WirePresentation, u64) {
self.refresh_presentation().await;
let epoch = NEXT_SUBSCRIBER_EPOCH.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
let subscriber = BrowserViewSubscriber::spawn(
Arc::downgrade(self),
client_id.to_string(),
epoch,
channel,
);
let mut fanout = self.fanout.lock().await;
fanout.subscribers.insert(client_id.to_string(), subscriber);
let snapshot = (fanout.last.clone(), fanout.cursor);
drop(fanout);
self.ensure_streamer().await;
snapshot
}
async fn adopt(self: &Arc<Self>, previous: &Arc<BrowserView>) {
previous.stop_streamer().await;
let (cursor, subscribers) = {
let mut old = previous.fanout.lock().await;
(old.cursor, std::mem::take(&mut old.subscribers))
};
let has_subscribers = {
let mut fanout = self.fanout.lock().await;
fanout.cursor = fanout.cursor.max(cursor);
for (client_id, subscriber) in subscribers {
subscriber.rebind(Arc::downgrade(self));
fanout.subscribers.entry(client_id).or_insert(subscriber);
}
!fanout.subscribers.is_empty()
};
if has_subscribers {
self.refresh_presentation().await;
self.ensure_streamer().await;
}
}
async fn unsubscribe(&self, client_id: &str) -> bool {
self.remove_subscriber(client_id, None).await
}
async fn unsubscribe_epoch(&self, client_id: &str, epoch: u64) -> bool {
self.remove_subscriber(client_id, Some(epoch)).await
}
async fn remove_subscriber(&self, client_id: &str, epoch: Option<u64>) -> bool {
let (removed, empty) = {
let mut fanout = self.fanout.lock().await;
let matches = fanout
.subscribers
.get(client_id)
.is_some_and(|s| epoch.is_none_or(|e| s.epoch == e));
let removed = matches && fanout.subscribers.remove(client_id).is_some();
(removed, fanout.subscribers.is_empty())
};
if empty {
self.stop_streamer_if_unwatched().await;
}
removed
}
pub(crate) async fn has_subscribers(&self) -> bool {
!self.fanout.lock().await.subscribers.is_empty()
}
#[cfg(test)]
async fn subscriber_count(&self) -> usize {
self.fanout.lock().await.subscribers.len()
}
#[cfg(test)]
pub(crate) async fn take_control_for_test(
self: &Arc<Self>,
client_id: &str,
) -> Result<(WirePresentation, u64), String> {
self.take_control(client_id).await
}
#[cfg(test)]
pub(crate) async fn control_holder_for_test(&self) -> Option<String> {
self.control.lock().await.holder.clone()
}
#[cfg(test)]
pub(crate) async fn grace_generation_for_test(&self) -> u64 {
self.control.lock().await.generation
}
#[cfg(test)]
pub(crate) async fn subscribe_for_test(
self: &Arc<Self>,
client_id: &str,
channel: Arc<WsChannel>,
) -> (WirePresentation, u64) {
self.subscribe(client_id, channel).await
}
#[cfg(test)]
pub(crate) async fn subscriber_count_for_test(&self) -> usize {
self.subscriber_count().await
}
pub(crate) async fn refresh_presentation(&self) {
let wire = self.browser.presentation().await;
self.publish_presentation(wire).await;
}
async fn publish_presentation(&self, wire: WirePresentation) {
{
let mut fanout = self.fanout.lock().await;
if wire.revision < fanout.last.revision || fanout.last == wire {
return;
}
fanout.last = wire.clone();
fanout.cursor += 1;
let event = BrowserViewEvent {
conversation_id: self.key.clone(),
cursor: fanout.cursor,
payload: BrowserViewPayload::Presentation { presentation: wire },
};
fanout_locked(&fanout.subscribers, &event);
}
}
async fn resolve_signin_attention_on_teardown(&self) {
match &self.browser {
ViewBrowser::Local(tools) => tools.resolve_signin_attention_on_teardown().await,
ViewBrowser::Relay(producer) => {
producer.detach_signin_attention(self.key.as_deref()).await
}
}
}
#[cfg(test)]
async fn publish_presentation_for_test(&self, wire: WirePresentation) {
self.publish_presentation(wire).await;
}
async fn emit_frame(&self, frame: ScreencastFrame) {
self.emit_wire_frame(WireFrame::from(frame)).await;
}
pub(crate) async fn emit_wire_frame(&self, wire: WireFrame) {
let mut fanout = self.fanout.lock().await;
fanout.cursor += 1;
let event = BrowserViewEvent {
conversation_id: self.key.clone(),
cursor: fanout.cursor,
payload: BrowserViewPayload::Frame { frame: wire },
};
fanout_locked(&fanout.subscribers, &event);
}
async fn snapshot(&self) -> (WirePresentation, u64) {
self.refresh_presentation().await;
let fanout = self.fanout.lock().await;
(fanout.last.clone(), fanout.cursor)
}
#[cfg(test)]
pub(crate) async fn snapshot_for_test(&self) -> (WirePresentation, u64) {
self.snapshot().await
}
async fn require_control(&self, client_id: &str) -> Result<(), String> {
let status = self.browser.control_status().await;
match status.owner {
ControlOwner::NoAgent => Ok(()),
ControlOwner::Agent if status.signin_pending => Ok(()),
ControlOwner::Agent => Err(AGENT_HOLDS_CONTROL.to_string()),
ControlOwner::User => {
if self.holder_admits(client_id).await {
Ok(())
} else {
Err(
"another connection holds control of this browser — input is accepted \
only from the control holder"
.to_string(),
)
}
}
}
}
async fn take_control(
self: &Arc<Self>,
client_id: &str,
) -> Result<(WirePresentation, u64), String> {
if !self.holder_admits(client_id).await {
return Err(
"another connection holds control of this browser — it must hand back before \
another can take control"
.to_string(),
);
}
let (owner, effects) = self.browser.control(ViewControl::TakeControl).await?;
let now_user = owner == ControlOwner::User;
{
let mut control = self.control.lock().await;
if now_user {
control.holder = Some(client_id.to_string());
}
control.generation += 1;
}
self.apply_effects(
effects,
GraceArming::Arm {
require_unwatched: false,
},
)
.await;
Ok(self.snapshot().await)
}
async fn holder_admits(&self, client_id: &str) -> bool {
match self.control.lock().await.holder.as_deref() {
None => true,
Some(holder) => holder == client_id,
}
}
async fn hand_back(
self: &Arc<Self>,
client_id: &str,
) -> Result<(WirePresentation, u64), String> {
if !self.holder_admits(client_id).await {
return Err(
"another connection holds control of this browser — only the control holder \
may hand it back"
.to_string(),
);
}
let (_, effects) = self.browser.control(ViewControl::HandBack).await?;
{
let mut control = self.control.lock().await;
control.holder = None;
control.generation += 1;
}
self.apply_effects(
effects,
GraceArming::Arm {
require_unwatched: false,
},
)
.await;
Ok(self.snapshot().await)
}
pub async fn note_disconnect(self: &Arc<Self>, client_id: &str, was_watching: bool) -> bool {
let held = {
let mut control = self.control.lock().await;
let held = control.holder.as_deref() == Some(client_id);
if held {
control.holder = None;
control.generation += 1;
}
held
};
if !held {
return false;
}
let require_unwatched = was_watching && self.fanout.lock().await.subscribers.is_empty();
self.spawn_grace_timer_inner(require_unwatched).await;
if matches!(&self.browser, ViewBrowser::Relay(_)) {
let view = Arc::clone(self);
tokio::spawn(async move { view.finish_holder_disconnect().await });
} else {
Arc::clone(self).finish_holder_disconnect().await;
}
true
}
async fn finish_holder_disconnect(self: Arc<Self>) {
let effects = self
.browser
.control_best_effort(ViewControl::HolderDisconnected)
.await;
self.apply_effects(effects, GraceArming::AlreadyArmed).await;
self.refresh_presentation().await;
}
pub async fn note_run_ended(self: &Arc<Self>) {
self.run_ended
.store(true, std::sync::atomic::Ordering::Release);
let effects = self
.browser
.control_best_effort(ViewControl::RunEnded)
.await;
self.apply_effects(
effects,
GraceArming::Arm {
require_unwatched: false,
},
)
.await;
{
let still_user_driven = self.browser.control_status().await.owner == ControlOwner::User;
let mut control = self.control.lock().await;
if !still_user_driven {
control.holder = None;
control.generation += 1;
}
}
self.refresh_presentation().await;
}
async fn apply_effects(self: &Arc<Self>, effects: Vec<ControlEffect>, grace: GraceArming) {
for effect in effects {
match effect {
ControlEffect::StartGracePeriod => match grace {
GraceArming::Arm { require_unwatched } => {
self.spawn_grace_timer_inner(require_unwatched).await
}
GraceArming::AlreadyArmed => {}
},
ControlEffect::SignInResolved { signed_in } => {
tracing::debug!(
view = ?self.key,
signed_in,
"browser view: pending sign-in resolved as a side effect"
);
}
}
}
}
pub async fn note_watcher_disconnect(self: &Arc<Self>) {
{
let control = self.control.lock().await;
if control.holder.is_some() {
return;
}
}
if !self.fanout.lock().await.subscribers.is_empty() {
return;
}
if matches!(&self.browser, ViewBrowser::Relay(_)) {
let view = Arc::clone(self);
tokio::spawn(async move { view.finish_watcher_disconnect().await });
} else {
Arc::clone(self).finish_watcher_disconnect().await;
}
}
async fn finish_watcher_disconnect(self: Arc<Self>) {
let effects = self
.browser
.control_best_effort(ViewControl::HolderDisconnected)
.await;
if effects.is_empty() {
return;
}
self.spawn_grace_timer_inner(true).await;
}
async fn spawn_grace_timer_inner(self: &Arc<Self>, require_unwatched: bool) {
let generation = {
let mut control = self.control.lock().await;
control.generation += 1;
control.generation
};
let view = Arc::downgrade(self);
tokio::spawn(async move {
tokio::time::sleep(CONTROL_GRACE).await;
let Some(view) = view.upgrade() else { return };
{
let mut control = view.control.lock().await;
if control.generation != generation || control.holder.is_some() {
return;
}
control.holder = None;
}
if require_unwatched && !view.fanout.lock().await.subscribers.is_empty() {
return;
}
view.browser
.control_best_effort(ViewControl::GraceExpired)
.await;
view.refresh_presentation().await;
});
}
async fn ensure_streamer(self: &Arc<Self>) {
let mut capture = self.capture.lock().await;
let pump_died = match capture.task.as_ref() {
Some(task) => task.is_finished(),
None => false,
};
if capture.active && !pump_died {
return;
}
if let Some(task) = capture.task.take() {
task.abort();
}
capture.task = self.browser.start_capture(Arc::downgrade(self)).await;
capture.active = true;
}
async fn stop_streamer(&self) {
self.stop_streamer_inner(false).await;
}
async fn stop_streamer_if_unwatched(&self) {
self.stop_streamer_inner(true).await;
}
async fn stop_streamer_inner(&self, only_if_unwatched: bool) {
let mut capture = self.capture.lock().await;
if !capture.active {
return;
}
if only_if_unwatched && !self.fanout.lock().await.subscribers.is_empty() {
return;
}
capture.active = false;
if let Some(task) = capture.task.take() {
task.abort();
let _ = task.await;
}
drop(capture);
self.browser.stop_capture().await;
}
}
fn fanout_locked(subscribers: &HashMap<String, BrowserViewSubscriber>, event: &BrowserViewEvent) {
for (client_id, subscriber) in subscribers.iter() {
if !subscriber.push(event.clone()) {
tracing::debug!(
client_id,
cursor = event.cursor,
"browser view: dropped event for a slow subscriber (channel full)"
);
}
}
}
async fn stream(view: std::sync::Weak<BrowserView>, tools: Arc<BrowserTools>) {
let mut changes = tools.subscribe_changes();
let (mut frames, _epoch) = tools.subscribe_frames().await;
let mut tabs = tools.subscribe_tabs().await;
loop {
let Some(view) = view.upgrade() else { return };
tokio::select! {
changed = changes.changed() => {
if changed.is_err() {
return;
}
if tabs.is_none() {
tabs = tools.subscribe_tabs().await;
}
view.refresh_presentation().await;
}
tabs_changed = async {
match tabs.as_mut() {
Some(rx) => rx.changed().await.is_ok(),
None => std::future::pending().await,
}
} => {
if !tabs_changed {
tabs = None;
continue;
}
view.refresh_presentation().await;
}
frame = frames.recv() => {
match frame {
Some(frame) => view.emit_frame(frame).await,
None => {
let (rx, _epoch) = tools.subscribe_frames().await;
frames = rx;
}
}
}
}
}
}
pub struct BrowserViewRegistry {
views: Mutex<HashMap<Option<String>, Arc<BrowserView>>>,
producers: ProducerRegistry,
root: PathBuf,
signin_attention: std::sync::OnceLock<Arc<dyn SignInAttention>>,
}
impl BrowserViewRegistry {
pub fn new(root: PathBuf) -> Self {
Self {
views: Mutex::new(HashMap::new()),
producers: ProducerRegistry::default(),
root,
signin_attention: std::sync::OnceLock::new(),
}
}
pub fn set_signin_attention(&self, attention: Arc<dyn SignInAttention>) {
let _ = self.signin_attention.set(attention);
}
fn signin_attention(&self) -> Option<Arc<dyn SignInAttention>> {
self.signin_attention.get().cloned()
}
pub async fn standing(&self) -> Arc<BrowserView> {
let mut views = self.views.lock().await;
Arc::clone(views.entry(None).or_insert_with(|| {
let tools = BrowserTools::standing_session(self.root.clone());
tools.set_host_connectivity(Arc::new(AlwaysConnected));
Arc::new(BrowserView::new(None, Arc::new(tools)))
}))
}
pub async fn register(
&self,
conversation_id: impl Into<String>,
tools: Arc<BrowserTools>,
) -> Arc<BrowserView> {
let key = Some(conversation_id.into());
let view = Arc::new(BrowserView::new(key.clone(), tools));
let previous = self.views.lock().await.insert(key, Arc::clone(&view));
if let Some(previous) = previous {
view.adopt(&previous).await;
previous.resolve_signin_attention_on_teardown().await;
}
view
}
pub async fn register_relay(
&self,
conversation_id: impl Into<String>,
producer: Arc<RelayProducer>,
) -> Arc<BrowserView> {
let key = Some(conversation_id.into());
if let Some(existing) = self.views.lock().await.get(&key) {
if existing.is_served_by(&producer) {
return Arc::clone(existing);
}
}
let view = Arc::new(BrowserView::with_browser(
key.clone(),
ViewBrowser::Relay(Arc::clone(&producer)),
));
producer.attach_view(&view).await;
let previous = self.views.lock().await.insert(key, Arc::clone(&view));
if let Some(previous) = previous {
view.adopt(&previous).await;
previous.resolve_signin_attention_on_teardown().await;
}
producer
.set_signin_attention(self.signin_attention(), view.key.clone())
.await;
self.retire_views_past_the_cap(&producer).await;
view
}
async fn retire_views_past_the_cap(&self, producer: &Arc<RelayProducer>) {
for view in producer.views_past_the_cap().await {
view.run_ended
.store(true, std::sync::atomic::Ordering::Release);
self.release_if_idle(&view).await;
if let Some(key) = view.key.as_deref() {
self.producers.forget_binding(key).await;
}
}
}
pub async fn producer_for(
&self,
client_id: &str,
agent_id: &str,
channel: &Arc<WsChannel>,
) -> Arc<RelayProducer> {
self.producers
.get_or_create(client_id, agent_id, channel)
.await
}
pub async fn broadcast_host_connected(&self, connected: bool) {
self.producers.broadcast_host_connected(connected).await;
}
pub async fn producer(&self, client_id: &str) -> Option<Arc<RelayProducer>> {
self.producers.get(client_id).await
}
pub async fn conversation_owner(&self, conversation_id: &str) -> Option<String> {
self.producers.conversation_owner(conversation_id).await
}
pub async fn bind_conversation(&self, conversation_id: &str, agent_id: &str) {
self.producers
.bind_conversation(conversation_id, agent_id)
.await;
}
pub async fn note_producer_disconnected(&self, client_id: &str) {
let producer = self.producers.get(client_id).await;
self.producers.note_disconnected(client_id).await;
let Some(producer) = producer else { return };
let views: Vec<Arc<BrowserView>> = self
.views
.lock()
.await
.values()
.filter(|view| view.is_served_by(&producer))
.cloned()
.collect();
for view in views {
view.run_ended
.store(true, std::sync::atomic::Ordering::Release);
self.release_if_idle(&view).await;
}
}
pub async fn release_if_idle(&self, view: &Arc<BrowserView>) -> bool {
if !view.run_ended.load(std::sync::atomic::Ordering::Acquire) {
return false;
}
let released = {
let fanout = view.fanout.lock().await;
if !fanout.subscribers.is_empty() {
return false;
}
let mut views = self.views.lock().await;
match views.get(&view.key) {
Some(current) if Arc::ptr_eq(current, view) => views.remove(&view.key).is_some(),
_ => false,
}
};
if !released {
return false;
}
if !self.stop_or_restore(view).await {
return false;
}
view.resolve_signin_attention_on_teardown().await;
true
}
pub async fn pending_signins(&self) -> Vec<BrowserSignInSnapshot> {
let views: Vec<Arc<BrowserView>> = self.views.lock().await.values().cloned().collect();
let mut seen_producers = HashSet::new();
let mut pending = Vec::new();
for view in views {
match &view.browser {
ViewBrowser::Local(tools) => {
if let Some(message) = tools.pending_signin_message().await {
pending.push(BrowserSignInSnapshot::new(view.key.as_deref(), message));
}
}
ViewBrowser::Relay(producer) => {
if seen_producers.insert(producer.client_id().to_string()) {
if let Some(signin) = producer.signin_snapshot().await {
pending.push(signin);
}
}
}
}
}
pending.sort_by(|a, b| a.conversation_id.cmp(&b.conversation_id));
pending
}
async fn stop_or_restore(&self, view: &Arc<BrowserView>) -> bool {
view.stop_streamer_if_unwatched().await;
if !view.fanout.lock().await.subscribers.is_empty() {
let mut views = self.views.lock().await;
views
.entry(view.key.clone())
.or_insert_with(|| Arc::clone(view));
return false;
}
true
}
pub async fn get(&self, conversation_id: Option<&str>) -> Option<Arc<BrowserView>> {
match conversation_id {
None => Some(self.standing().await),
Some(id) => self
.views
.lock()
.await
.get(&Some(id.to_string()))
.map(Arc::clone),
}
}
pub async fn drop_subscriptions_for_client(&self, client_id: &str) {
let views: Vec<Arc<BrowserView>> = self.views.lock().await.values().cloned().collect();
futures::future::join_all(views.into_iter().map(|view| async move {
let was_watching = view.unsubscribe(client_id).await;
let held_control = view.note_disconnect(client_id, was_watching).await;
if was_watching && !held_control {
view.note_watcher_disconnect().await;
}
self.release_if_idle(&view).await;
}))
.await;
}
}
impl Default for BrowserViewRegistry {
fn default() -> Self {
Self::new(car_home::root().unwrap_or_else(std::env::temp_dir))
}
}
#[derive(Debug, Default, Deserialize)]
struct ViewParams {
#[serde(default)]
conversation_id: Option<String>,
}
fn view_params(req: &JsonRpcMessage) -> Result<ViewParams, String> {
if req.params.is_null() {
return Ok(ViewParams::default());
}
serde_json::from_value(req.params.clone())
.map_err(|e| format!("browser.view.* takes an optional {{ conversation_id }}: {e}"))
}
fn authorize(session: &ClientSession) -> Result<(), String> {
if session.is_host.load(std::sync::atomic::Ordering::Acquire) {
return Ok(());
}
tracing::debug!(
client_id = %session.client_id,
"browser.view.* denied: connection is not the host management client"
);
Err(
"not authorized to use browser.view.*: this connection is not the host management \
client (session.auth { host_token })"
.to_string(),
)
}
async fn resolve(
state: &Arc<ServerState>,
conversation_id: Option<&str>,
) -> Result<Arc<BrowserView>, String> {
match state.browser_views.get(conversation_id).await {
Some(view) => Ok(view),
None => Err(format!(
"no browser view for conversation '{}' — that conversation has no agent-attached \
browser; omit `conversation_id` for the standing session",
conversation_id.unwrap_or_default()
)),
}
}
fn snapshot_value(conversation_id: Option<&str>, snapshot: (WirePresentation, u64)) -> Value {
let (presentation, cursor) = snapshot;
json!({
"conversation_id": conversation_id,
"standing_session": conversation_id.is_none(),
"cursor": cursor,
"presentation": presentation,
})
}
pub async fn handle_subscribe(
req: &JsonRpcMessage,
session: &ClientSession,
state: &Arc<ServerState>,
) -> Result<Value, String> {
authorize(session)?;
let params = view_params(req)?;
let view = resolve(state, params.conversation_id.as_deref()).await?;
let snapshot = view
.subscribe(&session.client_id, session.channel.clone())
.await;
Ok(snapshot_value(params.conversation_id.as_deref(), snapshot))
}
pub async fn handle_unsubscribe(
req: &JsonRpcMessage,
session: &ClientSession,
state: &Arc<ServerState>,
) -> Result<Value, String> {
authorize(session)?;
let params = view_params(req)?;
let view = resolve(state, params.conversation_id.as_deref()).await?;
let removed = view.unsubscribe(&session.client_id).await;
state.browser_views.release_if_idle(&view).await;
Ok(json!({
"conversation_id": params.conversation_id,
"removed": removed,
}))
}
pub async fn handle_take_control(
req: &JsonRpcMessage,
session: &ClientSession,
state: &Arc<ServerState>,
) -> Result<Value, String> {
authorize(session)?;
let params = view_params(req)?;
let view = resolve(state, params.conversation_id.as_deref()).await?;
let snapshot = view.take_control(&session.client_id).await?;
Ok(snapshot_value(params.conversation_id.as_deref(), snapshot))
}
pub async fn handle_hand_back(
req: &JsonRpcMessage,
session: &ClientSession,
state: &Arc<ServerState>,
) -> Result<Value, String> {
authorize(session)?;
let params = view_params(req)?;
let view = resolve(state, params.conversation_id.as_deref()).await?;
let snapshot = view.hand_back(&session.client_id).await?;
Ok(snapshot_value(params.conversation_id.as_deref(), snapshot))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputOp {
Navigate,
Click,
Type,
Keypress,
Scroll,
Paste,
Back,
Forward,
Reload,
TabOpen,
TabClose,
TabSwitch,
}
#[derive(Debug, Deserialize)]
struct InputParams {
#[serde(default)]
conversation_id: Option<String>,
#[serde(default)]
url: Option<String>,
#[serde(default)]
x: Option<f64>,
#[serde(default)]
y: Option<f64>,
#[serde(default)]
text: Option<String>,
#[serde(default)]
key: Option<String>,
#[serde(default)]
modifiers: Vec<String>,
#[serde(default)]
delta_y: Option<i32>,
#[serde(default)]
tab_id: Option<String>,
}
pub(crate) fn parse_modifier(name: &str) -> Result<Modifier, String> {
match name.to_ascii_lowercase().as_str() {
"alt" | "option" => Ok(Modifier::Alt),
"control" | "ctrl" => Ok(Modifier::Control),
"meta" | "command" | "cmd" => Ok(Modifier::Meta),
"shift" => Ok(Modifier::Shift),
other => Err(format!(
"unknown modifier '{other}' — use alt, control, meta, or shift"
)),
}
}
pub async fn handle_input(
op: InputOp,
req: &JsonRpcMessage,
session: &ClientSession,
state: &Arc<ServerState>,
) -> Result<Value, String> {
authorize(session)?;
let params: InputParams = if req.params.is_null() {
serde_json::from_value(json!({})).map_err(|e| e.to_string())?
} else {
serde_json::from_value(req.params.clone())
.map_err(|e| format!("invalid browser.view input params: {e}"))?
};
let view = resolve(state, params.conversation_id.as_deref()).await?;
view.require_control(&session.client_id).await?;
let input = match op {
InputOp::Navigate => ViewInput::Navigate {
url: params.url.ok_or("browser.view.navigate requires { url }")?,
},
InputOp::Click => match (params.x, params.y) {
(Some(x), Some(y)) => ViewInput::Click { x, y },
_ => return Err("browser.view.click requires { x, y }".to_string()),
},
InputOp::Type => ViewInput::Type {
text: params.text.ok_or("browser.view.type requires { text }")?,
},
InputOp::Keypress => ViewInput::Keypress {
key: params.key.ok_or("browser.view.keypress requires { key }")?,
modifiers: params
.modifiers
.iter()
.map(|m| parse_modifier(m))
.collect::<Result<Vec<_>, _>>()?,
},
InputOp::Scroll => ViewInput::Scroll {
delta_y: params
.delta_y
.ok_or("browser.view.scroll requires { delta_y }")?,
},
InputOp::Paste => ViewInput::Paste {
text: params.text.ok_or("browser.view.paste requires { text }")?,
},
InputOp::Back => ViewInput::Back,
InputOp::Forward => ViewInput::Forward,
InputOp::Reload => ViewInput::Reload,
InputOp::TabOpen => ViewInput::TabOpen,
InputOp::TabClose => ViewInput::TabClose {
tab_id: params
.tab_id
.ok_or("browser.view.tab_close requires { tab_id }")?,
},
InputOp::TabSwitch => ViewInput::TabSwitch {
tab_id: params
.tab_id
.ok_or("browser.view.tab_switch requires { tab_id }")?,
},
};
let opened_tab = view.browser.input(input).await?;
let mut out = json!({
"ok": true,
"conversation_id": params.conversation_id,
});
if let (Some(out), Some(tab_id)) = (out.as_object_mut(), opened_tab) {
out.insert("tab_id".to_string(), json!(tab_id));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assistant::browser_control::ControlEvent;
use crate::session::{ServerStateConfig, WsSink};
use futures::StreamExt;
fn capture_channel() -> (
Arc<WsChannel>,
futures::channel::mpsc::UnboundedReceiver<Message>,
) {
use futures::sink::SinkExt as _;
let (tx, rx) = futures::channel::mpsc::unbounded::<Message>();
let sink: WsSink =
Box::pin(tx.sink_map_err(|_| tokio_tungstenite::tungstenite::Error::ConnectionClosed));
let channel = Arc::new(WsChannel {
write: Mutex::new(sink),
pending: Mutex::new(HashMap::new()),
next_id: std::sync::atomic::AtomicU64::new(0),
});
(channel, rx)
}
fn wedged_channel() -> (Arc<WsChannel>, futures::channel::mpsc::Receiver<Message>) {
use futures::sink::SinkExt as _;
let (tx, rx) = futures::channel::mpsc::channel::<Message>(1);
let sink: WsSink =
Box::pin(tx.sink_map_err(|_| tokio_tungstenite::tungstenite::Error::ConnectionClosed));
let channel = Arc::new(WsChannel {
write: Mutex::new(sink),
pending: Mutex::new(HashMap::new()),
next_id: std::sync::atomic::AtomicU64::new(0),
});
(channel, rx)
}
fn test_view() -> Arc<BrowserView> {
Arc::new(BrowserView::new(
None,
Arc::new(BrowserTools::new(std::env::temp_dir())),
))
}
async fn next_event(
rx: &mut futures::channel::mpsc::UnboundedReceiver<Message>,
) -> BrowserViewEvent {
let frame = tokio::time::timeout(Duration::from_secs(2), rx.next())
.await
.expect("an event within the deadline")
.expect("a frame");
let text = match frame {
Message::Text(t) => t.to_string(),
other => panic!("expected a text frame, got {other:?}"),
};
let json: Value = serde_json::from_str(&text).unwrap();
assert_eq!(json["method"], "browser.view.event");
serde_json::from_value(json["params"].clone()).expect("a browser.view.event payload")
}
#[tokio::test]
async fn subscribe_returns_a_snapshot_and_cursor_and_events_advance_it_by_one() {
let view = test_view();
view.tools().attach_agent_for_test().await;
let (channel, mut rx) = capture_channel();
let (snapshot, cursor) = view.subscribe("host-1", channel).await;
assert_eq!(snapshot.owner, WireOwner::Agent);
assert!(snapshot.tabs.is_empty());
assert!(!snapshot.blackout_active);
view.tools().take_control().await;
view.refresh_presentation().await;
let first = next_event(&mut rx).await;
assert_eq!(first.cursor, cursor + 1);
view.tools().hand_back().await;
view.refresh_presentation().await;
let second = next_event(&mut rx).await;
assert_eq!(second.cursor, cursor + 2);
view.emit_frame(test_frame(3)).await;
let third = next_event(&mut rx).await;
assert_eq!(third.cursor, cursor + 3);
match third.payload {
BrowserViewPayload::Frame { frame } => {
assert_eq!(BASE64.decode(frame.jpeg_base64).unwrap(), vec![3]);
assert_eq!(frame.width, 1920);
}
BrowserViewPayload::Presentation { .. } => panic!("expected a frame event"),
}
}
#[tokio::test]
async fn take_control_with_no_agent_attached_records_no_owner() {
let view = test_view();
view.tools().take_control().await;
let (channel, _rx) = capture_channel();
let (snapshot, _) = view.subscribe("host-1", channel).await;
assert_eq!(snapshot.owner, WireOwner::None);
assert!(
!snapshot.blackout_active,
"and no blackout for a browser nobody is driving"
);
}
#[tokio::test]
async fn an_unchanged_presentation_emits_nothing_and_does_not_burn_a_cursor() {
let view = test_view();
let (channel, mut rx) = capture_channel();
let (_, cursor) = view.subscribe("host-1", channel).await;
view.refresh_presentation().await;
view.refresh_presentation().await;
assert_eq!(view.fanout.lock().await.cursor, cursor);
assert!(rx.try_recv().is_err(), "nothing changed, nothing emitted");
}
#[tokio::test]
async fn re_subscribing_yields_a_fresh_snapshot_at_the_current_cursor() {
let view = test_view();
let (channel, mut rx) = capture_channel();
view.subscribe("host-1", channel).await;
view.emit_frame(test_frame(1)).await;
assert_eq!(next_event(&mut rx).await.cursor, 1);
assert!(view.unsubscribe("host-1").await);
view.emit_frame(test_frame(2)).await;
let (channel2, mut rx2) = capture_channel();
let (snapshot, cursor) = view.subscribe("host-1", channel2).await;
assert_eq!(snapshot.owner, WireOwner::None);
assert_eq!(cursor, 2, "the reconnect snapshots the event it missed");
assert_eq!(cursor, view.fanout.lock().await.cursor);
view.emit_frame(test_frame(3)).await;
assert_eq!(next_event(&mut rx2).await.cursor, 3);
}
#[tokio::test]
async fn two_subscribers_both_get_every_event_and_dropping_one_leaves_the_other_streaming() {
let view = test_view();
let (channel_a, mut rx_a) = capture_channel();
let (channel_b, mut rx_b) = capture_channel();
view.subscribe("host-a", channel_a).await;
view.subscribe("host-b", channel_b).await;
view.emit_frame(test_frame(1)).await;
assert_eq!(next_event(&mut rx_a).await.cursor, 1);
assert_eq!(next_event(&mut rx_b).await.cursor, 1);
assert!(view.unsubscribe("host-a").await);
view.emit_frame(test_frame(2)).await;
assert_eq!(
next_event(&mut rx_b).await.cursor,
2,
"the surviving subscriber keeps streaming"
);
assert!(
rx_a.try_recv().is_err(),
"the dropped subscriber receives nothing further"
);
}
#[tokio::test]
async fn unsubscribing_twice_is_idempotent() {
let view = test_view();
let (channel, _rx) = capture_channel();
view.subscribe("host-1", channel).await;
assert!(view.unsubscribe("host-1").await);
assert!(!view.unsubscribe("host-1").await);
assert_eq!(view.subscriber_count().await, 0);
}
#[tokio::test]
async fn a_slow_subscriber_loses_events_instead_of_blocking_the_producer() {
let view = test_view();
let (channel, _rx) = wedged_channel();
view.subscribe("host-slow", channel).await;
let pushes = BROWSER_VIEW_CHANNEL_CAP * 4;
let start = std::time::Instant::now();
for i in 0..pushes {
view.emit_frame(test_frame(i as u8)).await;
}
assert!(
start.elapsed() < Duration::from_secs(2),
"the producer must never park behind a wedged subscriber"
);
assert_eq!(
view.fanout.lock().await.cursor,
pushes as u64,
"every event was stamped; the wedged subscriber simply lost most of them"
);
let (good, mut good_rx) = capture_channel();
view.subscribe("host-ok", good).await;
view.emit_frame(test_frame(0)).await;
assert_eq!(next_event(&mut good_rx).await.cursor, pushes as u64 + 1);
}
#[tokio::test]
async fn with_no_agent_involved_anyone_may_drive() {
let view = test_view();
view.require_control("host-1")
.await
.expect("zero ceremony: the standing session is just a browser");
}
#[tokio::test]
async fn while_the_agent_drives_nobody_may_input() {
let view = test_view();
view.tools().attach_agent_for_test().await;
let err = view.require_control("host-1").await.unwrap_err();
assert!(err.contains("take_control"), "got: {err}");
}
#[tokio::test]
async fn take_control_moves_input_rights_to_that_connection_and_hand_back_returns_them() {
let view = test_view();
view.tools().attach_agent_for_test().await;
let (snapshot, _) = view
.take_control("host-1")
.await
.expect("a local view never fails");
assert_eq!(snapshot.owner, WireOwner::User);
assert!(
snapshot.blackout_active,
"user control blacks the model out"
);
view.require_control("host-1")
.await
.expect("the control holder may drive");
let err = view.require_control("host-2").await.unwrap_err();
assert!(
err.contains("another connection holds control"),
"got: {err}"
);
let (snapshot, _) = view
.hand_back("host-1")
.await
.expect("a local view never fails");
assert_eq!(snapshot.owner, WireOwner::Agent);
assert!(!snapshot.blackout_active);
assert!(view.require_control("host-1").await.is_err());
}
#[tokio::test]
async fn a_pending_signin_lets_the_user_type_without_taking_control() {
let view = test_view();
view.tools().attach_agent_for_test().await;
assert!(view.require_control("host-1").await.is_err());
view.tools()
.apply_control_for_test(ControlEvent::SignInRequested("Sign in at x".into()))
.await;
view.require_control("host-1")
.await
.expect("the human must be able to type their password");
let (snapshot, _) = view.snapshot().await;
assert_eq!(snapshot.pending_signin.as_deref(), Some("Sign in at x"));
assert!(snapshot.blackout_active);
}
#[tokio::test]
async fn hand_back_resolves_a_pending_signin_on_the_wire() {
let view = test_view();
let (channel, mut rx) = capture_channel();
view.tools().attach_agent_for_test().await;
view.subscribe("host-1", channel).await;
view.tools()
.apply_control_for_test(ControlEvent::SignInRequested("Sign in at x".into()))
.await;
view.refresh_presentation().await;
let pending = next_event(&mut rx).await;
match pending.payload {
BrowserViewPayload::Presentation { presentation } => {
assert_eq!(presentation.pending_signin.as_deref(), Some("Sign in at x"));
assert!(presentation.blackout_active);
}
BrowserViewPayload::Frame { .. } => panic!("expected a presentation event"),
}
let (snapshot, _) = view
.hand_back("host-1")
.await
.expect("a local view never fails");
assert_eq!(snapshot.pending_signin, None, "the strip clears");
assert_eq!(snapshot.owner, WireOwner::Agent);
assert!(!snapshot.blackout_active);
}
#[tokio::test]
async fn a_timed_out_signin_clears_the_strip_and_leaves_the_agent_driving() {
let view = test_view();
view.tools().attach_agent_for_test().await;
view.tools()
.apply_control_for_test(ControlEvent::SignInRequested("Sign in at x".into()))
.await;
view.tools()
.apply_control_for_test(ControlEvent::SignInResolved { signed_in: false })
.await;
let (snapshot, _) = view.snapshot().await;
assert_eq!(snapshot.pending_signin, None);
assert_eq!(
snapshot.owner,
WireOwner::Agent,
"a timeout returns control to the agent, unchanged from today"
);
assert!(!snapshot.blackout_active);
}
#[tokio::test(start_paused = true)]
async fn control_reverts_to_the_agent_after_the_grace_period() {
let view = test_view();
view.tools().attach_agent_for_test().await;
view.take_control("host-1").await.unwrap();
view.note_disconnect("host-1", false).await;
assert_eq!(
view.snapshot().await.0.owner,
WireOwner::User,
"still the user's during the grace window"
);
tokio::time::sleep(CONTROL_GRACE + Duration::from_secs(1)).await;
assert_eq!(view.snapshot().await.0.owner, WireOwner::Agent);
assert!(view.control.lock().await.holder.is_none());
}
#[tokio::test(start_paused = true)]
async fn a_disconnect_by_a_connection_that_never_held_control_starts_no_timer() {
let view = test_view();
view.tools().attach_agent_for_test().await;
view.take_control("host-1").await.unwrap();
view.note_disconnect("host-2", false).await;
tokio::time::sleep(CONTROL_GRACE + Duration::from_secs(1)).await;
assert_eq!(
view.snapshot().await.0.owner,
WireOwner::User,
"host-1 still holds control; host-2 leaving is irrelevant"
);
}
#[tokio::test(start_paused = true)]
async fn taking_control_again_inside_the_window_survives_the_stale_expiry() {
let view = test_view();
view.tools().attach_agent_for_test().await;
view.take_control("host-1").await.unwrap();
view.note_disconnect("host-1", false).await;
tokio::time::sleep(Duration::from_secs(1)).await;
view.take_control("host-2").await.unwrap();
tokio::time::sleep(CONTROL_GRACE + Duration::from_secs(1)).await;
assert_eq!(view.snapshot().await.0.owner, WireOwner::User);
assert_eq!(view.control.lock().await.holder.as_deref(), Some("host-2"));
}
#[tokio::test]
async fn a_run_ending_returns_the_browser_to_the_user_with_no_ceremony() {
let view = test_view();
view.tools().attach_agent_for_test().await;
view.note_run_ended().await;
let (snapshot, _) = view.snapshot().await;
assert_eq!(snapshot.owner, WireOwner::None);
assert_eq!(snapshot.current_action, None);
assert!(!snapshot.blackout_active);
view.require_control("host-1")
.await
.expect("every control accepts input immediately after a run ends");
}
#[tokio::test]
async fn a_run_ending_while_the_user_drives_leaves_control_and_the_blackout_alone() {
let view = test_view();
view.tools().attach_agent_for_test().await;
view.take_control("host-1").await.unwrap();
view.note_run_ended().await;
let (snapshot, _) = view.snapshot().await;
assert_eq!(snapshot.owner, WireOwner::User, "still the user's");
assert!(
snapshot.blackout_active,
"and the model still cannot see the screen"
);
assert!(
view.require_control("host-2").await.is_err(),
"nor does it re-open input to every connection"
);
view.require_control("host-1")
.await
.expect("the person who holds control must still be able to type");
let (snapshot, _) = view.hand_back("host-1").await.unwrap();
assert_eq!(snapshot.owner, WireOwner::None);
assert!(!snapshot.blackout_active);
view.require_control("host-1").await.expect("user-drivable");
}
#[tokio::test]
async fn a_stop_that_lands_after_a_new_subscriber_arrived_leaves_the_stream_running() {
let view = test_view();
let (channel_a, _rx_a) = capture_channel();
view.subscribe("host-a", channel_a).await;
assert!(
view.capture.lock().await.active,
"the first subscriber arms capture"
);
let (channel_b, _rx_b) = capture_channel();
view.subscribe("host-b", channel_b).await;
view.stop_streamer_if_unwatched().await;
assert!(
view.capture.lock().await.active,
"a subscriber reappeared, so the stop must not land"
);
view.stop_streamer().await;
assert!(!view.capture.lock().await.active);
}
#[tokio::test]
async fn hand_back_is_refused_from_a_connection_that_does_not_hold_control() {
let view = test_view();
view.tools().attach_agent_for_test().await;
view.take_control("host-1").await.unwrap();
let err = view.hand_back("host-2").await.unwrap_err();
assert!(
err.contains("another connection holds control"),
"got: {err}"
);
assert_eq!(
view.snapshot().await.0.owner,
WireOwner::User,
"the holder keeps control"
);
view.hand_back("host-1")
.await
.expect("the holder may hand back");
assert_eq!(view.snapshot().await.0.owner, WireOwner::Agent);
}
#[tokio::test]
async fn an_older_concurrent_read_never_overwrites_a_newer_published_one() {
let view = test_view();
let (channel, mut rx) = capture_channel();
view.subscribe("host-1", channel).await;
view.tools().attach_agent_for_test().await;
view.refresh_presentation().await;
let newer = next_event(&mut rx).await;
let BrowserViewPayload::Presentation { presentation } = newer.payload else {
panic!("expected a presentation event")
};
assert_eq!(presentation.owner, WireOwner::Agent);
let published_revision = presentation.revision;
let stale = WirePresentation {
revision: published_revision - 1,
owner: WireOwner::None,
..presentation.clone()
};
view.publish_presentation_for_test(stale).await;
assert!(
tokio::time::timeout(Duration::from_millis(50), rx.next())
.await
.is_err(),
"an older read must not be emitted after a newer one"
);
assert_eq!(
view.fanout.lock().await.last.owner,
WireOwner::Agent,
"and the cached snapshot every later subscriber gets must not go stale"
);
}
#[tokio::test]
async fn a_pending_signin_does_not_admit_input_from_a_non_holder() {
let view = test_view();
view.tools().attach_agent_for_test().await;
view.tools()
.apply_control_for_test(
crate::assistant::browser_control::ControlEvent::SignInRequested(
"Sign in at x".into(),
),
)
.await;
view.require_control("host-1")
.await
.expect("the sign-in strip IS the affordance");
view.take_control("host-1").await.unwrap();
view.require_control("host-1")
.await
.expect("the holder keeps typing");
let err = view.require_control("host-2").await.unwrap_err();
assert!(
err.contains("another connection holds control"),
"a second connection must not interleave into the password field: {err}"
);
}
#[tokio::test]
async fn a_re_subscribe_on_the_same_connection_survives_the_old_drain_task_exiting() {
let view = test_view();
let (first, _rx1) = capture_channel();
view.subscribe("host-1", first).await;
let (second, mut rx2) = capture_channel();
view.subscribe("host-1", second).await;
for _ in 0..200 {
tokio::task::yield_now().await;
}
assert_eq!(
view.subscriber_count().await,
1,
"the live registration must survive the replaced one's teardown"
);
view.emit_frame(test_frame(7)).await;
let event = next_event(&mut rx2).await;
match event.payload {
BrowserViewPayload::Frame { .. } => {}
BrowserViewPayload::Presentation { .. } => panic!("expected a frame"),
}
}
#[tokio::test]
async fn a_release_that_races_a_new_subscriber_keeps_both_the_stream_and_the_view() {
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let view = registry
.register("conv-1", Arc::new(BrowserTools::new(std::env::temp_dir())))
.await;
view.note_run_ended().await;
registry.views.lock().await.remove(&view.key);
let (channel, _rx) = capture_channel();
view.subscribe("host-late", channel).await;
assert!(
!registry.stop_or_restore(&view).await,
"a subscriber arrived, so the release must not complete"
);
assert!(
view.capture.lock().await.active,
"its stream must not be stopped underneath that subscriber — zero events \
means the cursor-gap recovery can never fire, so the drawer would freeze"
);
assert!(
registry
.get(Some("conv-1"))
.await
.is_some_and(|v| Arc::ptr_eq(&v, &view)),
"and the key must resolve again, or every input and control call would \
answer 'no browser view for conversation' while frames kept arriving"
);
view.unsubscribe("host-late").await;
assert!(registry.release_if_idle(&view).await);
assert!(registry.get(Some("conv-1")).await.is_none());
}
#[tokio::test]
async fn take_control_is_refused_from_a_connection_that_does_not_hold_control() {
let view = test_view();
view.tools().attach_agent_for_test().await;
view.take_control("host-1").await.unwrap();
let err = view.take_control("host-2").await.unwrap_err();
assert!(
err.contains("another connection holds control"),
"got: {err}"
);
assert_eq!(
view.control.lock().await.holder.as_deref(),
Some("host-1"),
"the holder is unchanged, so hand_back still refuses host-2 too"
);
assert!(view.hand_back("host-2").await.is_err());
}
#[tokio::test]
async fn take_control_records_no_holder_when_the_reducer_did_not_move_ownership() {
let view = test_view();
let (snapshot, _) = view.take_control("host-1").await.unwrap();
assert_eq!(snapshot.owner, WireOwner::None, "documented no-op");
assert!(
view.control.lock().await.holder.is_none(),
"nobody took control, so nobody holds it"
);
view.take_control("host-2")
.await
.expect("and another connection is not locked out");
}
#[tokio::test(start_paused = true)]
async fn a_run_ending_after_the_holder_disconnected_still_reverts_when_grace_expires() {
let view = test_view();
view.tools().attach_agent_for_test().await;
view.take_control("host-1").await.unwrap();
view.note_disconnect("host-1", false).await;
tokio::time::sleep(Duration::from_secs(1)).await;
view.note_run_ended().await;
assert_eq!(
view.snapshot().await.0.owner,
WireOwner::User,
"deferred, as designed"
);
tokio::time::sleep(CONTROL_GRACE + Duration::from_secs(1)).await;
let (snapshot, _) = view.snapshot().await;
assert_eq!(
snapshot.owner,
WireOwner::None,
"the grace timer must still fire — nothing else re-arms it"
);
assert!(
!snapshot.blackout_active,
"and the blackout must not outlive the vanished controller"
);
}
#[tokio::test]
async fn a_disconnect_clears_the_holder_even_when_the_reducer_asks_for_no_grace_period() {
let view = test_view();
view.tools().attach_agent_for_test().await;
view.take_control("host-1").await.unwrap();
assert!(
view.require_control("host-2").await.is_err(),
"host-1 is driving"
);
view.note_disconnect("host-1", false).await;
assert!(
view.control.lock().await.holder.is_none(),
"the connection is provably gone"
);
view.require_control("host-2")
.await
.expect("a reconnected drawer must be able to drive, and to hand back");
view.hand_back("host-2")
.await
.expect("nobody holds control, so hand-back is admitted");
}
#[tokio::test]
async fn the_standing_session_is_one_shared_view_and_launches_nothing() {
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let a = registry.standing().await;
let b = registry.get(None).await.expect("always resolvable");
assert!(
Arc::ptr_eq(&a, &b),
"every conversation without an agent browser shares ONE standing session"
);
assert!(
a.snapshot().await.0.tabs.is_empty(),
"opening the drawer must not launch Chromium"
);
}
#[tokio::test]
async fn an_unknown_conversation_resolves_to_nothing() {
let registry = BrowserViewRegistry::new(std::env::temp_dir());
assert!(registry.get(Some("nope")).await.is_none());
}
#[tokio::test]
async fn a_registered_conversation_is_its_own_view() {
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
let view = registry.register("conv-1", Arc::clone(&tools)).await;
tools.attach_agent_for_test().await;
let found = registry.get(Some("conv-1")).await.expect("registered");
assert!(Arc::ptr_eq(&view, &found));
assert_eq!(found.snapshot().await.0.owner, WireOwner::Agent);
assert!(
!Arc::ptr_eq(&found, ®istry.standing().await),
"an agent's browser is not the standing session"
);
}
#[tokio::test]
async fn a_run_ending_leaves_the_browser_registered_subscribable_and_drivable() {
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
let view = registry.register("conv-1", Arc::clone(&tools)).await;
tools.attach_agent_for_test().await;
let (channel, mut rx) = capture_channel();
view.subscribe("host-1", channel).await;
view.note_run_ended().await;
let found = registry
.get(Some("conv-1"))
.await
.expect("the browser outlives the run that opened it");
assert!(Arc::ptr_eq(&view, &found));
let (snapshot, cursor) = found.snapshot().await;
assert_eq!(snapshot.owner, WireOwner::None);
assert_eq!(snapshot.current_action, None);
assert!(!snapshot.blackout_active);
found
.require_control("host-1")
.await
.expect("user-drivable");
let event = next_event(&mut rx).await;
match event.payload {
BrowserViewPayload::Presentation { presentation } => {
assert_eq!(presentation.owner, WireOwner::None);
}
BrowserViewPayload::Frame { .. } => panic!("expected a presentation event"),
}
found.emit_frame(test_frame(1)).await;
assert_eq!(next_event(&mut rx).await.cursor, cursor + 1);
}
#[tokio::test]
async fn a_new_run_for_the_same_conversation_replaces_and_releases_the_previous_browser() {
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let first_tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
let released = Arc::downgrade(&first_tools);
let first = registry.register("conv-1", Arc::clone(&first_tools)).await;
first_tools.attach_agent_for_test().await;
drop(first_tools);
let (channel, mut rx) = capture_channel();
first.subscribe("host-1", channel).await;
first.emit_frame(test_frame(1)).await;
let before = next_event(&mut rx).await.cursor;
let second_tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
let second = registry.register("conv-1", Arc::clone(&second_tools)).await;
second_tools.attach_agent_for_test().await;
let found = registry.get(Some("conv-1")).await.expect("registered");
assert!(
Arc::ptr_eq(&found, &second),
"the key now resolves to the new run's browser"
);
assert_eq!(first.subscriber_count().await, 0);
assert_eq!(second.subscriber_count().await, 1);
second.emit_frame(test_frame(2)).await;
let mut seen = next_event(&mut rx).await.cursor;
while seen <= before {
seen = next_event(&mut rx).await.cursor;
}
assert!(
seen > before,
"cursor never goes backwards across a handover"
);
drop(first);
drop(found);
for _ in 0..100 {
if released.upgrade().is_none() {
break;
}
tokio::task::yield_now().await;
}
assert!(
released.upgrade().is_none(),
"the replaced run's browser must be released, not accumulated"
);
}
#[tokio::test]
async fn replacing_a_local_view_resolves_its_pending_attention() {
use crate::assistant::browser_control::ControlEvent;
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let recorder = Arc::new(crate::browser_attention::RecordingAttention::default());
let first_tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
first_tools.set_signin_attention(recorder.clone(), Some("conv-1".to_string()));
registry.register("conv-1", Arc::clone(&first_tools)).await;
first_tools
.apply_control_for_test(ControlEvent::SignInRequested("Sign in".into()))
.await;
assert_eq!(registry.pending_signins().await[0].message, "Sign in");
registry
.register("conv-1", Arc::new(BrowserTools::new(std::env::temp_dir())))
.await;
assert_eq!(
recorder.kinds(),
vec![
crate::browser_attention::BROWSER_SIGNIN_NEEDED,
crate::browser_attention::BROWSER_SIGNIN_RESOLVED,
],
"an unreachable predecessor cannot strand its badge"
);
}
#[tokio::test]
async fn a_stalled_signin_broadcast_does_not_block_the_next_drawer_input() {
use crate::assistant::browser_control::ControlEvent;
use crate::browser_attention::SignInAttention;
struct BlockingAttention {
entered: Arc<tokio::sync::Notify>,
release: Arc<tokio::sync::Notify>,
}
#[async_trait::async_trait]
impl SignInAttention for BlockingAttention {
async fn signin_needed(&self, _conversation_id: Option<&str>, _message: &str) {
self.entered.notify_one();
self.release.notified().await;
}
async fn signin_resolved(&self, _conversation_id: Option<&str>) {}
}
let entered = Arc::new(tokio::sync::Notify::new());
let release = Arc::new(tokio::sync::Notify::new());
let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
tools.set_signin_attention(
Arc::new(BlockingAttention {
entered: Arc::clone(&entered),
release: Arc::clone(&release),
}),
Some("conv-1".to_string()),
);
let blocked = tokio::spawn({
let tools = Arc::clone(&tools);
async move {
tools
.apply_control_for_test(ControlEvent::SignInRequested("Sign in".into()))
.await;
}
});
entered.notified().await;
tokio::time::timeout(
std::time::Duration::from_secs(5),
tools.apply_control_for_test(ControlEvent::UserInput),
)
.await
.expect("a drawer input must not queue behind a stalled sign-in broadcast");
release.notify_one();
blocked.await.unwrap();
}
#[tokio::test]
async fn a_subscriber_that_lands_during_the_handover_window_survives_it() {
let previous = test_view();
let (channel_old, mut rx_old) = capture_channel();
previous.subscribe("host-old", channel_old).await;
let successor = test_view();
let (channel_new, mut rx_new) = capture_channel();
successor.subscribe("host-new", channel_new).await;
successor.adopt(&previous).await;
assert_eq!(
successor.subscriber_count().await,
2,
"both the inherited and the concurrent subscriber are registered"
);
successor.emit_frame(test_frame(1)).await;
let mut new_kinds = 0;
loop {
match next_event(&mut rx_new).await.payload {
BrowserViewPayload::Frame { .. } => break,
BrowserViewPayload::Presentation { .. } => {
new_kinds += 1;
assert!(new_kinds < 4, "expected a frame within a few events");
}
}
}
let mut old_kinds = 0;
loop {
match next_event(&mut rx_old).await.payload {
BrowserViewPayload::Frame { .. } => break,
BrowserViewPayload::Presentation { .. } => {
old_kinds += 1;
assert!(old_kinds < 4, "expected a frame within a few events");
}
}
}
}
#[tokio::test]
async fn a_watcher_leaving_mid_signin_starts_the_clock_that_ends_their_window() {
use crate::assistant::browser_control::ControlEvent;
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
let view = registry.register("conv-1", Arc::clone(&tools)).await;
let (channel, _rx) = capture_channel();
view.subscribe_for_test("host-1", channel).await;
tools
.apply_control_for_test(ControlEvent::AgentAttached)
.await;
tools
.apply_control_for_test(ControlEvent::SignInRequested("Sign in at x".into()))
.await;
tools.apply_control_for_test(ControlEvent::UserInput).await;
view.note_run_ended().await;
assert!(
tools.control_status().await.blackout_active,
"precondition: the engaged window survived the run ending"
);
assert!(
view.control_holder_for_test().await.is_none(),
"precondition: nothing holds control, so note_disconnect is inert here"
);
let before = view.grace_generation_for_test().await;
registry.drop_subscriptions_for_client("host-1").await;
assert!(
view.grace_generation_for_test().await > before,
"the watcher going away must arm the clock that settles their sign-in"
);
}
#[tokio::test]
async fn a_watcher_leaving_an_ordinary_agent_view_arms_nothing() {
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
let view = registry.register("conv-1", Arc::clone(&tools)).await;
let (channel, _rx) = capture_channel();
view.subscribe_for_test("host-1", channel).await;
tools.attach_agent_for_test().await;
let before = view.grace_generation_for_test().await;
registry.drop_subscriptions_for_client("host-1").await;
assert_eq!(view.grace_generation_for_test().await, before);
}
#[tokio::test]
async fn one_connection_on_both_sides_of_a_handover_keeps_its_subscription() {
let previous = test_view();
let (channel_old, _rx_old) = capture_channel();
previous.subscribe("host-1", channel_old).await;
let successor = test_view();
let (channel_new, mut rx_new) = capture_channel();
successor.subscribe("host-1", channel_new).await;
successor.adopt(&previous).await;
for _ in 0..50 {
tokio::task::yield_now().await;
}
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
successor.subscriber_count().await,
1,
"the live registration must survive the inherited one's deregistration"
);
successor.emit_frame(test_frame(1)).await;
let mut seen = 0;
loop {
match next_event(&mut rx_new).await.payload {
BrowserViewPayload::Frame { .. } => break,
BrowserViewPayload::Presentation { .. } => {
seen += 1;
assert!(seen < 4, "expected a frame within a few events");
}
}
}
}
#[tokio::test]
async fn a_handover_from_a_view_nobody_watched_still_starts_the_stream() {
let previous = test_view();
let successor = test_view();
let (channel, _rx) = capture_channel();
successor.subscribe("host-new", channel).await;
successor.adopt(&previous).await;
assert_eq!(successor.subscriber_count().await, 1);
let capture = successor.capture.lock().await;
assert!(
capture.active && capture.task.as_ref().is_some_and(|t| !t.is_finished()),
"the concurrent subscriber's stream must be running"
);
}
#[tokio::test]
async fn a_disconnect_drops_only_that_connection_s_subscriptions() {
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let view = registry.standing().await;
let (channel_a, _rx_a) = capture_channel();
let (channel_b, mut rx_b) = capture_channel();
view.subscribe("host-a", channel_a).await;
view.subscribe("host-b", channel_b).await;
registry.drop_subscriptions_for_client("host-a").await;
assert_eq!(view.subscriber_count().await, 1);
view.emit_frame(test_frame(9)).await;
assert_eq!(next_event(&mut rx_b).await.cursor, 1);
}
#[tokio::test]
async fn input_where_no_browser_exists_is_a_clean_error() {
let registry = BrowserViewRegistry::new(std::env::temp_dir());
let view = registry.standing().await;
let err = view.tools().user_click(10.0, 10.0).await.unwrap_err();
assert!(err.contains("no browser is running"), "got: {err}");
}
#[tokio::test]
async fn unknown_modifiers_are_rejected_rather_than_silently_dropped() {
assert_eq!(parse_modifier("Shift").unwrap(), Modifier::Shift);
assert_eq!(parse_modifier("cmd").unwrap(), Modifier::Meta);
let err = parse_modifier("hyper").unwrap_err();
assert!(err.contains("unknown modifier"), "got: {err}");
}
#[test]
fn the_event_wire_shape_is_tagged_and_flat() {
let event = BrowserViewEvent {
conversation_id: Some("conv-1".into()),
cursor: 7,
payload: BrowserViewPayload::Presentation {
presentation: WirePresentation::empty(),
},
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["conversation_id"], "conv-1");
assert_eq!(json["cursor"], 7);
assert_eq!(json["kind"], "presentation");
assert_eq!(json["presentation"]["owner"], "none");
let back: BrowserViewEvent = serde_json::from_value(json).unwrap();
assert_eq!(back, event);
}
#[test]
fn every_owner_state_has_its_own_wire_name() {
for (owner, name) in [
(ControlOwner::NoAgent, "none"),
(ControlOwner::Agent, "agent"),
(ControlOwner::User, "user"),
] {
let wire: WireOwner = owner.into();
assert_eq!(serde_json::to_value(wire).unwrap(), name);
}
}
async fn host_session(
state: &Arc<ServerState>,
client_id: &str,
) -> (
Arc<ClientSession>,
futures::channel::mpsc::UnboundedReceiver<Message>,
) {
let (channel, rx) = capture_channel();
let session = state.create_session(client_id, channel).await.unwrap();
session
.is_host
.store(true, std::sync::atomic::Ordering::Release);
(session, rx)
}
fn request(method: &str, params: Value) -> JsonRpcMessage {
JsonRpcMessage {
jsonrpc: "2.0".to_string(),
id: json!(1),
method: Some(method.to_string()),
params,
result: None,
error: None,
}
}
async fn test_state() -> (Arc<ServerState>, tempfile::TempDir) {
let temp = tempfile::tempdir().unwrap();
let state = Arc::new(ServerState::with_config(ServerStateConfig::new(
temp.path().to_path_buf(),
)));
(state, temp)
}
#[tokio::test]
async fn subscribe_answers_the_standing_session_with_a_snapshot_and_cursor() {
let (state, _temp) = test_state().await;
let (session, _rx) = host_session(&state, "host-1").await;
let out = handle_subscribe(
&request("browser.view.subscribe", json!({})),
&session,
&state,
)
.await
.expect("the standing session is always subscribable");
assert_eq!(out["standing_session"], true);
assert_eq!(out["conversation_id"], Value::Null);
assert_eq!(out["cursor"], 0);
assert_eq!(out["presentation"]["owner"], "none");
assert_eq!(out["presentation"]["tabs"], json!([]));
let out = handle_unsubscribe(
&request("browser.view.unsubscribe", json!({})),
&session,
&state,
)
.await
.unwrap();
assert_eq!(out["removed"], true);
}
#[tokio::test]
async fn unsubscribing_the_last_watcher_releases_a_finished_run_s_browser() {
let (state, _temp) = test_state().await;
let (session, _rx) = host_session(&state, "host-1").await;
let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
let weak = Arc::downgrade(&tools);
let view = state
.browser_views
.register("mcp-run-abc", Arc::clone(&tools))
.await;
drop(tools);
handle_subscribe(
&request(
"browser.view.subscribe",
json!({ "conversation_id": "mcp-run-abc" }),
),
&session,
&state,
)
.await
.expect("subscribed");
view.note_run_ended().await;
assert!(
state.browser_views.get(Some("mcp-run-abc")).await.is_some(),
"a watched view outlives its run, by ruling"
);
drop(view);
handle_unsubscribe(
&request(
"browser.view.unsubscribe",
json!({ "conversation_id": "mcp-run-abc" }),
),
&session,
&state,
)
.await
.expect("unsubscribed");
assert!(
state.browser_views.get(Some("mcp-run-abc")).await.is_none(),
"the ordinary close path must release a finished run's view"
);
assert!(weak.upgrade().is_none(), "and its browser with it");
}
#[tokio::test]
async fn a_connection_that_is_not_the_host_client_is_refused_everywhere() {
let (state, _temp) = test_state().await;
let (channel, _rx) = capture_channel();
let session = state.create_session("not-host", channel).await.unwrap();
let mut results = vec![
handle_subscribe(
&request("browser.view.subscribe", json!({})),
&session,
&state,
)
.await,
handle_unsubscribe(
&request("browser.view.unsubscribe", json!({})),
&session,
&state,
)
.await,
handle_take_control(
&request("browser.view.take_control", json!({})),
&session,
&state,
)
.await,
handle_hand_back(
&request("browser.view.hand_back", json!({})),
&session,
&state,
)
.await,
];
for op in [
InputOp::Navigate,
InputOp::Click,
InputOp::Type,
InputOp::Keypress,
InputOp::Scroll,
InputOp::Paste,
InputOp::Back,
InputOp::Forward,
InputOp::Reload,
InputOp::TabOpen,
InputOp::TabClose,
InputOp::TabSwitch,
] {
results.push(
handle_input(
op,
&request(
"browser.view.input",
json!({
"url": "https://x.test",
"x": 1.0, "y": 2.0,
"text": "x", "key": "Enter",
"delta_y": 1, "tab_id": "1",
}),
),
&session,
&state,
)
.await,
);
}
assert_eq!(results.len(), 16, "every dispatched method must be covered");
for result in results {
let err = result.unwrap_err();
assert!(err.contains("not authorized"), "got: {err}");
}
assert_eq!(
state
.browser_views
.standing()
.await
.subscriber_count()
.await,
0
);
}
#[tokio::test]
async fn an_unknown_conversation_is_a_clean_error_not_a_hang_or_an_empty_success() {
let (state, _temp) = test_state().await;
let (session, _rx) = host_session(&state, "host-1").await;
let err = handle_subscribe(
&request(
"browser.view.subscribe",
json!({"conversation_id": "ghost"}),
),
&session,
&state,
)
.await
.unwrap_err();
assert!(
err.contains("no browser view for conversation 'ghost'"),
"got: {err}"
);
assert!(
err.contains("standing session"),
"and it says what to do instead"
);
}
#[tokio::test]
async fn input_is_refused_while_the_agent_drives_and_accepted_after_take_control() {
let (state, _temp) = test_state().await;
let (session, _rx) = host_session(&state, "host-1").await;
let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
state
.browser_views
.register("conv-1", Arc::clone(&tools))
.await;
tools.attach_agent_for_test().await;
let navigate = request(
"browser.view.navigate",
json!({ "conversation_id": "conv-1", "url": "https://x.test" }),
);
let err = handle_input(InputOp::Navigate, &navigate, &session, &state)
.await
.unwrap_err();
assert!(err.contains("take_control"), "got: {err}");
let out = handle_take_control(
&request(
"browser.view.take_control",
json!({ "conversation_id": "conv-1" }),
),
&session,
&state,
)
.await
.unwrap();
assert_eq!(out["presentation"]["owner"], "user");
let err = handle_input(InputOp::Navigate, &navigate, &session, &state)
.await
.unwrap_err();
assert!(
!err.contains("take_control") && !err.contains("holds control"),
"the control gate must be satisfied now; got: {err}"
);
}
#[tokio::test]
async fn input_from_a_connection_that_does_not_hold_control_is_refused() {
let (state, _temp) = test_state().await;
let (holder, _rx_a) = host_session(&state, "host-holder").await;
let (other, _rx_b) = host_session(&state, "host-other").await;
let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
state
.browser_views
.register("conv-1", Arc::clone(&tools))
.await;
tools.attach_agent_for_test().await;
handle_take_control(
&request(
"browser.view.take_control",
json!({ "conversation_id": "conv-1" }),
),
&holder,
&state,
)
.await
.unwrap();
let err = handle_input(
InputOp::Click,
&request(
"browser.view.click",
json!({ "conversation_id": "conv-1", "x": 1.0, "y": 2.0 }),
),
&other,
&state,
)
.await
.unwrap_err();
assert!(
err.contains("another connection holds control"),
"got: {err}"
);
handle_hand_back(
&request(
"browser.view.hand_back",
json!({ "conversation_id": "conv-1" }),
),
&holder,
&state,
)
.await
.unwrap();
let err = handle_input(
InputOp::Click,
&request(
"browser.view.click",
json!({ "conversation_id": "conv-1", "x": 1.0, "y": 2.0 }),
),
&holder,
&state,
)
.await
.unwrap_err();
assert!(err.contains("take_control"), "got: {err}");
}
#[tokio::test]
async fn malformed_input_params_are_rejected_before_touching_the_browser() {
let (state, _temp) = test_state().await;
let (session, _rx) = host_session(&state, "host-1").await;
for (op, params, needle) in [
(InputOp::Navigate, json!({}), "requires { url }"),
(InputOp::Click, json!({ "x": 1.0 }), "requires { x, y }"),
(InputOp::Type, json!({}), "requires { text }"),
(InputOp::Keypress, json!({}), "requires { key }"),
(InputOp::Scroll, json!({}), "requires { delta_y }"),
(InputOp::Paste, json!({}), "requires { text }"),
(InputOp::TabClose, json!({}), "requires { tab_id }"),
(InputOp::TabSwitch, json!({}), "requires { tab_id }"),
] {
let err = handle_input(op, &request("browser.view.x", params), &session, &state)
.await
.unwrap_err();
assert!(err.contains(needle), "{op:?}: got {err}");
}
}
#[tokio::test]
async fn history_ops_are_refused_while_the_agent_drives() {
let (state, _temp) = test_state().await;
let (session, _rx) = host_session(&state, "host-1").await;
let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
state
.browser_views
.register("conv-1", Arc::clone(&tools))
.await;
tools.attach_agent_for_test().await;
for op in [InputOp::Back, InputOp::Forward, InputOp::Reload] {
let err = handle_input(
op,
&request("browser.view.x", json!({ "conversation_id": "conv-1" })),
&session,
&state,
)
.await
.unwrap_err();
assert!(err.contains("take_control"), "{op:?}: got {err}");
}
}
#[tokio::test]
async fn paste_is_refused_while_the_agent_drives() {
let (state, _temp) = test_state().await;
let (session, _rx) = host_session(&state, "host-1").await;
let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
state
.browser_views
.register("conv-1", Arc::clone(&tools))
.await;
tools.attach_agent_for_test().await;
let err = handle_input(
InputOp::Paste,
&request(
"browser.view.paste",
json!({ "conversation_id": "conv-1", "text": "secret" }),
),
&session,
&state,
)
.await
.unwrap_err();
assert!(err.contains("take_control"), "got: {err}");
}
#[tokio::test]
async fn paste_on_an_empty_state_reports_that_there_is_no_browser() {
let (state, _temp) = test_state().await;
let (session, _rx) = host_session(&state, "host-1").await;
let err = handle_input(
InputOp::Paste,
&request("browser.view.paste", json!({ "text": "hello" })),
&session,
&state,
)
.await
.unwrap_err();
assert!(err.contains("no browser is running"), "got: {err}");
}
#[tokio::test]
async fn history_ops_are_refused_from_a_connection_that_does_not_hold_control() {
let (state, _temp) = test_state().await;
let (holder, _rx_a) = host_session(&state, "host-holder").await;
let (other, _rx_b) = host_session(&state, "host-other").await;
let tools = Arc::new(BrowserTools::new(std::env::temp_dir()));
state
.browser_views
.register("conv-1", Arc::clone(&tools))
.await;
tools.attach_agent_for_test().await;
handle_take_control(
&request(
"browser.view.take_control",
json!({ "conversation_id": "conv-1" }),
),
&holder,
&state,
)
.await
.unwrap();
for op in [InputOp::Back, InputOp::Forward, InputOp::Reload] {
let err = handle_input(
op,
&request("browser.view.x", json!({ "conversation_id": "conv-1" })),
&other,
&state,
)
.await
.unwrap_err();
assert!(
err.contains("another connection holds control"),
"{op:?}: got {err}"
);
}
}
#[tokio::test]
async fn history_ops_on_an_empty_state_report_that_there_is_no_browser() {
let (state, _temp) = test_state().await;
let (session, _rx) = host_session(&state, "host-1").await;
for op in [InputOp::Back, InputOp::Forward, InputOp::Reload] {
let err = handle_input(op, &request("browser.view.x", json!({})), &session, &state)
.await
.unwrap_err();
assert!(err.contains("no browser is running"), "{op:?}: got {err}");
}
}
#[tokio::test]
async fn history_ops_need_no_params_of_their_own() {
let (state, _temp) = test_state().await;
let (session, _rx) = host_session(&state, "host-1").await;
for op in [InputOp::Back, InputOp::Forward, InputOp::Reload] {
let err = handle_input(
op,
&request("browser.view.x", Value::Null),
&session,
&state,
)
.await
.unwrap_err();
assert!(
!err.contains("requires"),
"{op:?} must not demand params; got {err}"
);
}
}
#[tokio::test]
async fn input_where_no_browser_exists_reports_that_rather_than_succeeding_emptily() {
let (state, _temp) = test_state().await;
let (session, _rx) = host_session(&state, "host-1").await;
let err = handle_input(
InputOp::Click,
&request("browser.view.click", json!({ "x": 1.0, "y": 2.0 })),
&session,
&state,
)
.await
.unwrap_err();
assert!(err.contains("no browser is running"), "got: {err}");
}
#[tokio::test]
async fn disconnect_cleanup_drops_that_connection_s_subscription() {
let (state, _temp) = test_state().await;
let (session, _rx) = host_session(&state, "host-1").await;
handle_subscribe(
&request("browser.view.subscribe", json!({})),
&session,
&state,
)
.await
.unwrap();
assert_eq!(
state
.browser_views
.standing()
.await
.subscriber_count()
.await,
1
);
state.remove_session("host-1").await;
assert_eq!(
state
.browser_views
.standing()
.await
.subscriber_count()
.await,
0
);
}
fn test_frame(byte: u8) -> ScreencastFrame {
ScreencastFrame {
jpeg: vec![byte].into(),
viewport: car_browser::Viewport {
width: 1920,
height: 1080,
device_pixel_ratio: 1.0,
},
captured_at: 0.5,
}
}
}