Skip to main content

hatchery_server/
operator_control.rs

1//! Core operator control (A2): same client wire as the former C2 control socket,
2//! but requests go through [`crate::node_controller::OperatorLink`] over the drain.
3//! C2 never sees this socket and never parses g4a.
4
5use 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_c2_protocol::{
11    c2_auth_transcript, c2_bound_auth_transcript, ArchitectureId, BUILD_STAMP, C2AuthDirection,
12    C2ClientAuthentication, C2ClientFrame, C2ClientHello, C2ControlCompatibilitySupport, C2Hello,
13    C2RelayFailure, C2RelayFailureCode, C2ReplyEnvelope, C2RequestEnvelope, C2ServerChallenge,
14    C2ServerFrame, C2Topology, CapabilityId, ClientCompatibilityOffer, HostDescriptor,
15    NegotiatedC2ControlCompatibility, NodeFreshness, NodeId, NodeTransportState, ObservedNode,
16    OperatingSystemId, PathEncoding, PathSemantics, PathStyle, RoutedNodeRequest, RoutedNodeResponse,
17    StatusResponse, C2_ACP_CONTROL_CAPABILITY, C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
18    C2_AGENT_STREAM_EVENTS_CAPABILITY, C2_API_VERSION, C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
19    C2_COMPATIBILITY_METADATA_CAPABILITY, C2_CONTROL_DETAIL_CAPABILITY,
20    C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY, C2_GIT_READ_CAPABILITY,
21    C2_HARNESS_MCP_READ_PROXY_CAPABILITY, C2_HISTORY_CONTEXT_PACK_CAPABILITY,
22    C2_HOST_DIRECTORY_BROWSE_CAPABILITY, C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
23    C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY, C2_NATIVE_SESSION_CATALOG_CAPABILITY,
24    C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY, C2_NATIVE_SESSION_INDEX_CAPABILITY,
25    C2_NATIVE_SESSION_PREVIEW_CAPABILITY, C2_OPAQUE_UNIX_PATH_CAPABILITY,
26    C2_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, C2_PROVIDER_ID_OPEN_CAPABILITY,
27    C2_PROVIDER_RUNTIME_STATUS_CAPABILITY, C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
28    C2_REPOSITORY_PATH_CAPABILITY, C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
29    C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY, C2_SESSION_TASK_CORRELATION_CAPABILITY,
30    C2_SPAWN_PROFILE_REVISION_CAPABILITY, C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
31    C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY, C2_TERMINAL_FRAME_EVENTS_CAPABILITY,
32    C2_WORKSPACE_ENTRY_CREATE_CAPABILITY, C2_WORKSPACE_FILE_READ_CAPABILITY,
33    C2_WORKSPACE_FILE_WRITE_CAPABILITY, C2_WORKTREE_SELECTION_CAPABILITY,
34    MAX_C2_AUTH_FRAME_BYTES, MAX_C2_CLIENT_FRAME_BYTES, MAX_C2_HELLO_FRAME_BYTES,
35    MAX_C2_SERVER_FRAME_BYTES,
36};
37use std::collections::BTreeMap;
38use std::io;
39use std::sync::atomic::{AtomicU64, Ordering};
40use std::sync::{Arc, Mutex};
41use std::time::Duration;
42use tokio::sync::{watch, Semaphore};
43#[cfg(test)]
44use tokio::sync::mpsc;
45use tokio::task::JoinSet;
46use tokio::time::timeout;
47
48const AUTH_DEADLINE: Duration = Duration::from_secs(5);
49const REQUEST_DEADLINE: Duration = Duration::from_secs(60);
50
51/// Live node control sessions on core (one OperatorLink per node).
52#[derive(Clone)]
53pub struct NodeOperatorMesh {
54    inner: Arc<Mutex<BTreeMap<String, OperatorLink>>>,
55    cursors: Arc<Mutex<BTreeMap<String, hatchery_c2_protocol::NodeCursor>>>,
56    topology: watch::Sender<Arc<StatusResponse>>,
57}
58
59impl NodeOperatorMesh {
60    pub fn new() -> Self {
61        let initial = Arc::new(StatusResponse {
62            api_version: C2_API_VERSION,
63            ready: true,
64            observed_at_unix_ms: unix_ms(),
65            nodes: BTreeMap::new(),
66        });
67        let (topology, _) = watch::channel(initial);
68        Self {
69            inner: Arc::new(Mutex::new(BTreeMap::new())),
70            cursors: Arc::new(Mutex::new(BTreeMap::new())),
71            topology,
72        }
73    }
74
75    pub fn subscribe(&self) -> watch::Receiver<Arc<StatusResponse>> {
76        self.topology.subscribe()
77    }
78
79    pub fn register(&self, node_id: impl Into<String>, link: OperatorLink) {
80        let id = node_id.into();
81        let _ = NodeId::new(id.clone()).expect("operator mesh node id");
82        self.inner.lock().expect("mesh").insert(id.clone(), link);
83        self.publish_topology();
84        tracing::info!(%id, "core operator mesh: node registered");
85    }
86
87    pub fn unregister(&self, node_id: &str) {
88        self.inner.lock().expect("mesh").remove(node_id);
89        self.cursors.lock().expect("cursors").remove(node_id);
90        self.publish_topology();
91    }
92
93    pub fn get(&self, node_id: &str) -> Option<OperatorLink> {
94        self.inner.lock().expect("mesh").get(node_id).cloned()
95    }
96
97    /// Publish the node's incarnation (from Snapshot / bridge hello) into operator topology.
98    pub fn set_cursor(&self, node_id: &str, cursor: hatchery_c2_protocol::NodeCursor) {
99        self.cursors.lock().expect("cursors").insert(node_id.to_owned(), cursor);
100        self.publish_topology();
101    }
102
103    fn publish_topology(&self) {
104        let ids: Vec<String> = self.inner.lock().expect("mesh").keys().cloned().collect();
105        let cursors = self.cursors.lock().expect("cursors").clone();
106        let mut nodes = BTreeMap::new();
107        for id in &ids {
108            let node_id = NodeId::new(id.clone()).expect("registered ids are valid");
109            nodes.insert(
110                node_id,
111                ObservedNode {
112                    endpoint: "drain".into(),
113                    transport_label: "drain".into(),
114                    transport: NodeTransportState::Online,
115                    freshness: NodeFreshness::Fresh,
116                    cursor: cursors.get(id).cloned(),
117                    inventory: None,
118                    last_attempt_unix_ms: None,
119                    last_success_unix_ms: Some(unix_ms()),
120                    consecutive_failures: 0,
121                    last_error: None,
122                    gaps: Vec::new(),
123                    gaps_truncated: 0,
124                },
125            );
126        }
127        let status = Arc::new(StatusResponse {
128            api_version: C2_API_VERSION,
129            ready: true,
130            observed_at_unix_ms: unix_ms(),
131            nodes,
132        });
133        self.topology.send_replace(status);
134    }
135}
136
137fn unix_ms() -> u64 {
138    std::time::SystemTime::now()
139        .duration_since(std::time::UNIX_EPOCH)
140        .unwrap_or_default()
141        .as_millis() as u64
142}
143
144/// Serve the operator control wire on an owner-only local endpoint (core, not C2).
145pub async fn serve_local(
146    endpoint: impl Into<String>,
147    token: impl Into<String>,
148    mesh: NodeOperatorMesh,
149    mut shutdown: watch::Receiver<bool>,
150) -> io::Result<()> {
151    let endpoint = endpoint.into();
152    let token = token.into();
153    let mut listener = OwnerOnlyLocalListener::bind(&endpoint).await?;
154    tracing::info!(%endpoint, "core operator control listening");
155    let preauth = Arc::new(Semaphore::new(4));
156    let authenticated = Arc::new(Semaphore::new(1));
157    let next_id = Arc::new(AtomicU64::new(1));
158    let mut connections = JoinSet::new();
159    loop {
160        let permit = tokio::select! {
161            p = Arc::clone(&preauth).acquire_owned() => p.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "preauth closed"))?,
162            changed = shutdown.changed() => {
163                if changed.is_err() || *shutdown.borrow() { break; }
164                continue;
165            }
166        };
167        let stream = tokio::select! {
168            a = listener.accept() => a?,
169            changed = shutdown.changed() => {
170                if changed.is_err() || *shutdown.borrow() { break; }
171                continue;
172            }
173        };
174        let token = token.clone();
175        let mesh = mesh.clone();
176        let authenticated = Arc::clone(&authenticated);
177        let next_id = Arc::clone(&next_id);
178        let sd = shutdown.clone();
179        connections.spawn(async move {
180            let _ = serve_connection(stream, permit, authenticated, next_id, &token, mesh, sd).await;
181        });
182        while connections.try_join_next().is_some() {}
183    }
184    connections.shutdown().await;
185    Ok(())
186}
187
188async fn serve_connection<S>(
189    mut pipe: S,
190    preauth: tokio::sync::OwnedSemaphorePermit,
191    authenticated: Arc<Semaphore>,
192    connection_ids: Arc<AtomicU64>,
193    token: &str,
194    mesh: NodeOperatorMesh,
195    mut shutdown: watch::Receiver<bool>,
196) -> Result<(), FrameError>
197where
198    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
199{
200    let hello = timeout(AUTH_DEADLINE, read_client_frame(&mut pipe, MAX_C2_AUTH_FRAME_BYTES))
201        .await.map_err(|_| FrameError::PrefixTimedOut)??;
202    let C2ClientFrame::Hello(hello) = hello else { return Ok(()) };
203    if hello.build_stamp != BUILD_STAMP {
204        let _ = timeout(AUTH_DEADLINE, write_json_frame_limited(
205            &mut pipe,
206            &C2ServerFrame::Rejected(fail(C2RelayFailureCode::BuildStampMismatch, "build stamp mismatch")),
207            MAX_C2_AUTH_FRAME_BYTES,
208        )).await;
209        return Ok(());
210    }
211    let negotiated = negotiate(&hello)?;
212    let compatibility = hello.compatibility.as_ref().map(|_| negotiated.clone());
213    let server_nonce = random_nonce().map_err(|e| FrameError::Io(io::Error::other(e)))?;
214    let auth_compat = hello.compatibility.as_ref().zip(compatibility.as_ref());
215    let server_proof = c2_proof(token, C2AuthDirection::Server, &hello.client_nonce, &server_nonce, auth_compat)?;
216    timeout(AUTH_DEADLINE, write_json_frame_limited(
217        &mut pipe,
218        &C2ServerFrame::Challenge(C2ServerChallenge {
219            build_stamp: BUILD_STAMP.to_owned(),
220            server_nonce,
221            server_proof,
222            compatibility: compatibility.clone(),
223        }),
224        MAX_C2_AUTH_FRAME_BYTES,
225    )).await.map_err(|_| FrameError::BodyTimedOut { length: 0 })??;
226
227    let authentication = timeout(AUTH_DEADLINE, read_client_frame(&mut pipe, MAX_C2_AUTH_FRAME_BYTES))
228        .await.map_err(|_| FrameError::PrefixTimedOut)??;
229    let C2ClientFrame::Authenticate(C2ClientAuthentication { client_proof }) = authentication else {
230        return Ok(());
231    };
232    let expected = c2_proof(token, C2AuthDirection::Client, &hello.client_nonce, &server_nonce, auth_compat)?;
233    if !proofs_match(&client_proof, &expected) {
234        return Ok(());
235    }
236    drop(preauth);
237
238    let Ok(_op) = authenticated.try_acquire_owned() else {
239        let _ = write_json_frame_limited(
240            &mut pipe,
241            &C2ServerFrame::Rejected(fail(C2RelayFailureCode::OperatorAlreadyConnected, "operator already connected")),
242            MAX_C2_SERVER_FRAME_BYTES,
243        ).await;
244        return Ok(());
245    };
246
247    let connection_id = connection_ids.fetch_add(1, Ordering::AcqRel);
248    let mut status_rx = mesh.subscribe();
249    let hello_status = status_rx.borrow().as_ref().clone();
250    timeout(AUTH_DEADLINE, write_json_frame_limited(
251        &mut pipe,
252        &C2ServerFrame::Hello(C2Hello {
253            build_stamp: BUILD_STAMP.to_owned(),
254            connection_id,
255            status: hello_status,
256            compatibility,
257        }),
258        MAX_C2_HELLO_FRAME_BYTES,
259    )).await.map_err(|_| FrameError::BodyTimedOut { length: 0 })??;
260
261    let (mut reader, mut writer) = tokio::io::split(pipe);
262    let mut last_topology = C2Topology::from_status(status_rx.borrow().as_ref());
263    tracing::info!(connection_id, "core operator control opened");
264
265    loop {
266        tokio::select! {
267            _ = shutdown.changed() => {
268                if *shutdown.borrow() { break; }
269            }
270            changed = status_rx.changed() => {
271                if changed.is_err() { break; }
272                let status = status_rx.borrow().as_ref().clone();
273                let topology = C2Topology::from_status(&status);
274                if topology != last_topology {
275                    last_topology = topology.clone();
276                    if write_json_frame_limited(&mut writer, &C2ServerFrame::Topology(topology), MAX_C2_SERVER_FRAME_BYTES).await.is_err() {
277                        break;
278                    }
279                }
280            }
281            frame = read_client_frame(&mut reader, MAX_C2_CLIENT_FRAME_BYTES) => {
282                match frame {
283                    Ok(C2ClientFrame::Request(envelope)) => {
284                        let reply = dispatch_request(&mesh, envelope).await;
285                        if write_json_frame_limited(&mut writer, &C2ServerFrame::Reply(reply), MAX_C2_SERVER_FRAME_BYTES).await.is_err() {
286                            break;
287                        }
288                    }
289                    Ok(_) | Err(_) => break,
290                }
291            }
292        }
293    }
294    Ok(())
295}
296
297async fn dispatch_request(mesh: &NodeOperatorMesh, envelope: C2RequestEnvelope) -> C2ReplyEnvelope {
298    let node_id = envelope.request.route.node_id.as_str().to_owned();
299    let Some(link) = mesh.get(&node_id) else {
300        return C2ReplyEnvelope {
301            request_id: envelope.request_id,
302            result: Err(fail(C2RelayFailureCode::NodeOffline, "node not on drain")),
303        };
304    };
305    let routed = RoutedNodeRequest {
306        route: envelope.request.route.clone(),
307        request: envelope.request.request.clone(),
308    };
309    match link.request(&routed, REQUEST_DEADLINE).await {
310        Ok(G4aMessage::Response(response)) => C2ReplyEnvelope {
311            request_id: envelope.request_id,
312            result: Ok(response),
313        },
314        Ok(G4aMessage::Json(v)) => {
315            // Forward-compatible: try as RoutedNodeResponse
316            match serde_json::from_value::<RoutedNodeResponse>(v) {
317                Ok(response) => C2ReplyEnvelope { request_id: envelope.request_id, result: Ok(response) },
318                Err(_) => C2ReplyEnvelope {
319                    request_id: envelope.request_id,
320                    result: Err(fail(C2RelayFailureCode::RequestForbidden, "node reply was not a g4a response")),
321                },
322            }
323        }
324        Ok(_) => C2ReplyEnvelope {
325            request_id: envelope.request_id,
326            result: Err(fail(C2RelayFailureCode::RequestForbidden, "unexpected g4a reply kind")),
327        },
328        Err(_) => C2ReplyEnvelope {
329            request_id: envelope.request_id,
330            result: Err(fail(C2RelayFailureCode::RelayBusy, "node did not answer")),
331        },
332    }
333}
334
335fn fail(code: C2RelayFailureCode, message: &str) -> C2RelayFailure {
336    C2RelayFailure { code, message: message.to_owned(), current_incarnation_id: None }
337}
338
339fn negotiate(hello: &C2ClientHello) -> Result<NegotiatedC2ControlCompatibility, FrameError> {
340    let support = C2ControlCompatibilitySupport {
341        build_stamp: BUILD_STAMP.to_owned(),
342        capabilities: vec![
343            CapabilityId::new(C2_COMPATIBILITY_METADATA_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
344            CapabilityId::new(C2_OPAQUE_UNIX_PATH_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
345            CapabilityId::new(C2_REPOSITORY_PATH_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
346            CapabilityId::new(C2_WORKSPACE_FILE_READ_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
347            CapabilityId::new(C2_WORKSPACE_FILE_WRITE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
348            CapabilityId::new(C2_WORKSPACE_ENTRY_CREATE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
349            CapabilityId::new(C2_GIT_READ_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
350            CapabilityId::new(C2_PROVIDER_CONTRACT_MANIFEST_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
351            CapabilityId::new(C2_PROVIDER_RUNTIME_STATUS_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
352            CapabilityId::new(C2_PROVIDER_ID_OPEN_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
353            CapabilityId::new(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
354            CapabilityId::new(C2_SPAWN_PROFILE_REVISION_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
355            CapabilityId::new(C2_TERMINAL_FRAME_EVENTS_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
356            CapabilityId::new(C2_AGENT_STREAM_EVENTS_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
357            CapabilityId::new(C2_WORKTREE_SELECTION_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
358            CapabilityId::new(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
359            CapabilityId::new(C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
360            CapabilityId::new(C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
361            CapabilityId::new(C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
362            CapabilityId::new(C2_HISTORY_CONTEXT_PACK_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
363            CapabilityId::new(C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
364            CapabilityId::new(C2_NATIVE_SESSION_CATALOG_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
365            CapabilityId::new(C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
366            CapabilityId::new(C2_NATIVE_SESSION_INDEX_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
367            CapabilityId::new(C2_NATIVE_SESSION_PREVIEW_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
368            CapabilityId::new(C2_HOST_DIRECTORY_BROWSE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
369            CapabilityId::new(C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
370            CapabilityId::new(C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
371            CapabilityId::new(C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
372            CapabilityId::new(C2_SESSION_TASK_CORRELATION_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
373            CapabilityId::new(C2_CONTROL_DETAIL_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
374            CapabilityId::new(C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
375            CapabilityId::new(C2_HARNESS_MCP_READ_PROXY_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
376            CapabilityId::new(C2_ACP_CONTROL_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
377        ],
378        host: HostDescriptor {
379            operating_system: OperatingSystemId::new(std::env::consts::OS)
380                .map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
381            architecture: ArchitectureId::new(std::env::consts::ARCH)
382                .map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
383        },
384        path_semantics: PathSemantics {
385            style: if cfg!(windows) { PathStyle::Windows } else { PathStyle::Posix },
386            encoding: if cfg!(windows) { PathEncoding::Utf8 } else { PathEncoding::UnixBytes },
387        },
388    };
389    support.negotiate(hello).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))
390}
391
392fn c2_proof(
393    token: &str,
394    direction: C2AuthDirection,
395    client_nonce: &[u8; 32],
396    server_nonce: &[u8; 32],
397    compatibility: Option<(&ClientCompatibilityOffer, &NegotiatedC2ControlCompatibility)>,
398) -> Result<[u8; 32], FrameError> {
399    let transcript = match compatibility {
400        Some((offer, selected)) => c2_bound_auth_transcript(direction, client_nonce, server_nonce, offer, selected)
401            .map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
402        None => c2_auth_transcript(direction, client_nonce, server_nonce),
403    };
404    local_hmac_sha256(token.as_bytes(), &transcript).map_err(|e| FrameError::Io(io::Error::other(e)))
405}
406
407async fn read_client_frame<S: tokio::io::AsyncRead + Unpin>(
408    stream: &mut S,
409    max: usize,
410) -> Result<C2ClientFrame, FrameError> {
411    read_json_frame_limited_body_timeout(stream, max, AUTH_DEADLINE).await
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use hatchery_c2_client::connect_local;
418        use std::time::Duration;
419
420    #[tokio::test]
421    async fn clients_connect_to_core_operator_not_c2() {
422        let root = tempfile::tempdir().unwrap();
423        let sockdir = root.path().join("sock");
424        #[cfg(unix)]
425        {
426            use std::os::unix::fs::DirBuilderExt;
427            std::fs::DirBuilder::new().mode(0o700).create(&sockdir).unwrap();
428        }
429        #[cfg(not(unix))]
430        std::fs::create_dir_all(&sockdir).unwrap();
431        let endpoint = sockdir.join("core.sock");
432        let mesh = NodeOperatorMesh::new();
433        let (events_tx, _events_rx) = mpsc::unbounded_channel();
434        let (link, _outbound) = OperatorLink::new(Some(events_tx));
435        // Fake node that answers inventory-ish JSON as a RoutedNodeResponse shape via G4aMessage::Json
436        // Register link; dispatch will wait — spawn a responder on a drain-like loop using the outbound isn't
437        // wired here. Instead register nothing and expect NodeOffline.
438        mesh.register("unix-local", link);
439
440        let (stop, rx) = watch::channel(false);
441        let endpoint_s = endpoint.to_string_lossy().into_owned();
442        let mesh2 = mesh.clone();
443        tokio::spawn(async move { serve_local(endpoint_s, "core-op-token-0123456789", mesh2, rx).await });
444        // Ensure topology is published after the server subscribed.
445        mesh.register("unix-local", {
446            let (events_tx, _) = mpsc::unbounded_channel();
447            OperatorLink::new(Some(events_tx)).0
448        });
449        tokio::time::sleep(Duration::from_millis(80)).await;
450
451        let (control, _ev) = tokio::time::timeout(
452            Duration::from_secs(5),
453            connect_local(endpoint.to_str().unwrap(), "core-op-token-0123456789"),
454        )
455        .await
456        .expect("timeout")
457        .expect("connect");
458        assert_eq!(control.hello().build_stamp, BUILD_STAMP);
459        let hello_nodes: Vec<_> = control.hello().status.nodes.keys().map(|k| k.as_str().to_owned()).collect();
460        assert!(hello_nodes.iter().any(|n| n == "unix-local"), "hello status nodes={hello_nodes:?}");
461        let topo = control.current_topology();
462        assert!(!topo.nodes.is_empty() || !hello_nodes.is_empty(), "topology={:?} hello={hello_nodes:?}", topo.nodes);
463        let _ = stop.send(true);
464    }
465}