1use crate::node_controller::{G4aMessage, OperatorLink};
6use gate4agent_node_protocol::{
7 read_json_frame_limited_body_timeout, write_json_frame_limited, FrameError,
8};
9use gate4agent_node_wire::{local_hmac_sha256, proofs_match, random_nonce, OwnerOnlyLocalListener};
10use hatchery_core::{
11 c2_auth_transcript, c2_bound_auth_transcript, ArchitectureId, BUILD_STAMP, BrowserClientCommand,
12 BrowserStreamEvent, C2AuthDirection,
13 C2ClientAuthentication, C2ClientFrame, C2ClientHello, C2ControlCompatibilitySupport, C2Hello,
14 C2RelayFailure, C2RelayFailureCode, C2ReplyEnvelope, C2RequestEnvelope, C2ServerChallenge,
15 C2ServerFrame, C2Topology, CapabilityId, ClientCompatibilityOffer, HostDescriptor,
16 NegotiatedC2ControlCompatibility, NodeFreshness, NodeId, NodeTransportState, ObservedNode,
17 OperatingSystemId, PathEncoding, PathSemantics, PathStyle, RoutedNodeRequest, RoutedNodeResponse,
18 StatusResponse, C2_ACP_CONTROL_CAPABILITY, C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
19 C2_AGENT_STREAM_EVENTS_CAPABILITY, C2_API_VERSION, C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
20 C2_COMPATIBILITY_METADATA_CAPABILITY, C2_CONTROL_DETAIL_CAPABILITY,
21 C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY, C2_GIT_READ_CAPABILITY,
22 C2_HARNESS_MCP_READ_PROXY_CAPABILITY, C2_HISTORY_CONTEXT_PACK_CAPABILITY,
23 C2_HOST_DIRECTORY_BROWSE_CAPABILITY, C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
24 C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY, C2_NATIVE_SESSION_CATALOG_CAPABILITY,
25 C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY, C2_NATIVE_SESSION_INDEX_CAPABILITY,
26 C2_NATIVE_SESSION_PREVIEW_CAPABILITY, C2_OPAQUE_UNIX_PATH_CAPABILITY,
27 C2_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, C2_PROVIDER_ID_OPEN_CAPABILITY,
28 C2_PROVIDER_RUNTIME_STATUS_CAPABILITY, C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
29 C2_REPOSITORY_PATH_CAPABILITY, C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
30 C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY, C2_SESSION_TASK_CORRELATION_CAPABILITY,
31 C2_SPAWN_PROFILE_REVISION_CAPABILITY, C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
32 C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY, C2_TERMINAL_FRAME_EVENTS_CAPABILITY,
33 C2_WORKSPACE_ENTRY_CREATE_CAPABILITY, C2_WORKSPACE_FILE_READ_CAPABILITY,
34 C2_WORKSPACE_FILE_WRITE_CAPABILITY, C2_WORKTREE_SELECTION_CAPABILITY,
35 MAX_C2_AUTH_FRAME_BYTES, MAX_C2_CLIENT_FRAME_BYTES, MAX_C2_HELLO_FRAME_BYTES,
36 MAX_C2_SERVER_FRAME_BYTES,
37};
38use std::collections::BTreeMap;
39use std::io;
40use std::sync::atomic::{AtomicU64, Ordering};
41use std::sync::{Arc, Mutex};
42use std::time::Duration;
43use tokio::sync::{broadcast, watch, Semaphore};
44#[cfg(test)]
45use tokio::sync::mpsc;
46use tokio::task::JoinSet;
47use tokio::time::timeout;
48
49const AUTH_DEADLINE: Duration = Duration::from_secs(5);
50const REQUEST_DEADLINE: Duration = Duration::from_secs(60);
51
52#[derive(Clone)]
54pub struct NodeOperatorMesh {
55 inner: Arc<Mutex<BTreeMap<String, OperatorLink>>>,
56 cursors: Arc<Mutex<BTreeMap<String, hatchery_core::NodeCursor>>>,
57 machines: Arc<Mutex<BTreeMap<String, hatchery_core::MachineEnvironment>>>,
58 topology: watch::Sender<Arc<StatusResponse>>,
59 events: broadcast::Sender<hatchery_core::RoutedNodeEvent>,
61 browser: broadcast::Sender<BrowserStreamEvent>,
63 last_event_seq: Arc<Mutex<BTreeMap<(String, hatchery_core::NodeIncarnationId), u64>>>,
64}
65
66impl NodeOperatorMesh {
67 pub fn new() -> Self {
68 let initial = Arc::new(StatusResponse {
69 api_version: C2_API_VERSION,
70 ready: true,
71 observed_at_unix_ms: unix_ms(),
72 nodes: BTreeMap::new(),
73 });
74 let (topology, _) = watch::channel(initial);
75 let (events, _) = broadcast::channel(4096);
76 let (browser, _) = broadcast::channel(512);
77 Self {
78 inner: Arc::new(Mutex::new(BTreeMap::new())),
79 cursors: Arc::new(Mutex::new(BTreeMap::new())),
80 machines: Arc::new(Mutex::new(BTreeMap::new())),
81 topology,
82 events,
83 browser,
84 last_event_seq: Arc::new(Mutex::new(BTreeMap::new())),
85 }
86 }
87
88 pub fn subscribe(&self) -> watch::Receiver<Arc<StatusResponse>> {
89 self.topology.subscribe()
90 }
91
92 pub fn register(&self, node_id: impl Into<String>, link: OperatorLink) {
93 let id = node_id.into();
94 let _ = NodeId::new(id.clone()).expect("operator mesh node id");
95 self.inner.lock().expect("mesh").insert(id.clone(), link);
96 self.publish_topology();
97 tracing::info!(%id, "core operator mesh: node registered");
98 }
99
100 pub fn unregister(&self, node_id: &str) {
101 self.inner.lock().expect("mesh").remove(node_id);
102 self.cursors.lock().expect("cursors").remove(node_id);
103 self.machines.lock().expect("machines").remove(node_id);
104 self.publish_topology();
105 }
106
107 pub fn get(&self, node_id: &str) -> Option<OperatorLink> {
108 self.inner.lock().expect("mesh").get(node_id).cloned()
109 }
110
111 pub fn set_cursor(&self, node_id: &str, cursor: hatchery_core::NodeCursor) {
113 self.cursors.lock().expect("cursors").insert(node_id.to_owned(), cursor);
114 self.publish_topology();
115 }
116
117 pub fn set_machine(&self, node_id: &str, machine: hatchery_core::MachineEnvironment) {
118 self.machines.lock().expect("machines").insert(node_id.to_owned(), machine);
119 self.publish_topology();
120 }
121
122 pub fn publish_event(&self, event: hatchery_core::RoutedNodeEvent) {
124 if matches!(event.event, hatchery_core::C2NodeEvent::TerminalFrame { .. }) {
125 if let Some(at) = PTY_PROBE.lock().unwrap_or_else(|p| p.into_inner()).take() {
126 tracing::debug!(target: "pty_lat", core_us = at.elapsed().as_micros() as u64, "core: pty input enqueued to echo frame published");
127 }
128 }
129 {
131 let mut last = self.last_event_seq.lock().expect("last seq");
132 let key = (event.node_id.as_str().to_owned(), event.cursor.incarnation_id);
133 let prev = last.insert(key, event.cursor.sequence);
134 if let Some(prev) = prev {
135 if event.cursor.sequence > prev + 1 {
136 tracing::info!(node = %event.node_id, prev, next = event.cursor.sequence, kind = format!("{:?}", event.event).split(|c: char| !c.is_alphanumeric()).next().unwrap_or("").to_owned(), "node event sequence gap");
137 }
138 }
139 }
140 self.set_cursor(
142 event.node_id.as_str(),
143 hatchery_core::NodeCursor {
144 incarnation_id: event.cursor.incarnation_id,
145 sequence: event.cursor.sequence,
146 },
147 );
148 let _ = self.events.send(event);
149 }
150
151 pub fn subscribe_events(&self) -> broadcast::Receiver<hatchery_core::RoutedNodeEvent> {
152 self.events.subscribe()
153 }
154
155 pub fn publish_browser(&self, event: BrowserStreamEvent) {
156 let _ = self.browser.send(event);
157 }
158
159 pub fn subscribe_browser(&self) -> broadcast::Receiver<BrowserStreamEvent> {
160 self.browser.subscribe()
161 }
162
163 fn publish_topology(&self) {
164 let ids: Vec<String> = self.inner.lock().expect("mesh").keys().cloned().collect();
165 let cursors = self.cursors.lock().expect("cursors").clone();
166 let machines = self.machines.lock().expect("machines").clone();
167 let mut nodes = BTreeMap::new();
168 for id in &ids {
169 let node_id = NodeId::new(id.clone()).expect("registered ids are valid");
170 nodes.insert(
171 node_id,
172 ObservedNode {
173 endpoint: "drain".into(),
174 transport_label: "drain".into(),
175 transport: NodeTransportState::Online,
176 freshness: NodeFreshness::Fresh,
177 cursor: cursors.get(id).cloned(),
178 inventory: None,
179 machine: machines.get(id).cloned(),
180 last_attempt_unix_ms: None,
181 last_success_unix_ms: Some(unix_ms()),
182 consecutive_failures: 0,
183 last_error: None,
184 gaps: Vec::new(),
185 gaps_truncated: 0,
186 },
187 );
188 }
189 let status = Arc::new(StatusResponse {
190 api_version: C2_API_VERSION,
191 ready: true,
192 observed_at_unix_ms: unix_ms(),
193 nodes,
194 });
195 self.topology.send_replace(status);
196 }
197}
198
199fn unix_ms() -> u64 {
200 std::time::SystemTime::now()
201 .duration_since(std::time::UNIX_EPOCH)
202 .unwrap_or_default()
203 .as_millis() as u64
204}
205
206pub async fn serve_local(
208 endpoint: impl Into<String>,
209 token: impl Into<String>,
210 mesh: NodeOperatorMesh,
211 mut shutdown: watch::Receiver<bool>,
212) -> io::Result<()> {
213 let endpoint = endpoint.into();
214 let token = token.into();
215 let mut listener = OwnerOnlyLocalListener::bind(&endpoint).await?;
216 tracing::info!(%endpoint, "core operator control listening");
217 let preauth = Arc::new(Semaphore::new(32));
221 let authenticated = Arc::new(Semaphore::new(128));
222 let next_id = Arc::new(AtomicU64::new(1));
223 let mut connections = JoinSet::new();
224 loop {
225 let permit = tokio::select! {
226 p = Arc::clone(&preauth).acquire_owned() => p.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "preauth closed"))?,
227 changed = shutdown.changed() => {
228 if changed.is_err() || *shutdown.borrow() { break; }
229 continue;
230 }
231 };
232 let stream = tokio::select! {
233 a = listener.accept() => a?,
234 changed = shutdown.changed() => {
235 if changed.is_err() || *shutdown.borrow() { break; }
236 continue;
237 }
238 };
239 let token = token.clone();
240 let mesh = mesh.clone();
241 let authenticated = Arc::clone(&authenticated);
242 let next_id = Arc::clone(&next_id);
243 let sd = shutdown.clone();
244 connections.spawn(async move {
245 let _ = serve_connection(stream, permit, authenticated, next_id, &token, mesh, sd).await;
246 });
247 while connections.try_join_next().is_some() {}
248 }
249 connections.shutdown().await;
250 Ok(())
251}
252
253async fn serve_connection<S>(
254 mut pipe: S,
255 preauth: tokio::sync::OwnedSemaphorePermit,
256 authenticated: Arc<Semaphore>,
257 connection_ids: Arc<AtomicU64>,
258 token: &str,
259 mesh: NodeOperatorMesh,
260 mut shutdown: watch::Receiver<bool>,
261) -> Result<(), FrameError>
262where
263 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
264{
265 let hello = timeout(AUTH_DEADLINE, read_client_frame(&mut pipe, MAX_C2_AUTH_FRAME_BYTES))
266 .await.map_err(|_| FrameError::PrefixTimedOut)??;
267 let C2ClientFrame::Hello(hello) = hello else { return Ok(()) };
268 if hello.build_stamp != BUILD_STAMP {
269 let _ = timeout(AUTH_DEADLINE, write_json_frame_limited(
270 &mut pipe,
271 &C2ServerFrame::Rejected(fail(C2RelayFailureCode::BuildStampMismatch, "build stamp mismatch")),
272 MAX_C2_AUTH_FRAME_BYTES,
273 )).await;
274 return Ok(());
275 }
276 let negotiated = negotiate(&hello)?;
277 let compatibility = hello.compatibility.as_ref().map(|_| negotiated.clone());
278 let server_nonce = random_nonce().map_err(|e| FrameError::Io(io::Error::other(e)))?;
279 let auth_compat = hello.compatibility.as_ref().zip(compatibility.as_ref());
280 let server_proof = c2_proof(token, C2AuthDirection::Server, &hello.client_nonce, &server_nonce, auth_compat)?;
281 timeout(AUTH_DEADLINE, write_json_frame_limited(
282 &mut pipe,
283 &C2ServerFrame::Challenge(C2ServerChallenge {
284 build_stamp: BUILD_STAMP.to_owned(),
285 server_nonce,
286 server_proof,
287 compatibility: compatibility.clone(),
288 }),
289 MAX_C2_AUTH_FRAME_BYTES,
290 )).await.map_err(|_| FrameError::BodyTimedOut { length: 0 })??;
291
292 let authentication = timeout(AUTH_DEADLINE, read_client_frame(&mut pipe, MAX_C2_AUTH_FRAME_BYTES))
293 .await.map_err(|_| FrameError::PrefixTimedOut)??;
294 let C2ClientFrame::Authenticate(C2ClientAuthentication { client_proof }) = authentication else {
295 return Ok(());
296 };
297 let expected = c2_proof(token, C2AuthDirection::Client, &hello.client_nonce, &server_nonce, auth_compat)?;
298 if !proofs_match(&client_proof, &expected) {
299 return Ok(());
300 }
301 drop(preauth);
302
303 let Ok(_op) = authenticated.try_acquire_owned() else {
304 let _ = write_json_frame_limited(
305 &mut pipe,
306 &C2ServerFrame::Rejected(fail(C2RelayFailureCode::OperatorAlreadyConnected, "operator connection limit reached")),
307 MAX_C2_SERVER_FRAME_BYTES,
308 ).await;
309 return Ok(());
310 };
311
312 let connection_id = connection_ids.fetch_add(1, Ordering::AcqRel);
313 let mut status_rx = mesh.subscribe();
314 let mut events_rx = mesh.subscribe_events();
315 let mut browser_rx = mesh.subscribe_browser();
316 let hello_status = status_rx.borrow().as_ref().clone();
317 timeout(AUTH_DEADLINE, write_json_frame_limited(
318 &mut pipe,
319 &C2ServerFrame::Hello(C2Hello {
320 build_stamp: BUILD_STAMP.to_owned(),
321 connection_id,
322 status: hello_status,
323 compatibility,
324 }),
325 MAX_C2_HELLO_FRAME_BYTES,
326 )).await.map_err(|_| FrameError::BodyTimedOut { length: 0 })??;
327
328 let (mut reader, mut writer) = tokio::io::split(pipe);
329 let (frame_tx, mut frame_rx) = tokio::sync::mpsc::channel(512);
333 let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::<C2ServerFrame>();
336 let reader_task = tokio::spawn(async move {
337 loop {
338 let frame = read_client_frame(&mut reader, MAX_C2_CLIENT_FRAME_BYTES).await;
339 let stop = frame.is_err();
340 if frame_tx.send(frame).await.is_err() || stop {
341 break;
342 }
343 }
344 });
345 let mut last_topology = C2Topology::from_status(status_rx.borrow().as_ref());
346 tracing::info!(connection_id, "core operator control opened");
347
348 loop {
349 tokio::select! {
350 _ = shutdown.changed() => {
351 if *shutdown.borrow() { break; }
352 }
353 changed = status_rx.changed() => {
354 if changed.is_err() { break; }
355 let status = status_rx.borrow().as_ref().clone();
356 let topology = C2Topology::from_status(&status);
357 if topology != last_topology {
358 last_topology = topology.clone();
359 if write_json_frame_limited(&mut writer, &C2ServerFrame::Topology(topology), MAX_C2_SERVER_FRAME_BYTES).await.is_err() {
360 break;
361 }
362 }
363 }
364 event = events_rx.recv() => {
365 match event {
366 Ok(routed) => {
367 if write_json_frame_limited(
368 &mut writer,
369 &C2ServerFrame::Event(routed),
370 MAX_C2_SERVER_FRAME_BYTES,
371 ).await.is_err() {
372 break;
373 }
374 }
375 Err(broadcast::error::RecvError::Lagged(n)) => {
376 tracing::warn!(skipped = n, "operator client lagged; node events dropped");
377 continue;
378 }
379 Err(broadcast::error::RecvError::Closed) => break,
380 }
381 }
382 browser = browser_rx.recv() => {
383 match browser {
384 Ok(ev) => {
385 if write_json_frame_limited(
386 &mut writer,
387 &C2ServerFrame::Browser(ev),
388 MAX_C2_SERVER_FRAME_BYTES,
389 ).await.is_err() {
390 break;
391 }
392 }
393 Err(broadcast::error::RecvError::Lagged(_)) => continue,
394 Err(broadcast::error::RecvError::Closed) => break,
395 }
396 }
397 out = out_rx.recv() => {
398 let Some(out) = out else { break };
399 if write_json_frame_limited(&mut writer, &out, MAX_C2_SERVER_FRAME_BYTES).await.is_err() {
400 break;
401 }
402 }
403 frame = frame_rx.recv() => {
404 let Some(frame) = frame else { break };
405 match frame {
406 Ok(C2ClientFrame::Request(envelope)) => {
407 let wait = start_request(&mesh, envelope).await;
410 let out = out_tx.clone();
411 tokio::spawn(async move {
412 let _ = out.send(C2ServerFrame::Reply(wait.await));
413 });
414 }
415 Ok(C2ClientFrame::Browser(cmd)) => {
416 let wait = start_browser(&mesh, cmd).await;
418 let out = out_tx.clone();
419 tokio::spawn(async move {
420 if let Some(ev) = wait.await {
421 let _ = out.send(C2ServerFrame::Browser(ev));
422 }
423 });
424 }
425 Ok(_) | Err(_) => break,
426 }
427 }
428 }
429 }
430 reader_task.abort();
431 Ok(())
432}
433
434
435async fn start_browser(
437 mesh: &NodeOperatorMesh,
438 cmd: BrowserClientCommand,
439) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<BrowserStreamEvent>> + Send>> {
440 let session = match &cmd.op {
441 hatchery_core::BrowserClientOp::Start { session, .. }
442 | hatchery_core::BrowserClientOp::Input { session, .. }
443 | hatchery_core::BrowserClientOp::Stop { session }
444 | hatchery_core::BrowserClientOp::LocalServerStart { session, .. }
445 | hatchery_core::BrowserClientOp::LocalServerStop { session, .. }
446 | hatchery_core::BrowserClientOp::LocalServerList { session } => session.clone(),
447 };
448 let is_local_server = matches!(
449 &cmd.op,
450 hatchery_core::BrowserClientOp::LocalServerStart { .. }
451 | hatchery_core::BrowserClientOp::LocalServerStop { .. }
452 | hatchery_core::BrowserClientOp::LocalServerList { .. }
453 );
454 let link = { let guard = mesh.inner.lock().expect("mesh"); guard.get(&cmd.node_id).cloned() };
455 let node_id = cmd.node_id.clone();
456 let Some(link) = link else {
457 return Box::pin(async move { Some(BrowserStreamEvent::Error { node_id, session, message: "node is not registered on core mesh".into() }) });
458 };
459 let sent = link.browser_op_pipelined(&cmd.op, Duration::from_secs(60)).await;
460 Box::pin(async move {
461 match sent {
462 Err(e) => Some(BrowserStreamEvent::Error { node_id, session, message: format!("{e:?}") }),
463 Ok(wait) => match wait.await {
464 Ok(reply) if is_local_server => Some(BrowserStreamEvent::LocalServer {
465 node_id,
466 session: reply.session,
467 ok: reply.ok,
468 message: reply.message,
469 body: reply.body,
470 }),
471 Ok(reply) if reply.ok => None,
472 Ok(reply) => Some(BrowserStreamEvent::Error { node_id, session: reply.session, message: reply.message }),
473 Err(e) => Some(BrowserStreamEvent::Error { node_id, session, message: format!("{e:?}") }),
474 },
475 }
476 })
477}
478
479
480#[allow(dead_code)]
481async fn dispatch_browser(
482 mesh: &NodeOperatorMesh,
483 cmd: BrowserClientCommand,
484) -> Option<BrowserStreamEvent> {
485 let link = {
486 let guard = mesh.inner.lock().expect("mesh");
487 guard.get(&cmd.node_id).cloned()
488 };
489 let Some(link) = link else {
490 return Some(BrowserStreamEvent::Error {
491 node_id: cmd.node_id,
492 session: match &cmd.op {
493 hatchery_core::BrowserClientOp::Start { session, .. }
494 | hatchery_core::BrowserClientOp::Input { session, .. }
495 | hatchery_core::BrowserClientOp::Stop { session }
496 | hatchery_core::BrowserClientOp::LocalServerStart { session, .. }
497 | hatchery_core::BrowserClientOp::LocalServerStop { session, .. }
498 | hatchery_core::BrowserClientOp::LocalServerList { session } => session.clone(),
499 },
500 message: "node is not registered on core mesh".into(),
501 });
502 };
503 match link.browser_op(&cmd.op, Duration::from_secs(60)).await {
504 Ok(reply) if reply.ok => None,
505 Ok(reply) => Some(BrowserStreamEvent::Error {
506 node_id: cmd.node_id,
507 session: reply.session,
508 message: reply.message,
509 }),
510 Err(err) => Some(BrowserStreamEvent::Error {
511 node_id: cmd.node_id,
512 session: match &cmd.op {
513 hatchery_core::BrowserClientOp::Start { session, .. }
514 | hatchery_core::BrowserClientOp::Input { session, .. }
515 | hatchery_core::BrowserClientOp::Stop { session }
516 | hatchery_core::BrowserClientOp::LocalServerStart { session, .. }
517 | hatchery_core::BrowserClientOp::LocalServerStop { session, .. }
518 | hatchery_core::BrowserClientOp::LocalServerList { session } => session.clone(),
519 },
520 message: err.to_string(),
521 }),
522 }
523}
524
525#[allow(dead_code)]
526async fn dispatch_request(mesh: &NodeOperatorMesh, envelope: C2RequestEnvelope) -> C2ReplyEnvelope {
527 let node_id = envelope.request.route.node_id.as_str().to_owned();
528 let cid = envelope.correlation_id.clone();
529 let Some(link) = mesh.get(&node_id) else {
530 return C2ReplyEnvelope {
531 request_id: envelope.request_id,
532 result: Err(fail(C2RelayFailureCode::NodeOffline, "node not on drain")),
533 correlation_id: cid,
534 };
535 };
536 let routed = RoutedNodeRequest {
537 route: envelope.request.route.clone(),
538 request: envelope.request.request.clone(),
539 };
540 let result = match link.request_pipelined(&routed, REQUEST_DEADLINE).await {
541 Ok(wait) => wait.await,
542 Err(e) => Err(e),
543 };
544 finish_request(mesh, &node_id, envelope.request_id, result, cid)
545}
546
547static PTY_PROBE: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
551
552async fn start_request(
553 mesh: &NodeOperatorMesh,
554 envelope: C2RequestEnvelope,
555) -> std::pin::Pin<Box<dyn std::future::Future<Output = C2ReplyEnvelope> + Send>> {
556 let correlation_id = envelope.correlation_id.clone();
557 let req_kind = match &envelope.request.request {
558 gate4agent_node_protocol::NodeRequest::Spawn { provider, .. } => {
559 tracing::info!(
560 cid = correlation_id.as_deref().unwrap_or("-"),
561 node = %envelope.request.route.node_id,
562 %provider,
563 "operator spawn forward",
564 );
565 "spawn"
566 }
567 gate4agent_node_protocol::NodeRequest::Resize { .. } => "resize",
568 gate4agent_node_protocol::NodeRequest::Stop { .. } => "stop",
569 gate4agent_node_protocol::NodeRequest::TerminalBytes { .. } => "terminal_bytes",
570 _ => "other",
571 };
572 if req_kind == "terminal_bytes" {
573 let mut g = PTY_PROBE.lock().unwrap_or_else(|p| p.into_inner());
574 if g.is_none() {
575 *g = Some(std::time::Instant::now());
576 }
577 tracing::debug!(target: "pty_lat", "core: pty input enqueued toward node");
578 }
579 let node_id = envelope.request.route.node_id.as_str().to_owned();
580 let request_id = envelope.request_id;
581 let Some(link) = mesh.get(&node_id) else {
582 let cid = correlation_id.clone();
583 return Box::pin(async move {
584 C2ReplyEnvelope {
585 request_id,
586 result: Err(fail(C2RelayFailureCode::NodeOffline, "node not on drain")),
587 correlation_id: cid,
588 }
589 });
590 };
591 let routed = RoutedNodeRequest {
592 route: envelope.request.route.clone(),
593 request: envelope.request.request.clone(),
594 };
595 let sent = link.request_pipelined(&routed, REQUEST_DEADLINE).await;
596 let mesh = mesh.clone();
597 let cid = correlation_id.clone();
598 Box::pin(async move {
599 let result = match sent {
600 Ok(wait) => wait.await,
601 Err(e) => Err(e),
602 };
603 if matches!(req_kind, "spawn" | "resize" | "stop") {
604 tracing::info!(
605 cid = cid.as_deref().unwrap_or("-"),
606 node = %node_id,
607 kind = req_kind,
608 ok = result.is_ok(),
609 "operator request settled",
610 );
611 }
612 finish_request(&mesh, &node_id, request_id, result, cid)
613 })
614}
615
616fn finish_request(
617 mesh: &NodeOperatorMesh,
618 node_id: &str,
619 request_id: hatchery_core::C2RequestId,
620 result: Result<G4aMessage, crate::node_controller::OperatorError>,
621 correlation_id: Option<String>,
622) -> C2ReplyEnvelope {
623 let node_id = node_id.to_owned();
624 match result {
625 Ok(G4aMessage::Response(response)) => {
626 mesh.set_cursor(
627 &node_id,
628 hatchery_core::NodeCursor {
629 incarnation_id: response.incarnation_id,
630 sequence: 0,
631 },
632 );
633 if let Ok(hatchery_core::C2NodeResponse::Snapshot { snapshot, .. }) = &response.response {
634 if let Some(machine) = snapshot.machine.clone() {
635 mesh.set_machine(&node_id, machine);
636 }
637 }
638 C2ReplyEnvelope {
639 request_id,
640 result: Ok(response),
641 correlation_id,
642 }
643 }
644 Ok(G4aMessage::Json(v)) => match serde_json::from_value::<RoutedNodeResponse>(v) {
645 Ok(response) => {
646 mesh.set_cursor(
647 &node_id,
648 hatchery_core::NodeCursor {
649 incarnation_id: response.incarnation_id,
650 sequence: 0,
651 },
652 );
653 C2ReplyEnvelope {
654 request_id,
655 result: Ok(response),
656 correlation_id,
657 }
658 }
659 Err(_) => C2ReplyEnvelope {
660 request_id,
661 result: Err(fail(
662 C2RelayFailureCode::RequestForbidden,
663 "node reply was not a g4a response",
664 )),
665 correlation_id,
666 },
667 },
668 Ok(_) => C2ReplyEnvelope {
669 request_id,
670 result: Err(fail(
671 C2RelayFailureCode::RequestForbidden,
672 "unexpected g4a reply kind",
673 )),
674 correlation_id,
675 },
676 Err(_) => C2ReplyEnvelope {
677 request_id,
678 result: Err(fail(C2RelayFailureCode::RelayBusy, "node did not answer")),
679 correlation_id,
680 },
681 }
682}
683
684fn fail(code: C2RelayFailureCode, message: &str) -> C2RelayFailure {
685 C2RelayFailure { code, message: message.to_owned(), current_incarnation_id: None }
686}
687
688fn negotiate(hello: &C2ClientHello) -> Result<NegotiatedC2ControlCompatibility, FrameError> {
689 let support = C2ControlCompatibilitySupport {
690 build_stamp: BUILD_STAMP.to_owned(),
691 capabilities: vec![
692 CapabilityId::new(C2_COMPATIBILITY_METADATA_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
693 CapabilityId::new(C2_OPAQUE_UNIX_PATH_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
694 CapabilityId::new(C2_REPOSITORY_PATH_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
695 CapabilityId::new(C2_WORKSPACE_FILE_READ_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
696 CapabilityId::new(C2_WORKSPACE_FILE_WRITE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
697 CapabilityId::new(C2_WORKSPACE_ENTRY_CREATE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
698 CapabilityId::new(C2_GIT_READ_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
699 CapabilityId::new(C2_PROVIDER_CONTRACT_MANIFEST_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
700 CapabilityId::new(C2_PROVIDER_RUNTIME_STATUS_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
701 CapabilityId::new(C2_PROVIDER_ID_OPEN_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
702 CapabilityId::new(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
703 CapabilityId::new(C2_SPAWN_PROFILE_REVISION_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
704 CapabilityId::new(C2_TERMINAL_FRAME_EVENTS_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
705 CapabilityId::new(C2_AGENT_STREAM_EVENTS_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
706 CapabilityId::new(C2_WORKTREE_SELECTION_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
707 CapabilityId::new(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
708 CapabilityId::new(C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
709 CapabilityId::new(C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
710 CapabilityId::new(C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
711 CapabilityId::new(C2_HISTORY_CONTEXT_PACK_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
712 CapabilityId::new(C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
713 CapabilityId::new(C2_NATIVE_SESSION_CATALOG_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
714 CapabilityId::new(C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
715 CapabilityId::new(C2_NATIVE_SESSION_INDEX_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
716 CapabilityId::new(C2_NATIVE_SESSION_PREVIEW_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
717 CapabilityId::new(C2_HOST_DIRECTORY_BROWSE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
718 CapabilityId::new(C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
719 CapabilityId::new(C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
720 CapabilityId::new(C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
721 CapabilityId::new(C2_SESSION_TASK_CORRELATION_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
722 CapabilityId::new(C2_CONTROL_DETAIL_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
723 CapabilityId::new(C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
724 CapabilityId::new(C2_HARNESS_MCP_READ_PROXY_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
725 CapabilityId::new(C2_ACP_CONTROL_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
726 ],
727 host: HostDescriptor {
728 operating_system: OperatingSystemId::new(std::env::consts::OS)
729 .map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
730 architecture: ArchitectureId::new(std::env::consts::ARCH)
731 .map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
732 },
733 path_semantics: PathSemantics {
734 style: if cfg!(windows) { PathStyle::Windows } else { PathStyle::Posix },
735 encoding: if cfg!(windows) { PathEncoding::Utf8 } else { PathEncoding::UnixBytes },
736 },
737 };
738 support.negotiate(hello).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))
739}
740
741fn c2_proof(
742 token: &str,
743 direction: C2AuthDirection,
744 client_nonce: &[u8; 32],
745 server_nonce: &[u8; 32],
746 compatibility: Option<(&ClientCompatibilityOffer, &NegotiatedC2ControlCompatibility)>,
747) -> Result<[u8; 32], FrameError> {
748 let transcript = match compatibility {
749 Some((offer, selected)) => c2_bound_auth_transcript(direction, client_nonce, server_nonce, offer, selected)
750 .map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
751 None => c2_auth_transcript(direction, client_nonce, server_nonce),
752 };
753 local_hmac_sha256(token.as_bytes(), &transcript).map_err(|e| FrameError::Io(io::Error::other(e)))
754}
755
756async fn read_client_frame<S: tokio::io::AsyncRead + Unpin>(
757 stream: &mut S,
758 max: usize,
759) -> Result<C2ClientFrame, FrameError> {
760 read_json_frame_limited_body_timeout(stream, max, AUTH_DEADLINE).await
761}
762
763#[cfg(test)]
764mod tests {
765 use super::*;
766 use hatchery_c2_client::connect_local;
767 use std::time::Duration;
768
769 #[tokio::test]
770 async fn clients_connect_to_core_operator_not_c2() {
771 let root = tempfile::tempdir().unwrap();
772 let sockdir = root.path().join("sock");
773 #[cfg(unix)]
774 {
775 use std::os::unix::fs::DirBuilderExt;
776 std::fs::DirBuilder::new().mode(0o700).create(&sockdir).unwrap();
777 }
778 #[cfg(not(unix))]
779 std::fs::create_dir_all(&sockdir).unwrap();
780 let endpoint = sockdir.join("core.sock");
781 let mesh = NodeOperatorMesh::new();
782 let (events_tx, _events_rx) = mpsc::unbounded_channel();
783 let (link, _outbound) = OperatorLink::new(Some(events_tx));
784 mesh.register("unix-local", link);
788
789 let (stop, rx) = watch::channel(false);
790 let endpoint_s = endpoint.to_string_lossy().into_owned();
791 let mesh2 = mesh.clone();
792 tokio::spawn(async move { serve_local(endpoint_s, "core-op-token-0123456789", mesh2, rx).await });
793 mesh.register("unix-local", {
795 let (events_tx, _) = mpsc::unbounded_channel();
796 OperatorLink::new(Some(events_tx)).0
797 });
798 tokio::time::sleep(Duration::from_millis(80)).await;
799
800 let (control, _ev) = tokio::time::timeout(
801 Duration::from_secs(5),
802 connect_local(endpoint.to_str().unwrap(), "core-op-token-0123456789"),
803 )
804 .await
805 .expect("timeout")
806 .expect("connect");
807 assert_eq!(control.hello().build_stamp, BUILD_STAMP);
808 let hello_nodes: Vec<_> = control.hello().status.nodes.keys().map(|k| k.as_str().to_owned()).collect();
809 assert!(hello_nodes.iter().any(|n| n == "unix-local"), "hello status nodes={hello_nodes:?}");
810 let topo = control.current_topology();
811 assert!(!topo.nodes.is_empty() || !hello_nodes.is_empty(), "topology={:?} hello={hello_nodes:?}", topo.nodes);
812 let _ = stop.send(true);
813 }
814}