1use crate::restream_session::{NodeSession, 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
29pub async fn run(
31 local_endpoint: impl Into<String>,
32 node_id: impl Into<String>,
33 local_token: impl Into<String>,
34 uplink: SocketAddr,
35 keys: UpstreamKeys,
36 journal: Option<Arc<UplinkJournal>>,
37 browser_profile_root: Option<PathBuf>,
38 calls: Option<crate::uplink_calls::NodeCalls>,
39 mut shutdown: watch::Receiver<bool>,
40) -> Result<(), String> {
41 let local_endpoint = local_endpoint.into();
42 let node_id_str = node_id.into();
43 let local_token = local_token.into();
44 let node_id = NodeId::new(node_id_str.clone()).map_err(|e| e.to_string())?;
45 let browser_profile_root = browser_profile_root.unwrap_or_else(|| {
46 std::env::temp_dir().join("hatchery-browser-profiles")
47 });
48
49 let mut client =
50 LocalNodeClient::connect(&local_endpoint, &node_id, ClientRole::Operator, &local_token)
51 .await
52 .map_err(|e| e.to_string())?;
53
54 let mut session = NodeSession::connect(
55 uplink,
56 &node_id_str,
57 CONTROL_CHANNEL,
58 ChannelKind::G4aControl,
59 keys,
60 journal,
61 HANDSHAKE,
62 )
63 .await?;
64
65 let hello = client.hello().clone();
66 let incarnation_id = hello.incarnation_id;
67 let connection_id = hello.connection_id;
68 tracing::info!(%node_id_str, connection_id, "restream g4a bridge up");
69 if let Some(c) = &calls {
71 c.set_uplink(Some(session.uplink())).await;
72 }
73 let result = run_loop(&mut client, &mut session, &node_id, incarnation_id, &local_endpoint, &local_token, browser_profile_root, &mut shutdown).await;
74 if let Some(c) = &calls {
75 c.set_uplink(None).await;
76 }
77 result
78}
79
80#[allow(clippy::too_many_arguments)]
81async fn run_loop(
82 client: &mut LocalNodeClient,
83 session: &mut NodeSession,
84 node_id: &NodeId,
85 incarnation_id: NodeIncarnationId,
86 local_endpoint: &str,
87 local_token: &str,
88 browser_profile_root: PathBuf,
89 shutdown: &mut watch::Receiver<bool>,
90) -> Result<(), String> {
91
92 let worker_client =
96 LocalNodeClient::connect(&local_endpoint, &node_id, ClientRole::Operator, &local_token)
97 .await
98 .map_err(|e| e.to_string())?;
99 let (req_tx, req_rx) = mpsc::unbounded_channel::<Vec<u8>>();
100 let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
101 let (browser_ev_tx, mut browser_ev_rx) = mpsc::unbounded_channel::<BrowserStreamEvent>();
102 let (worker_stop_tx, worker_stop) = watch::channel(false);
103 let mut worker = tokio::spawn(request_worker(
104 worker_client,
105 node_id.clone(),
106 req_rx,
107 out_tx,
108 browser_profile_root.clone(),
109 browser_ev_tx,
110 worker_stop,
111 ));
112 let mut last_pty_input: Option<std::time::Instant> = None;
113 let result = loop {
114 if *shutdown.borrow() {
115 break Ok(());
116 }
117 tokio::select! {
118 changed = shutdown.changed() => {
119 if changed.is_err() || *shutdown.borrow() { break Ok(()); }
120 }
121 done = &mut worker => {
122 break match done { Ok(r) => r.and(Err("request worker ended".into())), Err(e) => Err(e.to_string()) };
123 }
124 frame = client.recv() => {
125 match frame {
126 Ok(ServerFrame::Event(envelope)) => {
127 if matches!(envelope.event, gate4agent_node_protocol::NodeEvent::TerminalFrame { .. }) {
128 if let Some(at) = last_pty_input.take() {
129 tracing::debug!(target: "pty_lat", bridge_us = at.elapsed().as_micros() as u64, "bridge: pty input received to echo frame forwarded");
130 }
131 }
132 if envelope.event.requires_harness_mcp_proxy_capability() { continue; }
133 let plain = event_bytes(&node_id, incarnation_id, envelope)?;
134 if let Err(e) = session.send(&plain).await { break Err(e); }
135 }
136 Ok(_) => break Err("local g4a sent an unexpected frame on the event connection".into()),
137 Err(err) => break Err(err.to_string()),
138 }
139 }
140 out = out_rx.recv() => {
141 let Some(out) = out else { break Err("request worker closed".into()) };
142 if let Err(e) = session.send(&out).await { break Err(e); }
143 }
144 plain = session.recv(Duration::from_secs(120)) => {
145 match plain {
146 Ok(bytes) => {
147 if last_pty_input.is_none() && is_pty_input(&bytes) { last_pty_input = Some(std::time::Instant::now()); }
148 if req_tx.send(bytes).is_err() { break Err("request worker closed".into()); }
149 }
150 Err(err) if err.contains("timeout") => continue,
151 Err(err) => break Err(err),
152 }
153 }
154 ev = browser_ev_rx.recv() => {
155 let Some(ev) = ev else { continue };
156 let plain = serde_json::to_vec(&json!({ "browser": ev })).map_err(|e| e.to_string())?;
157 if let Err(e) = session.send(&plain).await { break Err(e); }
158 }
159 }
160 };
161 let _ = worker_stop_tx.send(true);
163 let _ = tokio::time::timeout(Duration::from_secs(3), worker).await;
164 result
165}
166
167fn is_pty_input(plain: &[u8]) -> bool {
168 let s = String::from_utf8_lossy(&plain[..plain.len().min(512)]);
170 s.contains("TerminalBytes") || s.contains("terminal_bytes")
171}
172
173type Sink = mpsc::UnboundedSender<Vec<u8>>;
174
175async fn request_worker(
179 mut client: LocalNodeClient,
180 node_id: NodeId,
181 mut rx: mpsc::UnboundedReceiver<Vec<u8>>,
182 out: Sink,
183 browser_profile_root: PathBuf,
184 browser_ev_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
185 mut stop: watch::Receiver<bool>,
186) -> Result<(), String> {
187 #[cfg(feature = "d2b")]
188 let browsers = crate::browser_capture::shared_map();
189 let hello = client.hello().clone();
190 let mut incarnation_id = hello.incarnation_id;
191 let connection_id = hello.connection_id;
192 let mut controller_owned = hello.controller.as_ref().is_some_and(|c| c.connection_id == connection_id);
193 let mut inflight: std::collections::HashMap<u64, (u64, NodeRequest)> = std::collections::HashMap::new();
194 let mut retry: Vec<(u64, NodeRequest)> = Vec::new();
195 let fwd = |out: &Sink, incarnation_id: NodeIncarnationId, envelope: NodeEventEnvelope| -> Result<(), String> {
198 if envelope.event.requires_harness_mcp_proxy_capability() {
199 out.send(event_bytes(&node_id, incarnation_id, envelope)?).map_err(|_| "bridge closed".to_string())?;
200 }
201 Ok(())
202 };
203 loop {
204 tokio::select! {
205 _ = stop.changed() => {
206 let _ = release_controller(&mut client, &mut controller_owned).await;
207 return Ok(());
208 }
209 frame = client.recv() => {
210 match frame.map_err(|e| e.to_string())? {
211 ServerFrame::Event(envelope) => fwd(&out, incarnation_id, envelope)?,
212 ServerFrame::Reply(reply) => {
213 on_pipelined_reply(&out, &node_id, &mut incarnation_id, &mut controller_owned, &mut inflight, &mut retry, reply)?;
214 }
215 _ => return Err("local g4a sent an unexpected idle frame".into()),
216 }
217 }
218 plain = rx.recv() => {
219 let Some(bytes) = plain else {
220 let _ = release_controller(&mut client, &mut controller_owned).await;
221 return Ok(());
222 };
223 if controller_owned && retry.is_empty() {
224 if let Some((op_id, request)) = pipelinable(&bytes, &node_id, incarnation_id) {
225 let request_id = client.send(request.clone()).await.map_err(|e| e.to_string())?;
226 inflight.insert(request_id, (op_id, request));
227 continue;
228 }
229 }
230 while !inflight.is_empty() {
231 match client.recv().await.map_err(|e| e.to_string())? {
232 ServerFrame::Event(envelope) => fwd(&out, incarnation_id, envelope)?,
233 ServerFrame::Reply(reply) => on_pipelined_reply(&out, &node_id, &mut incarnation_id, &mut controller_owned, &mut inflight, &mut retry, reply)?,
234 _ => return Err("local g4a sent an unexpected idle frame".into()),
235 }
236 }
237 let mut queue: Vec<Vec<u8>> = Vec::new();
238 for (op_id, request) in std::mem::take(&mut retry) {
239 let routed = RoutedNodeRequest {
241 route: hatchery_core::NodeRoute {
242 node_id: hatchery_core::NodeId::new(node_id.as_str().to_owned()).map_err(|e| e.to_string())?,
243 expected_incarnation_id: NodeIncarnationId::from_bytes([0; 16]),
244 },
245 request,
246 };
247 queue.push(serde_json::to_vec(&json!({ "op_id": op_id, "g4a": routed })).map_err(|e| e.to_string())?);
248 }
249 queue.push(bytes);
250 for plain in queue {
251 let started = std::time::Instant::now();
252 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());
253 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?;
254 while let Some(envelope) = client.take_event() {
255 fwd(&out, incarnation_id, envelope)?;
256 }
257 if started.elapsed() > Duration::from_millis(500) {
258 tracing::warn!(request = %kind, elapsed_ms = started.elapsed().as_millis() as u64, "bridge: slow node request held the request queue");
259 }
260 }
261 }
262 }
263 }
264}
265
266fn pipelinable(plain: &[u8], node_id: &NodeId, incarnation_id: NodeIncarnationId) -> Option<(u64, NodeRequest)> {
268 let v: Value = serde_json::from_slice(plain).ok()?;
269 let op_id = v.get("op_id")?.as_u64()?;
270 let routed: RoutedNodeRequest = serde_json::from_value(v.get("g4a")?.clone()).ok()?;
271 if routed.route.node_id.as_str() != node_id.as_str() { return None; }
272 let expected = routed.route.expected_incarnation_id;
273 if expected != NodeIncarnationId::from_bytes([0; 16]) && expected != incarnation_id { return None; }
274 match routed.request {
275 NodeRequest::TerminalBytes { .. } | NodeRequest::TerminalControl { .. } | NodeRequest::Interrupt { .. } => Some((op_id, routed.request)),
276 _ => None,
277 }
278}
279
280fn on_pipelined_reply(
281 out: &Sink,
282 node_id: &NodeId,
283 incarnation_id: &mut NodeIncarnationId,
284 controller_owned: &mut bool,
285 inflight: &mut std::collections::HashMap<u64, (u64, NodeRequest)>,
286 retry: &mut Vec<(u64, NodeRequest)>,
287 reply: gate4agent_node_protocol::ResponseEnvelope,
288) -> Result<(), String> {
289 let Some((op_id, request)) = inflight.remove(&reply.request_id) else {
290 return Err(format!("unexpected local reply id {}", reply.request_id));
291 };
292 if let Err(f) = &reply.result {
293 if f.code == NodeFailureCode::ControllerRequired {
294 *controller_owned = false;
295 retry.push((op_id, request));
296 return Ok(());
297 }
298 }
299 if !retry.is_empty() {
300 retry.push((op_id, request));
302 return Ok(());
303 }
304 let mapped = reply
305 .result
306 .map(|r| C2NodeResponse::from_node_response_with_control_detail(&r))
307 .map_err(|f| C2NodeFailure::from(&f));
308 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())?;
309 out.send(plain).map_err(|_| "bridge closed".to_string())
310}
311
312async fn handle_control(
313 session: &Sink,
314 client: &mut LocalNodeClient,
315 node_id: &NodeId,
316 incarnation_id: &mut NodeIncarnationId,
317 connection_id: u64,
318 controller_owned: &mut bool,
319 plain: &[u8],
320 browser_profile_root: &std::path::Path,
321 browser_ev_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
322 #[cfg(feature = "d2b")] browsers: &std::sync::Arc<crate::browser_capture::BrowserSessionMap>,
323) -> Result<(), String> {
324 let v: Value = serde_json::from_slice(plain).map_err(|e| e.to_string())?;
325 let Some(op_id) = v.get("op_id").and_then(Value::as_u64) else {
326 return Ok(());
327 };
328 if let Some(browser) = v.get("browser") {
329 let op: BrowserClientOp = serde_json::from_value(browser.clone()).map_err(|e| e.to_string())?;
330 let reply = handle_browser_op(
331 &node_id_str_from(node_id),
332 op,
333 browser_profile_root,
334 browser_ev_tx,
335 #[cfg(feature = "d2b")]
336 browsers,
337 )
338 .await;
339 let plain = serde_json::to_vec(&json!({ "op_id": op_id, "browser": reply })).map_err(|e| e.to_string())?;
340 return session.send(plain).map_err(|_| "bridge closed".to_string());
341 }
342 let g4a = v.get("g4a").cloned().unwrap_or(Value::Null);
343 let routed: RoutedNodeRequest = serde_json::from_value(g4a).map_err(|e| e.to_string())?;
344 if routed.route.node_id.as_str() != node_id.as_str() {
345 return send_reply(
346 session,
347 op_id,
348 &failure_response(node_id, *incarnation_id, NodeFailureCode::InvalidRequest, "route node id does not match bridge"),
349 )
350 .await;
351 }
352 let expected = routed.route.expected_incarnation_id;
354 if expected != NodeIncarnationId::from_bytes([0; 16]) && expected != *incarnation_id {
355 return send_reply(
356 session,
357 op_id,
358 &failure_response(
359 node_id,
360 *incarnation_id,
361 NodeFailureCode::InvalidRequest,
362 "node incarnation changed",
363 ),
364 )
365 .await;
366 }
367
368 let request = routed.request;
369 let controller_gated = !is_read_only(&request);
370 if controller_gated && !*controller_owned {
371 match acquire_controller(client, connection_id).await {
372 Ok(true) => *controller_owned = true,
373 Ok(false) => {
374 return send_reply(
375 session,
376 op_id,
377 &failure_response(
378 node_id,
379 *incarnation_id,
380 NodeFailureCode::ControllerBusy,
381 "node controller lease is unavailable",
382 ),
383 )
384 .await;
385 }
386 Err(err) => return Err(err.to_string()),
387 }
388 }
389
390 let deadline = request_deadline(&request);
391 let mut remaining_retries = 1u8;
398 let response = loop {
399 match tokio::time::timeout(deadline, client.request(request.clone())).await {
400 Ok(Ok(response)) => break Ok(response),
401 Ok(Err(NodeClientError::Node(failure))) => {
402 if controller_gated
403 && failure.code == NodeFailureCode::ControllerRequired
404 && remaining_retries > 0
405 {
406 remaining_retries -= 1;
407 *controller_owned = false;
408 match acquire_controller(client, connection_id).await {
409 Ok(true) => {
410 *controller_owned = true;
411 continue;
412 }
413 Ok(false) => break Err(failure),
414 Err(err) => return Err(err.to_string()),
415 }
416 }
417 break Err(failure);
418 }
419 Ok(Err(err)) => return Err(err.to_string()),
420 Err(_) => {
421 return send_reply(
422 session,
423 op_id,
424 &failure_response(
425 node_id,
426 *incarnation_id,
427 NodeFailureCode::HostDirectoryReadTimedOut,
428 "node request deadline exceeded",
429 ),
430 )
431 .await;
432 }
433 }
434 };
435
436 *incarnation_id = client.hello().incarnation_id;
437
438 let mut mapped = response
439 .map(|r| C2NodeResponse::from_node_response_with_control_detail(&r))
440 .map_err(|f| C2NodeFailure::from(&f));
441 if let Ok(C2NodeResponse::Snapshot { snapshot, .. }) = &mut mapped {
442 snapshot.machine = Some(crate::collect_machine_environment());
443 }
444 send_reply(
445 session,
446 op_id,
447 &RoutedNodeResponse {
448 node_id: node_id.clone(),
449 incarnation_id: *incarnation_id,
450 response: mapped,
451 },
452 )
453 .await
454}
455
456fn failure_response(
457 node_id: &NodeId,
458 incarnation_id: NodeIncarnationId,
459 code: NodeFailureCode,
460 message: &str,
461) -> RoutedNodeResponse {
462 RoutedNodeResponse {
463 node_id: node_id.clone(),
464 incarnation_id,
465 response: Err(C2NodeFailure {
466 code,
467 message: message.into(),
468 }),
469 }
470}
471
472async fn send_reply(
473 session: &Sink,
474 op_id: u64,
475 response: &RoutedNodeResponse,
476) -> Result<(), String> {
477 let plain =
478 serde_json::to_vec(&json!({ "op_id": op_id, "g4a": response })).map_err(|e| e.to_string())?;
479 session.send(plain).map_err(|_| "bridge closed".to_string())
480}
481
482fn event_bytes(
483 node_id: &NodeId,
484 incarnation_id: NodeIncarnationId,
485 envelope: NodeEventEnvelope,
486) -> Result<Vec<u8>, String> {
487 let event = RoutedNodeEvent {
488 node_id: node_id.clone(),
489 cursor: NodeCursor {
490 incarnation_id,
491 sequence: envelope.sequence,
492 },
493 event: C2NodeEvent::from_node_event_with_control_detail(&envelope.event),
494 };
495 serde_json::to_vec(&json!({ "g4a": event })).map_err(|e| e.to_string())
496}
497
498async fn acquire_controller(
499 client: &mut LocalNodeClient,
500 connection_id: u64,
501) -> Result<bool, NodeClientError> {
502 match client
503 .request(NodeRequest::AcquireController {
504 lease_ms: gate4agent_node_protocol::MAX_CONTROLLER_LEASE_MS,
505 })
506 .await?
507 {
508 NodeResponse::Controller { controller } => Ok(controller
509 .as_ref()
510 .is_some_and(|state| state.connection_id == connection_id)),
511 _ => Err(NodeClientError::Protocol(
512 "controller acquisition returned a different response".into(),
513 )),
514 }
515}
516
517async fn release_controller(
518 client: &mut LocalNodeClient,
519 controller_owned: &mut bool,
520) -> Result<(), NodeClientError> {
521 if !*controller_owned {
522 return Ok(());
523 }
524 let _ = client.request(NodeRequest::ReleaseController).await?;
525 *controller_owned = false;
526 Ok(())
527}
528
529fn is_read_only(request: &NodeRequest) -> bool {
530 matches!(
531 request,
532 NodeRequest::Snapshot
533 | NodeRequest::Resync { .. }
534 | NodeRequest::BrowseHostDirectories { .. }
535 | NodeRequest::InspectWorkspace { .. }
536 | NodeRequest::ReadWorkspaceFile { .. }
537 | NodeRequest::ReadGitHistory { .. }
538 | NodeRequest::ReadGitDiff { .. }
539 | NodeRequest::CatalogNativeSessions { .. }
540 | NodeRequest::PageNativeSessions { .. }
541 | NodeRequest::PreviewNativeSession { .. }
542 | NodeRequest::PreviewSessionRecord { .. }
543 )
544}
545
546fn request_deadline(request: &NodeRequest) -> Duration {
547 match request {
548 NodeRequest::CreateStandaloneWorkspace { .. }
549 | NodeRequest::CreateWorktree { .. }
550 | NodeRequest::RemoveWorktree { .. }
551 | NodeRequest::CleanupManagedWorktree { .. } => Duration::from_secs(240),
552 NodeRequest::Spawn { .. }
553 | NodeRequest::Resume { .. }
554 | NodeRequest::Stop { .. }
555 | NodeRequest::SpawnSpec { .. }
556 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
557 | NodeRequest::SpawnManagedWorktree { .. } => Duration::from_secs(60),
558 NodeRequest::InspectWorkspace { .. } => Duration::from_secs(15),
559 _ => Duration::from_secs(30),
560 }
561}
562
563
564fn node_id_str_from(node_id: &NodeId) -> String {
565 node_id.as_str().to_owned()
566}
567
568async fn handle_browser_op(
569 node_id: &str,
570 op: BrowserClientOp,
571 browser_profile_root: &std::path::Path,
572 _browser_ev_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
573 #[cfg(feature = "d2b")] browsers: &std::sync::Arc<crate::browser_capture::BrowserSessionMap>,
574) -> BrowserOpReply {
575 #[cfg(not(feature = "d2b"))]
576 {
577 let _ = (node_id, op, browser_profile_root, _browser_ev_tx);
578 return BrowserOpReply {
579 ok: false,
580 session: String::new(),
581 message: "dig2browser feature (d2b) is not enabled in this node build".into(),
582 };
583 }
584 #[cfg(feature = "d2b")]
585 {
586 match op {
587 BrowserClientOp::Start {
588 session,
589 url,
590 profile_dir,
591 } => {
592 let dir = match profile_dir {
593 Some(p) if !p.is_empty() => PathBuf::from(p),
594 _ => browser_profile_root.join(&session),
595 };
596 match browsers
597 .start(node_id.to_owned(), session.clone(), url, dir, _browser_ev_tx)
598 .await
599 {
600 Ok(()) => BrowserOpReply {
601 ok: true,
602 session,
603 message: String::new(),
604 },
605 Err(message) => BrowserOpReply {
606 ok: false,
607 session,
608 message,
609 },
610 }
611 }
612 BrowserClientOp::Input { session, bytes } => match browsers.input(&session, &bytes).await
613 {
614 Ok(()) => BrowserOpReply {
615 ok: true,
616 session,
617 message: String::new(),
618 },
619 Err(message) => BrowserOpReply {
620 ok: false,
621 session,
622 message,
623 },
624 },
625 BrowserClientOp::Stop { session } => match browsers.stop(&session).await {
626 Ok(()) => BrowserOpReply {
627 ok: true,
628 session,
629 message: String::new(),
630 },
631 Err(message) => BrowserOpReply {
632 ok: false,
633 session,
634 message,
635 },
636 },
637 }
638 }
639}