Skip to main content

hatchery_node/
restream_g4a_bridge.rs

1//! Node restream g4a bridge (A2).
2//!
3//! Tunnels the local g4a `NodeServer` through a [`crate::restream_session::NodeSession`].
4//! Core speaks the OperatorLink wire (`{"op_id","g4a"}`); C2 only ever sees opaque
5//! Noise ciphertext. The node never learns what sits behind C2.
6
7use crate::restream_session::{NodeSession, UpstreamKeys};
8use crate::uplink_journal::UplinkJournal;
9use gate4agent_node_protocol::{
10    ClientRole, NodeEventEnvelope, NodeFailureCode, NodeId, NodeIncarnationId, NodeRequest,
11    NodeResponse, ServerFrame,
12};
13use gate4agent_node_wire::{LocalNodeClient, NodeClientError};
14use hatchery_core::{
15    BrowserClientOp, BrowserOpReply, BrowserStreamEvent, C2NodeEvent, C2NodeFailure,
16    C2NodeResponse, ChannelKind, NodeCursor, RoutedNodeEvent, RoutedNodeRequest,
17    RoutedNodeResponse,
18};
19use serde_json::{json, Value};
20use std::net::SocketAddr;
21use std::path::PathBuf;
22use std::sync::Arc;
23use std::time::Duration;
24use tokio::sync::{mpsc, watch};
25
26const HANDSHAKE: Duration = Duration::from_secs(10);
27const CONTROL_CHANNEL: u32 = 1;
28
29/// Run until `shutdown` is true: local g4a operator client ↔ restream session.
30pub async fn run(
31    local_endpoint: impl Into<String>,
32    node_id: impl Into<String>,
33    local_token: impl Into<String>,
34    uplink: SocketAddr,
35    keys: UpstreamKeys,
36    journal: Option<Arc<UplinkJournal>>,
37    browser_profile_root: Option<PathBuf>,
38    calls: Option<crate::uplink_calls::NodeCalls>,
39    mut shutdown: watch::Receiver<bool>,
40) -> Result<(), String> {
41    let local_endpoint = local_endpoint.into();
42    let node_id_str = node_id.into();
43    let local_token = local_token.into();
44    let node_id = NodeId::new(node_id_str.clone()).map_err(|e| e.to_string())?;
45    let browser_profile_root = browser_profile_root.unwrap_or_else(|| {
46        std::env::temp_dir().join("hatchery-browser-profiles")
47    });
48
49    let mut client =
50        LocalNodeClient::connect(&local_endpoint, &node_id, ClientRole::Operator, &local_token)
51            .await
52            .map_err(|e| e.to_string())?;
53
54    let mut session = NodeSession::connect(
55        uplink,
56        &node_id_str,
57        CONTROL_CHANNEL,
58        ChannelKind::G4aControl,
59        keys,
60        journal,
61        HANDSHAKE,
62    )
63    .await?;
64
65    let hello = client.hello().clone();
66    let incarnation_id = hello.incarnation_id;
67    let connection_id = hello.connection_id;
68    tracing::info!(%node_id_str, connection_id, "restream g4a bridge up");
69    // Node calls (mail.*, task.*) open their own channel on this same uplink.
70    if let Some(c) = &calls {
71        c.set_uplink(Some(session.uplink())).await;
72    }
73    let result = run_loop(&mut client, &mut session, &node_id, incarnation_id, &local_endpoint, &local_token, browser_profile_root, &mut shutdown).await;
74    if let Some(c) = &calls {
75        c.set_uplink(None).await;
76    }
77    result
78}
79
80#[allow(clippy::too_many_arguments)]
81async fn run_loop(
82    client: &mut LocalNodeClient,
83    session: &mut NodeSession,
84    node_id: &NodeId,
85    incarnation_id: NodeIncarnationId,
86    local_endpoint: &str,
87    local_token: &str,
88    browser_profile_root: PathBuf,
89    shutdown: &mut watch::Receiver<bool>,
90) -> Result<(), String> {
91
92    // Requests run on their own local connection in a worker task. A request that
93    // waits on the node (input settle, spawn readiness, snapshot) used to hold this
94    // loop, and with it every PTY frame; now this connection only carries events.
95    let worker_client =
96        LocalNodeClient::connect(&local_endpoint, &node_id, ClientRole::Operator, &local_token)
97            .await
98            .map_err(|e| e.to_string())?;
99    let (req_tx, req_rx) = mpsc::unbounded_channel::<Vec<u8>>();
100    let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
101    let (browser_ev_tx, mut browser_ev_rx) = mpsc::unbounded_channel::<BrowserStreamEvent>();
102    let (worker_stop_tx, worker_stop) = watch::channel(false);
103    let mut worker = tokio::spawn(request_worker(
104        worker_client,
105        node_id.clone(),
106        req_rx,
107        out_tx,
108        browser_profile_root.clone(),
109        browser_ev_tx,
110        worker_stop,
111    ));
112    let mut last_pty_input: Option<std::time::Instant> = None;
113    let result = loop {
114        if *shutdown.borrow() {
115            break Ok(());
116        }
117        tokio::select! {
118            changed = shutdown.changed() => {
119                if changed.is_err() || *shutdown.borrow() { break Ok(()); }
120            }
121            done = &mut worker => {
122                break match done { Ok(r) => r.and(Err("request worker ended".into())), Err(e) => Err(e.to_string()) };
123            }
124            frame = client.recv() => {
125                match frame {
126                    Ok(ServerFrame::Event(envelope)) => {
127                        if matches!(envelope.event, gate4agent_node_protocol::NodeEvent::TerminalFrame { .. }) {
128                            if let Some(at) = last_pty_input.take() {
129                                tracing::debug!(target: "pty_lat", bridge_us = at.elapsed().as_micros() as u64, "bridge: pty input received to echo frame forwarded");
130                            }
131                        }
132                        if envelope.event.requires_harness_mcp_proxy_capability() { continue; }
133                        let plain = event_bytes(&node_id, incarnation_id, envelope)?;
134                        if let Err(e) = session.send(&plain).await { break Err(e); }
135                    }
136                    Ok(_) => break Err("local g4a sent an unexpected frame on the event connection".into()),
137                    Err(err) => break Err(err.to_string()),
138                }
139            }
140            out = out_rx.recv() => {
141                let Some(out) = out else { break Err("request worker closed".into()) };
142                if let Err(e) = session.send(&out).await { break Err(e); }
143            }
144            plain = session.recv(Duration::from_secs(120)) => {
145                match plain {
146                    Ok(bytes) => {
147                        if last_pty_input.is_none() && is_pty_input(&bytes) { last_pty_input = Some(std::time::Instant::now()); }
148                        if req_tx.send(bytes).is_err() { break Err("request worker closed".into()); }
149                    }
150                    Err(err) if err.contains("timeout") => continue,
151                    Err(err) => break Err(err),
152                }
153            }
154            ev = browser_ev_rx.recv() => {
155                let Some(ev) = ev else { continue };
156                let plain = serde_json::to_vec(&json!({ "browser": ev })).map_err(|e| e.to_string())?;
157                if let Err(e) = session.send(&plain).await { break Err(e); }
158            }
159        }
160    };
161    // The worker releases the controller lease it holds before it exits.
162    let _ = worker_stop_tx.send(true);
163    let _ = tokio::time::timeout(Duration::from_secs(3), worker).await;
164    result
165}
166
167fn is_pty_input(plain: &[u8]) -> bool {
168    // Cheap probe (timing only); the worker does the real parsing.
169    let s = String::from_utf8_lossy(&plain[..plain.len().min(512)]);
170    s.contains("TerminalBytes") || s.contains("terminal_bytes")
171}
172
173type Sink = mpsc::UnboundedSender<Vec<u8>>;
174
175/// All node requests, in arrival order, on a dedicated local connection.
176/// PTY input is pipelined (sent at once, answered when its reply arrives);
177/// anything else first settles in-flight input, then runs request/reply.
178async fn request_worker(
179    mut client: LocalNodeClient,
180    node_id: NodeId,
181    mut rx: mpsc::UnboundedReceiver<Vec<u8>>,
182    out: Sink,
183    browser_profile_root: PathBuf,
184    browser_ev_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
185    mut stop: watch::Receiver<bool>,
186) -> Result<(), String> {
187    #[cfg(feature = "d2b")]
188    let browsers = crate::browser_capture::shared_map();
189    let hello = client.hello().clone();
190    let mut incarnation_id = hello.incarnation_id;
191    let connection_id = hello.connection_id;
192    let mut controller_owned = hello.controller.as_ref().is_some_and(|c| c.connection_id == connection_id);
193    let mut inflight: std::collections::HashMap<u64, (u64, NodeRequest)> = std::collections::HashMap::new();
194    let mut retry: Vec<(u64, NodeRequest)> = Vec::new();
195    // Events on this connection: only the controller-scoped harness MCP proxy
196    // events are forwarded (the event connection does not receive them).
197    let fwd = |out: &Sink, incarnation_id: NodeIncarnationId, envelope: NodeEventEnvelope| -> Result<(), String> {
198        if envelope.event.requires_harness_mcp_proxy_capability() {
199            out.send(event_bytes(&node_id, incarnation_id, envelope)?).map_err(|_| "bridge closed".to_string())?;
200        }
201        Ok(())
202    };
203    loop {
204        tokio::select! {
205            _ = stop.changed() => {
206                let _ = release_controller(&mut client, &mut controller_owned).await;
207                return Ok(());
208            }
209            frame = client.recv() => {
210                match frame.map_err(|e| e.to_string())? {
211                    ServerFrame::Event(envelope) => fwd(&out, incarnation_id, envelope)?,
212                    ServerFrame::Reply(reply) => {
213                        on_pipelined_reply(&out, &node_id, &mut incarnation_id, &mut controller_owned, &mut inflight, &mut retry, reply)?;
214                    }
215                    _ => return Err("local g4a sent an unexpected idle frame".into()),
216                }
217            }
218            plain = rx.recv() => {
219                let Some(bytes) = plain else {
220                    let _ = release_controller(&mut client, &mut controller_owned).await;
221                    return Ok(());
222                };
223                if controller_owned && retry.is_empty() {
224                    if let Some((op_id, request)) = pipelinable(&bytes, &node_id, incarnation_id) {
225                        let request_id = client.send(request.clone()).await.map_err(|e| e.to_string())?;
226                        inflight.insert(request_id, (op_id, request));
227                        continue;
228                    }
229                }
230                while !inflight.is_empty() {
231                    match client.recv().await.map_err(|e| e.to_string())? {
232                        ServerFrame::Event(envelope) => fwd(&out, incarnation_id, envelope)?,
233                        ServerFrame::Reply(reply) => on_pipelined_reply(&out, &node_id, &mut incarnation_id, &mut controller_owned, &mut inflight, &mut retry, reply)?,
234                        _ => return Err("local g4a sent an unexpected idle frame".into()),
235                    }
236                }
237                let mut queue: Vec<Vec<u8>> = Vec::new();
238                for (op_id, request) in std::mem::take(&mut retry) {
239                    // Controller lapsed under pipelined input: reacquire and replay in order.
240                    let routed = RoutedNodeRequest {
241                        route: hatchery_core::NodeRoute {
242                            node_id: hatchery_core::NodeId::new(node_id.as_str().to_owned()).map_err(|e| e.to_string())?,
243                            expected_incarnation_id: NodeIncarnationId::from_bytes([0; 16]),
244                        },
245                        request,
246                    };
247                    queue.push(serde_json::to_vec(&json!({ "op_id": op_id, "g4a": routed })).map_err(|e| e.to_string())?);
248                }
249                queue.push(bytes);
250                for plain in queue {
251                    let started = std::time::Instant::now();
252                    let kind: String = serde_json::from_slice::<Value>(&plain).ok().and_then(|v| v.pointer("/g4a/request").and_then(|r| r.as_object().and_then(|o| o.keys().next().cloned()).or_else(|| r.as_str().map(str::to_owned)))).unwrap_or_else(|| "browser".into());
253                    handle_control(&out, &mut client, &node_id, &mut incarnation_id, connection_id, &mut controller_owned, &plain, &browser_profile_root, browser_ev_tx.clone(), #[cfg(feature = "d2b")] &browsers).await?;
254                    while let Some(envelope) = client.take_event() {
255                        fwd(&out, incarnation_id, envelope)?;
256                    }
257                    if started.elapsed() > Duration::from_millis(500) {
258                        tracing::warn!(request = %kind, elapsed_ms = started.elapsed().as_millis() as u64, "bridge: slow node request held the request queue");
259                    }
260                }
261            }
262        }
263    }
264}
265
266/// PTY input that may be pipelined (no ordering dependency on replies).
267fn pipelinable(plain: &[u8], node_id: &NodeId, incarnation_id: NodeIncarnationId) -> Option<(u64, NodeRequest)> {
268    let v: Value = serde_json::from_slice(plain).ok()?;
269    let op_id = v.get("op_id")?.as_u64()?;
270    let routed: RoutedNodeRequest = serde_json::from_value(v.get("g4a")?.clone()).ok()?;
271    if routed.route.node_id.as_str() != node_id.as_str() { return None; }
272    let expected = routed.route.expected_incarnation_id;
273    if expected != NodeIncarnationId::from_bytes([0; 16]) && expected != incarnation_id { return None; }
274    match routed.request {
275        NodeRequest::TerminalBytes { .. } | NodeRequest::TerminalControl { .. } | NodeRequest::Interrupt { .. } => Some((op_id, routed.request)),
276        _ => None,
277    }
278}
279
280fn on_pipelined_reply(
281    out: &Sink,
282    node_id: &NodeId,
283    incarnation_id: &mut NodeIncarnationId,
284    controller_owned: &mut bool,
285    inflight: &mut std::collections::HashMap<u64, (u64, NodeRequest)>,
286    retry: &mut Vec<(u64, NodeRequest)>,
287    reply: gate4agent_node_protocol::ResponseEnvelope,
288) -> Result<(), String> {
289    let Some((op_id, request)) = inflight.remove(&reply.request_id) else {
290        return Err(format!("unexpected local reply id {}", reply.request_id));
291    };
292    if let Err(f) = &reply.result {
293        if f.code == NodeFailureCode::ControllerRequired {
294            *controller_owned = false;
295            retry.push((op_id, request));
296            return Ok(());
297        }
298    }
299    if !retry.is_empty() {
300        // Keep order: everything after a lapsed one replays behind it.
301        retry.push((op_id, request));
302        return Ok(());
303    }
304    let mapped = reply
305        .result
306        .map(|r| C2NodeResponse::from_node_response_with_control_detail(&r))
307        .map_err(|f| C2NodeFailure::from(&f));
308    let plain = serde_json::to_vec(&json!({ "op_id": op_id, "g4a": RoutedNodeResponse { node_id: node_id.clone(), incarnation_id: *incarnation_id, response: mapped } })).map_err(|e| e.to_string())?;
309    out.send(plain).map_err(|_| "bridge closed".to_string())
310}
311
312async fn handle_control(
313    session: &Sink,
314    client: &mut LocalNodeClient,
315    node_id: &NodeId,
316    incarnation_id: &mut NodeIncarnationId,
317    connection_id: u64,
318    controller_owned: &mut bool,
319    plain: &[u8],
320    browser_profile_root: &std::path::Path,
321    browser_ev_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
322    #[cfg(feature = "d2b")] browsers: &std::sync::Arc<crate::browser_capture::BrowserSessionMap>,
323) -> Result<(), String> {
324    let v: Value = serde_json::from_slice(plain).map_err(|e| e.to_string())?;
325    let Some(op_id) = v.get("op_id").and_then(Value::as_u64) else {
326        return Ok(());
327    };
328    if let Some(browser) = v.get("browser") {
329        let op: BrowserClientOp = serde_json::from_value(browser.clone()).map_err(|e| e.to_string())?;
330        let reply = handle_browser_op(
331            &node_id_str_from(node_id),
332            op,
333            browser_profile_root,
334            browser_ev_tx,
335            #[cfg(feature = "d2b")]
336            browsers,
337        )
338        .await;
339        let plain = serde_json::to_vec(&json!({ "op_id": op_id, "browser": reply })).map_err(|e| e.to_string())?;
340        return session.send(plain).map_err(|_| "bridge closed".to_string());
341    }
342    let g4a = v.get("g4a").cloned().unwrap_or(Value::Null);
343    let routed: RoutedNodeRequest = serde_json::from_value(g4a).map_err(|e| e.to_string())?;
344    if routed.route.node_id.as_str() != node_id.as_str() {
345        return send_reply(
346            session,
347            op_id,
348            &failure_response(node_id, *incarnation_id, NodeFailureCode::InvalidRequest, "route node id does not match bridge"),
349        )
350        .await;
351    }
352    // All-zero expected incarnation is the core cursor-bootstrap wildcard.
353    let expected = routed.route.expected_incarnation_id;
354    if expected != NodeIncarnationId::from_bytes([0; 16]) && expected != *incarnation_id {
355        return send_reply(
356            session,
357            op_id,
358            &failure_response(
359                node_id,
360                *incarnation_id,
361                NodeFailureCode::InvalidRequest,
362                "node incarnation changed",
363            ),
364        )
365        .await;
366    }
367
368    let request = routed.request;
369    let controller_gated = !is_read_only(&request);
370    if controller_gated && !*controller_owned {
371        match acquire_controller(client, connection_id).await {
372            Ok(true) => *controller_owned = true,
373            Ok(false) => {
374                return send_reply(
375                    session,
376                    op_id,
377                    &failure_response(
378                        node_id,
379                        *incarnation_id,
380                        NodeFailureCode::ControllerBusy,
381                        "node controller lease is unavailable",
382                    ),
383                )
384                .await;
385            }
386            Err(err) => return Err(err.to_string()),
387        }
388    }
389
390    let deadline = request_deadline(&request);
391    // The node controller lease is short-lived and the node clears it on
392    // expiry, but `controller_owned` stays set on this long-lived bridge. So a
393    // controller-gated request can come back `ControllerRequired` even though
394    // we believe we hold it (lease lapsed, or the local client reconnected
395    // under a new connection id). Re-acquire once and retry so live PTY input
396    // and fresh spawns keep working on a held stream.
397    let mut remaining_retries = 1u8;
398    let response = loop {
399        match tokio::time::timeout(deadline, client.request(request.clone())).await {
400            Ok(Ok(response)) => break Ok(response),
401            Ok(Err(NodeClientError::Node(failure))) => {
402                if controller_gated
403                    && failure.code == NodeFailureCode::ControllerRequired
404                    && remaining_retries > 0
405                {
406                    remaining_retries -= 1;
407                    *controller_owned = false;
408                    match acquire_controller(client, connection_id).await {
409                        Ok(true) => {
410                            *controller_owned = true;
411                            continue;
412                        }
413                        Ok(false) => break Err(failure),
414                        Err(err) => return Err(err.to_string()),
415                    }
416                }
417                break Err(failure);
418            }
419            Ok(Err(err)) => return Err(err.to_string()),
420            Err(_) => {
421                return send_reply(
422                    session,
423                    op_id,
424                    &failure_response(
425                        node_id,
426                        *incarnation_id,
427                        NodeFailureCode::HostDirectoryReadTimedOut,
428                        "node request deadline exceeded",
429                    ),
430                )
431                .await;
432            }
433        }
434    };
435
436    *incarnation_id = client.hello().incarnation_id;
437
438    let mut mapped = response
439        .map(|r| C2NodeResponse::from_node_response_with_control_detail(&r))
440        .map_err(|f| C2NodeFailure::from(&f));
441    if let Ok(C2NodeResponse::Snapshot { snapshot, .. }) = &mut mapped {
442        snapshot.machine = Some(crate::collect_machine_environment());
443    }
444    send_reply(
445        session,
446        op_id,
447        &RoutedNodeResponse {
448            node_id: node_id.clone(),
449            incarnation_id: *incarnation_id,
450            response: mapped,
451        },
452    )
453    .await
454}
455
456fn failure_response(
457    node_id: &NodeId,
458    incarnation_id: NodeIncarnationId,
459    code: NodeFailureCode,
460    message: &str,
461) -> RoutedNodeResponse {
462    RoutedNodeResponse {
463        node_id: node_id.clone(),
464        incarnation_id,
465        response: Err(C2NodeFailure {
466            code,
467            message: message.into(),
468        }),
469    }
470}
471
472async fn send_reply(
473    session: &Sink,
474    op_id: u64,
475    response: &RoutedNodeResponse,
476) -> Result<(), String> {
477    let plain =
478        serde_json::to_vec(&json!({ "op_id": op_id, "g4a": response })).map_err(|e| e.to_string())?;
479    session.send(plain).map_err(|_| "bridge closed".to_string())
480}
481
482fn event_bytes(
483    node_id: &NodeId,
484    incarnation_id: NodeIncarnationId,
485    envelope: NodeEventEnvelope,
486) -> Result<Vec<u8>, String> {
487    let event = RoutedNodeEvent {
488        node_id: node_id.clone(),
489        cursor: NodeCursor {
490            incarnation_id,
491            sequence: envelope.sequence,
492        },
493        event: C2NodeEvent::from_node_event_with_control_detail(&envelope.event),
494    };
495    serde_json::to_vec(&json!({ "g4a": event })).map_err(|e| e.to_string())
496}
497
498async fn acquire_controller(
499    client: &mut LocalNodeClient,
500    connection_id: u64,
501) -> Result<bool, NodeClientError> {
502    match client
503        .request(NodeRequest::AcquireController {
504            lease_ms: gate4agent_node_protocol::MAX_CONTROLLER_LEASE_MS,
505        })
506        .await?
507    {
508        NodeResponse::Controller { controller } => Ok(controller
509            .as_ref()
510            .is_some_and(|state| state.connection_id == connection_id)),
511        _ => Err(NodeClientError::Protocol(
512            "controller acquisition returned a different response".into(),
513        )),
514    }
515}
516
517async fn release_controller(
518    client: &mut LocalNodeClient,
519    controller_owned: &mut bool,
520) -> Result<(), NodeClientError> {
521    if !*controller_owned {
522        return Ok(());
523    }
524    let _ = client.request(NodeRequest::ReleaseController).await?;
525    *controller_owned = false;
526    Ok(())
527}
528
529fn is_read_only(request: &NodeRequest) -> bool {
530    matches!(
531        request,
532        NodeRequest::Snapshot
533            | NodeRequest::Resync { .. }
534            | NodeRequest::BrowseHostDirectories { .. }
535            | NodeRequest::InspectWorkspace { .. }
536            | NodeRequest::ReadWorkspaceFile { .. }
537            | NodeRequest::ReadGitHistory { .. }
538            | NodeRequest::ReadGitDiff { .. }
539            | NodeRequest::CatalogNativeSessions { .. }
540            | NodeRequest::PageNativeSessions { .. }
541            | NodeRequest::PreviewNativeSession { .. }
542            | NodeRequest::PreviewSessionRecord { .. }
543    )
544}
545
546fn request_deadline(request: &NodeRequest) -> Duration {
547    match request {
548        NodeRequest::CreateStandaloneWorkspace { .. }
549        | NodeRequest::CreateWorktree { .. }
550        | NodeRequest::RemoveWorktree { .. }
551        | NodeRequest::CleanupManagedWorktree { .. } => Duration::from_secs(240),
552        NodeRequest::Spawn { .. }
553        | NodeRequest::Resume { .. }
554        | NodeRequest::Stop { .. }
555        | NodeRequest::SpawnSpec { .. }
556        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
557        | NodeRequest::SpawnManagedWorktree { .. } => Duration::from_secs(60),
558        NodeRequest::InspectWorkspace { .. } => Duration::from_secs(15),
559        _ => Duration::from_secs(30),
560    }
561}
562
563
564fn node_id_str_from(node_id: &NodeId) -> String {
565    node_id.as_str().to_owned()
566}
567
568async fn handle_browser_op(
569    node_id: &str,
570    op: BrowserClientOp,
571    browser_profile_root: &std::path::Path,
572    _browser_ev_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
573    #[cfg(feature = "d2b")] browsers: &std::sync::Arc<crate::browser_capture::BrowserSessionMap>,
574) -> BrowserOpReply {
575    #[cfg(not(feature = "d2b"))]
576    {
577        let _ = (node_id, op, browser_profile_root, _browser_ev_tx);
578        return BrowserOpReply {
579            ok: false,
580            session: String::new(),
581            message: "dig2browser feature (d2b) is not enabled in this node build".into(),
582        };
583    }
584    #[cfg(feature = "d2b")]
585    {
586        match op {
587            BrowserClientOp::Start {
588                session,
589                url,
590                profile_dir,
591            } => {
592                let dir = match profile_dir {
593                    Some(p) if !p.is_empty() => PathBuf::from(p),
594                    _ => browser_profile_root.join(&session),
595                };
596                match browsers
597                    .start(node_id.to_owned(), session.clone(), url, dir, _browser_ev_tx)
598                    .await
599                {
600                    Ok(()) => BrowserOpReply {
601                        ok: true,
602                        session,
603                        message: String::new(),
604                    },
605                    Err(message) => BrowserOpReply {
606                        ok: false,
607                        session,
608                        message,
609                    },
610                }
611            }
612            BrowserClientOp::Input { session, bytes } => match browsers.input(&session, &bytes).await
613            {
614                Ok(()) => BrowserOpReply {
615                    ok: true,
616                    session,
617                    message: String::new(),
618                },
619                Err(message) => BrowserOpReply {
620                    ok: false,
621                    session,
622                    message,
623                },
624            },
625            BrowserClientOp::Stop { session } => match browsers.stop(&session).await {
626                Ok(()) => BrowserOpReply {
627                    ok: true,
628                    session,
629                    message: String::new(),
630                },
631                Err(message) => BrowserOpReply {
632                    ok: false,
633                    session,
634                    message,
635                },
636            },
637        }
638    }
639}