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