1use 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;
28const PTY_DATA_CHANNEL: u32 = 3;
30
31pub 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 let _ = tokio::task::spawn_blocking(crate::collect_machine_environment);
74 if let Some(c) = &calls {
77 c.set_uplink(Some(session.uplink())).await;
78 }
79 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 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 #[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 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 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 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 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);
302const 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
312struct PtyPlane {
314 addr: SocketAddr,
315 node_id: String,
316 keys: UpstreamKeys,
317 open_backoff: Duration,
318 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 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 if self.udp_off_until.is_some_and(|t| std::time::Instant::now() >= t) {
426 self.udp_off_until = None;
427 return PtyTick::FallBackToTcp; }
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 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
464async 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 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 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 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
560fn 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 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
603fn 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 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 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 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 #[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 _ => 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}