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/// Separate Noise session for TerminalFrame bulk (UDP data plane).
29const PTY_DATA_CHANNEL: u32 = 3;
30
31/// Run until `shutdown` is true: local g4a operator client ↔ restream session.
32pub async fn run(
33    local_endpoint: impl Into<String>,
34    node_id: impl Into<String>,
35    local_token: impl Into<String>,
36    uplink: SocketAddr,
37    keys: UpstreamKeys,
38    journal: Option<Arc<UplinkJournal>>,
39    browser_profile_root: Option<PathBuf>,
40    calls: Option<crate::uplink_calls::NodeCalls>,
41    mut shutdown: watch::Receiver<bool>,
42) -> Result<(), String> {
43    let local_endpoint = local_endpoint.into();
44    let node_id_str = node_id.into();
45    let local_token = local_token.into();
46    let node_id = NodeId::new(node_id_str.clone()).map_err(|e| e.to_string())?;
47    let browser_profile_root = browser_profile_root.unwrap_or_else(|| {
48        std::env::temp_dir().join("hatchery-browser-profiles")
49    });
50
51    let mut client =
52        LocalNodeClient::connect(&local_endpoint, &node_id, ClientRole::Operator, &local_token)
53            .await
54            .map_err(|e| e.to_string())?;
55
56    let mut session = NodeSession::connect(
57        uplink,
58        &node_id_str,
59        CONTROL_CHANNEL,
60        ChannelKind::G4aControl,
61        keys,
62        journal,
63        HANDSHAKE,
64    )
65    .await?;
66
67    let hello = client.hello().clone();
68    let incarnation_id = hello.incarnation_id;
69    let connection_id = hello.connection_id;
70    tracing::info!(%node_id_str, connection_id, "restream g4a bridge up");
71    // Warm the machine_env cache off the request queue so the first Snapshot
72    // after reconnect does not hold Resize / attach for ~700ms.
73    let _ = tokio::task::spawn_blocking(crate::collect_machine_environment);
74    // Presence only: mail/task use a dedicated TCP (spawn_mail_uplink). Passing the
75    // bridge uplink here used to open ch2 on the shared socket and could stall PTY.
76    if let Some(c) = &calls {
77        c.set_uplink(Some(session.uplink())).await;
78    }
79    // PTY bulk channel (3) is opened inside run_loop so control is not blocked on its handshake.
80    let result = run_loop(&mut client, &mut session, uplink, keys, &node_id_str, &node_id, incarnation_id, &local_endpoint, &local_token, browser_profile_root, &mut shutdown).await;
81    if let Some(c) = &calls {
82        c.set_uplink(None).await;
83    }
84    result
85}
86
87#[allow(clippy::too_many_arguments)]
88async fn run_loop(
89    client: &mut LocalNodeClient,
90    session: &mut NodeSession,
91    uplink_addr: SocketAddr,
92    keys: crate::restream_session::UpstreamKeys,
93    node_id_str: &str,
94    node_id: &NodeId,
95    incarnation_id: NodeIncarnationId,
96    local_endpoint: &str,
97    local_token: &str,
98    browser_profile_root: PathBuf,
99    shutdown: &mut watch::Receiver<bool>,
100) -> Result<(), String> {
101
102    // Requests run on their own local connection in a worker task. A request that
103    // waits on the node (input settle, spawn readiness, snapshot) used to hold this
104    // loop, and with it every PTY frame; now this connection only carries events.
105    let worker_client =
106        LocalNodeClient::connect(&local_endpoint, &node_id, ClientRole::Operator, &local_token)
107            .await
108            .map_err(|e| e.to_string())?;
109    let (req_tx, req_rx) = mpsc::unbounded_channel::<Vec<u8>>();
110    let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
111    let (browser_ev_tx, mut browser_ev_rx) = mpsc::unbounded_channel::<BrowserStreamEvent>();
112    // Re-bind live dig2 sessions onto this bridge so frames keep flowing after
113    // noise decrypt / early-eof reconnects (otherwise HQ stays on a white canvas).
114    #[cfg(feature = "d2b")]
115    {
116        crate::browser_capture::shared_map()
117            .bind_bridge(browser_ev_tx.clone())
118            .await;
119    }
120    let (worker_stop_tx, worker_stop) = watch::channel(false);
121    let mut worker = tokio::spawn(request_worker(
122        worker_client,
123        node_id.clone(),
124        req_rx,
125        out_tx,
126        browser_profile_root.clone(),
127        browser_ev_tx,
128        worker_stop,
129    ));
130    let mut last_pty_input: Option<std::time::Instant> = None;
131    let uplink_watch = session.uplink();
132    // PTY data plane: ch3 (G4aPty) on its OWN TCP (dial + OPEN + Noise never touch
133    // the control socket), terminal bytes over UDP inside WG. A UDP probe/ack confirms
134    // the datagram path; no ack → ch3 reopens TCP-only and UDP is retried with backoff.
135    // While ch3 is down, TerminalFrame rides the control TCP. HATCHERY_PTY_DATA=0 disables.
136    let pty_data_on = !matches!(
137        std::env::var("HATCHERY_PTY_DATA").ok().as_deref(),
138        Some("0") | Some("false") | Some("FALSE") | Some("no")
139    );
140    let mut pty = PtyPlane::new(uplink_addr, node_id_str.to_owned(), keys);
141    let mut pty_open = if pty_data_on { pty.spawn_open(Duration::ZERO) } else { tokio::spawn(std::future::pending()) };
142    let mut pty_session: Option<crate::restream_session::NodeSession> = None;
143    let mut pty_tick = tokio::time::interval(PTY_PROBE_EVERY);
144    pty_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
145    let result = loop {
146        if *shutdown.borrow() {
147            break Ok(());
148        }
149        tokio::select! {
150            changed = shutdown.changed() => {
151                if changed.is_err() || *shutdown.borrow() { break Ok(()); }
152            }
153            done = &mut worker => {
154                break match done { Ok(r) => r.and(Err("request worker ended".into())), Err(e) => Err(e.to_string()) };
155            }
156            opened = &mut pty_open, if pty_data_on && pty_session.is_none() => {
157                match opened {
158                    Ok(Ok(mut s)) => {
159                        let want = pty.want_udp();
160                        s.set_udp(want);
161                        pty.on_open(s.udp());
162                        tracing::info!(channel = PTY_DATA_CHANNEL, transport = if s.udp() { "udp" } else { "tcp-ch3" }, udp_wanted = want, "PTY data channel open on dedicated TCP");
163                        pty_session = Some(s);
164                    }
165                    Ok(Err(e)) => {
166                        let d = pty.fail_delay();
167                        tracing::warn!(error = %e, retry_in_ms = d.as_millis() as u64, "PTY data channel open failed; TerminalFrame on control TCP");
168                        pty_open = pty.spawn_open(d);
169                    }
170                    Err(e) => {
171                        let d = pty.fail_delay();
172                        tracing::warn!(error = %e, "PTY data channel task join failed");
173                        pty_open = pty.spawn_open(d);
174                    }
175                }
176            }
177            down = pty_recv(&mut pty_session) => {
178                match down {
179                    Ok(bytes) => pty.on_down(&bytes),
180                    Err(e) => {
181                        // Nudge from core (desync after a lost datagram) or ch3 TCP closed.
182                        let was_udp = pty_session.as_ref().is_some_and(|s| s.udp());
183                        pty_session = None;
184                        pty.on_desync(was_udp);
185                        let d = pty.fail_delay();
186                        tracing::warn!(error = %e, was_udp, retry_in_ms = d.as_millis() as u64, "PTY data channel lost; reopening (frames on control TCP meanwhile)");
187                        pty_open = pty.spawn_open(d);
188                    }
189                }
190            }
191            _ = pty_tick.tick(), if pty_data_on => {
192                if let Some(s) = pty_session.as_mut() {
193                    match pty.tick(s.udp(), s.uplink().udp_sent()) {
194                        PtyTick::Idle => {}
195                        PtyTick::Probe(body) => {
196                            if let Err(e) = s.send_prio(&body, SendPrio::Pty).await {
197                                tracing::warn!(error = %e, "PTY probe send failed; reopening ch3");
198                                pty_session = None;
199                                pty.on_desync(true);
200                                let d = pty.fail_delay();
201                                    pty_open = pty.spawn_open(d);
202                            }
203                        }
204                        PtyTick::FallBackToTcp => {
205                            pty_session = None;
206                            pty_open = pty.spawn_open(Duration::ZERO);
207                        }
208                    }
209                }
210            }
211            frame = client.recv() => {
212                match frame {
213                    Ok(ServerFrame::Event(envelope)) => {
214                        let is_pty = matches!(envelope.event, gate4agent_node_protocol::NodeEvent::TerminalFrame { .. });
215                        if is_pty {
216                            if let Some(at) = last_pty_input.take() {
217                                tracing::debug!(target: "pty_lat", bridge_us = at.elapsed().as_micros() as u64, "bridge: pty input received to echo frame forwarded");
218                            }
219                        }
220                        if envelope.event.requires_harness_mcp_proxy_capability() { continue; }
221                        let plain = event_bytes(&node_id, incarnation_id, envelope)?;
222                        if is_pty {
223                            if let Some(ps) = pty_session.as_mut() {
224                                if let Err(e) = ps.send_prio(&plain, SendPrio::Pty).await {
225                                    tracing::warn!(error = %e, "PTY data channel send failed; falling back to control");
226                                    let was_udp = ps.udp();
227                                    pty_session = None;
228                                    pty.on_desync(was_udp);
229                                    let d = pty.fail_delay();
230                                    pty_open = pty.spawn_open(d);
231                                    if let Err(e) = session.send_prio(&plain, SendPrio::Pty).await { break Err(e); }
232                                }
233                            } else if let Err(e) = session.send_prio(&plain, SendPrio::Pty).await {
234                                break Err(e);
235                            }
236                        } else if let Err(e) = session.send_prio(&plain, SendPrio::Control).await {
237                            break Err(e);
238                        }
239                    }
240                    Ok(_) => break Err("local g4a sent an unexpected frame on the event connection".into()),
241                    Err(err) => break Err(err.to_string()),
242                }
243            }
244            out = out_rx.recv() => {
245                let Some(out) = out else { break Err("request worker closed".into()) };
246                if let Err(e) = session.send_prio(&out, SendPrio::Control).await { break Err(e); }
247            }
248            plain = session.recv(Duration::from_secs(120)) => {
249                match plain {
250                    Ok(bytes) => {
251                        if last_pty_input.is_none() && is_pty_input(&bytes) { last_pty_input = Some(std::time::Instant::now()); }
252                        if req_tx.send(bytes).is_err() { break Err("request worker closed".into()); }
253                    }
254                    Err(err) if err.contains("timeout") => continue,
255                    Err(err) => break Err(err),
256                }
257            }
258            _ = uplink_watch.closed() => break Err("uplink write stalled; reconnecting".into()),
259            ev = browser_ev_rx.recv() => {
260                let Some(mut ev) = ev else { continue };
261                // Coalesce consecutive Frames before encrypt (post-encrypt drop = nonce gap).
262                let mut flush: Vec<BrowserStreamEvent> = Vec::new();
263                while matches!(ev, BrowserStreamEvent::Frame { .. }) {
264                    match browser_ev_rx.try_recv() {
265                        Ok(more @ BrowserStreamEvent::Frame { .. }) => ev = more,
266                        Ok(more) => { flush.push(ev); ev = more; break; }
267                        Err(_) => break,
268                    }
269                }
270                flush.push(ev);
271                let mut send_err: Option<String> = None;
272                for ev in flush {
273                    let kind = match &ev {
274                        BrowserStreamEvent::Frame { bytes, session, .. } => format!("frame:{session}:{}", bytes.len()),
275                        BrowserStreamEvent::Started { session, .. } => format!("started:{session}"),
276                        BrowserStreamEvent::Stopped { session, .. } => format!("stopped:{session}"),
277                        BrowserStreamEvent::Error { session, .. } => format!("error:{session}"),
278                        _ => "other".into(),
279                    };
280                    let plain = match serde_json::to_vec(&json!({ "browser": ev })) {
281                        Ok(p) => p,
282                        Err(e) => { send_err = Some(e.to_string()); break; }
283                    };
284                    tracing::debug!(%kind, plain_len = plain.len(), "bridge browser → uplink");
285                    if let Err(e) = session.send_prio(&plain, SendPrio::Browser).await {
286                        tracing::warn!(%kind, error = %e, "bridge browser send_prio failed");
287                        send_err = Some(e);
288                        break;
289                    }
290                }
291                if let Some(e) = send_err { break Err(e); }
292            }
293        }
294    };
295    // The worker releases the controller lease it holds before it exits.
296    let _ = worker_stop_tx.send(true);
297    let _ = tokio::time::timeout(Duration::from_secs(3), worker).await;
298    result
299}
300
301const PTY_PROBE_EVERY: Duration = Duration::from_secs(5);
302/// Unanswered UDP probes in a row before ch3 falls back to TCP.
303const PTY_PROBE_MISSES: u32 = 3;
304const PTY_STATS_EVERY: Duration = Duration::from_secs(60);
305
306enum PtyTick {
307    Idle,
308    Probe(Vec<u8>),
309    FallBackToTcp,
310}
311
312/// State of the ch3 PTY data plane: dedicated-TCP open backoff and UDP health.
313struct PtyPlane {
314    addr: SocketAddr,
315    node_id: String,
316    keys: UpstreamKeys,
317    open_backoff: Duration,
318    /// UDP disabled until this instant (after lost probes / repeated desync).
319    udp_off_until: Option<std::time::Instant>,
320    udp_backoff: Duration,
321    probe_seq: u64,
322    outstanding: bool,
323    misses: u32,
324    confirmed: bool,
325    udp_desyncs: u32,
326    last_stats: std::time::Instant,
327    acks: u64,
328}
329
330impl PtyPlane {
331    fn new(addr: SocketAddr, node_id: String, keys: UpstreamKeys) -> Self {
332        Self {
333            addr,
334            node_id,
335            keys,
336            open_backoff: Duration::from_secs(1),
337            udp_off_until: None,
338            udp_backoff: Duration::from_secs(30),
339            probe_seq: 0,
340            outstanding: false,
341            misses: 0,
342            confirmed: false,
343            udp_desyncs: 0,
344            last_stats: std::time::Instant::now(),
345            acks: 0,
346        }
347    }
348
349    fn spawn_open(&self, delay: Duration) -> tokio::task::JoinHandle<Result<NodeSession, String>> {
350        let (addr, nid, keys) = (self.addr, self.node_id.clone(), self.keys);
351        tokio::spawn(async move {
352            if !delay.is_zero() {
353                tokio::time::sleep(delay).await;
354            }
355            // Own TCP: OPEN/WINDOW/Noise of ch3 never queue behind or ahead of control.
356            NodeSession::connect(addr, &nid, PTY_DATA_CHANNEL, ChannelKind::G4aPty, keys, None, HANDSHAKE).await
357        })
358    }
359
360    fn want_udp(&mut self) -> bool {
361        match self.udp_off_until {
362            Some(t) if std::time::Instant::now() < t => false,
363            Some(_) => {
364                self.udp_off_until = None;
365                true
366            }
367            None => true,
368        }
369    }
370
371    fn on_open(&mut self, _udp: bool) {
372        self.open_backoff = Duration::from_secs(1);
373        self.outstanding = false;
374        self.misses = 0;
375        self.confirmed = false;
376    }
377
378    fn fail_delay(&mut self) -> Duration {
379        let d = self.open_backoff;
380        self.open_backoff = (self.open_backoff * 2).min(Duration::from_secs(30));
381        d
382    }
383
384    fn disable_udp(&mut self, why: &str) {
385        let d = self.udp_backoff;
386        self.udp_backoff = (self.udp_backoff * 2).min(Duration::from_secs(300));
387        self.udp_off_until = Some(std::time::Instant::now() + d);
388        tracing::warn!(reason = why, retry_udp_in_s = d.as_secs(), "PTY UDP data plane off; ch3 uses TCP fallback");
389    }
390
391    fn on_desync(&mut self, was_udp: bool) {
392        if was_udp {
393            self.udp_desyncs += 1;
394            if self.udp_desyncs >= 3 {
395                self.udp_desyncs = 0;
396                self.disable_udp("repeated ch3 desync over UDP");
397            }
398        }
399    }
400
401    fn on_down(&mut self, bytes: &[u8]) {
402        let Ok(v) = serde_json::from_slice::<Value>(bytes) else { return };
403        if let Some(seq) = v.get("udp_ack").and_then(Value::as_u64) {
404            if seq <= self.probe_seq {
405                self.outstanding = false;
406                self.misses = 0;
407                self.acks += 1;
408                self.udp_backoff = Duration::from_secs(30);
409                self.udp_desyncs = 0;
410                if !self.confirmed {
411                    self.confirmed = true;
412                    tracing::info!(seq, "PTY transport=udp confirmed (core acked UDP probe)");
413                }
414            }
415        }
416    }
417
418    fn tick(&mut self, udp: bool, udp_sent: u64) -> PtyTick {
419        if self.last_stats.elapsed() >= PTY_STATS_EVERY {
420            self.last_stats = std::time::Instant::now();
421            tracing::info!(transport = if udp { "udp" } else { "tcp-ch3" }, udp_datagrams = udp_sent, udp_acks = self.acks, confirmed = self.confirmed, "PTY data plane stats");
422        }
423        if !udp {
424            // Cooldown over: reopen ch3 so want_udp() can flip back on.
425            if self.udp_off_until.is_some_and(|t| std::time::Instant::now() >= t) {
426                self.udp_off_until = None;
427                return PtyTick::FallBackToTcp; // reuse: drop session, spawn_open(0)
428            }
429            return PtyTick::Idle;
430        }
431        if self.outstanding {
432            self.misses += 1;
433            if self.misses >= PTY_PROBE_MISSES {
434                self.disable_udp("UDP probes unanswered");
435                return PtyTick::FallBackToTcp;
436            }
437        }
438        self.probe_seq += 1;
439        self.outstanding = true;
440        PtyTick::Probe(serde_json::to_vec(&json!({ "udp_probe": self.probe_seq })).unwrap_or_default())
441    }
442}
443
444async fn pty_recv(s: &mut Option<NodeSession>) -> Result<Vec<u8>, String> {
445    match s.as_mut() {
446        Some(s) => loop {
447            match s.recv(Duration::from_secs(3600)).await {
448                Err(e) if e.contains("timeout") => continue,
449                other => return other,
450            }
451        },
452        None => std::future::pending().await,
453    }
454}
455
456fn is_pty_input(plain: &[u8]) -> bool {
457    // Cheap probe (timing only); the worker does the real parsing.
458    let s = String::from_utf8_lossy(&plain[..plain.len().min(512)]);
459    s.contains("TerminalBytes") || s.contains("terminal_bytes")
460}
461
462type Sink = mpsc::UnboundedSender<Vec<u8>>;
463
464/// All node requests, in arrival order, on a dedicated local connection.
465/// PTY input is pipelined (sent at once, answered when its reply arrives);
466/// anything else first settles in-flight input, then runs request/reply.
467async fn request_worker(
468    mut client: LocalNodeClient,
469    node_id: NodeId,
470    mut rx: mpsc::UnboundedReceiver<Vec<u8>>,
471    out: Sink,
472    browser_profile_root: PathBuf,
473    browser_ev_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
474    mut stop: watch::Receiver<bool>,
475) -> Result<(), String> {
476    #[cfg(feature = "d2b")]
477    let browsers = crate::browser_capture::shared_map();
478    let hello = client.hello().clone();
479    let mut incarnation_id = hello.incarnation_id;
480    let connection_id = hello.connection_id;
481    let mut controller_owned = hello.controller.as_ref().is_some_and(|c| c.connection_id == connection_id);
482    let mut inflight: std::collections::HashMap<u64, (u64, NodeRequest)> = std::collections::HashMap::new();
483    let mut retry: Vec<(u64, NodeRequest)> = Vec::new();
484    // Events on this connection: only the controller-scoped harness MCP proxy
485    // events are forwarded (the event connection does not receive them).
486    let fwd = |out: &Sink, incarnation_id: NodeIncarnationId, envelope: NodeEventEnvelope| -> Result<(), String> {
487        if envelope.event.requires_harness_mcp_proxy_capability() {
488            out.send(event_bytes(&node_id, incarnation_id, envelope)?).map_err(|_| "bridge closed".to_string())?;
489        }
490        Ok(())
491    };
492    loop {
493        tokio::select! {
494            _ = stop.changed() => {
495                let _ = release_controller(&mut client, &mut controller_owned).await;
496                return Ok(());
497            }
498            frame = client.recv() => {
499                match frame.map_err(|e| e.to_string())? {
500                    ServerFrame::Event(envelope) => fwd(&out, incarnation_id, envelope)?,
501                    ServerFrame::Reply(reply) => {
502                        on_pipelined_reply(&out, &node_id, &mut incarnation_id, &mut controller_owned, &mut inflight, &mut retry, reply)?;
503                    }
504                    _ => return Err("local g4a sent an unexpected idle frame".into()),
505                }
506            }
507            plain = rx.recv() => {
508                let Some(bytes) = plain else {
509                    let _ = release_controller(&mut client, &mut controller_owned).await;
510                    return Ok(());
511                };
512                if controller_owned && retry.is_empty() {
513                    if let Some((op_id, request)) = pipelinable(&bytes, &node_id, incarnation_id) {
514                        let request_id = client.send(request.clone()).await.map_err(|e| e.to_string())?;
515                        inflight.insert(request_id, (op_id, request));
516                        continue;
517                    }
518                }
519                while !inflight.is_empty() {
520                    match client.recv().await.map_err(|e| e.to_string())? {
521                        ServerFrame::Event(envelope) => fwd(&out, incarnation_id, envelope)?,
522                        ServerFrame::Reply(reply) => on_pipelined_reply(&out, &node_id, &mut incarnation_id, &mut controller_owned, &mut inflight, &mut retry, reply)?,
523                        _ => return Err("local g4a sent an unexpected idle frame".into()),
524                    }
525                }
526                let mut queue: Vec<Vec<u8>> = Vec::new();
527                for (op_id, request) in std::mem::take(&mut retry) {
528                    // Controller lapsed under pipelined input: reacquire and replay in order.
529                    let routed = RoutedNodeRequest {
530                        route: hatchery_core::NodeRoute {
531                            node_id: hatchery_core::NodeId::new(node_id.as_str().to_owned()).map_err(|e| e.to_string())?,
532                            expected_incarnation_id: NodeIncarnationId::from_bytes([0; 16]),
533                        },
534                        request,
535                    };
536                    queue.push(serde_json::to_vec(&json!({ "op_id": op_id, "g4a": routed })).map_err(|e| e.to_string())?);
537                }
538                queue.push(bytes);
539                // Drain anything already waiting so attach/view can jump ahead of Snapshot.
540                while let Ok(more) = rx.try_recv() {
541                    queue.push(more);
542                }
543                prioritize_attach(&mut queue);
544                for plain in queue {
545                    let started = std::time::Instant::now();
546                    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());
547                    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?;
548                    while let Some(envelope) = client.take_event() {
549                        fwd(&out, incarnation_id, envelope)?;
550                    }
551                    if started.elapsed() > Duration::from_millis(500) {
552                        tracing::warn!(request = %kind, elapsed_ms = started.elapsed().as_millis() as u64, "bridge: slow node request held the request queue");
553                    }
554                }
555            }
556        }
557    }
558}
559
560/// Rank for bridge request ordering. Lower = sooner.
561/// Attach/view (Resize, TerminalBytes, Spawn) must beat Snapshot so the provisional
562/// draft timer is claimed before grace expires under a slow machine_env probe.
563fn request_prio(plain: &[u8]) -> u8 {
564    let Ok(v) = serde_json::from_slice::<Value>(plain) else {
565        return 50;
566    };
567    if v.get("browser").is_some() || v.get("hatchery").is_some() {
568        return 80;
569    }
570    let Some(req) = v.pointer("/g4a/request") else {
571        return 50;
572    };
573    let key = req
574        .as_object()
575        .and_then(|o| o.keys().next().cloned())
576        .or_else(|| req.as_str().map(str::to_owned))
577        .unwrap_or_default();
578    match key.as_str() {
579        "Resize" | "resize" => 0,
580        "TerminalBytes" | "terminal_bytes" | "TerminalControl" | "terminal_control" | "Interrupt" | "interrupt" => 1,
581        "Spawn" | "spawn" | "SpawnWithOptions" | "spawn_with_options" | "SpawnManagedWorktree" | "spawn_managed_worktree"
582        | "Resume" | "resume" | "ResumeSessionRecord" | "resume_session_record" | "Stop" | "stop" => 2,
583        "Input" | "input" | "Paste" | "paste" | "SubmitPrompt" | "submit_prompt" => 3,
584        "Snapshot" | "snapshot" | "Resync" | "resync" => 90,
585        _ => 40,
586    }
587}
588
589fn prioritize_attach(queue: &mut Vec<Vec<u8>>) {
590    if queue.len() < 2 {
591        return;
592    }
593    // Stable: equal prio keeps arrival order.
594    let mut indexed: Vec<(usize, u8, Vec<u8>)> = queue
595        .drain(..)
596        .enumerate()
597        .map(|(i, b)| (i, request_prio(&b), b))
598        .collect();
599    indexed.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
600    *queue = indexed.into_iter().map(|(_, _, b)| b).collect();
601}
602
603/// PTY input that may be pipelined (no ordering dependency on replies).
604fn pipelinable(plain: &[u8], node_id: &NodeId, incarnation_id: NodeIncarnationId) -> Option<(u64, NodeRequest)> {
605    let v: Value = serde_json::from_slice(plain).ok()?;
606    let op_id = v.get("op_id")?.as_u64()?;
607    let routed: RoutedNodeRequest = serde_json::from_value(v.get("g4a")?.clone()).ok()?;
608    if routed.route.node_id.as_str() != node_id.as_str() { return None; }
609    let expected = routed.route.expected_incarnation_id;
610    if expected != NodeIncarnationId::from_bytes([0; 16]) && expected != incarnation_id { return None; }
611    match routed.request {
612        NodeRequest::TerminalBytes { .. } | NodeRequest::TerminalControl { .. } | NodeRequest::Interrupt { .. } => Some((op_id, routed.request)),
613        _ => None,
614    }
615}
616
617fn on_pipelined_reply(
618    out: &Sink,
619    node_id: &NodeId,
620    incarnation_id: &mut NodeIncarnationId,
621    controller_owned: &mut bool,
622    inflight: &mut std::collections::HashMap<u64, (u64, NodeRequest)>,
623    retry: &mut Vec<(u64, NodeRequest)>,
624    reply: gate4agent_node_protocol::ResponseEnvelope,
625) -> Result<(), String> {
626    let Some((op_id, request)) = inflight.remove(&reply.request_id) else {
627        return Err(format!("unexpected local reply id {}", reply.request_id));
628    };
629    if let Err(f) = &reply.result {
630        if f.code == NodeFailureCode::ControllerRequired {
631            *controller_owned = false;
632            retry.push((op_id, request));
633            return Ok(());
634        }
635    }
636    if !retry.is_empty() {
637        // Keep order: everything after a lapsed one replays behind it.
638        retry.push((op_id, request));
639        return Ok(());
640    }
641    let mapped = reply
642        .result
643        .map(|r| C2NodeResponse::from_node_response_with_control_detail(&r))
644        .map_err(|f| C2NodeFailure::from(&f));
645    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())?;
646    out.send(plain).map_err(|_| "bridge closed".to_string())
647}
648
649async fn handle_control(
650    session: &Sink,
651    client: &mut LocalNodeClient,
652    node_id: &NodeId,
653    incarnation_id: &mut NodeIncarnationId,
654    connection_id: u64,
655    controller_owned: &mut bool,
656    plain: &[u8],
657    browser_profile_root: &std::path::Path,
658    browser_ev_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
659    #[cfg(feature = "d2b")] browsers: &std::sync::Arc<crate::browser_capture::BrowserSessionMap>,
660) -> Result<(), String> {
661    let v: Value = serde_json::from_slice(plain).map_err(|e| e.to_string())?;
662    let Some(op_id) = v.get("op_id").and_then(Value::as_u64) else {
663        return Ok(());
664    };
665    // Hatchery ops of core's own link (not g4a): the transcript tail of a session. Core
666    // decided access (owner or operator grant) before it asked.
667    if let Some(op) = v.get("hatchery") {
668        let reply = match op["op"].as_str() {
669            Some("session.tail") => {
670                let req = op.clone();
671                tokio::task::spawn_blocking(move || crate::session_tail::reply(&req)).await.unwrap_or_else(|_| json!({ "ok": false, "status": 500, "message": "tail failed" }))
672            }
673            _ => json!({ "ok": false, "status": 400, "message": "unknown hatchery op" }),
674        };
675        let plain = serde_json::to_vec(&json!({ "op_id": op_id, "hatchery": reply })).map_err(|e| e.to_string())?;
676        return session.send(plain).map_err(|_| "bridge closed".to_string());
677    }
678    if let Some(browser) = v.get("browser") {
679        let op: BrowserClientOp = serde_json::from_value(browser.clone()).map_err(|e| e.to_string())?;
680        let reply = handle_browser_op(
681            &node_id_str_from(node_id),
682            op,
683            browser_profile_root,
684            browser_ev_tx,
685            #[cfg(feature = "d2b")]
686            browsers,
687        )
688        .await;
689        let plain = serde_json::to_vec(&json!({ "op_id": op_id, "browser": reply })).map_err(|e| e.to_string())?;
690        return session.send(plain).map_err(|_| "bridge closed".to_string());
691    }
692    let g4a = v.get("g4a").cloned().unwrap_or(Value::Null);
693    let routed: RoutedNodeRequest = serde_json::from_value(g4a).map_err(|e| e.to_string())?;
694    if routed.route.node_id.as_str() != node_id.as_str() {
695        return send_reply(
696            session,
697            op_id,
698            &failure_response(node_id, *incarnation_id, NodeFailureCode::InvalidRequest, "route node id does not match bridge"),
699        )
700        .await;
701    }
702    // All-zero expected incarnation is the core cursor-bootstrap wildcard.
703    let expected = routed.route.expected_incarnation_id;
704    if expected != NodeIncarnationId::from_bytes([0; 16]) && expected != *incarnation_id {
705        return send_reply(
706            session,
707            op_id,
708            &failure_response(
709                node_id,
710                *incarnation_id,
711                NodeFailureCode::InvalidRequest,
712                "node incarnation changed",
713            ),
714        )
715        .await;
716    }
717
718    let request = routed.request;
719    let controller_gated = !is_read_only(&request);
720    if controller_gated && !*controller_owned {
721        match acquire_controller(client, connection_id).await {
722            Ok(true) => *controller_owned = true,
723            Ok(false) => {
724                return send_reply(
725                    session,
726                    op_id,
727                    &failure_response(
728                        node_id,
729                        *incarnation_id,
730                        NodeFailureCode::ControllerBusy,
731                        "node controller lease is unavailable",
732                    ),
733                )
734                .await;
735            }
736            Err(err) => return Err(err.to_string()),
737        }
738    }
739
740    let deadline = request_deadline(&request);
741    // The node controller lease is short-lived and the node clears it on
742    // expiry, but `controller_owned` stays set on this long-lived bridge. So a
743    // controller-gated request can come back `ControllerRequired` even though
744    // we believe we hold it (lease lapsed, or the local client reconnected
745    // under a new connection id). Re-acquire once and retry so live PTY input
746    // and fresh spawns keep working on a held stream.
747    let mut remaining_retries = 1u8;
748    let response = loop {
749        match tokio::time::timeout(deadline, client.request(request.clone())).await {
750            Ok(Ok(response)) => break Ok(response),
751            Ok(Err(NodeClientError::Node(failure))) => {
752                if controller_gated
753                    && failure.code == NodeFailureCode::ControllerRequired
754                    && remaining_retries > 0
755                {
756                    remaining_retries -= 1;
757                    *controller_owned = false;
758                    match acquire_controller(client, connection_id).await {
759                        Ok(true) => {
760                            *controller_owned = true;
761                            continue;
762                        }
763                        Ok(false) => break Err(failure),
764                        Err(err) => return Err(err.to_string()),
765                    }
766                }
767                break Err(failure);
768            }
769            Ok(Err(err)) => return Err(err.to_string()),
770            Err(_) => {
771                return send_reply(
772                    session,
773                    op_id,
774                    &failure_response(
775                        node_id,
776                        *incarnation_id,
777                        NodeFailureCode::HostDirectoryReadTimedOut,
778                        "node request deadline exceeded",
779                    ),
780                )
781                .await;
782            }
783        }
784    };
785
786    *incarnation_id = client.hello().incarnation_id;
787
788    let mut mapped = response
789        .map(|r| C2NodeResponse::from_node_response_with_control_detail(&r))
790        .map_err(|f| C2NodeFailure::from(&f));
791    if let Ok(C2NodeResponse::Snapshot { snapshot, .. }) = &mut mapped {
792        snapshot.machine = Some(crate::collect_machine_environment());
793    }
794    send_reply(
795        session,
796        op_id,
797        &RoutedNodeResponse {
798            node_id: node_id.clone(),
799            incarnation_id: *incarnation_id,
800            response: mapped,
801        },
802    )
803    .await
804}
805
806fn failure_response(
807    node_id: &NodeId,
808    incarnation_id: NodeIncarnationId,
809    code: NodeFailureCode,
810    message: &str,
811) -> RoutedNodeResponse {
812    RoutedNodeResponse {
813        node_id: node_id.clone(),
814        incarnation_id,
815        response: Err(C2NodeFailure {
816            code,
817            message: message.into(),
818        }),
819    }
820}
821
822async fn send_reply(
823    session: &Sink,
824    op_id: u64,
825    response: &RoutedNodeResponse,
826) -> Result<(), String> {
827    let plain =
828        serde_json::to_vec(&json!({ "op_id": op_id, "g4a": response })).map_err(|e| e.to_string())?;
829    session.send(plain).map_err(|_| "bridge closed".to_string())
830}
831
832fn event_bytes(
833    node_id: &NodeId,
834    incarnation_id: NodeIncarnationId,
835    envelope: NodeEventEnvelope,
836) -> Result<Vec<u8>, String> {
837    let event = RoutedNodeEvent {
838        node_id: node_id.clone(),
839        cursor: NodeCursor {
840            incarnation_id,
841            sequence: envelope.sequence,
842        },
843        event: C2NodeEvent::from_node_event_with_control_detail(&envelope.event),
844    };
845    serde_json::to_vec(&json!({ "g4a": event })).map_err(|e| e.to_string())
846}
847
848async fn acquire_controller(
849    client: &mut LocalNodeClient,
850    connection_id: u64,
851) -> Result<bool, NodeClientError> {
852    match client
853        .request(NodeRequest::AcquireController {
854            lease_ms: gate4agent_node_protocol::MAX_CONTROLLER_LEASE_MS,
855        })
856        .await?
857    {
858        NodeResponse::Controller { controller } => Ok(controller
859            .as_ref()
860            .is_some_and(|state| state.connection_id == connection_id)),
861        _ => Err(NodeClientError::Protocol(
862            "controller acquisition returned a different response".into(),
863        )),
864    }
865}
866
867async fn release_controller(
868    client: &mut LocalNodeClient,
869    controller_owned: &mut bool,
870) -> Result<(), NodeClientError> {
871    if !*controller_owned {
872        return Ok(());
873    }
874    let _ = client.request(NodeRequest::ReleaseController).await?;
875    *controller_owned = false;
876    Ok(())
877}
878
879fn is_read_only(request: &NodeRequest) -> bool {
880    matches!(
881        request,
882        NodeRequest::Snapshot
883            | NodeRequest::Resync { .. }
884            | NodeRequest::BrowseHostDirectories { .. }
885            | NodeRequest::InspectWorkspace { .. }
886            | NodeRequest::ReadWorkspaceFile { .. }
887            | NodeRequest::ReadGitHistory { .. }
888            | NodeRequest::ReadGitDiff { .. }
889            | NodeRequest::CatalogNativeSessions { .. }
890            | NodeRequest::PageNativeSessions { .. }
891            | NodeRequest::PreviewNativeSession { .. }
892            | NodeRequest::PreviewSessionRecord { .. }
893    )
894}
895
896fn request_deadline(request: &NodeRequest) -> Duration {
897    match request {
898        NodeRequest::CreateStandaloneWorkspace { .. }
899        | NodeRequest::CreateWorktree { .. }
900        | NodeRequest::RemoveWorktree { .. }
901        | NodeRequest::CleanupManagedWorktree { .. } => Duration::from_secs(240),
902        NodeRequest::Spawn { .. }
903        | NodeRequest::Resume { .. }
904        | NodeRequest::Stop { .. }
905        | NodeRequest::SpawnSpec { .. }
906        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
907        | NodeRequest::SpawnManagedWorktree { .. } => Duration::from_secs(60),
908        NodeRequest::InspectWorkspace { .. } => Duration::from_secs(15),
909        _ => Duration::from_secs(30),
910    }
911}
912
913
914fn node_id_str_from(node_id: &NodeId) -> String {
915    node_id.as_str().to_owned()
916}
917
918async fn handle_browser_op(
919    node_id: &str,
920    op: BrowserClientOp,
921    browser_profile_root: &std::path::Path,
922    _browser_ev_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
923    #[cfg(feature = "d2b")] browsers: &std::sync::Arc<crate::browser_capture::BrowserSessionMap>,
924) -> BrowserOpReply {
925    // Session-local servers are independent of dig2browser; handle them first.
926    #[cfg(feature = "local-server")]
927    {
928        if let Some(reply) = handle_local_server_op(node_id, &op).await {
929            return reply;
930        }
931    }
932    #[cfg(not(feature = "d2b"))]
933    {
934        let _ = (node_id, op, browser_profile_root, _browser_ev_tx);
935        return BrowserOpReply {
936            ok: false,
937            session: String::new(),
938            message: "dig2browser feature (d2b) is not enabled in this node build".into(),
939            body: None,
940        };
941    }
942    #[cfg(feature = "d2b")]
943    {
944        match op {
945            BrowserClientOp::Start {
946                session,
947                url,
948                profile_dir,
949            } => {
950                let dir = match profile_dir {
951                    Some(p) if !p.is_empty() => PathBuf::from(p),
952                    // One persistent node profile for every session when configured.
953                    _ => crate::browser_capture::shared::profile()
954                        .unwrap_or_else(|| browser_profile_root.join(&session)),
955                };
956                match browsers
957                    .start(node_id.to_owned(), session.clone(), url, dir, _browser_ev_tx)
958                    .await
959                {
960                    Ok(()) => BrowserOpReply {
961                        ok: true,
962                        session,
963                        message: String::new(),
964                        body: None,
965                    },
966                    Err(message) => BrowserOpReply {
967                        ok: false,
968                        session,
969                        message,
970                        body: None,
971                    },
972                }
973            }
974            BrowserClientOp::Input { session, bytes } => match browsers.input(&session, &bytes).await
975            {
976                Ok(()) => BrowserOpReply {
977                    ok: true,
978                    session,
979                    message: String::new(),
980                    body: None,
981                },
982                Err(message) => BrowserOpReply {
983                    ok: false,
984                    session,
985                    message,
986                    body: None,
987                },
988            },
989            BrowserClientOp::Stop { session } => {
990                #[cfg(feature = "local-server")]
991                {
992                    crate::local_server::LocalServerPool::global()
993                        .stop_session(&session)
994                        .await;
995                }
996                match browsers.stop(&session).await {
997                    Ok(()) => BrowserOpReply {
998                        ok: true,
999                        session,
1000                        message: String::new(),
1001                        body: None,
1002                    },
1003                    Err(message) => BrowserOpReply {
1004                        ok: false,
1005                        session,
1006                        message,
1007                        body: None,
1008                    },
1009                }
1010            }
1011            BrowserClientOp::LocalServerStart { session, .. }
1012            | BrowserClientOp::LocalServerStop { session, .. }
1013            | BrowserClientOp::LocalServerList { session } => BrowserOpReply {
1014                ok: false,
1015                session,
1016                message: "local_server op was not handled".into(),
1017                body: None,
1018            },
1019        }
1020    }
1021}
1022
1023#[cfg(feature = "local-server")]
1024async fn handle_local_server_op(node_id: &str, op: &BrowserClientOp) -> Option<BrowserOpReply> {
1025    let _ = node_id;
1026    let (session, door_op, payload) = match op {
1027        BrowserClientOp::LocalServerList { session } => (
1028            session.clone(),
1029            "local_server.list",
1030            serde_json::json!({ "session": session }),
1031        ),
1032        BrowserClientOp::LocalServerStop { session, id } => (
1033            session.clone(),
1034            "local_server.stop",
1035            serde_json::json!({ "session": session, "id": id }),
1036        ),
1037        BrowserClientOp::LocalServerStart {
1038            session,
1039            kind,
1040            root,
1041            cwd,
1042            bin,
1043            open,
1044            spa,
1045        } => {
1046            let mut payload = serde_json::json!({
1047                "session": session,
1048                "kind": kind,
1049                "open": open,
1050            });
1051            if let Some(r) = root {
1052                payload["root"] = serde_json::json!(r);
1053            }
1054            if let Some(c) = cwd {
1055                payload["cwd"] = serde_json::json!(c);
1056            }
1057            if let Some(b) = bin {
1058                payload["bin"] = serde_json::json!(b);
1059            }
1060            if let Some(s) = spa {
1061                payload["spa"] = serde_json::json!(s);
1062            }
1063            (session.clone(), "local_server.start", payload)
1064        }
1065        _ => return None,
1066    };
1067    match crate::local_server::handle_op(door_op, &payload).await {
1068        Ok(body) => Some(BrowserOpReply {
1069            ok: true,
1070            session,
1071            message: String::new(),
1072            body: Some(body),
1073        }),
1074        Err(message) => Some(BrowserOpReply {
1075            ok: false,
1076            session,
1077            message,
1078            body: None,
1079        }),
1080    }
1081}