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() || 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 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(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 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 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 #[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 _ => 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}