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, SendPrio, 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 uplink_watch = session.uplink();
114    let result = loop {
115        if *shutdown.borrow() {
116            break Ok(());
117        }
118        tokio::select! {
119            changed = shutdown.changed() => {
120                if changed.is_err() || *shutdown.borrow() { break Ok(()); }
121            }
122            done = &mut worker => {
123                break match done { Ok(r) => r.and(Err("request worker ended".into())), Err(e) => Err(e.to_string()) };
124            }
125            frame = client.recv() => {
126                match frame {
127                    Ok(ServerFrame::Event(envelope)) => {
128                        let is_pty = matches!(envelope.event, gate4agent_node_protocol::NodeEvent::TerminalFrame { .. });
129                        if is_pty {
130                            if let Some(at) = last_pty_input.take() {
131                                tracing::debug!(target: "pty_lat", bridge_us = at.elapsed().as_micros() as u64, "bridge: pty input received to echo frame forwarded");
132                            }
133                        }
134                        if envelope.event.requires_harness_mcp_proxy_capability() { continue; }
135                        let plain = event_bytes(&node_id, incarnation_id, envelope)?;
136                        let prio = if is_pty { SendPrio::Pty } else { SendPrio::Control };
137                        if let Err(e) = session.send_prio(&plain, prio).await { break Err(e); }
138                    }
139                    Ok(_) => break Err("local g4a sent an unexpected frame on the event connection".into()),
140                    Err(err) => break Err(err.to_string()),
141                }
142            }
143            out = out_rx.recv() => {
144                let Some(out) = out else { break Err("request worker closed".into()) };
145                if let Err(e) = session.send_prio(&out, SendPrio::Control).await { break Err(e); }
146            }
147            plain = session.recv(Duration::from_secs(120)) => {
148                match plain {
149                    Ok(bytes) => {
150                        if last_pty_input.is_none() && is_pty_input(&bytes) { last_pty_input = Some(std::time::Instant::now()); }
151                        if req_tx.send(bytes).is_err() { break Err("request worker closed".into()); }
152                    }
153                    Err(err) if err.contains("timeout") => continue,
154                    Err(err) => break Err(err),
155                }
156            }
157            _ = uplink_watch.closed() => break Err("uplink write stalled; reconnecting".into()),
158            ev = browser_ev_rx.recv() => {
159                let Some(ev) = ev else { continue };
160                let plain = serde_json::to_vec(&json!({ "browser": ev })).map_err(|e| e.to_string())?;
161                // Under pressure the uplink coalesces/drops browser frames; never blocks PTY.
162                if let Err(e) = session.send_prio(&plain, SendPrio::Browser).await { break Err(e); }
163            }
164        }
165    };
166    // The worker releases the controller lease it holds before it exits.
167    let _ = worker_stop_tx.send(true);
168    let _ = tokio::time::timeout(Duration::from_secs(3), worker).await;
169    result
170}
171
172fn is_pty_input(plain: &[u8]) -> bool {
173    // Cheap probe (timing only); the worker does the real parsing.
174    let s = String::from_utf8_lossy(&plain[..plain.len().min(512)]);
175    s.contains("TerminalBytes") || s.contains("terminal_bytes")
176}
177
178type Sink = mpsc::UnboundedSender<Vec<u8>>;
179
180/// All node requests, in arrival order, on a dedicated local connection.
181/// PTY input is pipelined (sent at once, answered when its reply arrives);
182/// anything else first settles in-flight input, then runs request/reply.
183async fn request_worker(
184    mut client: LocalNodeClient,
185    node_id: NodeId,
186    mut rx: mpsc::UnboundedReceiver<Vec<u8>>,
187    out: Sink,
188    browser_profile_root: PathBuf,
189    browser_ev_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
190    mut stop: watch::Receiver<bool>,
191) -> Result<(), String> {
192    #[cfg(feature = "d2b")]
193    let browsers = crate::browser_capture::shared_map();
194    let hello = client.hello().clone();
195    let mut incarnation_id = hello.incarnation_id;
196    let connection_id = hello.connection_id;
197    let mut controller_owned = hello.controller.as_ref().is_some_and(|c| c.connection_id == connection_id);
198    let mut inflight: std::collections::HashMap<u64, (u64, NodeRequest)> = std::collections::HashMap::new();
199    let mut retry: Vec<(u64, NodeRequest)> = Vec::new();
200    // Events on this connection: only the controller-scoped harness MCP proxy
201    // events are forwarded (the event connection does not receive them).
202    let fwd = |out: &Sink, incarnation_id: NodeIncarnationId, envelope: NodeEventEnvelope| -> Result<(), String> {
203        if envelope.event.requires_harness_mcp_proxy_capability() {
204            out.send(event_bytes(&node_id, incarnation_id, envelope)?).map_err(|_| "bridge closed".to_string())?;
205        }
206        Ok(())
207    };
208    loop {
209        tokio::select! {
210            _ = stop.changed() => {
211                let _ = release_controller(&mut client, &mut controller_owned).await;
212                return Ok(());
213            }
214            frame = client.recv() => {
215                match frame.map_err(|e| e.to_string())? {
216                    ServerFrame::Event(envelope) => fwd(&out, incarnation_id, envelope)?,
217                    ServerFrame::Reply(reply) => {
218                        on_pipelined_reply(&out, &node_id, &mut incarnation_id, &mut controller_owned, &mut inflight, &mut retry, reply)?;
219                    }
220                    _ => return Err("local g4a sent an unexpected idle frame".into()),
221                }
222            }
223            plain = rx.recv() => {
224                let Some(bytes) = plain else {
225                    let _ = release_controller(&mut client, &mut controller_owned).await;
226                    return Ok(());
227                };
228                if controller_owned && retry.is_empty() {
229                    if let Some((op_id, request)) = pipelinable(&bytes, &node_id, incarnation_id) {
230                        let request_id = client.send(request.clone()).await.map_err(|e| e.to_string())?;
231                        inflight.insert(request_id, (op_id, request));
232                        continue;
233                    }
234                }
235                while !inflight.is_empty() {
236                    match client.recv().await.map_err(|e| e.to_string())? {
237                        ServerFrame::Event(envelope) => fwd(&out, incarnation_id, envelope)?,
238                        ServerFrame::Reply(reply) => on_pipelined_reply(&out, &node_id, &mut incarnation_id, &mut controller_owned, &mut inflight, &mut retry, reply)?,
239                        _ => return Err("local g4a sent an unexpected idle frame".into()),
240                    }
241                }
242                let mut queue: Vec<Vec<u8>> = Vec::new();
243                for (op_id, request) in std::mem::take(&mut retry) {
244                    // Controller lapsed under pipelined input: reacquire and replay in order.
245                    let routed = RoutedNodeRequest {
246                        route: hatchery_core::NodeRoute {
247                            node_id: hatchery_core::NodeId::new(node_id.as_str().to_owned()).map_err(|e| e.to_string())?,
248                            expected_incarnation_id: NodeIncarnationId::from_bytes([0; 16]),
249                        },
250                        request,
251                    };
252                    queue.push(serde_json::to_vec(&json!({ "op_id": op_id, "g4a": routed })).map_err(|e| e.to_string())?);
253                }
254                queue.push(bytes);
255                for plain in queue {
256                    let started = std::time::Instant::now();
257                    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());
258                    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?;
259                    while let Some(envelope) = client.take_event() {
260                        fwd(&out, incarnation_id, envelope)?;
261                    }
262                    if started.elapsed() > Duration::from_millis(500) {
263                        tracing::warn!(request = %kind, elapsed_ms = started.elapsed().as_millis() as u64, "bridge: slow node request held the request queue");
264                    }
265                }
266            }
267        }
268    }
269}
270
271/// PTY input that may be pipelined (no ordering dependency on replies).
272fn pipelinable(plain: &[u8], node_id: &NodeId, incarnation_id: NodeIncarnationId) -> Option<(u64, NodeRequest)> {
273    let v: Value = serde_json::from_slice(plain).ok()?;
274    let op_id = v.get("op_id")?.as_u64()?;
275    let routed: RoutedNodeRequest = serde_json::from_value(v.get("g4a")?.clone()).ok()?;
276    if routed.route.node_id.as_str() != node_id.as_str() { return None; }
277    let expected = routed.route.expected_incarnation_id;
278    if expected != NodeIncarnationId::from_bytes([0; 16]) && expected != incarnation_id { return None; }
279    match routed.request {
280        NodeRequest::TerminalBytes { .. } | NodeRequest::TerminalControl { .. } | NodeRequest::Interrupt { .. } => Some((op_id, routed.request)),
281        _ => None,
282    }
283}
284
285fn on_pipelined_reply(
286    out: &Sink,
287    node_id: &NodeId,
288    incarnation_id: &mut NodeIncarnationId,
289    controller_owned: &mut bool,
290    inflight: &mut std::collections::HashMap<u64, (u64, NodeRequest)>,
291    retry: &mut Vec<(u64, NodeRequest)>,
292    reply: gate4agent_node_protocol::ResponseEnvelope,
293) -> Result<(), String> {
294    let Some((op_id, request)) = inflight.remove(&reply.request_id) else {
295        return Err(format!("unexpected local reply id {}", reply.request_id));
296    };
297    if let Err(f) = &reply.result {
298        if f.code == NodeFailureCode::ControllerRequired {
299            *controller_owned = false;
300            retry.push((op_id, request));
301            return Ok(());
302        }
303    }
304    if !retry.is_empty() {
305        // Keep order: everything after a lapsed one replays behind it.
306        retry.push((op_id, request));
307        return Ok(());
308    }
309    let mapped = reply
310        .result
311        .map(|r| C2NodeResponse::from_node_response_with_control_detail(&r))
312        .map_err(|f| C2NodeFailure::from(&f));
313    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())?;
314    out.send(plain).map_err(|_| "bridge closed".to_string())
315}
316
317async fn handle_control(
318    session: &Sink,
319    client: &mut LocalNodeClient,
320    node_id: &NodeId,
321    incarnation_id: &mut NodeIncarnationId,
322    connection_id: u64,
323    controller_owned: &mut bool,
324    plain: &[u8],
325    browser_profile_root: &std::path::Path,
326    browser_ev_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
327    #[cfg(feature = "d2b")] browsers: &std::sync::Arc<crate::browser_capture::BrowserSessionMap>,
328) -> Result<(), String> {
329    let v: Value = serde_json::from_slice(plain).map_err(|e| e.to_string())?;
330    let Some(op_id) = v.get("op_id").and_then(Value::as_u64) else {
331        return Ok(());
332    };
333    if let Some(browser) = v.get("browser") {
334        let op: BrowserClientOp = serde_json::from_value(browser.clone()).map_err(|e| e.to_string())?;
335        let reply = handle_browser_op(
336            &node_id_str_from(node_id),
337            op,
338            browser_profile_root,
339            browser_ev_tx,
340            #[cfg(feature = "d2b")]
341            browsers,
342        )
343        .await;
344        let plain = serde_json::to_vec(&json!({ "op_id": op_id, "browser": reply })).map_err(|e| e.to_string())?;
345        return session.send(plain).map_err(|_| "bridge closed".to_string());
346    }
347    let g4a = v.get("g4a").cloned().unwrap_or(Value::Null);
348    let routed: RoutedNodeRequest = serde_json::from_value(g4a).map_err(|e| e.to_string())?;
349    if routed.route.node_id.as_str() != node_id.as_str() {
350        return send_reply(
351            session,
352            op_id,
353            &failure_response(node_id, *incarnation_id, NodeFailureCode::InvalidRequest, "route node id does not match bridge"),
354        )
355        .await;
356    }
357    // All-zero expected incarnation is the core cursor-bootstrap wildcard.
358    let expected = routed.route.expected_incarnation_id;
359    if expected != NodeIncarnationId::from_bytes([0; 16]) && expected != *incarnation_id {
360        return send_reply(
361            session,
362            op_id,
363            &failure_response(
364                node_id,
365                *incarnation_id,
366                NodeFailureCode::InvalidRequest,
367                "node incarnation changed",
368            ),
369        )
370        .await;
371    }
372
373    let request = routed.request;
374    let controller_gated = !is_read_only(&request);
375    if controller_gated && !*controller_owned {
376        match acquire_controller(client, connection_id).await {
377            Ok(true) => *controller_owned = true,
378            Ok(false) => {
379                return send_reply(
380                    session,
381                    op_id,
382                    &failure_response(
383                        node_id,
384                        *incarnation_id,
385                        NodeFailureCode::ControllerBusy,
386                        "node controller lease is unavailable",
387                    ),
388                )
389                .await;
390            }
391            Err(err) => return Err(err.to_string()),
392        }
393    }
394
395    let deadline = request_deadline(&request);
396    // The node controller lease is short-lived and the node clears it on
397    // expiry, but `controller_owned` stays set on this long-lived bridge. So a
398    // controller-gated request can come back `ControllerRequired` even though
399    // we believe we hold it (lease lapsed, or the local client reconnected
400    // under a new connection id). Re-acquire once and retry so live PTY input
401    // and fresh spawns keep working on a held stream.
402    let mut remaining_retries = 1u8;
403    let response = loop {
404        match tokio::time::timeout(deadline, client.request(request.clone())).await {
405            Ok(Ok(response)) => break Ok(response),
406            Ok(Err(NodeClientError::Node(failure))) => {
407                if controller_gated
408                    && failure.code == NodeFailureCode::ControllerRequired
409                    && remaining_retries > 0
410                {
411                    remaining_retries -= 1;
412                    *controller_owned = false;
413                    match acquire_controller(client, connection_id).await {
414                        Ok(true) => {
415                            *controller_owned = true;
416                            continue;
417                        }
418                        Ok(false) => break Err(failure),
419                        Err(err) => return Err(err.to_string()),
420                    }
421                }
422                break Err(failure);
423            }
424            Ok(Err(err)) => return Err(err.to_string()),
425            Err(_) => {
426                return send_reply(
427                    session,
428                    op_id,
429                    &failure_response(
430                        node_id,
431                        *incarnation_id,
432                        NodeFailureCode::HostDirectoryReadTimedOut,
433                        "node request deadline exceeded",
434                    ),
435                )
436                .await;
437            }
438        }
439    };
440
441    *incarnation_id = client.hello().incarnation_id;
442
443    let mut mapped = response
444        .map(|r| C2NodeResponse::from_node_response_with_control_detail(&r))
445        .map_err(|f| C2NodeFailure::from(&f));
446    if let Ok(C2NodeResponse::Snapshot { snapshot, .. }) = &mut mapped {
447        snapshot.machine = Some(crate::collect_machine_environment());
448    }
449    send_reply(
450        session,
451        op_id,
452        &RoutedNodeResponse {
453            node_id: node_id.clone(),
454            incarnation_id: *incarnation_id,
455            response: mapped,
456        },
457    )
458    .await
459}
460
461fn failure_response(
462    node_id: &NodeId,
463    incarnation_id: NodeIncarnationId,
464    code: NodeFailureCode,
465    message: &str,
466) -> RoutedNodeResponse {
467    RoutedNodeResponse {
468        node_id: node_id.clone(),
469        incarnation_id,
470        response: Err(C2NodeFailure {
471            code,
472            message: message.into(),
473        }),
474    }
475}
476
477async fn send_reply(
478    session: &Sink,
479    op_id: u64,
480    response: &RoutedNodeResponse,
481) -> Result<(), String> {
482    let plain =
483        serde_json::to_vec(&json!({ "op_id": op_id, "g4a": response })).map_err(|e| e.to_string())?;
484    session.send(plain).map_err(|_| "bridge closed".to_string())
485}
486
487fn event_bytes(
488    node_id: &NodeId,
489    incarnation_id: NodeIncarnationId,
490    envelope: NodeEventEnvelope,
491) -> Result<Vec<u8>, String> {
492    let event = RoutedNodeEvent {
493        node_id: node_id.clone(),
494        cursor: NodeCursor {
495            incarnation_id,
496            sequence: envelope.sequence,
497        },
498        event: C2NodeEvent::from_node_event_with_control_detail(&envelope.event),
499    };
500    serde_json::to_vec(&json!({ "g4a": event })).map_err(|e| e.to_string())
501}
502
503async fn acquire_controller(
504    client: &mut LocalNodeClient,
505    connection_id: u64,
506) -> Result<bool, NodeClientError> {
507    match client
508        .request(NodeRequest::AcquireController {
509            lease_ms: gate4agent_node_protocol::MAX_CONTROLLER_LEASE_MS,
510        })
511        .await?
512    {
513        NodeResponse::Controller { controller } => Ok(controller
514            .as_ref()
515            .is_some_and(|state| state.connection_id == connection_id)),
516        _ => Err(NodeClientError::Protocol(
517            "controller acquisition returned a different response".into(),
518        )),
519    }
520}
521
522async fn release_controller(
523    client: &mut LocalNodeClient,
524    controller_owned: &mut bool,
525) -> Result<(), NodeClientError> {
526    if !*controller_owned {
527        return Ok(());
528    }
529    let _ = client.request(NodeRequest::ReleaseController).await?;
530    *controller_owned = false;
531    Ok(())
532}
533
534fn is_read_only(request: &NodeRequest) -> bool {
535    matches!(
536        request,
537        NodeRequest::Snapshot
538            | NodeRequest::Resync { .. }
539            | NodeRequest::BrowseHostDirectories { .. }
540            | NodeRequest::InspectWorkspace { .. }
541            | NodeRequest::ReadWorkspaceFile { .. }
542            | NodeRequest::ReadGitHistory { .. }
543            | NodeRequest::ReadGitDiff { .. }
544            | NodeRequest::CatalogNativeSessions { .. }
545            | NodeRequest::PageNativeSessions { .. }
546            | NodeRequest::PreviewNativeSession { .. }
547            | NodeRequest::PreviewSessionRecord { .. }
548    )
549}
550
551fn request_deadline(request: &NodeRequest) -> Duration {
552    match request {
553        NodeRequest::CreateStandaloneWorkspace { .. }
554        | NodeRequest::CreateWorktree { .. }
555        | NodeRequest::RemoveWorktree { .. }
556        | NodeRequest::CleanupManagedWorktree { .. } => Duration::from_secs(240),
557        NodeRequest::Spawn { .. }
558        | NodeRequest::Resume { .. }
559        | NodeRequest::Stop { .. }
560        | NodeRequest::SpawnSpec { .. }
561        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
562        | NodeRequest::SpawnManagedWorktree { .. } => Duration::from_secs(60),
563        NodeRequest::InspectWorkspace { .. } => Duration::from_secs(15),
564        _ => Duration::from_secs(30),
565    }
566}
567
568
569fn node_id_str_from(node_id: &NodeId) -> String {
570    node_id.as_str().to_owned()
571}
572
573async fn handle_browser_op(
574    node_id: &str,
575    op: BrowserClientOp,
576    browser_profile_root: &std::path::Path,
577    _browser_ev_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
578    #[cfg(feature = "d2b")] browsers: &std::sync::Arc<crate::browser_capture::BrowserSessionMap>,
579) -> BrowserOpReply {
580    // Session-local servers are independent of dig2browser; handle them first.
581    #[cfg(feature = "local-server")]
582    {
583        if let Some(reply) = handle_local_server_op(node_id, &op).await {
584            return reply;
585        }
586    }
587    #[cfg(not(feature = "d2b"))]
588    {
589        let _ = (node_id, op, browser_profile_root, _browser_ev_tx);
590        return BrowserOpReply {
591            ok: false,
592            session: String::new(),
593            message: "dig2browser feature (d2b) is not enabled in this node build".into(),
594            body: None,
595        };
596    }
597    #[cfg(feature = "d2b")]
598    {
599        match op {
600            BrowserClientOp::Start {
601                session,
602                url,
603                profile_dir,
604            } => {
605                let dir = match profile_dir {
606                    Some(p) if !p.is_empty() => PathBuf::from(p),
607                    _ => browser_profile_root.join(&session),
608                };
609                match browsers
610                    .start(node_id.to_owned(), session.clone(), url, dir, _browser_ev_tx)
611                    .await
612                {
613                    Ok(()) => BrowserOpReply {
614                        ok: true,
615                        session,
616                        message: String::new(),
617                        body: None,
618                    },
619                    Err(message) => BrowserOpReply {
620                        ok: false,
621                        session,
622                        message,
623                        body: None,
624                    },
625                }
626            }
627            BrowserClientOp::Input { session, bytes } => match browsers.input(&session, &bytes).await
628            {
629                Ok(()) => BrowserOpReply {
630                    ok: true,
631                    session,
632                    message: String::new(),
633                    body: None,
634                },
635                Err(message) => BrowserOpReply {
636                    ok: false,
637                    session,
638                    message,
639                    body: None,
640                },
641            },
642            BrowserClientOp::Stop { session } => {
643                #[cfg(feature = "local-server")]
644                {
645                    crate::local_server::LocalServerPool::global()
646                        .stop_session(&session)
647                        .await;
648                }
649                match browsers.stop(&session).await {
650                    Ok(()) => BrowserOpReply {
651                        ok: true,
652                        session,
653                        message: String::new(),
654                        body: None,
655                    },
656                    Err(message) => BrowserOpReply {
657                        ok: false,
658                        session,
659                        message,
660                        body: None,
661                    },
662                }
663            }
664            BrowserClientOp::LocalServerStart { session, .. }
665            | BrowserClientOp::LocalServerStop { session, .. }
666            | BrowserClientOp::LocalServerList { session } => BrowserOpReply {
667                ok: false,
668                session,
669                message: "local_server op was not handled".into(),
670                body: None,
671            },
672        }
673    }
674}
675
676#[cfg(feature = "local-server")]
677async fn handle_local_server_op(node_id: &str, op: &BrowserClientOp) -> Option<BrowserOpReply> {
678    let _ = node_id;
679    let (session, door_op, payload) = match op {
680        BrowserClientOp::LocalServerList { session } => (
681            session.clone(),
682            "local_server.list",
683            serde_json::json!({ "session": session }),
684        ),
685        BrowserClientOp::LocalServerStop { session, id } => (
686            session.clone(),
687            "local_server.stop",
688            serde_json::json!({ "session": session, "id": id }),
689        ),
690        BrowserClientOp::LocalServerStart {
691            session,
692            kind,
693            root,
694            cwd,
695            bin,
696            open,
697            spa,
698        } => {
699            let mut payload = serde_json::json!({
700                "session": session,
701                "kind": kind,
702                "open": open,
703            });
704            if let Some(r) = root {
705                payload["root"] = serde_json::json!(r);
706            }
707            if let Some(c) = cwd {
708                payload["cwd"] = serde_json::json!(c);
709            }
710            if let Some(b) = bin {
711                payload["bin"] = serde_json::json!(b);
712            }
713            if let Some(s) = spa {
714                payload["spa"] = serde_json::json!(s);
715            }
716            (session.clone(), "local_server.start", payload)
717        }
718        _ => return None,
719    };
720    match crate::local_server::handle_op(door_op, &payload).await {
721        Ok(body) => Some(BrowserOpReply {
722            ok: true,
723            session,
724            message: String::new(),
725            body: Some(body),
726        }),
727        Err(message) => Some(BrowserOpReply {
728            ok: false,
729            session,
730            message,
731            body: None,
732        }),
733    }
734}