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() {
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    if let Some(browser) = v.get("browser") {
666        let op: BrowserClientOp = serde_json::from_value(browser.clone()).map_err(|e| e.to_string())?;
667        let reply = handle_browser_op(
668            &node_id_str_from(node_id),
669            op,
670            browser_profile_root,
671            browser_ev_tx,
672            #[cfg(feature = "d2b")]
673            browsers,
674        )
675        .await;
676        let plain = serde_json::to_vec(&json!({ "op_id": op_id, "browser": reply })).map_err(|e| e.to_string())?;
677        return session.send(plain).map_err(|_| "bridge closed".to_string());
678    }
679    let g4a = v.get("g4a").cloned().unwrap_or(Value::Null);
680    let routed: RoutedNodeRequest = serde_json::from_value(g4a).map_err(|e| e.to_string())?;
681    if routed.route.node_id.as_str() != node_id.as_str() {
682        return send_reply(
683            session,
684            op_id,
685            &failure_response(node_id, *incarnation_id, NodeFailureCode::InvalidRequest, "route node id does not match bridge"),
686        )
687        .await;
688    }
689    // All-zero expected incarnation is the core cursor-bootstrap wildcard.
690    let expected = routed.route.expected_incarnation_id;
691    if expected != NodeIncarnationId::from_bytes([0; 16]) && expected != *incarnation_id {
692        return send_reply(
693            session,
694            op_id,
695            &failure_response(
696                node_id,
697                *incarnation_id,
698                NodeFailureCode::InvalidRequest,
699                "node incarnation changed",
700            ),
701        )
702        .await;
703    }
704
705    let request = routed.request;
706    let controller_gated = !is_read_only(&request);
707    if controller_gated && !*controller_owned {
708        match acquire_controller(client, connection_id).await {
709            Ok(true) => *controller_owned = true,
710            Ok(false) => {
711                return send_reply(
712                    session,
713                    op_id,
714                    &failure_response(
715                        node_id,
716                        *incarnation_id,
717                        NodeFailureCode::ControllerBusy,
718                        "node controller lease is unavailable",
719                    ),
720                )
721                .await;
722            }
723            Err(err) => return Err(err.to_string()),
724        }
725    }
726
727    let deadline = request_deadline(&request);
728    // The node controller lease is short-lived and the node clears it on
729    // expiry, but `controller_owned` stays set on this long-lived bridge. So a
730    // controller-gated request can come back `ControllerRequired` even though
731    // we believe we hold it (lease lapsed, or the local client reconnected
732    // under a new connection id). Re-acquire once and retry so live PTY input
733    // and fresh spawns keep working on a held stream.
734    let mut remaining_retries = 1u8;
735    let response = loop {
736        match tokio::time::timeout(deadline, client.request(request.clone())).await {
737            Ok(Ok(response)) => break Ok(response),
738            Ok(Err(NodeClientError::Node(failure))) => {
739                if controller_gated
740                    && failure.code == NodeFailureCode::ControllerRequired
741                    && remaining_retries > 0
742                {
743                    remaining_retries -= 1;
744                    *controller_owned = false;
745                    match acquire_controller(client, connection_id).await {
746                        Ok(true) => {
747                            *controller_owned = true;
748                            continue;
749                        }
750                        Ok(false) => break Err(failure),
751                        Err(err) => return Err(err.to_string()),
752                    }
753                }
754                break Err(failure);
755            }
756            Ok(Err(err)) => return Err(err.to_string()),
757            Err(_) => {
758                return send_reply(
759                    session,
760                    op_id,
761                    &failure_response(
762                        node_id,
763                        *incarnation_id,
764                        NodeFailureCode::HostDirectoryReadTimedOut,
765                        "node request deadline exceeded",
766                    ),
767                )
768                .await;
769            }
770        }
771    };
772
773    *incarnation_id = client.hello().incarnation_id;
774
775    let mut mapped = response
776        .map(|r| C2NodeResponse::from_node_response_with_control_detail(&r))
777        .map_err(|f| C2NodeFailure::from(&f));
778    if let Ok(C2NodeResponse::Snapshot { snapshot, .. }) = &mut mapped {
779        snapshot.machine = Some(crate::collect_machine_environment());
780    }
781    send_reply(
782        session,
783        op_id,
784        &RoutedNodeResponse {
785            node_id: node_id.clone(),
786            incarnation_id: *incarnation_id,
787            response: mapped,
788        },
789    )
790    .await
791}
792
793fn failure_response(
794    node_id: &NodeId,
795    incarnation_id: NodeIncarnationId,
796    code: NodeFailureCode,
797    message: &str,
798) -> RoutedNodeResponse {
799    RoutedNodeResponse {
800        node_id: node_id.clone(),
801        incarnation_id,
802        response: Err(C2NodeFailure {
803            code,
804            message: message.into(),
805        }),
806    }
807}
808
809async fn send_reply(
810    session: &Sink,
811    op_id: u64,
812    response: &RoutedNodeResponse,
813) -> Result<(), String> {
814    let plain =
815        serde_json::to_vec(&json!({ "op_id": op_id, "g4a": response })).map_err(|e| e.to_string())?;
816    session.send(plain).map_err(|_| "bridge closed".to_string())
817}
818
819fn event_bytes(
820    node_id: &NodeId,
821    incarnation_id: NodeIncarnationId,
822    envelope: NodeEventEnvelope,
823) -> Result<Vec<u8>, String> {
824    let event = RoutedNodeEvent {
825        node_id: node_id.clone(),
826        cursor: NodeCursor {
827            incarnation_id,
828            sequence: envelope.sequence,
829        },
830        event: C2NodeEvent::from_node_event_with_control_detail(&envelope.event),
831    };
832    serde_json::to_vec(&json!({ "g4a": event })).map_err(|e| e.to_string())
833}
834
835async fn acquire_controller(
836    client: &mut LocalNodeClient,
837    connection_id: u64,
838) -> Result<bool, NodeClientError> {
839    match client
840        .request(NodeRequest::AcquireController {
841            lease_ms: gate4agent_node_protocol::MAX_CONTROLLER_LEASE_MS,
842        })
843        .await?
844    {
845        NodeResponse::Controller { controller } => Ok(controller
846            .as_ref()
847            .is_some_and(|state| state.connection_id == connection_id)),
848        _ => Err(NodeClientError::Protocol(
849            "controller acquisition returned a different response".into(),
850        )),
851    }
852}
853
854async fn release_controller(
855    client: &mut LocalNodeClient,
856    controller_owned: &mut bool,
857) -> Result<(), NodeClientError> {
858    if !*controller_owned {
859        return Ok(());
860    }
861    let _ = client.request(NodeRequest::ReleaseController).await?;
862    *controller_owned = false;
863    Ok(())
864}
865
866fn is_read_only(request: &NodeRequest) -> bool {
867    matches!(
868        request,
869        NodeRequest::Snapshot
870            | NodeRequest::Resync { .. }
871            | NodeRequest::BrowseHostDirectories { .. }
872            | NodeRequest::InspectWorkspace { .. }
873            | NodeRequest::ReadWorkspaceFile { .. }
874            | NodeRequest::ReadGitHistory { .. }
875            | NodeRequest::ReadGitDiff { .. }
876            | NodeRequest::CatalogNativeSessions { .. }
877            | NodeRequest::PageNativeSessions { .. }
878            | NodeRequest::PreviewNativeSession { .. }
879            | NodeRequest::PreviewSessionRecord { .. }
880    )
881}
882
883fn request_deadline(request: &NodeRequest) -> Duration {
884    match request {
885        NodeRequest::CreateStandaloneWorkspace { .. }
886        | NodeRequest::CreateWorktree { .. }
887        | NodeRequest::RemoveWorktree { .. }
888        | NodeRequest::CleanupManagedWorktree { .. } => Duration::from_secs(240),
889        NodeRequest::Spawn { .. }
890        | NodeRequest::Resume { .. }
891        | NodeRequest::Stop { .. }
892        | NodeRequest::SpawnSpec { .. }
893        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
894        | NodeRequest::SpawnManagedWorktree { .. } => Duration::from_secs(60),
895        NodeRequest::InspectWorkspace { .. } => Duration::from_secs(15),
896        _ => Duration::from_secs(30),
897    }
898}
899
900
901fn node_id_str_from(node_id: &NodeId) -> String {
902    node_id.as_str().to_owned()
903}
904
905async fn handle_browser_op(
906    node_id: &str,
907    op: BrowserClientOp,
908    browser_profile_root: &std::path::Path,
909    _browser_ev_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
910    #[cfg(feature = "d2b")] browsers: &std::sync::Arc<crate::browser_capture::BrowserSessionMap>,
911) -> BrowserOpReply {
912    // Session-local servers are independent of dig2browser; handle them first.
913    #[cfg(feature = "local-server")]
914    {
915        if let Some(reply) = handle_local_server_op(node_id, &op).await {
916            return reply;
917        }
918    }
919    #[cfg(not(feature = "d2b"))]
920    {
921        let _ = (node_id, op, browser_profile_root, _browser_ev_tx);
922        return BrowserOpReply {
923            ok: false,
924            session: String::new(),
925            message: "dig2browser feature (d2b) is not enabled in this node build".into(),
926            body: None,
927        };
928    }
929    #[cfg(feature = "d2b")]
930    {
931        match op {
932            BrowserClientOp::Start {
933                session,
934                url,
935                profile_dir,
936            } => {
937                let dir = match profile_dir {
938                    Some(p) if !p.is_empty() => PathBuf::from(p),
939                    // One persistent node profile for every session when configured.
940                    _ => crate::browser_capture::shared::profile()
941                        .unwrap_or_else(|| browser_profile_root.join(&session)),
942                };
943                match browsers
944                    .start(node_id.to_owned(), session.clone(), url, dir, _browser_ev_tx)
945                    .await
946                {
947                    Ok(()) => BrowserOpReply {
948                        ok: true,
949                        session,
950                        message: String::new(),
951                        body: None,
952                    },
953                    Err(message) => BrowserOpReply {
954                        ok: false,
955                        session,
956                        message,
957                        body: None,
958                    },
959                }
960            }
961            BrowserClientOp::Input { session, bytes } => match browsers.input(&session, &bytes).await
962            {
963                Ok(()) => BrowserOpReply {
964                    ok: true,
965                    session,
966                    message: String::new(),
967                    body: None,
968                },
969                Err(message) => BrowserOpReply {
970                    ok: false,
971                    session,
972                    message,
973                    body: None,
974                },
975            },
976            BrowserClientOp::Stop { session } => {
977                #[cfg(feature = "local-server")]
978                {
979                    crate::local_server::LocalServerPool::global()
980                        .stop_session(&session)
981                        .await;
982                }
983                match browsers.stop(&session).await {
984                    Ok(()) => BrowserOpReply {
985                        ok: true,
986                        session,
987                        message: String::new(),
988                        body: None,
989                    },
990                    Err(message) => BrowserOpReply {
991                        ok: false,
992                        session,
993                        message,
994                        body: None,
995                    },
996                }
997            }
998            BrowserClientOp::LocalServerStart { session, .. }
999            | BrowserClientOp::LocalServerStop { session, .. }
1000            | BrowserClientOp::LocalServerList { session } => BrowserOpReply {
1001                ok: false,
1002                session,
1003                message: "local_server op was not handled".into(),
1004                body: None,
1005            },
1006        }
1007    }
1008}
1009
1010#[cfg(feature = "local-server")]
1011async fn handle_local_server_op(node_id: &str, op: &BrowserClientOp) -> Option<BrowserOpReply> {
1012    let _ = node_id;
1013    let (session, door_op, payload) = match op {
1014        BrowserClientOp::LocalServerList { session } => (
1015            session.clone(),
1016            "local_server.list",
1017            serde_json::json!({ "session": session }),
1018        ),
1019        BrowserClientOp::LocalServerStop { session, id } => (
1020            session.clone(),
1021            "local_server.stop",
1022            serde_json::json!({ "session": session, "id": id }),
1023        ),
1024        BrowserClientOp::LocalServerStart {
1025            session,
1026            kind,
1027            root,
1028            cwd,
1029            bin,
1030            open,
1031            spa,
1032        } => {
1033            let mut payload = serde_json::json!({
1034                "session": session,
1035                "kind": kind,
1036                "open": open,
1037            });
1038            if let Some(r) = root {
1039                payload["root"] = serde_json::json!(r);
1040            }
1041            if let Some(c) = cwd {
1042                payload["cwd"] = serde_json::json!(c);
1043            }
1044            if let Some(b) = bin {
1045                payload["bin"] = serde_json::json!(b);
1046            }
1047            if let Some(s) = spa {
1048                payload["spa"] = serde_json::json!(s);
1049            }
1050            (session.clone(), "local_server.start", payload)
1051        }
1052        _ => return None,
1053    };
1054    match crate::local_server::handle_op(door_op, &payload).await {
1055        Ok(body) => Some(BrowserOpReply {
1056            ok: true,
1057            session,
1058            message: String::new(),
1059            body: Some(body),
1060        }),
1061        Err(message) => Some(BrowserOpReply {
1062            ok: false,
1063            session,
1064            message,
1065            body: None,
1066        }),
1067    }
1068}