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_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/// Live node control sessions on core (one OperatorLink per node).
53#[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    /// Fan-out of node-initiated g4a events (PTY frames, etc.) to every operator client.
60    events: broadcast::Sender<hatchery_core::RoutedNodeEvent>,
61    /// Fan-out of dig2browser frames / lifecycle events.
62    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(256);
75        let (browser, _) = broadcast::channel(64);
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    /// Publish the node's incarnation (from Snapshot / bridge hello) into operator topology.
110    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    /// Publish a node-initiated event to every connected operator client.
121    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        // Keep topology cursor fresh from live traffic.
128        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
193/// Serve the operator control wire on an owner-only local endpoint (core, not C2).
194pub 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(4));
205    let authenticated = Arc::new(Semaphore::new(8));
206    let next_id = Arc::new(AtomicU64::new(1));
207    let mut connections = JoinSet::new();
208    loop {
209        let permit = tokio::select! {
210            p = Arc::clone(&preauth).acquire_owned() => p.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "preauth closed"))?,
211            changed = shutdown.changed() => {
212                if changed.is_err() || *shutdown.borrow() { break; }
213                continue;
214            }
215        };
216        let stream = tokio::select! {
217            a = listener.accept() => a?,
218            changed = shutdown.changed() => {
219                if changed.is_err() || *shutdown.borrow() { break; }
220                continue;
221            }
222        };
223        let token = token.clone();
224        let mesh = mesh.clone();
225        let authenticated = Arc::clone(&authenticated);
226        let next_id = Arc::clone(&next_id);
227        let sd = shutdown.clone();
228        connections.spawn(async move {
229            let _ = serve_connection(stream, permit, authenticated, next_id, &token, mesh, sd).await;
230        });
231        while connections.try_join_next().is_some() {}
232    }
233    connections.shutdown().await;
234    Ok(())
235}
236
237async fn serve_connection<S>(
238    mut pipe: S,
239    preauth: tokio::sync::OwnedSemaphorePermit,
240    authenticated: Arc<Semaphore>,
241    connection_ids: Arc<AtomicU64>,
242    token: &str,
243    mesh: NodeOperatorMesh,
244    mut shutdown: watch::Receiver<bool>,
245) -> Result<(), FrameError>
246where
247    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
248{
249    let hello = timeout(AUTH_DEADLINE, read_client_frame(&mut pipe, MAX_C2_AUTH_FRAME_BYTES))
250        .await.map_err(|_| FrameError::PrefixTimedOut)??;
251    let C2ClientFrame::Hello(hello) = hello else { return Ok(()) };
252    if hello.build_stamp != BUILD_STAMP {
253        let _ = timeout(AUTH_DEADLINE, write_json_frame_limited(
254            &mut pipe,
255            &C2ServerFrame::Rejected(fail(C2RelayFailureCode::BuildStampMismatch, "build stamp mismatch")),
256            MAX_C2_AUTH_FRAME_BYTES,
257        )).await;
258        return Ok(());
259    }
260    let negotiated = negotiate(&hello)?;
261    let compatibility = hello.compatibility.as_ref().map(|_| negotiated.clone());
262    let server_nonce = random_nonce().map_err(|e| FrameError::Io(io::Error::other(e)))?;
263    let auth_compat = hello.compatibility.as_ref().zip(compatibility.as_ref());
264    let server_proof = c2_proof(token, C2AuthDirection::Server, &hello.client_nonce, &server_nonce, auth_compat)?;
265    timeout(AUTH_DEADLINE, write_json_frame_limited(
266        &mut pipe,
267        &C2ServerFrame::Challenge(C2ServerChallenge {
268            build_stamp: BUILD_STAMP.to_owned(),
269            server_nonce,
270            server_proof,
271            compatibility: compatibility.clone(),
272        }),
273        MAX_C2_AUTH_FRAME_BYTES,
274    )).await.map_err(|_| FrameError::BodyTimedOut { length: 0 })??;
275
276    let authentication = timeout(AUTH_DEADLINE, read_client_frame(&mut pipe, MAX_C2_AUTH_FRAME_BYTES))
277        .await.map_err(|_| FrameError::PrefixTimedOut)??;
278    let C2ClientFrame::Authenticate(C2ClientAuthentication { client_proof }) = authentication else {
279        return Ok(());
280    };
281    let expected = c2_proof(token, C2AuthDirection::Client, &hello.client_nonce, &server_nonce, auth_compat)?;
282    if !proofs_match(&client_proof, &expected) {
283        return Ok(());
284    }
285    drop(preauth);
286
287    let Ok(_op) = authenticated.try_acquire_owned() else {
288        let _ = write_json_frame_limited(
289            &mut pipe,
290            &C2ServerFrame::Rejected(fail(C2RelayFailureCode::OperatorAlreadyConnected, "operator already connected")),
291            MAX_C2_SERVER_FRAME_BYTES,
292        ).await;
293        return Ok(());
294    };
295
296    let connection_id = connection_ids.fetch_add(1, Ordering::AcqRel);
297    let mut status_rx = mesh.subscribe();
298    let mut events_rx = mesh.subscribe_events();
299    let mut browser_rx = mesh.subscribe_browser();
300    let hello_status = status_rx.borrow().as_ref().clone();
301    timeout(AUTH_DEADLINE, write_json_frame_limited(
302        &mut pipe,
303        &C2ServerFrame::Hello(C2Hello {
304            build_stamp: BUILD_STAMP.to_owned(),
305            connection_id,
306            status: hello_status,
307            compatibility,
308        }),
309        MAX_C2_HELLO_FRAME_BYTES,
310    )).await.map_err(|_| FrameError::BodyTimedOut { length: 0 })??;
311
312    let (mut reader, mut writer) = tokio::io::split(pipe);
313    // read_client_frame is not cancellation-safe: inside select! a busy event/browser
314    // branch could drop a half-read frame, desync the stream and close the operator
315    // connection. Read frames on their own task and hand complete ones over.
316    let (frame_tx, mut frame_rx) = tokio::sync::mpsc::channel(64);
317    // Requests and browser ops run on their own tasks so a slow node RPC never
318    // stalls frame/event delivery on this connection; replies come back here.
319    let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::<C2ServerFrame>();
320    let reader_task = tokio::spawn(async move {
321        loop {
322            let frame = read_client_frame(&mut reader, MAX_C2_CLIENT_FRAME_BYTES).await;
323            let stop = frame.is_err();
324            if frame_tx.send(frame).await.is_err() || stop {
325                break;
326            }
327        }
328    });
329    let mut last_topology = C2Topology::from_status(status_rx.borrow().as_ref());
330    tracing::info!(connection_id, "core operator control opened");
331
332    loop {
333        tokio::select! {
334            _ = shutdown.changed() => {
335                if *shutdown.borrow() { break; }
336            }
337            changed = status_rx.changed() => {
338                if changed.is_err() { break; }
339                let status = status_rx.borrow().as_ref().clone();
340                let topology = C2Topology::from_status(&status);
341                if topology != last_topology {
342                    last_topology = topology.clone();
343                    if write_json_frame_limited(&mut writer, &C2ServerFrame::Topology(topology), MAX_C2_SERVER_FRAME_BYTES).await.is_err() {
344                        break;
345                    }
346                }
347            }
348            event = events_rx.recv() => {
349                match event {
350                    Ok(routed) => {
351                        if write_json_frame_limited(
352                            &mut writer,
353                            &C2ServerFrame::Event(routed),
354                            MAX_C2_SERVER_FRAME_BYTES,
355                        ).await.is_err() {
356                            break;
357                        }
358                    }
359                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
360                    Err(broadcast::error::RecvError::Closed) => break,
361                }
362            }
363            browser = browser_rx.recv() => {
364                match browser {
365                    Ok(ev) => {
366                        if write_json_frame_limited(
367                            &mut writer,
368                            &C2ServerFrame::Browser(ev),
369                            MAX_C2_SERVER_FRAME_BYTES,
370                        ).await.is_err() {
371                            break;
372                        }
373                    }
374                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
375                    Err(broadcast::error::RecvError::Closed) => break,
376                }
377            }
378            out = out_rx.recv() => {
379                let Some(out) = out else { break };
380                if write_json_frame_limited(&mut writer, &out, MAX_C2_SERVER_FRAME_BYTES).await.is_err() {
381                    break;
382                }
383            }
384            frame = frame_rx.recv() => {
385                let Some(frame) = frame else { break };
386                match frame {
387                    Ok(C2ClientFrame::Request(envelope)) => {
388                        // Enqueue toward the node right here (keeps PTY byte order),
389                        // then wait for the reply off-loop (never blocks events).
390                        let wait = start_request(&mesh, envelope).await;
391                        let out = out_tx.clone();
392                        tokio::spawn(async move {
393                            let _ = out.send(C2ServerFrame::Reply(wait.await));
394                        });
395                    }
396                    Ok(C2ClientFrame::Browser(cmd)) => {
397                        // Browser ops are acked via Browser stream events (Error on failure).
398                        let wait = start_browser(&mesh, cmd).await;
399                        let out = out_tx.clone();
400                        tokio::spawn(async move {
401                            if let Some(ev) = wait.await {
402                                let _ = out.send(C2ServerFrame::Browser(ev));
403                            }
404                        });
405                    }
406                    Ok(_) | Err(_) => break,
407                }
408            }
409        }
410    }
411    reader_task.abort();
412    Ok(())
413}
414
415
416/// Ordered enqueue of a browser op; the future waits for the node's reply.
417async fn start_browser(
418    mesh: &NodeOperatorMesh,
419    cmd: BrowserClientCommand,
420) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<BrowserStreamEvent>> + Send>> {
421    let session = match &cmd.op {
422        hatchery_core::BrowserClientOp::Start { session, .. }
423        | hatchery_core::BrowserClientOp::Input { session, .. }
424        | hatchery_core::BrowserClientOp::Stop { session } => session.clone(),
425    };
426    let link = { let guard = mesh.inner.lock().expect("mesh"); guard.get(&cmd.node_id).cloned() };
427    let node_id = cmd.node_id.clone();
428    let Some(link) = link else {
429        return Box::pin(async move { Some(BrowserStreamEvent::Error { node_id, session, message: "node is not registered on core mesh".into() }) });
430    };
431    let sent = link.browser_op_pipelined(&cmd.op, Duration::from_secs(60)).await;
432    Box::pin(async move {
433        match sent {
434            Err(e) => Some(BrowserStreamEvent::Error { node_id, session, message: format!("{e:?}") }),
435            Ok(wait) => match wait.await {
436                Ok(reply) if reply.ok => None,
437                Ok(reply) => Some(BrowserStreamEvent::Error { node_id, session: reply.session, message: reply.message }),
438                Err(e) => Some(BrowserStreamEvent::Error { node_id, session, message: format!("{e:?}") }),
439            },
440        }
441    })
442}
443
444#[allow(dead_code)]
445async fn dispatch_browser(
446    mesh: &NodeOperatorMesh,
447    cmd: BrowserClientCommand,
448) -> Option<BrowserStreamEvent> {
449    let link = {
450        let guard = mesh.inner.lock().expect("mesh");
451        guard.get(&cmd.node_id).cloned()
452    };
453    let Some(link) = link else {
454        return Some(BrowserStreamEvent::Error {
455            node_id: cmd.node_id,
456            session: match &cmd.op {
457                hatchery_core::BrowserClientOp::Start { session, .. }
458                | hatchery_core::BrowserClientOp::Input { session, .. }
459                | hatchery_core::BrowserClientOp::Stop { session } => session.clone(),
460            },
461            message: "node is not registered on core mesh".into(),
462        });
463    };
464    match link.browser_op(&cmd.op, Duration::from_secs(60)).await {
465        Ok(reply) if reply.ok => None,
466        Ok(reply) => Some(BrowserStreamEvent::Error {
467            node_id: cmd.node_id,
468            session: reply.session,
469            message: reply.message,
470        }),
471        Err(err) => Some(BrowserStreamEvent::Error {
472            node_id: cmd.node_id,
473            session: match &cmd.op {
474                hatchery_core::BrowserClientOp::Start { session, .. }
475                | hatchery_core::BrowserClientOp::Input { session, .. }
476                | hatchery_core::BrowserClientOp::Stop { session } => session.clone(),
477            },
478            message: err.to_string(),
479        }),
480    }
481}
482
483#[allow(dead_code)]
484async fn dispatch_request(mesh: &NodeOperatorMesh, envelope: C2RequestEnvelope) -> C2ReplyEnvelope {
485    let node_id = envelope.request.route.node_id.as_str().to_owned();
486    let Some(link) = mesh.get(&node_id) else {
487        return C2ReplyEnvelope {
488            request_id: envelope.request_id,
489            result: Err(fail(C2RelayFailureCode::NodeOffline, "node not on drain")),
490        };
491    };
492    let routed = RoutedNodeRequest {
493        route: envelope.request.route.clone(),
494        request: envelope.request.request.clone(),
495    };
496    let result = match link.request_pipelined(&routed, REQUEST_DEADLINE).await {
497        Ok(wait) => wait.await,
498        Err(e) => Err(e),
499    };
500    finish_request(mesh, &node_id, envelope.request_id, result)
501}
502
503/// Enqueue a request on the node link in arrival order; the returned future
504/// only waits for the reply (so PTY bytes are pipelined, never reordered).
505/// PTY latency probe: first TerminalBytes enqueued at core -> next terminal frame published.
506static PTY_PROBE: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
507
508async fn start_request(
509    mesh: &NodeOperatorMesh,
510    envelope: C2RequestEnvelope,
511) -> std::pin::Pin<Box<dyn std::future::Future<Output = C2ReplyEnvelope> + Send>> {
512    if matches!(envelope.request.request, gate4agent_node_protocol::NodeRequest::TerminalBytes { .. }) {
513        let mut g = PTY_PROBE.lock().unwrap_or_else(|p| p.into_inner());
514        if g.is_none() { *g = Some(std::time::Instant::now()); }
515        tracing::debug!(target: "pty_lat", "core: pty input enqueued toward node");
516    }
517    let node_id = envelope.request.route.node_id.as_str().to_owned();
518    let request_id = envelope.request_id;
519    let Some(link) = mesh.get(&node_id) else {
520        return Box::pin(async move { C2ReplyEnvelope {
521            request_id,
522            result: Err(fail(C2RelayFailureCode::NodeOffline, "node not on drain")),
523        } });
524    };
525    let routed = RoutedNodeRequest {
526        route: envelope.request.route.clone(),
527        request: envelope.request.request.clone(),
528    };
529    let sent = link.request_pipelined(&routed, REQUEST_DEADLINE).await;
530    let mesh = mesh.clone();
531    Box::pin(async move {
532        let result = match sent { Ok(wait) => wait.await, Err(e) => Err(e) };
533        finish_request(&mesh, &node_id, request_id, result)
534    })
535}
536
537fn finish_request(
538    mesh: &NodeOperatorMesh,
539    node_id: &str,
540    request_id: hatchery_core::C2RequestId,
541    result: Result<G4aMessage, crate::node_controller::OperatorError>,
542) -> C2ReplyEnvelope {
543    let node_id = node_id.to_owned();
544    struct E { request_id: hatchery_core::C2RequestId }
545    let envelope = E { request_id };
546    match result {
547        Ok(G4aMessage::Response(response)) => {
548            mesh.set_cursor(
549                &node_id,
550                hatchery_core::NodeCursor {
551                    incarnation_id: response.incarnation_id,
552                    sequence: 0,
553                },
554            );
555            if let Ok(hatchery_core::C2NodeResponse::Snapshot { snapshot, .. }) = &response.response {
556                if let Some(machine) = snapshot.machine.clone() {
557                    mesh.set_machine(&node_id, machine);
558                }
559            }
560            C2ReplyEnvelope {
561                request_id: envelope.request_id,
562                result: Ok(response),
563            }
564        }
565        Ok(G4aMessage::Json(v)) => {
566            // Forward-compatible: try as RoutedNodeResponse
567            match serde_json::from_value::<RoutedNodeResponse>(v) {
568                Ok(response) => {
569                    mesh.set_cursor(
570                        &node_id,
571                        hatchery_core::NodeCursor {
572                            incarnation_id: response.incarnation_id,
573                            sequence: 0,
574                        },
575                    );
576                    C2ReplyEnvelope { request_id: envelope.request_id, result: Ok(response) }
577                }
578                Err(_) => C2ReplyEnvelope {
579                    request_id: envelope.request_id,
580                    result: Err(fail(C2RelayFailureCode::RequestForbidden, "node reply was not a g4a response")),
581                },
582            }
583        }
584        Ok(_) => C2ReplyEnvelope {
585            request_id: envelope.request_id,
586            result: Err(fail(C2RelayFailureCode::RequestForbidden, "unexpected g4a reply kind")),
587        },
588        Err(_) => C2ReplyEnvelope {
589            request_id: envelope.request_id,
590            result: Err(fail(C2RelayFailureCode::RelayBusy, "node did not answer")),
591        },
592    }
593}
594
595fn fail(code: C2RelayFailureCode, message: &str) -> C2RelayFailure {
596    C2RelayFailure { code, message: message.to_owned(), current_incarnation_id: None }
597}
598
599fn negotiate(hello: &C2ClientHello) -> Result<NegotiatedC2ControlCompatibility, FrameError> {
600    let support = C2ControlCompatibilitySupport {
601        build_stamp: BUILD_STAMP.to_owned(),
602        capabilities: vec![
603            CapabilityId::new(C2_COMPATIBILITY_METADATA_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
604            CapabilityId::new(C2_OPAQUE_UNIX_PATH_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
605            CapabilityId::new(C2_REPOSITORY_PATH_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
606            CapabilityId::new(C2_WORKSPACE_FILE_READ_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
607            CapabilityId::new(C2_WORKSPACE_FILE_WRITE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
608            CapabilityId::new(C2_WORKSPACE_ENTRY_CREATE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
609            CapabilityId::new(C2_GIT_READ_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
610            CapabilityId::new(C2_PROVIDER_CONTRACT_MANIFEST_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
611            CapabilityId::new(C2_PROVIDER_RUNTIME_STATUS_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
612            CapabilityId::new(C2_PROVIDER_ID_OPEN_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
613            CapabilityId::new(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
614            CapabilityId::new(C2_SPAWN_PROFILE_REVISION_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
615            CapabilityId::new(C2_TERMINAL_FRAME_EVENTS_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
616            CapabilityId::new(C2_AGENT_STREAM_EVENTS_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
617            CapabilityId::new(C2_WORKTREE_SELECTION_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
618            CapabilityId::new(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
619            CapabilityId::new(C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
620            CapabilityId::new(C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
621            CapabilityId::new(C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
622            CapabilityId::new(C2_HISTORY_CONTEXT_PACK_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
623            CapabilityId::new(C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
624            CapabilityId::new(C2_NATIVE_SESSION_CATALOG_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
625            CapabilityId::new(C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
626            CapabilityId::new(C2_NATIVE_SESSION_INDEX_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
627            CapabilityId::new(C2_NATIVE_SESSION_PREVIEW_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
628            CapabilityId::new(C2_HOST_DIRECTORY_BROWSE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
629            CapabilityId::new(C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
630            CapabilityId::new(C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
631            CapabilityId::new(C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
632            CapabilityId::new(C2_SESSION_TASK_CORRELATION_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
633            CapabilityId::new(C2_CONTROL_DETAIL_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
634            CapabilityId::new(C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
635            CapabilityId::new(C2_HARNESS_MCP_READ_PROXY_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
636            CapabilityId::new(C2_ACP_CONTROL_CAPABILITY).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
637        ],
638        host: HostDescriptor {
639            operating_system: OperatingSystemId::new(std::env::consts::OS)
640                .map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
641            architecture: ArchitectureId::new(std::env::consts::ARCH)
642                .map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
643        },
644        path_semantics: PathSemantics {
645            style: if cfg!(windows) { PathStyle::Windows } else { PathStyle::Posix },
646            encoding: if cfg!(windows) { PathEncoding::Utf8 } else { PathEncoding::UnixBytes },
647        },
648    };
649    support.negotiate(hello).map_err(|e| FrameError::Io(io::Error::other(e.to_string())))
650}
651
652fn c2_proof(
653    token: &str,
654    direction: C2AuthDirection,
655    client_nonce: &[u8; 32],
656    server_nonce: &[u8; 32],
657    compatibility: Option<(&ClientCompatibilityOffer, &NegotiatedC2ControlCompatibility)>,
658) -> Result<[u8; 32], FrameError> {
659    let transcript = match compatibility {
660        Some((offer, selected)) => c2_bound_auth_transcript(direction, client_nonce, server_nonce, offer, selected)
661            .map_err(|e| FrameError::Io(io::Error::other(e.to_string())))?,
662        None => c2_auth_transcript(direction, client_nonce, server_nonce),
663    };
664    local_hmac_sha256(token.as_bytes(), &transcript).map_err(|e| FrameError::Io(io::Error::other(e)))
665}
666
667async fn read_client_frame<S: tokio::io::AsyncRead + Unpin>(
668    stream: &mut S,
669    max: usize,
670) -> Result<C2ClientFrame, FrameError> {
671    read_json_frame_limited_body_timeout(stream, max, AUTH_DEADLINE).await
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677    use hatchery_c2_client::connect_local;
678        use std::time::Duration;
679
680    #[tokio::test]
681    async fn clients_connect_to_core_operator_not_c2() {
682        let root = tempfile::tempdir().unwrap();
683        let sockdir = root.path().join("sock");
684        #[cfg(unix)]
685        {
686            use std::os::unix::fs::DirBuilderExt;
687            std::fs::DirBuilder::new().mode(0o700).create(&sockdir).unwrap();
688        }
689        #[cfg(not(unix))]
690        std::fs::create_dir_all(&sockdir).unwrap();
691        let endpoint = sockdir.join("core.sock");
692        let mesh = NodeOperatorMesh::new();
693        let (events_tx, _events_rx) = mpsc::unbounded_channel();
694        let (link, _outbound) = OperatorLink::new(Some(events_tx));
695        // Fake node that answers inventory-ish JSON as a RoutedNodeResponse shape via G4aMessage::Json
696        // Register link; dispatch will wait — spawn a responder on a drain-like loop using the outbound isn't
697        // wired here. Instead register nothing and expect NodeOffline.
698        mesh.register("unix-local", link);
699
700        let (stop, rx) = watch::channel(false);
701        let endpoint_s = endpoint.to_string_lossy().into_owned();
702        let mesh2 = mesh.clone();
703        tokio::spawn(async move { serve_local(endpoint_s, "core-op-token-0123456789", mesh2, rx).await });
704        // Ensure topology is published after the server subscribed.
705        mesh.register("unix-local", {
706            let (events_tx, _) = mpsc::unbounded_channel();
707            OperatorLink::new(Some(events_tx)).0
708        });
709        tokio::time::sleep(Duration::from_millis(80)).await;
710
711        let (control, _ev) = tokio::time::timeout(
712            Duration::from_secs(5),
713            connect_local(endpoint.to_str().unwrap(), "core-op-token-0123456789"),
714        )
715        .await
716        .expect("timeout")
717        .expect("connect");
718        assert_eq!(control.hello().build_stamp, BUILD_STAMP);
719        let hello_nodes: Vec<_> = control.hello().status.nodes.keys().map(|k| k.as_str().to_owned()).collect();
720        assert!(hello_nodes.iter().any(|n| n == "unix-local"), "hello status nodes={hello_nodes:?}");
721        let topo = control.current_topology();
722        assert!(!topo.nodes.is_empty() || !hello_nodes.is_empty(), "topology={:?} hello={hello_nodes:?}", topo.nodes);
723        let _ = stop.send(true);
724    }
725}